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 {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -50,11 +50,13 @@ export interface ProviderInput {
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
models: Models;
|
||||
target: ApplyTarget;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
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) =>
|
||||
request<Provider>("/api/providers", { method: "POST", body: JSON.stringify(input) }),
|
||||
updateProvider: (id: string, input: ProviderInput) =>
|
||||
|
||||
+7
-4
@@ -3,7 +3,7 @@ import { api, type ProviderInput } from "./api";
|
||||
|
||||
export const queryKeys = {
|
||||
status: ["status"] as const,
|
||||
providers: ["providers"] as const,
|
||||
providers: (target: string) => ["providers", target] as const,
|
||||
logs: ["logs"] as const,
|
||||
backups: (target: string) => ["backups", target] as const,
|
||||
settings: ["settings"] as const,
|
||||
@@ -13,8 +13,11 @@ export function useStatus() {
|
||||
return useQuery({ queryKey: queryKeys.status, queryFn: api.getStatus, refetchInterval: 5000 });
|
||||
}
|
||||
|
||||
export function useProviders() {
|
||||
return useQuery({ queryKey: queryKeys.providers, queryFn: api.listProviders });
|
||||
export function useProviders(target: "claude" | "codex") {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.providers(target),
|
||||
queryFn: () => api.listProviders(target),
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogs() {
|
||||
@@ -31,7 +34,7 @@ export function useSettings() {
|
||||
|
||||
function invalidateAll(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.providers }),
|
||||
queryClient.invalidateQueries({ queryKey: ["providers"] }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.status }),
|
||||
queryClient.invalidateQueries({ queryKey: ["backups"] }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.settings }),
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface Provider {
|
||||
models: Models;
|
||||
enabled_for_claude: boolean;
|
||||
enabled_for_codex: boolean;
|
||||
target: "claude" | "codex";
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,15 @@ import { renderWithProviders } from "@/test/utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const listProviders = vi.fn();
|
||||
const createProvider = vi.fn();
|
||||
const applyProvider = vi.fn();
|
||||
const restore = vi.fn();
|
||||
const getStatus = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
listProviders: () => listProviders(),
|
||||
createProvider: vi.fn(),
|
||||
listProviders: (target: string) => listProviders(target),
|
||||
createProvider: (input: unknown) => createProvider(input),
|
||||
updateProvider: vi.fn(),
|
||||
deleteProvider: vi.fn(),
|
||||
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" },
|
||||
enabled_for_claude: false,
|
||||
enabled_for_codex: false,
|
||||
target: "codex",
|
||||
created_at: "2026-10-18T00:00:00Z",
|
||||
};
|
||||
|
||||
@@ -53,6 +55,7 @@ beforeEach(() => {
|
||||
port_mode: "auto",
|
||||
fixed_port: 0,
|
||||
});
|
||||
createProvider.mockResolvedValue({ ...provider, id: "p_2" });
|
||||
applyProvider.mockResolvedValue({
|
||||
provider,
|
||||
injection: {
|
||||
@@ -77,6 +80,28 @@ describe("Codex page", () => {
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<Codex />);
|
||||
|
||||
+65
-10
@@ -1,25 +1,41 @@
|
||||
import { useState } from "react";
|
||||
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 { useProviders, useProviderMutations, useRestore, useStatus } from "@/lib/queries";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ProviderForm, type ProviderSubmitInput } from "@/components/ProviderForm";
|
||||
|
||||
export default function Codex() {
|
||||
const { t } = useTranslation();
|
||||
const status = useStatus();
|
||||
const { data, isLoading, isError } = useProviders();
|
||||
const { data, isLoading, isError } = useProviders("codex");
|
||||
const mutations = useProviderMutations();
|
||||
const restore = useRestore();
|
||||
const { show } = useToast();
|
||||
const [injection, setInjection] = useState<Record<string, string> | null>(null);
|
||||
const [editing, setEditing] = useState<{ provider: Provider | null } | null>(null);
|
||||
|
||||
const providers = data?.items ?? [];
|
||||
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) => {
|
||||
mutations.apply.mutate(
|
||||
{ id: provider.id, target: "codex" },
|
||||
@@ -53,16 +69,32 @@ export default function Codex() {
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{t("codex.title")}</h2>
|
||||
<Button variant="outline" size="sm" disabled={restore.isPending} onClick={restoreConfig}>
|
||||
{restore.isPending ? (
|
||||
<RotateCcw size={14} className="animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Zap size={14} aria-hidden />
|
||||
)}
|
||||
{restore.isPending ? t("common.restoring") : t("codex.restore")}
|
||||
</Button>
|
||||
<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}>
|
||||
{restore.isPending ? (
|
||||
<RotateCcw size={14} className="animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Zap size={14} aria-hidden />
|
||||
)}
|
||||
{restore.isPending ? t("common.restoring") : t("codex.restore")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<ProviderForm
|
||||
target="codex"
|
||||
editing={editing.provider}
|
||||
pending={mutations.create.isPending || mutations.update.isPending}
|
||||
onSubmit={submitForm}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("codex.status")}</CardTitle>
|
||||
@@ -126,6 +158,29 @@ export default function Codex() {
|
||||
<Button variant="outline" size="sm" onClick={() => apply(provider)}>
|
||||
{t("codex.activate")}
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -41,6 +41,7 @@ const provider = {
|
||||
api_key: "sk-x",
|
||||
models: { default: "deepseek-chat", sonnet: "deepseek-chat", haiku: "deepseek-chat", opus: "deepseek-chat" },
|
||||
enabled_for_claude: false,
|
||||
target: "claude",
|
||||
created_at: "2026-10-18T00:00:00Z",
|
||||
};
|
||||
|
||||
@@ -87,6 +88,7 @@ describe("Providers page", () => {
|
||||
name: "Kimi",
|
||||
base_url: "https://api.moonshot.cn/v1",
|
||||
api_key: "sk-kimi",
|
||||
target: "claude",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+30
-254
@@ -1,118 +1,33 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2, Pencil } from "lucide-react";
|
||||
import { PRESETS } from "@/lib/types";
|
||||
import type { Models, Provider } from "@/lib/types";
|
||||
import type { Provider } from "@/lib/types";
|
||||
import { useProviders, useProviderMutations } from "@/lib/queries";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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";
|
||||
|
||||
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()]),
|
||||
);
|
||||
}
|
||||
import { ProviderForm, type ProviderSubmitInput } from "@/components/ProviderForm";
|
||||
|
||||
export default function Providers() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError } = useProviders();
|
||||
const { data, isLoading, isError } = useProviders("claude");
|
||||
const mutations = useProviderMutations();
|
||||
const { show } = useToast();
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
||||
const [editing, setEditing] = useState<{ provider: Provider | null } | null>(null);
|
||||
const [lastAppliedEnv, setLastAppliedEnv] = useState<Record<string, string> | null>(null);
|
||||
|
||||
const providers = data?.items ?? [];
|
||||
|
||||
const openCreate = (presetKey = "") => {
|
||||
const preset = PRESETS.find((p) => p.key === presetKey);
|
||||
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 submitForm = (input: ProviderSubmitInput) => {
|
||||
const target = editing?.provider ?? null;
|
||||
const onSuccess = () => {
|
||||
show(form.id ? t("providers.updated") : t("providers.created"));
|
||||
setForm(null);
|
||||
show(target ? t("providers.updated") : t("providers.created"));
|
||||
setEditing(null);
|
||||
};
|
||||
const onError = (err: Error) => show(err.message, "error");
|
||||
if (form.id) {
|
||||
mutations.update.mutate({ id: form.id, input }, { onSuccess, onError });
|
||||
if (target) {
|
||||
mutations.update.mutate({ id: target.id, input }, { onSuccess, onError });
|
||||
} else {
|
||||
mutations.create.mutate(input, { onSuccess, onError });
|
||||
}
|
||||
@@ -126,7 +41,7 @@ export default function Providers() {
|
||||
setLastAppliedEnv(result.env ?? null);
|
||||
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 items-center justify-between">
|
||||
<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 />
|
||||
{t("providers.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{form && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{form.id ? 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")}
|
||||
{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>
|
||||
{editing && (
|
||||
<ProviderForm
|
||||
target="claude"
|
||||
editing={editing.provider}
|
||||
pending={mutations.create.isPending || mutations.update.isPending}
|
||||
onSubmit={submitForm}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{lastAppliedEnv && (
|
||||
@@ -334,7 +103,9 @@ export default function Providers() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>}
|
||||
</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)}>
|
||||
{t("providers.activate")}
|
||||
</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 />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -354,7 +130,7 @@ export default function Providers() {
|
||||
if (window.confirm(t("providers.deleteConfirm"))) {
|
||||
mutations.remove.mutate(provider.id, {
|
||||
onSuccess: () => show(t("providers.deleted")),
|
||||
onError: (err) => show(err.message, "error"),
|
||||
onError: (err: Error) => show(err.message, "error"),
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
||||
Reference in new issue
Block a user