Isolate providers per client: add provider targets and a Codex-side form

This commit is contained in:
kate522 committed 2026-10-18 19:22:38 +08:00
1 parent adc8234cc9
commit d759fe5e5d
13 files changed
+595 -309

No files matched your search

+24 -1
View File
@@ -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
}
+74 -15
View File
@@ -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)
}
+16 -6
View File
@@ -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 {
+71 -13
View File
@@ -58,11 +58,12 @@ func writeSSELine(w http.ResponseWriter, line string) {
}
type testEnv struct {
gateway *httptest.Server
upstream *fakeUpstream
store *config.Store
settings string
codex string
gateway *httptest.Server
upstream *fakeUpstream
store *config.Store
settings string
codex string
upstreamURL string
}
func newTestEnv(t *testing.T) *testEnv {
@@ -87,7 +88,7 @@ func newTestEnv(t *testing.T) *testEnv {
Sonnet: "ds-chat",
Haiku: "ds-haiku",
Opus: "ds-opus",
})
}, config.TargetClaude)
if err != nil {
t.Fatalf("AddProvider: %v", err)
}
@@ -97,7 +98,7 @@ func newTestEnv(t *testing.T) *testEnv {
gw := httptest.NewServer(New(store, 18787).Handler())
t.Cleanup(gw.Close)
return &testEnv{gateway: gw, upstream: upstream, store: store, settings: settingsPath}
return &testEnv{gateway: gw, upstream: upstream, store: store, settings: settingsPath, upstreamURL: up.URL}
}
func (e *testEnv) post(t *testing.T, path string, body any, token string) *http.Response {
@@ -135,7 +136,7 @@ func messagesBody(model string, stream bool) map[string]any {
}
// newCodexTestEnv builds the standard environment with a realistic
// config.toml in place and the shared provider activated for Codex.
// config.toml in place plus a dedicated Codex provider, activated.
func newCodexTestEnv(t *testing.T) *testEnv {
t.Helper()
env := newTestEnv(t)
@@ -145,12 +146,26 @@ func newCodexTestEnv(t *testing.T) *testEnv {
if err := os.WriteFile(env.codex, []byte("model_provider = \"custom\"\n\n[model_providers.custom]\nname = \"custom\"\nwire_api = \"responses\"\nbase_url = \"https://old.example.com/v1\"\n"), 0o600); err != nil {
t.Fatalf("write config.toml: %v", err)
}
if _, err := env.store.ActivateCodex(env.store.Config().Providers[0].ID); err != nil {
p, err := env.store.AddProvider("codex-up", "", env.upstreamURL, "up-key",
config.Models{Default: "ds-chat"}, config.TargetCodex)
if err != nil {
t.Fatalf("AddProvider: %v", err)
}
if _, err := env.store.ActivateCodex(p.ID); err != nil {
t.Fatalf("ActivateCodex: %v", err)
}
return env
}
func providerIDForTarget(env *testEnv, target string) string {
for _, p := range env.store.Config().Providers {
if p.Target == target {
return p.ID
}
}
return ""
}
func (e *testEnv) get(t *testing.T, path string) map[string]any {
t.Helper()
resp, err := http.Get(e.gateway.URL + path)
@@ -297,7 +312,7 @@ func TestResponsesWithoutActiveProvider(t *testing.T) {
func TestApplyAndRestoreCodex(t *testing.T) {
env := newCodexTestEnv(t)
id := env.store.Config().Providers[0].ID
id := providerIDForTarget(env, config.TargetCodex)
resp := env.post(t, "/api/providers/"+id+"/apply", map[string]any{"target": "codex"}, "")
defer resp.Body.Close()
@@ -367,11 +382,12 @@ func TestApplyAndRestoreCodex(t *testing.T) {
func TestBackupsTargetDimension(t *testing.T) {
env := newCodexTestEnv(t)
id := env.store.Config().Providers[0].ID
claudeID := providerIDForTarget(env, config.TargetClaude)
codexID := providerIDForTarget(env, config.TargetCodex)
resp := env.post(t, "/api/providers/"+id+"/apply", map[string]any{"target": "claude"}, "")
resp := env.post(t, "/api/providers/"+claudeID+"/apply", map[string]any{"target": "claude"}, "")
resp.Body.Close()
resp = env.post(t, "/api/providers/"+id+"/apply", map[string]any{"target": "codex"}, "")
resp = env.post(t, "/api/providers/"+codexID+"/apply", map[string]any{"target": "codex"}, "")
resp.Body.Close()
codexBackups := env.get(t, "/api/backups?target=codex")["items"].([]any)
@@ -642,6 +658,11 @@ func TestProviderCRUD(t *testing.T) {
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create status = %d", resp.StatusCode)
}
var created map[string]any
_ = json.NewDecoder(resp.Body).Decode(&created)
if created["target"] != "claude" {
t.Errorf("created provider target = %v, want claude by default", created["target"])
}
if len(env.store.Config().Providers) != 2 {
t.Fatalf("providers = %d, want 2", len(env.store.Config().Providers))
}
@@ -651,3 +672,40 @@ func TestProviderCRUD(t *testing.T) {
t.Errorf("invalid create status = %d, want 400", resp.StatusCode)
}
}
func TestApplyRejectsWrongTarget(t *testing.T) {
env := newTestEnv(t)
id := providerIDForTarget(env, config.TargetClaude)
resp := env.post(t, "/api/providers/"+id+"/apply", map[string]any{"target": "codex"}, "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("apply status = %d, want 404 for a provider of the other target", resp.StatusCode)
}
}
func TestListProvidersFiltersByTarget(t *testing.T) {
env := newCodexTestEnv(t)
codexItems := env.get(t, "/api/providers?target=codex")["items"].([]any)
if len(codexItems) != 1 {
t.Fatalf("codex providers = %d, want 1", len(codexItems))
}
if codexItems[0].(map[string]any)["target"] != "codex" {
t.Errorf("codex list leaked another target: %v", codexItems[0])
}
claudeItems := env.get(t, "/api/providers?target=claude")["items"].([]any)
if len(claudeItems) != 1 {
t.Fatalf("claude providers = %d, want 1", len(claudeItems))
}
allItems := env.get(t, "/api/providers")["items"].([]any)
if len(allItems) != 2 {
t.Errorf("unfiltered providers = %d, want 2", len(allItems))
}
resp, err := http.Get(env.gateway.URL + "/api/providers?target=bogus")
if err != nil {
t.Fatalf("get: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("bogus target status = %d, want 400", resp.StatusCode)
}
}
+17 -3
View File
@@ -17,6 +17,7 @@ type providerInput struct {
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Models config.Models `json:"models"`
Target string `json:"target"`
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
@@ -54,7 +55,20 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleListProviders(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"items": s.store.Config().Providers})
target := r.URL.Query().Get("target")
switch target {
case "", config.TargetClaude, config.TargetCodex:
default:
writeAPIError(w, http.StatusBadRequest, `target must be "claude" or "codex"`)
return
}
items := []config.Provider{}
for _, p := range s.store.Config().Providers {
if target == "" || p.Target == target {
items = append(items, p)
}
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) handleCreateProvider(w http.ResponseWriter, r *http.Request) {
@@ -63,7 +77,7 @@ func (s *Server) handleCreateProvider(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
p, err := s.store.AddProvider(in.Name, in.Preset, in.BaseURL, in.APIKey, in.Models)
p, err := s.store.AddProvider(in.Name, in.Preset, in.BaseURL, in.APIKey, in.Models, in.Target)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
@@ -78,7 +92,7 @@ func (s *Server) handleUpdateProvider(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
p, err := s.store.UpdateProvider(id, in.Name, in.Preset, in.BaseURL, in.APIKey, in.Models)
p, err := s.store.UpdateProvider(id, in.Name, in.Preset, in.BaseURL, in.APIKey, in.Models, in.Target)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return