diff --git a/internal/config/config.go b/internal/config/config.go index 613ee9f..22435f0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,7 +46,15 @@ type Models struct { Extra map[string]string `json:"extra,omitempty"` } -// Provider is one upstream Chat Completions endpoint. +// Supported upstream protocols (matching CCSwitch format identifiers). +const ( + ProtocolAnthropic = "anthropic" + ProtocolOpenAIChat = "openai_chat" + ProtocolOpenAIResponses = "openai_responses" + ProtocolGeminiNative = "gemini_native" +) + +// Provider is one upstream endpoint. type Provider struct { ID string `json:"id"` Name string `json:"name"` @@ -54,6 +62,8 @@ type Provider struct { BaseURL string `json:"base_url"` APIKey string `json:"api_key"` Models Models `json:"models"` + Protocol string `json:"protocol,omitempty"` + APIFormat string `json:"api_format,omitempty"` EnabledForClaude bool `json:"enabled_for_claude"` EnabledForCodex bool `json:"enabled_for_codex"` Target string `json:"target,omitempty"` @@ -170,6 +180,13 @@ func (c *Config) normalize() { } for i := range c.Providers { c.Providers[i].Models.normalize() + if c.Providers[i].Protocol == "" && c.Providers[i].APIFormat != "" { + c.Providers[i].Protocol = c.Providers[i].APIFormat + } + if c.Providers[i].Protocol == "" { + c.Providers[i].Protocol = ProtocolOpenAIChat + } + c.Providers[i].APIFormat = c.Providers[i].Protocol switch c.Providers[i].Target { case TargetClaude, TargetCodex: default: @@ -206,7 +223,7 @@ func (c *Config) clone() *Config { return &out } -func newProvider(name, preset, baseURL, apiKey string, models Models, target string) (*Provider, error) { +func newProvider(name, preset, baseURL, apiKey string, models Models, target string, protocol ...string) (*Provider, error) { name = strings.TrimSpace(name) if name == "" { return nil, errors.New("name is required") @@ -231,6 +248,13 @@ func newProvider(name, preset, baseURL, apiKey string, models Models, target str default: return nil, fmt.Errorf("target must be %q or %q", TargetClaude, TargetCodex) } + proto := "" + if len(protocol) > 0 { + proto = strings.TrimSpace(protocol[0]) + } + if proto == "" { + proto = ProtocolOpenAIChat + } return &Provider{ ID: newID("p_"), Name: name, @@ -238,6 +262,8 @@ func newProvider(name, preset, baseURL, apiKey string, models Models, target str BaseURL: strings.TrimRight(baseURL, "/"), APIKey: apiKey, Models: models, + Protocol: proto, + APIFormat: proto, Target: target, CreatedAt: time.Now().UTC(), }, nil diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4ee6d88..dad7fbf 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -337,3 +337,48 @@ func TestSetGatewayValidation(t *testing.T) { t.Error("unknown language accepted") } } + +func TestProviderProtocol(t *testing.T) { + t.Setenv("CROSSRELAY_HOME", t.TempDir()) + store, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + + // 1. Default protocol when omitted should be openai_chat. + p1, err := store.AddProvider("Claude-Default", "", "https://api.example.com/v1", "sk-1", Models{}, TargetClaude) + if err != nil { + t.Fatalf("AddProvider: %v", err) + } + if p1.Protocol != ProtocolOpenAIChat || p1.APIFormat != ProtocolOpenAIChat { + t.Errorf("expected default protocol %q, got protocol=%q, api_format=%q", ProtocolOpenAIChat, p1.Protocol, p1.APIFormat) + } + + // 2. Explicit protocol should be saved. + p2, err := store.AddProvider("Claude-Anthropic", "", "https://api.anthropic.com/v1", "sk-2", Models{}, TargetClaude, ProtocolAnthropic) + if err != nil { + t.Fatalf("AddProvider with protocol: %v", err) + } + if p2.Protocol != ProtocolAnthropic { + t.Errorf("expected protocol %q, got %q", ProtocolAnthropic, p2.Protocol) + } + + // 3. Update protocol. + updated, err := store.UpdateProvider(p2.ID, "Claude-Anthropic-Updated", "", "https://api.anthropic.com/v1", "sk-2", Models{}, TargetClaude, ProtocolOpenAIResponses) + if err != nil { + t.Fatalf("UpdateProvider with protocol: %v", err) + } + if updated.Protocol != ProtocolOpenAIResponses { + t.Errorf("expected updated protocol %q, got %q", ProtocolOpenAIResponses, updated.Protocol) + } + + // 4. Persistence round-trip. + reloaded, err := Load() + if err != nil { + t.Fatalf("Load reloaded: %v", err) + } + found := reloaded.FindProvider(p2.ID) + if found == nil || found.Protocol != ProtocolOpenAIResponses { + t.Errorf("expected persisted protocol %q, got %+v", ProtocolOpenAIResponses, found) + } +} diff --git a/internal/config/store.go b/internal/config/store.go index b80d34a..11c9f7f 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -104,10 +104,10 @@ func (s *Store) ActiveCodex() *Provider { } // 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) { +func (s *Store) AddProvider(name, preset, baseURL, apiKey string, models Models, target string, protocol ...string) (*Provider, error) { s.mu.Lock() defer s.mu.Unlock() - p, err := newProvider(name, preset, baseURL, apiKey, models, target) + p, err := newProvider(name, preset, baseURL, apiKey, models, target, protocol...) if err != nil { return nil, err } @@ -121,7 +121,7 @@ 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; an empty // target keeps the stored one. -func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models Models, target string) (*Provider, error) { +func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models Models, target string, protocol ...string) (*Provider, error) { s.mu.Lock() defer s.mu.Unlock() idx := -1 @@ -141,7 +141,11 @@ func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models if target == "" { target = existing.Target } - updated, err := newProvider(name, preset, baseURL, apiKey, models, target) + proto := existing.Protocol + if len(protocol) > 0 && strings.TrimSpace(protocol[0]) != "" { + proto = strings.TrimSpace(protocol[0]) + } + updated, err := newProvider(name, preset, baseURL, apiKey, models, target, proto) if err != nil { return nil, err } diff --git a/internal/gateway/handlers.go b/internal/gateway/handlers.go index 4068a3d..8265ebb 100644 --- a/internal/gateway/handlers.go +++ b/internal/gateway/handlers.go @@ -17,12 +17,14 @@ import ( // providerInput is the provider payload accepted by the management API. type providerInput struct { - Name string `json:"name"` - Preset string `json:"preset"` - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` - Models config.Models `json:"models"` - Target string `json:"target"` + Name string `json:"name"` + Preset string `json:"preset"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Models config.Models `json:"models"` + Target string `json:"target"` + Protocol string `json:"protocol"` + APIFormat string `json:"api_format"` } func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { @@ -34,6 +36,7 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { "name": p.Name, "base_url": p.BaseURL, "models": p.Models, + "protocol": p.Protocol, } } var activeCodex any @@ -43,6 +46,7 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { "name": p.Name, "base_url": p.BaseURL, "models": p.Models, + "protocol": p.Protocol, } } writeJSON(w, http.StatusOK, map[string]any{ @@ -124,7 +128,11 @@ 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, in.Target) + proto := strings.TrimSpace(in.Protocol) + if proto == "" { + proto = strings.TrimSpace(in.APIFormat) + } + p, err := s.store.AddProvider(in.Name, in.Preset, in.BaseURL, in.APIKey, in.Models, in.Target, proto) if err != nil { writeAPIError(w, http.StatusBadRequest, err.Error()) return @@ -139,7 +147,11 @@ 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, in.Target) + proto := strings.TrimSpace(in.Protocol) + if proto == "" { + proto = strings.TrimSpace(in.APIFormat) + } + p, err := s.store.UpdateProvider(id, in.Name, in.Preset, in.BaseURL, in.APIKey, in.Models, in.Target, proto) if err != nil { writeAPIError(w, http.StatusBadRequest, err.Error()) return diff --git a/internal/presets/presets.go b/internal/presets/presets.go index b078b22..a07f94d 100644 --- a/internal/presets/presets.go +++ b/internal/presets/presets.go @@ -6,16 +6,18 @@ import "gitea.rutua.cn/kate522/CrossRelay/internal/config" // Preset is a ready-made provider template. Model names reflect each // vendor's catalog as of 2026-10 and can be edited per provider. type Preset struct { - Key string `json:"key"` - BaseURL string `json:"base_url"` - Models config.Models `json:"models"` + Key string `json:"key"` + BaseURL string `json:"base_url"` + Protocol string `json:"protocol,omitempty"` + Models config.Models `json:"models"` } // All lists the built-in presets. var All = []Preset{ { - Key: "deepseek", - BaseURL: "https://api.deepseek.com/v1", + Key: "deepseek", + BaseURL: "https://api.deepseek.com/v1", + Protocol: config.ProtocolOpenAIChat, Models: config.Models{ Default: "deepseek-chat", Sonnet: "deepseek-chat", @@ -25,8 +27,9 @@ var All = []Preset{ }, }, { - Key: "moonshot", - BaseURL: "https://api.moonshot.cn/v1", + Key: "moonshot", + BaseURL: "https://api.moonshot.cn/v1", + Protocol: config.ProtocolOpenAIChat, Models: config.Models{ Default: "kimi-k2-0905-preview", Sonnet: "kimi-k2-0905-preview", @@ -36,8 +39,9 @@ var All = []Preset{ }, }, { - Key: "zhipu", - BaseURL: "https://open.bigmodel.cn/api/paas/v4", + Key: "zhipu", + BaseURL: "https://open.bigmodel.cn/api/paas/v4", + Protocol: config.ProtocolOpenAIChat, Models: config.Models{ Default: "glm-4.6", Sonnet: "glm-4.6", diff --git a/src/components/ProviderForm.tsx b/src/components/ProviderForm.tsx index c9792d7..45ea614 100644 --- a/src/components/ProviderForm.tsx +++ b/src/components/ProviderForm.tsx @@ -14,6 +14,7 @@ import { useToast } from "@/components/toast"; export interface ProviderSubmitInput { name: string; preset: string; + protocol: string; base_url: string; api_key: string; models: Models; @@ -23,6 +24,7 @@ export interface ProviderSubmitInput { interface FormState { name: string; preset: string; + protocol: string; base_url: string; api_key: string; models: Omit & { extra: { key: string; value: string }[] }; @@ -31,6 +33,7 @@ interface FormState { const emptyForm: FormState = { name: "", preset: "", + protocol: "openai_chat", base_url: "", api_key: "", models: { default: "", sonnet: "", haiku: "", opus: "", extra: [] }, @@ -54,15 +57,31 @@ interface ProviderFormProps { onCancel: () => void; } +const CLAUDE_PROTOCOLS = [ + { value: "anthropic", labelKey: "providers.protocolAnthropicClaude" }, + { value: "openai_chat", labelKey: "providers.protocolOpenAIChatClaude" }, + { value: "openai_responses", labelKey: "providers.protocolOpenAIResponsesClaude" }, + { value: "gemini_native", labelKey: "providers.protocolGeminiNativeClaude" }, +]; + +const CODEX_PROTOCOLS = [ + { value: "openai_responses", labelKey: "providers.protocolOpenAIResponsesCodex" }, + { value: "openai_chat", labelKey: "providers.protocolOpenAIChatCodex" }, + { value: "anthropic", labelKey: "providers.protocolAnthropicCodex" }, +]; + export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: ProviderFormProps) { const { t } = useTranslation(); const { show } = useToast(); const uid = useId(); + const defaultProtocol = "openai_chat"; + const [form, setForm] = useState(() => editing ? { name: editing.name, preset: editing.preset ?? "", + protocol: editing.protocol ?? editing.api_format ?? defaultProtocol, base_url: editing.base_url, api_key: "", models: { @@ -73,7 +92,7 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P extra: toRows(editing.models.extra), }, } - : { ...emptyForm }, + : { ...emptyForm, protocol: defaultProtocol }, ); const [errors, setErrors] = useState>>({}); const [modelList, setModelList] = useState<{ id: string }[]>([]); @@ -81,6 +100,12 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P const listId = (name: string) => `${uid}-${name}`; + const protocolOptions = target === "claude" ? CLAUDE_PROTOCOLS : CODEX_PROTOCOLS; + const hasCurrentProtocol = protocolOptions.some((p) => p.value === form.protocol); + const finalProtocolOptions = hasCurrentProtocol || !form.protocol + ? protocolOptions + : [...protocolOptions, { value: form.protocol, labelKey: form.protocol }]; + const fetchModels = async () => { const baseUrl = form.base_url.trim(); const apiKey = form.api_key.trim() || editing?.api_key || ""; @@ -114,6 +139,7 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P onSubmit({ name: form.name.trim(), preset: form.preset, + protocol: form.protocol, base_url: form.base_url.trim(), api_key: form.api_key.trim(), models: { @@ -153,6 +179,7 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P setForm({ ...form, preset: key, + protocol: preset?.protocol ?? form.protocol, base_url: preset?.base_url ?? form.base_url, models: preset ? { ...preset.models, extra: toRows(preset.models.extra) } @@ -168,6 +195,20 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P ))} + -