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
+581
-295
No files matched your search
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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;
|
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
@@ -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 }),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in new issue
Block a user