Isolate providers per client: add provider targets and a Codex-side form
This commit is contained in:
1 parent
adc8234cc9
commit
d759fe5e5d
13 files changed
+595
-309
No files matched your search
@@ -28,6 +28,13 @@ const (
|
||||
LanguageEnglish = "en-US"
|
||||
)
|
||||
|
||||
// Client targets. The Claude and Codex pages each manage their own
|
||||
// isolated set of providers.
|
||||
const (
|
||||
TargetClaude = "claude"
|
||||
TargetCodex = "codex"
|
||||
)
|
||||
|
||||
// Models maps requested model names onto upstream model names. The role
|
||||
// fields are matched by keyword (a request mentioning "haiku" uses Haiku);
|
||||
// Extra holds exact requested-model overrides and wins over everything.
|
||||
@@ -49,6 +56,7 @@ type Provider struct {
|
||||
Models Models `json:"models"`
|
||||
EnabledForClaude bool `json:"enabled_for_claude"`
|
||||
EnabledForCodex bool `json:"enabled_for_codex"`
|
||||
Target string `json:"target,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -162,6 +170,13 @@ func (c *Config) normalize() {
|
||||
}
|
||||
for i := range c.Providers {
|
||||
c.Providers[i].Models.normalize()
|
||||
switch c.Providers[i].Target {
|
||||
case TargetClaude, TargetCodex:
|
||||
default:
|
||||
// Providers created before targets existed belong to the
|
||||
// Claude page.
|
||||
c.Providers[i].Target = TargetClaude
|
||||
}
|
||||
}
|
||||
switch c.Gateway.PortMode {
|
||||
case PortModeAuto, PortModeFixed:
|
||||
@@ -186,7 +201,7 @@ func (c *Config) clone() *Config {
|
||||
return &out
|
||||
}
|
||||
|
||||
func newProvider(name, preset, baseURL, apiKey string, models Models) (*Provider, error) {
|
||||
func newProvider(name, preset, baseURL, apiKey string, models Models, target string) (*Provider, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errors.New("name is required")
|
||||
@@ -204,6 +219,13 @@ func newProvider(name, preset, baseURL, apiKey string, models Models) (*Provider
|
||||
return nil, errors.New("API key is required")
|
||||
}
|
||||
models.normalize()
|
||||
switch target {
|
||||
case TargetClaude, TargetCodex:
|
||||
case "":
|
||||
target = TargetClaude
|
||||
default:
|
||||
return nil, fmt.Errorf("target must be %q or %q", TargetClaude, TargetCodex)
|
||||
}
|
||||
return &Provider{
|
||||
ID: newID("p_"),
|
||||
Name: name,
|
||||
@@ -211,6 +233,7 @@ func newProvider(name, preset, baseURL, apiKey string, models Models) (*Provider
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
APIKey: apiKey,
|
||||
Models: models,
|
||||
Target: target,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestStoreRoundTrip(t *testing.T) {
|
||||
t.Fatalf("default language = %q", store.Config().Language)
|
||||
}
|
||||
p, err := store.AddProvider("DeepSeek", "deepseek", "https://api.deepseek.com/v1", "sk-test",
|
||||
Models{Default: "deepseek-chat", Sonnet: "deepseek-chat", Haiku: "deepseek-chat", Opus: "deepseek-chat"})
|
||||
Models{Default: "deepseek-chat", Sonnet: "deepseek-chat", Haiku: "deepseek-chat", Opus: "deepseek-chat"}, TargetClaude)
|
||||
if err != nil {
|
||||
t.Fatalf("AddProvider: %v", err)
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func TestAddProviderValidation(t *testing.T) {
|
||||
{"p", "https://x.com/v1", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if _, err := store.AddProvider(tc.name, "", tc.baseURL, tc.apiKey, Models{}); err == nil {
|
||||
if _, err := store.AddProvider(tc.name, "", tc.baseURL, tc.apiKey, Models{}, TargetClaude); err == nil {
|
||||
t.Errorf("AddProvider(%q, %q, %q) succeeded, want error", tc.name, tc.baseURL, tc.apiKey)
|
||||
}
|
||||
}
|
||||
@@ -99,8 +99,8 @@ func TestAddProviderValidation(t *testing.T) {
|
||||
func TestUpdateProviderKeepsKeyWhenEmpty(t *testing.T) {
|
||||
t.Setenv("CROSSRELAY_HOME", t.TempDir())
|
||||
store, _ := Load()
|
||||
p, _ := store.AddProvider("p", "", "https://x.com/v1", "sk-secret", Models{Default: "m1"})
|
||||
updated, err := store.UpdateProvider(p.ID, "p2", "", "https://x.com/v1/", "", Models{Default: "m2"})
|
||||
p, _ := store.AddProvider("p", "", "https://x.com/v1", "sk-secret", Models{Default: "m1"}, TargetClaude)
|
||||
updated, err := store.UpdateProvider(p.ID, "p2", "", "https://x.com/v1/", "", Models{Default: "m2"}, TargetClaude)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateProvider: %v", err)
|
||||
}
|
||||
@@ -118,7 +118,7 @@ func TestUpdateProviderKeepsKeyWhenEmpty(t *testing.T) {
|
||||
func TestDeleteProviderClearsActive(t *testing.T) {
|
||||
t.Setenv("CROSSRELAY_HOME", t.TempDir())
|
||||
store, _ := Load()
|
||||
p, _ := store.AddProvider("p", "", "https://x.com/v1", "sk", Models{})
|
||||
p, _ := store.AddProvider("p", "", "https://x.com/v1", "sk", Models{}, TargetClaude)
|
||||
store.ActivateClaude(p.ID)
|
||||
if err := store.DeleteProvider(p.ID); err != nil {
|
||||
t.Fatalf("DeleteProvider: %v", err)
|
||||
@@ -135,7 +135,7 @@ func TestActivateCodexRoundTrip(t *testing.T) {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
p, err := store.AddProvider("DeepSeek", "deepseek", "https://api.deepseek.com/v1", "sk-test",
|
||||
Models{Default: "deepseek-chat"})
|
||||
Models{Default: "deepseek-chat"}, TargetCodex)
|
||||
if err != nil {
|
||||
t.Fatalf("AddProvider: %v", err)
|
||||
}
|
||||
@@ -178,28 +178,87 @@ func TestUpdateProviderKeepsEnabledFlags(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
p, err := store.AddProvider("DeepSeek", "deepseek", "https://api.deepseek.com/v1", "sk-test",
|
||||
Models{Default: "deepseek-chat"})
|
||||
claude, err := store.AddProvider("DeepSeek", "deepseek", "https://api.deepseek.com/v1", "sk-test",
|
||||
Models{Default: "deepseek-chat"}, TargetClaude)
|
||||
if err != nil {
|
||||
t.Fatalf("AddProvider: %v", err)
|
||||
}
|
||||
if _, err := store.ActivateClaude(p.ID); err != nil {
|
||||
codex, err := store.AddProvider("Kimi", "moonshot", "https://api.moonshot.cn/v1", "sk-kimi",
|
||||
Models{Default: "kimi-k2-0905-preview"}, TargetCodex)
|
||||
if err != nil {
|
||||
t.Fatalf("AddProvider: %v", err)
|
||||
}
|
||||
if _, err := store.ActivateClaude(claude.ID); err != nil {
|
||||
t.Fatalf("ActivateClaude: %v", err)
|
||||
}
|
||||
if _, err := store.ActivateCodex(p.ID); err != nil {
|
||||
if _, err := store.ActivateCodex(codex.ID); err != nil {
|
||||
t.Fatalf("ActivateCodex: %v", err)
|
||||
}
|
||||
updated, err := store.UpdateProvider(p.ID, "DeepSeek 2", "deepseek", "https://api.deepseek.com/v1", "",
|
||||
Models{Default: "deepseek-reasoner"})
|
||||
updated, err := store.UpdateProvider(claude.ID, "DeepSeek 2", "deepseek", "https://api.deepseek.com/v1", "",
|
||||
Models{Default: "deepseek-reasoner"}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateProvider: %v", err)
|
||||
}
|
||||
if !updated.EnabledForClaude || !updated.EnabledForCodex {
|
||||
t.Errorf("enabled flags lost: claude=%v codex=%v", updated.EnabledForClaude, updated.EnabledForCodex)
|
||||
if !updated.EnabledForClaude {
|
||||
t.Error("enabled_for_claude lost across update")
|
||||
}
|
||||
if updated.EnabledForCodex {
|
||||
t.Error("a Claude provider must not carry the Codex flag")
|
||||
}
|
||||
if updated.Target != TargetClaude {
|
||||
t.Errorf("target = %q after an update without target, want claude", updated.Target)
|
||||
}
|
||||
if updated.APIKey != "sk-test" {
|
||||
t.Errorf("api key = %q, want the stored key kept", updated.APIKey)
|
||||
}
|
||||
updatedCodex, err := store.UpdateProvider(codex.ID, "Kimi 2", "moonshot", "https://api.moonshot.cn/v1", "",
|
||||
Models{Default: "kimi-k2-0905-preview"}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateProvider codex: %v", err)
|
||||
}
|
||||
if !updatedCodex.EnabledForCodex || updatedCodex.Target != TargetCodex {
|
||||
t.Errorf("codex provider after update = %+v", updatedCodex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderTargetDefaultsAndValidation(t *testing.T) {
|
||||
t.Setenv("CROSSRELAY_HOME", t.TempDir())
|
||||
store, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
p, err := store.AddProvider("p", "", "https://x.com/v1", "sk", Models{}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("AddProvider: %v", err)
|
||||
}
|
||||
if p.Target != TargetClaude {
|
||||
t.Errorf("default target = %q, want claude", p.Target)
|
||||
}
|
||||
if _, err := store.AddProvider("p", "", "https://x.com/v1", "sk", Models{}, "bogus"); err == nil {
|
||||
t.Error("AddProvider accepted an unknown target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateRejectsWrongTarget(t *testing.T) {
|
||||
t.Setenv("CROSSRELAY_HOME", t.TempDir())
|
||||
store, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
claude, _ := store.AddProvider("c", "", "https://x.com/v1", "sk", Models{}, TargetClaude)
|
||||
codex, _ := store.AddProvider("x", "", "https://x.com/v1", "sk", Models{}, TargetCodex)
|
||||
if _, err := store.ActivateClaude(codex.ID); err == nil {
|
||||
t.Error("ActivateClaude accepted a Codex provider")
|
||||
}
|
||||
if _, err := store.ActivateCodex(claude.ID); err == nil {
|
||||
t.Error("ActivateCodex accepted a Claude provider")
|
||||
}
|
||||
if _, err := store.ActivateClaude(claude.ID); err != nil {
|
||||
t.Errorf("ActivateClaude on a Claude provider: %v", err)
|
||||
}
|
||||
if _, err := store.ActivateCodex(codex.ID); err != nil {
|
||||
t.Errorf("ActivateCodex on a Codex provider: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProviderClearsCodexActive(t *testing.T) {
|
||||
@@ -209,7 +268,7 @@ func TestDeleteProviderClearsCodexActive(t *testing.T) {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
p, err := store.AddProvider("DeepSeek", "deepseek", "https://api.deepseek.com/v1", "sk-test",
|
||||
Models{Default: "deepseek-chat"})
|
||||
Models{Default: "deepseek-chat"}, TargetCodex)
|
||||
if err != nil {
|
||||
t.Fatalf("AddProvider: %v", err)
|
||||
}
|
||||
|
||||
@@ -103,11 +103,11 @@ func (s *Store) ActiveCodex() *Provider {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddProvider validates and appends a provider.
|
||||
func (s *Store) AddProvider(name, preset, baseURL, apiKey string, models Models) (*Provider, error) {
|
||||
// AddProvider validates and appends a provider for the given target.
|
||||
func (s *Store) AddProvider(name, preset, baseURL, apiKey string, models Models, target string) (*Provider, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
p, err := newProvider(name, preset, baseURL, apiKey, models)
|
||||
p, err := newProvider(name, preset, baseURL, apiKey, models, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -119,8 +119,9 @@ func (s *Store) AddProvider(name, preset, baseURL, apiKey string, models Models)
|
||||
}
|
||||
|
||||
// UpdateProvider replaces a provider's editable fields. An empty apiKey
|
||||
// keeps the stored key so edits never wipe it by accident.
|
||||
func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models Models) (*Provider, error) {
|
||||
// keeps the stored key so edits never wipe it by accident; an empty
|
||||
// target keeps the stored one.
|
||||
func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models Models, target string) (*Provider, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
idx := -1
|
||||
@@ -137,7 +138,10 @@ func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models
|
||||
if apiKey == "" {
|
||||
apiKey = existing.APIKey
|
||||
}
|
||||
updated, err := newProvider(name, preset, baseURL, apiKey, models)
|
||||
if target == "" {
|
||||
target = existing.Target
|
||||
}
|
||||
updated, err := newProvider(name, preset, baseURL, apiKey, models, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -191,6 +195,9 @@ func (s *Store) ActivateClaude(id string) (*Provider, error) {
|
||||
if idx < 0 {
|
||||
return nil, fmt.Errorf("provider %q not found", id)
|
||||
}
|
||||
if s.cfg.Providers[idx].Target != TargetClaude {
|
||||
return nil, fmt.Errorf("provider %q is not a Claude provider", id)
|
||||
}
|
||||
s.cfg.Providers[idx].EnabledForClaude = true
|
||||
s.cfg.ActiveClaudeID = id
|
||||
if err := s.saveLocked(); err != nil {
|
||||
@@ -226,6 +233,9 @@ func (s *Store) ActivateCodex(id string) (*Provider, error) {
|
||||
if idx < 0 {
|
||||
return nil, fmt.Errorf("provider %q not found", id)
|
||||
}
|
||||
if s.cfg.Providers[idx].Target != TargetCodex {
|
||||
return nil, fmt.Errorf("provider %q is not a Codex provider", id)
|
||||
}
|
||||
s.cfg.Providers[idx].EnabledForCodex = true
|
||||
s.cfg.ActiveCodexID = id
|
||||
if err := s.saveLocked(); err != nil {
|
||||
|
||||
Reference in new issue
Block a user