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
+581 -295

No files matched your search

+24 -1
View File
@@ -28,6 +28,13 @@ const (
LanguageEnglish = "en-US" 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 // Models maps requested model names onto upstream model names. The role
// fields are matched by keyword (a request mentioning "haiku" uses Haiku); // fields are matched by keyword (a request mentioning "haiku" uses Haiku);
// Extra holds exact requested-model overrides and wins over everything. // Extra holds exact requested-model overrides and wins over everything.
@@ -49,6 +56,7 @@ type Provider struct {
Models Models `json:"models"` Models Models `json:"models"`
EnabledForClaude bool `json:"enabled_for_claude"` EnabledForClaude bool `json:"enabled_for_claude"`
EnabledForCodex bool `json:"enabled_for_codex"` EnabledForCodex bool `json:"enabled_for_codex"`
Target string `json:"target,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -162,6 +170,13 @@ func (c *Config) normalize() {
} }
for i := range c.Providers { for i := range c.Providers {
c.Providers[i].Models.normalize() 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 { switch c.Gateway.PortMode {
case PortModeAuto, PortModeFixed: case PortModeAuto, PortModeFixed:
@@ -186,7 +201,7 @@ func (c *Config) clone() *Config {
return &out 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) name = strings.TrimSpace(name)
if name == "" { if name == "" {
return nil, errors.New("name is required") 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") return nil, errors.New("API key is required")
} }
models.normalize() 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{ return &Provider{
ID: newID("p_"), ID: newID("p_"),
Name: name, Name: name,
@@ -211,6 +233,7 @@ func newProvider(name, preset, baseURL, apiKey string, models Models) (*Provider
BaseURL: strings.TrimRight(baseURL, "/"), BaseURL: strings.TrimRight(baseURL, "/"),
APIKey: apiKey, APIKey: apiKey,
Models: models, Models: models,
Target: target,
CreatedAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
}, nil }, nil
} }
+74 -15
View File
@@ -58,7 +58,7 @@ func TestStoreRoundTrip(t *testing.T) {
t.Fatalf("default language = %q", store.Config().Language) t.Fatalf("default language = %q", store.Config().Language)
} }
p, err := store.AddProvider("DeepSeek", "deepseek", "https://api.deepseek.com/v1", "sk-test", 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 { if err != nil {
t.Fatalf("AddProvider: %v", err) t.Fatalf("AddProvider: %v", err)
} }
@@ -90,7 +90,7 @@ func TestAddProviderValidation(t *testing.T) {
{"p", "https://x.com/v1", ""}, {"p", "https://x.com/v1", ""},
} }
for _, tc := range cases { 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) 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) { func TestUpdateProviderKeepsKeyWhenEmpty(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir()) t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, _ := Load() store, _ := Load()
p, _ := store.AddProvider("p", "", "https://x.com/v1", "sk-secret", Models{Default: "m1"}) 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"}) updated, err := store.UpdateProvider(p.ID, "p2", "", "https://x.com/v1/", "", Models{Default: "m2"}, TargetClaude)
if err != nil { if err != nil {
t.Fatalf("UpdateProvider: %v", err) t.Fatalf("UpdateProvider: %v", err)
} }
@@ -118,7 +118,7 @@ func TestUpdateProviderKeepsKeyWhenEmpty(t *testing.T) {
func TestDeleteProviderClearsActive(t *testing.T) { func TestDeleteProviderClearsActive(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir()) t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, _ := Load() 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) store.ActivateClaude(p.ID)
if err := store.DeleteProvider(p.ID); err != nil { if err := store.DeleteProvider(p.ID); err != nil {
t.Fatalf("DeleteProvider: %v", err) t.Fatalf("DeleteProvider: %v", err)
@@ -135,7 +135,7 @@ func TestActivateCodexRoundTrip(t *testing.T) {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
} }
p, err := store.AddProvider("DeepSeek", "deepseek", "https://api.deepseek.com/v1", "sk-test", 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 { if err != nil {
t.Fatalf("AddProvider: %v", err) t.Fatalf("AddProvider: %v", err)
} }
@@ -178,28 +178,87 @@ func TestUpdateProviderKeepsEnabledFlags(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
} }
p, err := store.AddProvider("DeepSeek", "deepseek", "https://api.deepseek.com/v1", "sk-test", claude, err := store.AddProvider("DeepSeek", "deepseek", "https://api.deepseek.com/v1", "sk-test",
Models{Default: "deepseek-chat"}) Models{Default: "deepseek-chat"}, TargetClaude)
if err != nil { if err != nil {
t.Fatalf("AddProvider: %v", err) 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) 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) t.Fatalf("ActivateCodex: %v", err)
} }
updated, err := store.UpdateProvider(p.ID, "DeepSeek 2", "deepseek", "https://api.deepseek.com/v1", "", updated, err := store.UpdateProvider(claude.ID, "DeepSeek 2", "deepseek", "https://api.deepseek.com/v1", "",
Models{Default: "deepseek-reasoner"}) Models{Default: "deepseek-reasoner"}, "")
if err != nil { if err != nil {
t.Fatalf("UpdateProvider: %v", err) t.Fatalf("UpdateProvider: %v", err)
} }
if !updated.EnabledForClaude || !updated.EnabledForCodex { if !updated.EnabledForClaude {
t.Errorf("enabled flags lost: claude=%v codex=%v", updated.EnabledForClaude, updated.EnabledForCodex) 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" { if updated.APIKey != "sk-test" {
t.Errorf("api key = %q, want the stored key kept", updated.APIKey) 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) { func TestDeleteProviderClearsCodexActive(t *testing.T) {
@@ -209,7 +268,7 @@ func TestDeleteProviderClearsCodexActive(t *testing.T) {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
} }
p, err := store.AddProvider("DeepSeek", "deepseek", "https://api.deepseek.com/v1", "sk-test", 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 { if err != nil {
t.Fatalf("AddProvider: %v", err) t.Fatalf("AddProvider: %v", err)
} }
+16 -6
View File
@@ -103,11 +103,11 @@ func (s *Store) ActiveCodex() *Provider {
return nil return nil
} }
// AddProvider validates and appends a provider. // AddProvider validates and appends a provider for the given target.
func (s *Store) AddProvider(name, preset, baseURL, apiKey string, models Models) (*Provider, error) { func (s *Store) AddProvider(name, preset, baseURL, apiKey string, models Models, target string) (*Provider, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
p, err := newProvider(name, preset, baseURL, apiKey, models) p, err := newProvider(name, preset, baseURL, apiKey, models, target)
if err != nil { if err != nil {
return nil, err 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 // UpdateProvider replaces a provider's editable fields. An empty apiKey
// keeps the stored key so edits never wipe it by accident. // keeps the stored key so edits never wipe it by accident; an empty
func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models Models) (*Provider, error) { // target keeps the stored one.
func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models Models, target string) (*Provider, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
idx := -1 idx := -1
@@ -137,7 +138,10 @@ func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models
if apiKey == "" { if apiKey == "" {
apiKey = existing.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 { if err != nil {
return nil, err return nil, err
} }
@@ -191,6 +195,9 @@ func (s *Store) ActivateClaude(id string) (*Provider, error) {
if idx < 0 { if idx < 0 {
return nil, fmt.Errorf("provider %q not found", id) 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.Providers[idx].EnabledForClaude = true
s.cfg.ActiveClaudeID = id s.cfg.ActiveClaudeID = id
if err := s.saveLocked(); err != nil { if err := s.saveLocked(); err != nil {
@@ -226,6 +233,9 @@ func (s *Store) ActivateCodex(id string) (*Provider, error) {
if idx < 0 { if idx < 0 {
return nil, fmt.Errorf("provider %q not found", id) 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.Providers[idx].EnabledForCodex = true
s.cfg.ActiveCodexID = id s.cfg.ActiveCodexID = id
if err := s.saveLocked(); err != nil { if err := s.saveLocked(); err != nil {
+66 -8
View File
@@ -63,6 +63,7 @@ type testEnv struct {
store *config.Store store *config.Store
settings string settings string
codex string codex string
upstreamURL string
} }
func newTestEnv(t *testing.T) *testEnv { func newTestEnv(t *testing.T) *testEnv {
@@ -87,7 +88,7 @@ func newTestEnv(t *testing.T) *testEnv {
Sonnet: "ds-chat", Sonnet: "ds-chat",
Haiku: "ds-haiku", Haiku: "ds-haiku",
Opus: "ds-opus", Opus: "ds-opus",
}) }, config.TargetClaude)
if err != nil { if err != nil {
t.Fatalf("AddProvider: %v", err) t.Fatalf("AddProvider: %v", err)
} }
@@ -97,7 +98,7 @@ func newTestEnv(t *testing.T) *testEnv {
gw := httptest.NewServer(New(store, 18787).Handler()) gw := httptest.NewServer(New(store, 18787).Handler())
t.Cleanup(gw.Close) 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 { 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 // 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 { func newCodexTestEnv(t *testing.T) *testEnv {
t.Helper() t.Helper()
env := newTestEnv(t) 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 { 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) 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) t.Fatalf("ActivateCodex: %v", err)
} }
return env 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 { func (e *testEnv) get(t *testing.T, path string) map[string]any {
t.Helper() t.Helper()
resp, err := http.Get(e.gateway.URL + path) resp, err := http.Get(e.gateway.URL + path)
@@ -297,7 +312,7 @@ func TestResponsesWithoutActiveProvider(t *testing.T) {
func TestApplyAndRestoreCodex(t *testing.T) { func TestApplyAndRestoreCodex(t *testing.T) {
env := newCodexTestEnv(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"}, "") resp := env.post(t, "/api/providers/"+id+"/apply", map[string]any{"target": "codex"}, "")
defer resp.Body.Close() defer resp.Body.Close()
@@ -367,11 +382,12 @@ func TestApplyAndRestoreCodex(t *testing.T) {
func TestBackupsTargetDimension(t *testing.T) { func TestBackupsTargetDimension(t *testing.T) {
env := newCodexTestEnv(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.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() resp.Body.Close()
codexBackups := env.get(t, "/api/backups?target=codex")["items"].([]any) codexBackups := env.get(t, "/api/backups?target=codex")["items"].([]any)
@@ -642,6 +658,11 @@ func TestProviderCRUD(t *testing.T) {
if resp.StatusCode != http.StatusCreated { if resp.StatusCode != http.StatusCreated {
t.Fatalf("create status = %d", resp.StatusCode) 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 { if len(env.store.Config().Providers) != 2 {
t.Fatalf("providers = %d, want 2", len(env.store.Config().Providers)) 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) 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"` BaseURL string `json:"base_url"`
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
Models config.Models `json:"models"` Models config.Models `json:"models"`
Target string `json:"target"`
} }
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { 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) { 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) { 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()) writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return 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 { if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error()) writeAPIError(w, http.StatusBadRequest, err.Error())
return return
@@ -78,7 +92,7 @@ func (s *Server) handleUpdateProvider(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return 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 { if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error()) writeAPIError(w, http.StatusBadRequest, err.Error())
return return
+258
View File
@@ -0,0 +1,258 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2 } from "lucide-react";
import { PRESETS } from "@/lib/types";
import type { Models } from "@/lib/types";
import type { ApplyTarget } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input, Label } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
export interface ProviderSubmitInput {
name: string;
preset: string;
base_url: string;
api_key: string;
models: Models;
target: ApplyTarget;
}
interface FormState {
name: string;
preset: string;
base_url: string;
api_key: string;
models: Omit<Models, "extra"> & { extra: { key: string; value: string }[] };
}
const emptyForm: FormState = {
name: "",
preset: "",
base_url: "",
api_key: "",
models: { default: "", sonnet: "", haiku: "", opus: "", extra: [] },
};
function toRows(extra: Record<string, string> | undefined): { key: string; value: string }[] {
return Object.entries(extra ?? {}).map(([key, value]) => ({ key, value }));
}
function toMap(rows: { key: string; value: string }[]): Record<string, string> {
return Object.fromEntries(
rows.filter((r) => r.key.trim() && r.value.trim()).map((r) => [r.key.trim(), r.value.trim()]),
);
}
interface ProviderFormProps {
target: ApplyTarget;
editing: { id: string; name: string; preset?: string; base_url: string; models: Models } | null;
pending: boolean;
onSubmit: (input: ProviderSubmitInput) => void;
onCancel: () => void;
}
export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: ProviderFormProps) {
const { t } = useTranslation();
const [form, setForm] = useState<FormState>(() =>
editing
? {
name: editing.name,
preset: editing.preset ?? "",
base_url: editing.base_url,
api_key: "",
models: {
default: editing.models.default ?? "",
sonnet: editing.models.sonnet ?? "",
haiku: editing.models.haiku ?? "",
opus: editing.models.opus ?? "",
extra: toRows(editing.models.extra),
},
}
: { ...emptyForm },
);
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const validate = (): boolean => {
const next: Partial<Record<keyof FormState, string>> = {};
if (!form.name.trim()) next.name = t("providers.validationName");
if (!/^https?:\/\/.+/.test(form.base_url.trim())) next.base_url = t("providers.validationBaseUrl");
if (!editing && !form.api_key.trim()) next.api_key = t("providers.validationApiKey");
setErrors(next);
return Object.keys(next).length === 0;
};
const submit = () => {
if (!validate()) return;
onSubmit({
name: form.name.trim(),
preset: form.preset,
base_url: form.base_url.trim(),
api_key: form.api_key.trim(),
models: {
default: form.models.default?.trim(),
sonnet: form.models.sonnet?.trim(),
haiku: form.models.haiku?.trim(),
opus: form.models.opus?.trim(),
extra: toMap(form.models.extra),
},
target,
});
};
return (
<Card>
<CardHeader>
<CardTitle>{editing ? t("providers.edit") : t("providers.add")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1">
<Label>{t("providers.name")}</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="DeepSeek"
/>
{errors.name && <span className="text-xs text-red-600">{errors.name}</span>}
</label>
<label className="flex flex-col gap-1">
<Label>{t("providers.preset")}</Label>
<Select
value={form.preset}
onChange={(e) => {
const key = e.target.value;
const preset = PRESETS.find((p) => p.key === key);
setForm({
...form,
preset: key,
base_url: preset?.base_url ?? form.base_url,
models: preset
? { ...preset.models, extra: toRows(preset.models.extra) }
: form.models,
});
}}
>
<option value="">{t("providers.presetCustom")}</option>
{PRESETS.map((p) => (
<option key={p.key} value={p.key}>
{p.key}
</option>
))}
</Select>
</label>
<label className="flex flex-col gap-1">
<Label>{t("providers.baseUrl")}</Label>
<Input
value={form.base_url}
onChange={(e) => setForm({ ...form, base_url: e.target.value })}
placeholder="https://api.deepseek.com/v1"
/>
{errors.base_url && <span className="text-xs text-red-600">{errors.base_url}</span>}
</label>
<label className="flex flex-col gap-1">
<Label>
{t("providers.apiKey")}
{editing && <span className="ml-1 text-zinc-400">({t("providers.keyHint")})</span>}
</Label>
<Input
type="password"
value={form.api_key}
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
placeholder="sk-..."
/>
{errors.api_key && <span className="text-xs text-red-600">{errors.api_key}</span>}
</label>
</div>
<fieldset className="flex flex-col gap-2">
<legend className="text-xs font-medium text-zinc-600">{t("providers.models")}</legend>
<div className="grid grid-cols-2 gap-3">
{(["default", "sonnet", "haiku", "opus"] as const).map((role) => (
<label key={role} className="flex flex-col gap-1">
<Label>{t(`providers.${role}Model`)}</Label>
<Input
value={form.models[role] ?? ""}
onChange={(e) =>
setForm({ ...form, models: { ...form.models, [role]: e.target.value } })
}
/>
</label>
))}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-zinc-600">{t("providers.extraMappings")}</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
setForm({
...form,
models: {
...form.models,
extra: [...form.models.extra, { key: "", value: "" }],
},
})
}
>
<Plus size={12} aria-hidden />
{t("providers.addMapping")}
</Button>
</div>
{form.models.extra.map((row, index) => (
<div key={index} className="flex items-center gap-2">
<Input
value={row.key}
placeholder={t("providers.extraKey")}
onChange={(e) => {
const extra = form.models.extra.map((r, i) =>
i === index ? { ...r, key: e.target.value } : r,
);
setForm({ ...form, models: { ...form.models, extra } });
}}
/>
<Input
value={row.value}
placeholder={t("providers.extraValue")}
onChange={(e) => {
const extra = form.models.extra.map((r, i) =>
i === index ? { ...r, value: e.target.value } : r,
);
setForm({ ...form, models: { ...form.models, extra } });
}}
/>
<Button
variant="ghost"
size="icon"
aria-label={t("common.delete")}
onClick={() =>
setForm({
...form,
models: {
...form.models,
extra: form.models.extra.filter((_, i) => i !== index),
},
})
}
>
<Trash2 size={14} aria-hidden />
</Button>
</div>
))}
<p className="text-xs text-zinc-500">{t("providers.extraHint")}</p>
</div>
</fieldset>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={onCancel}>
{t("common.cancel")}
</Button>
<Button size="sm" onClick={submit} disabled={pending}>
{t("common.save")}
</Button>
</div>
</CardContent>
</Card>
);
}
+3 -1
View File
@@ -50,11 +50,13 @@ export interface ProviderInput {
base_url: string; base_url: string;
api_key: string; api_key: string;
models: Models; models: Models;
target: ApplyTarget;
} }
export const api = { export const api = {
getStatus: () => request<Status>("/api/status"), getStatus: () => request<Status>("/api/status"),
listProviders: () => request<{ items: Provider[] }>("/api/providers"), listProviders: (target?: ApplyTarget) =>
request<{ items: Provider[] }>(target ? `/api/providers?target=${target}` : "/api/providers"),
createProvider: (input: ProviderInput) => createProvider: (input: ProviderInput) =>
request<Provider>("/api/providers", { method: "POST", body: JSON.stringify(input) }), request<Provider>("/api/providers", { method: "POST", body: JSON.stringify(input) }),
updateProvider: (id: string, input: ProviderInput) => updateProvider: (id: string, input: ProviderInput) =>
+7 -4
View File
@@ -3,7 +3,7 @@ import { api, type ProviderInput } from "./api";
export const queryKeys = { export const queryKeys = {
status: ["status"] as const, status: ["status"] as const,
providers: ["providers"] as const, providers: (target: string) => ["providers", target] as const,
logs: ["logs"] as const, logs: ["logs"] as const,
backups: (target: string) => ["backups", target] as const, backups: (target: string) => ["backups", target] as const,
settings: ["settings"] as const, settings: ["settings"] as const,
@@ -13,8 +13,11 @@ export function useStatus() {
return useQuery({ queryKey: queryKeys.status, queryFn: api.getStatus, refetchInterval: 5000 }); return useQuery({ queryKey: queryKeys.status, queryFn: api.getStatus, refetchInterval: 5000 });
} }
export function useProviders() { export function useProviders(target: "claude" | "codex") {
return useQuery({ queryKey: queryKeys.providers, queryFn: api.listProviders }); return useQuery({
queryKey: queryKeys.providers(target),
queryFn: () => api.listProviders(target),
});
} }
export function useLogs() { export function useLogs() {
@@ -31,7 +34,7 @@ export function useSettings() {
function invalidateAll(queryClient: ReturnType<typeof useQueryClient>) { function invalidateAll(queryClient: ReturnType<typeof useQueryClient>) {
return Promise.all([ return Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.providers }), queryClient.invalidateQueries({ queryKey: ["providers"] }),
queryClient.invalidateQueries({ queryKey: queryKeys.status }), queryClient.invalidateQueries({ queryKey: queryKeys.status }),
queryClient.invalidateQueries({ queryKey: ["backups"] }), queryClient.invalidateQueries({ queryKey: ["backups"] }),
queryClient.invalidateQueries({ queryKey: queryKeys.settings }), queryClient.invalidateQueries({ queryKey: queryKeys.settings }),
+1
View File
@@ -15,6 +15,7 @@ export interface Provider {
models: Models; models: Models;
enabled_for_claude: boolean; enabled_for_claude: boolean;
enabled_for_codex: boolean; enabled_for_codex: boolean;
target: "claude" | "codex";
created_at: string; created_at: string;
} }
+27 -2
View File
@@ -4,14 +4,15 @@ import { renderWithProviders } from "@/test/utils";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
const listProviders = vi.fn(); const listProviders = vi.fn();
const createProvider = vi.fn();
const applyProvider = vi.fn(); const applyProvider = vi.fn();
const restore = vi.fn(); const restore = vi.fn();
const getStatus = vi.fn(); const getStatus = vi.fn();
vi.mock("@/lib/api", () => ({ vi.mock("@/lib/api", () => ({
api: { api: {
listProviders: () => listProviders(), listProviders: (target: string) => listProviders(target),
createProvider: vi.fn(), createProvider: (input: unknown) => createProvider(input),
updateProvider: vi.fn(), updateProvider: vi.fn(),
deleteProvider: vi.fn(), deleteProvider: vi.fn(),
applyProvider: (id: string, target: string) => applyProvider(id, target), applyProvider: (id: string, target: string) => applyProvider(id, target),
@@ -35,6 +36,7 @@ const provider = {
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" },
enabled_for_claude: false, enabled_for_claude: false,
enabled_for_codex: false, enabled_for_codex: false,
target: "codex",
created_at: "2026-10-18T00:00:00Z", created_at: "2026-10-18T00:00:00Z",
}; };
@@ -53,6 +55,7 @@ beforeEach(() => {
port_mode: "auto", port_mode: "auto",
fixed_port: 0, fixed_port: 0,
}); });
createProvider.mockResolvedValue({ ...provider, id: "p_2" });
applyProvider.mockResolvedValue({ applyProvider.mockResolvedValue({
provider, provider,
injection: { injection: {
@@ -77,6 +80,28 @@ describe("Codex page", () => {
expect(screen.getByText("DeepSeek")).toBeInTheDocument(); expect(screen.getByText("DeepSeek")).toBeInTheDocument();
}); });
it("requests only Codex providers", async () => {
renderWithProviders(<Codex />);
await screen.findByText("DeepSeek");
expect(listProviders).toHaveBeenCalledWith("codex");
});
it("creates a provider scoped to Codex", async () => {
const user = userEvent.setup();
renderWithProviders(<Codex />);
await user.click(await screen.findByRole("button", { name: /新增供应商|Add provider/ }));
await user.type(screen.getByLabelText(/名称|Name/), "Kimi");
await user.type(screen.getByLabelText(/Base URL/), "https://api.moonshot.cn/v1");
await user.type(screen.getByLabelText(/API Key/), "sk-kimi");
await user.click(screen.getByRole("button", { name: /保存|Save/ }));
await waitFor(() => expect(createProvider).toHaveBeenCalledTimes(1));
expect(createProvider.mock.calls[0][0]).toMatchObject({
name: "Kimi",
base_url: "https://api.moonshot.cn/v1",
target: "codex",
});
});
it("activates a provider for Codex and previews the injected keys", async () => { it("activates a provider for Codex and previews the injected keys", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderWithProviders(<Codex />); renderWithProviders(<Codex />);
+57 -2
View File
@@ -1,25 +1,41 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { RotateCcw, Zap } from "lucide-react"; import { Plus, RotateCcw, Trash2, Pencil, Zap } from "lucide-react";
import type { Provider } from "@/lib/types"; import type { Provider } from "@/lib/types";
import { useProviders, useProviderMutations, useRestore, useStatus } from "@/lib/queries"; import { useProviders, useProviderMutations, useRestore, useStatus } from "@/lib/queries";
import { useToast } from "@/components/toast"; import { useToast } from "@/components/toast";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ProviderForm, type ProviderSubmitInput } from "@/components/ProviderForm";
export default function Codex() { export default function Codex() {
const { t } = useTranslation(); const { t } = useTranslation();
const status = useStatus(); const status = useStatus();
const { data, isLoading, isError } = useProviders(); const { data, isLoading, isError } = useProviders("codex");
const mutations = useProviderMutations(); const mutations = useProviderMutations();
const restore = useRestore(); const restore = useRestore();
const { show } = useToast(); const { show } = useToast();
const [injection, setInjection] = useState<Record<string, string> | null>(null); const [injection, setInjection] = useState<Record<string, string> | null>(null);
const [editing, setEditing] = useState<{ provider: Provider | null } | null>(null);
const providers = data?.items ?? []; const providers = data?.items ?? [];
const active = status.data?.active_codex_provider ?? null; const active = status.data?.active_codex_provider ?? null;
const submitForm = (input: ProviderSubmitInput) => {
const target = editing?.provider ?? null;
const onSuccess = () => {
show(target ? t("providers.updated") : t("providers.created"));
setEditing(null);
};
const onError = (err: Error) => show(err.message, "error");
if (target) {
mutations.update.mutate({ id: target.id, input }, { onSuccess, onError });
} else {
mutations.create.mutate(input, { onSuccess, onError });
}
};
const apply = (provider: Provider) => { const apply = (provider: Provider) => {
mutations.apply.mutate( mutations.apply.mutate(
{ id: provider.id, target: "codex" }, { id: provider.id, target: "codex" },
@@ -53,6 +69,11 @@ export default function Codex() {
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{t("codex.title")}</h2> <h2 className="text-lg font-semibold">{t("codex.title")}</h2>
<div className="flex gap-2">
<Button size="sm" onClick={() => setEditing({ provider: null })}>
<Plus size={14} aria-hidden />
{t("providers.add")}
</Button>
<Button variant="outline" size="sm" disabled={restore.isPending} onClick={restoreConfig}> <Button variant="outline" size="sm" disabled={restore.isPending} onClick={restoreConfig}>
{restore.isPending ? ( {restore.isPending ? (
<RotateCcw size={14} className="animate-spin" aria-hidden /> <RotateCcw size={14} className="animate-spin" aria-hidden />
@@ -62,6 +83,17 @@ export default function Codex() {
{restore.isPending ? t("common.restoring") : t("codex.restore")} {restore.isPending ? t("common.restoring") : t("codex.restore")}
</Button> </Button>
</div> </div>
</div>
{editing && (
<ProviderForm
target="codex"
editing={editing.provider}
pending={mutations.create.isPending || mutations.update.isPending}
onSubmit={submitForm}
onCancel={() => setEditing(null)}
/>
)}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -126,6 +158,29 @@ export default function Codex() {
<Button variant="outline" size="sm" onClick={() => apply(provider)}> <Button variant="outline" size="sm" onClick={() => apply(provider)}>
{t("codex.activate")} {t("codex.activate")}
</Button> </Button>
<Button
variant="ghost"
size="icon"
aria-label={t("common.edit")}
onClick={() => setEditing({ provider })}
>
<Pencil size={14} aria-hidden />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("common.delete")}
onClick={() => {
if (window.confirm(t("providers.deleteConfirm"))) {
mutations.remove.mutate(provider.id, {
onSuccess: () => show(t("providers.deleted")),
onError: (err: Error) => show(err.message, "error"),
});
}
}}
>
<Trash2 size={14} aria-hidden />
</Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+2
View File
@@ -41,6 +41,7 @@ const provider = {
api_key: "sk-x", api_key: "sk-x",
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" },
enabled_for_claude: false, enabled_for_claude: false,
target: "claude",
created_at: "2026-10-18T00:00:00Z", created_at: "2026-10-18T00:00:00Z",
}; };
@@ -87,6 +88,7 @@ describe("Providers page", () => {
name: "Kimi", name: "Kimi",
base_url: "https://api.moonshot.cn/v1", base_url: "https://api.moonshot.cn/v1",
api_key: "sk-kimi", api_key: "sk-kimi",
target: "claude",
}); });
}); });
+29 -253
View File
@@ -1,118 +1,33 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Plus, Trash2, Pencil } from "lucide-react"; import { Plus, Trash2, Pencil } from "lucide-react";
import { PRESETS } from "@/lib/types"; import type { Provider } from "@/lib/types";
import type { Models, Provider } from "@/lib/types";
import { useProviders, useProviderMutations } from "@/lib/queries"; import { useProviders, useProviderMutations } from "@/lib/queries";
import { useToast } from "@/components/toast"; import { useToast } from "@/components/toast";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input, Label } from "@/components/ui/input"; import { ProviderForm, type ProviderSubmitInput } from "@/components/ProviderForm";
import { Select } from "@/components/ui/select";
interface FormState {
id?: string;
name: string;
preset: string;
base_url: string;
api_key: string;
models: Omit<Models, "extra"> & { extra: { key: string; value: string }[] };
}
const emptyForm: FormState = {
name: "",
preset: "",
base_url: "",
api_key: "",
models: { default: "", sonnet: "", haiku: "", opus: "", extra: [] },
};
function toRows(extra: Record<string, string> | undefined): { key: string; value: string }[] {
return Object.entries(extra ?? {}).map(([key, value]) => ({ key, value }));
}
function toMap(rows: { key: string; value: string }[]): Record<string, string> {
return Object.fromEntries(
rows.filter((r) => r.key.trim() && r.value.trim()).map((r) => [r.key.trim(), r.value.trim()]),
);
}
export default function Providers() { export default function Providers() {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, isLoading, isError } = useProviders(); const { data, isLoading, isError } = useProviders("claude");
const mutations = useProviderMutations(); const mutations = useProviderMutations();
const { show } = useToast(); const { show } = useToast();
const [form, setForm] = useState<FormState | null>(null); const [editing, setEditing] = useState<{ provider: Provider | null } | null>(null);
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const [lastAppliedEnv, setLastAppliedEnv] = useState<Record<string, string> | null>(null); const [lastAppliedEnv, setLastAppliedEnv] = useState<Record<string, string> | null>(null);
const providers = data?.items ?? []; const providers = data?.items ?? [];
const openCreate = (presetKey = "") => { const submitForm = (input: ProviderSubmitInput) => {
const preset = PRESETS.find((p) => p.key === presetKey); const target = editing?.provider ?? null;
setErrors({});
setForm({
...emptyForm,
preset: presetKey,
name: presetKey ? presetKey : "",
base_url: preset?.base_url ?? "",
models: preset
? { ...preset.models, extra: toRows(preset.models.extra) }
: { ...emptyForm.models },
});
};
const openEdit = (provider: Provider) => {
setErrors({});
setForm({
id: provider.id,
name: provider.name,
preset: provider.preset ?? "",
base_url: provider.base_url,
api_key: "",
models: {
default: provider.models.default ?? "",
sonnet: provider.models.sonnet ?? "",
haiku: provider.models.haiku ?? "",
opus: provider.models.opus ?? "",
extra: toRows(provider.models.extra),
},
});
};
const validate = (): boolean => {
if (!form) return false;
const next: Partial<Record<keyof FormState, string>> = {};
if (!form.name.trim()) next.name = t("providers.validationName");
if (!/^https?:\/\/.+/.test(form.base_url.trim())) next.base_url = t("providers.validationBaseUrl");
if (!form.id && !form.api_key.trim()) next.api_key = t("providers.validationApiKey");
setErrors(next);
return Object.keys(next).length === 0;
};
const submit = () => {
if (!form || !validate()) return;
const input = {
name: form.name.trim(),
preset: form.preset,
base_url: form.base_url.trim(),
api_key: form.api_key.trim(),
models: {
default: form.models.default?.trim(),
sonnet: form.models.sonnet?.trim(),
haiku: form.models.haiku?.trim(),
opus: form.models.opus?.trim(),
extra: toMap(form.models.extra),
},
};
const onSuccess = () => { const onSuccess = () => {
show(form.id ? t("providers.updated") : t("providers.created")); show(target ? t("providers.updated") : t("providers.created"));
setForm(null); setEditing(null);
}; };
const onError = (err: Error) => show(err.message, "error"); const onError = (err: Error) => show(err.message, "error");
if (form.id) { if (target) {
mutations.update.mutate({ id: form.id, input }, { onSuccess, onError }); mutations.update.mutate({ id: target.id, input }, { onSuccess, onError });
} else { } else {
mutations.create.mutate(input, { onSuccess, onError }); mutations.create.mutate(input, { onSuccess, onError });
} }
@@ -126,7 +41,7 @@ export default function Providers() {
setLastAppliedEnv(result.env ?? null); setLastAppliedEnv(result.env ?? null);
show(`${t("providers.activated")}: ${provider.name}`); show(`${t("providers.activated")}: ${provider.name}`);
}, },
onError: (err) => show(err.message, "error"), onError: (err: Error) => show(err.message, "error"),
}, },
); );
}; };
@@ -138,166 +53,20 @@ export default function Providers() {
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{t("providers.title")}</h2> <h2 className="text-lg font-semibold">{t("providers.title")}</h2>
<Button size="sm" onClick={() => openCreate()}> <Button size="sm" onClick={() => setEditing({ provider: null })}>
<Plus size={14} aria-hidden /> <Plus size={14} aria-hidden />
{t("providers.add")} {t("providers.add")}
</Button> </Button>
</div> </div>
{form && ( {editing && (
<Card> <ProviderForm
<CardHeader> target="claude"
<CardTitle>{form.id ? t("providers.edit") : t("providers.add")}</CardTitle> editing={editing.provider}
</CardHeader> pending={mutations.create.isPending || mutations.update.isPending}
<CardContent className="flex flex-col gap-4"> onSubmit={submitForm}
<div className="grid grid-cols-2 gap-3"> onCancel={() => setEditing(null)}
<label className="flex flex-col gap-1">
<Label>{t("providers.name")}</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="DeepSeek"
/> />
{errors.name && <span className="text-xs text-red-600">{errors.name}</span>}
</label>
<label className="flex flex-col gap-1">
<Label>{t("providers.preset")}</Label>
<Select
value={form.preset}
onChange={(e) => {
const key = e.target.value;
const preset = PRESETS.find((p) => p.key === key);
setForm({
...form,
preset: key,
base_url: preset?.base_url ?? form.base_url,
models: preset
? { ...preset.models, extra: toRows(preset.models.extra) }
: form.models,
});
}}
>
<option value="">{t("providers.presetCustom")}</option>
{PRESETS.map((p) => (
<option key={p.key} value={p.key}>
{p.key}
</option>
))}
</Select>
</label>
<label className="flex flex-col gap-1">
<Label>{t("providers.baseUrl")}</Label>
<Input
value={form.base_url}
onChange={(e) => setForm({ ...form, base_url: e.target.value })}
placeholder="https://api.deepseek.com/v1"
/>
{errors.base_url && <span className="text-xs text-red-600">{errors.base_url}</span>}
</label>
<label className="flex flex-col gap-1">
<Label>
{t("providers.apiKey")}
{form.id && <span className="ml-1 text-zinc-400">({t("providers.keyHint")})</span>}
</Label>
<Input
type="password"
value={form.api_key}
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
placeholder="sk-..."
/>
{errors.api_key && <span className="text-xs text-red-600">{errors.api_key}</span>}
</label>
</div>
<fieldset className="flex flex-col gap-2">
<legend className="text-xs font-medium text-zinc-600">{t("providers.models")}</legend>
<div className="grid grid-cols-2 gap-3">
{(["default", "sonnet", "haiku", "opus"] as const).map((role) => (
<label key={role} className="flex flex-col gap-1">
<Label>{t(`providers.${role}Model`)}</Label>
<Input
value={form.models[role] ?? ""}
onChange={(e) =>
setForm({ ...form, models: { ...form.models, [role]: e.target.value } })
}
/>
</label>
))}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-zinc-600">{t("providers.extraMappings")}</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
setForm({
...form,
models: {
...form.models,
extra: [...form.models.extra, { key: "", value: "" }],
},
})
}
>
<Plus size={12} aria-hidden />
{t("providers.addMapping")}
</Button>
</div>
{form.models.extra.map((row, index) => (
<div key={index} className="flex items-center gap-2">
<Input
value={row.key}
placeholder={t("providers.extraKey")}
onChange={(e) => {
const extra = form.models.extra.map((r, i) =>
i === index ? { ...r, key: e.target.value } : r,
);
setForm({ ...form, models: { ...form.models, extra } });
}}
/>
<Input
value={row.value}
placeholder={t("providers.extraValue")}
onChange={(e) => {
const extra = form.models.extra.map((r, i) =>
i === index ? { ...r, value: e.target.value } : r,
);
setForm({ ...form, models: { ...form.models, extra } });
}}
/>
<Button
variant="ghost"
size="icon"
aria-label={t("common.delete")}
onClick={() =>
setForm({
...form,
models: {
...form.models,
extra: form.models.extra.filter((_, i) => i !== index),
},
})
}
>
<Trash2 size={14} aria-hidden />
</Button>
</div>
))}
<p className="text-xs text-zinc-500">{t("providers.extraHint")}</p>
</div>
</fieldset>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setForm(null)}>
{t("common.cancel")}
</Button>
<Button size="sm" onClick={submit}>
{t("common.save")}
</Button>
</div>
</CardContent>
</Card>
)} )}
{lastAppliedEnv && ( {lastAppliedEnv && (
@@ -334,7 +103,9 @@ export default function Providers() {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium">{provider.name}</span> <span className="text-sm font-medium">{provider.name}</span>
{provider.enabled_for_claude && <Badge tone="success">{t("providers.activated")}</Badge>} {provider.enabled_for_claude && (
<Badge tone="success">{t("providers.activated")}</Badge>
)}
{provider.preset && <Badge>{provider.preset}</Badge>} {provider.preset && <Badge>{provider.preset}</Badge>}
</div> </div>
<div className="truncate text-xs text-zinc-500">{provider.base_url}</div> <div className="truncate text-xs text-zinc-500">{provider.base_url}</div>
@@ -343,7 +114,12 @@ export default function Providers() {
<Button variant="outline" size="sm" onClick={() => apply(provider)}> <Button variant="outline" size="sm" onClick={() => apply(provider)}>
{t("providers.activate")} {t("providers.activate")}
</Button> </Button>
<Button variant="ghost" size="icon" aria-label={t("common.edit")} onClick={() => openEdit(provider)}> <Button
variant="ghost"
size="icon"
aria-label={t("common.edit")}
onClick={() => setEditing({ provider })}
>
<Pencil size={14} aria-hidden /> <Pencil size={14} aria-hidden />
</Button> </Button>
<Button <Button
@@ -354,7 +130,7 @@ export default function Providers() {
if (window.confirm(t("providers.deleteConfirm"))) { if (window.confirm(t("providers.deleteConfirm"))) {
mutations.remove.mutate(provider.id, { mutations.remove.mutate(provider.id, {
onSuccess: () => show(t("providers.deleted")), onSuccess: () => show(t("providers.deleted")),
onError: (err) => show(err.message, "error"), onError: (err: Error) => show(err.message, "error"),
}); });
} }
}} }}