diff --git a/internal/config/config.go b/internal/config/config.go index 3fba003..c7fb9b2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b739c1e..5da803d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) } diff --git a/internal/config/store.go b/internal/config/store.go index 6d6e8c9..b80d34a 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -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 { diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index ec0ed80..2e80822 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -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) + } +} diff --git a/internal/gateway/handlers.go b/internal/gateway/handlers.go index 8fab299..c459243 100644 --- a/internal/gateway/handlers.go +++ b/internal/gateway/handlers.go @@ -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 diff --git a/src/components/ProviderForm.tsx b/src/components/ProviderForm.tsx new file mode 100644 index 0000000..65ea9c2 --- /dev/null +++ b/src/components/ProviderForm.tsx @@ -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 & { extra: { key: string; value: string }[] }; +} + +const emptyForm: FormState = { + name: "", + preset: "", + base_url: "", + api_key: "", + models: { default: "", sonnet: "", haiku: "", opus: "", extra: [] }, +}; + +function toRows(extra: Record | undefined): { key: string; value: string }[] { + return Object.entries(extra ?? {}).map(([key, value]) => ({ key, value })); +} + +function toMap(rows: { key: string; value: string }[]): Record { + 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(() => + 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>>({}); + + const validate = (): boolean => { + const next: Partial> = {}; + 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 ( + + + {editing ? t("providers.edit") : t("providers.add")} + + +
+ + + + +
+ +
+ {t("providers.models")} +
+ {(["default", "sonnet", "haiku", "opus"] as const).map((role) => ( + + ))} +
+
+
+ {t("providers.extraMappings")} + +
+ {form.models.extra.map((row, index) => ( +
+ { + const extra = form.models.extra.map((r, i) => + i === index ? { ...r, key: e.target.value } : r, + ); + setForm({ ...form, models: { ...form.models, extra } }); + }} + /> + { + const extra = form.models.extra.map((r, i) => + i === index ? { ...r, value: e.target.value } : r, + ); + setForm({ ...form, models: { ...form.models, extra } }); + }} + /> + +
+ ))} +

{t("providers.extraHint")}

+
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 3e74c3e..32df5e8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -50,11 +50,13 @@ export interface ProviderInput { base_url: string; api_key: string; models: Models; + target: ApplyTarget; } export const api = { getStatus: () => request("/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("/api/providers", { method: "POST", body: JSON.stringify(input) }), updateProvider: (id: string, input: ProviderInput) => diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 712e5a3..2beb63b 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -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) { 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 }), diff --git a/src/lib/types.ts b/src/lib/types.ts index 271f738..bd55cd4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -15,6 +15,7 @@ export interface Provider { models: Models; enabled_for_claude: boolean; enabled_for_codex: boolean; + target: "claude" | "codex"; created_at: string; } diff --git a/src/pages/Codex.test.tsx b/src/pages/Codex.test.tsx index b07c69e..0b11db4 100644 --- a/src/pages/Codex.test.tsx +++ b/src/pages/Codex.test.tsx @@ -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(); + await screen.findByText("DeepSeek"); + expect(listProviders).toHaveBeenCalledWith("codex"); + }); + + it("creates a provider scoped to Codex", async () => { + const user = userEvent.setup(); + renderWithProviders(); + 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(); diff --git a/src/pages/Codex.tsx b/src/pages/Codex.tsx index e8b3a5b..7ef6ab5 100644 --- a/src/pages/Codex.tsx +++ b/src/pages/Codex.tsx @@ -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 | 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() {

{t("codex.title")}

- +
+ + +
+ {editing && ( + setEditing(null)} + /> + )} + {t("codex.status")} @@ -126,6 +158,29 @@ export default function Codex() { + +
diff --git a/src/pages/Providers.test.tsx b/src/pages/Providers.test.tsx index a4f3949..cd97502 100644 --- a/src/pages/Providers.test.tsx +++ b/src/pages/Providers.test.tsx @@ -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", }); }); diff --git a/src/pages/Providers.tsx b/src/pages/Providers.tsx index c220912..e398766 100644 --- a/src/pages/Providers.tsx +++ b/src/pages/Providers.tsx @@ -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 & { extra: { key: string; value: string }[] }; -} - -const emptyForm: FormState = { - name: "", - preset: "", - base_url: "", - api_key: "", - models: { default: "", sonnet: "", haiku: "", opus: "", extra: [] }, -}; - -function toRows(extra: Record | undefined): { key: string; value: string }[] { - return Object.entries(extra ?? {}).map(([key, value]) => ({ key, value })); -} - -function toMap(rows: { key: string; value: string }[]): Record { - 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(null); - const [errors, setErrors] = useState>>({}); + const [editing, setEditing] = useState<{ provider: Provider | null } | null>(null); const [lastAppliedEnv, setLastAppliedEnv] = useState | 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> = {}; - 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() {

{t("providers.title")}

-
- {form && ( - - - {form.id ? t("providers.edit") : t("providers.add")} - - -
- - - - -
- -
- {t("providers.models")} -
- {(["default", "sonnet", "haiku", "opus"] as const).map((role) => ( - - ))} -
-
-
- {t("providers.extraMappings")} - -
- {form.models.extra.map((row, index) => ( -
- { - const extra = form.models.extra.map((r, i) => - i === index ? { ...r, key: e.target.value } : r, - ); - setForm({ ...form, models: { ...form.models, extra } }); - }} - /> - { - const extra = form.models.extra.map((r, i) => - i === index ? { ...r, value: e.target.value } : r, - ); - setForm({ ...form, models: { ...form.models, extra } }); - }} - /> - -
- ))} -

{t("providers.extraHint")}

-
-
- -
- - -
-
-
+ {editing && ( + setEditing(null)} + /> )} {lastAppliedEnv && ( @@ -334,7 +103,9 @@ export default function Providers() {
{provider.name} - {provider.enabled_for_claude && {t("providers.activated")}} + {provider.enabled_for_claude && ( + {t("providers.activated")} + )} {provider.preset && {provider.preset}}
{provider.base_url}
@@ -343,7 +114,12 @@ export default function Providers() { -