Add protocol selection to Claude and Codex provider forms referencing CCSwitch

This commit is contained in:
kate522 committed 2026-10-18 23:00:05 +08:00
1 parent bae24cc439
commit 16957decb2
14 files changed
+274 -25

No files matched your search

+28 -2
View File
@@ -46,7 +46,15 @@ type Models struct {
Extra map[string]string `json:"extra,omitempty"` 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 { type Provider struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -54,6 +62,8 @@ type Provider struct {
BaseURL string `json:"base_url"` BaseURL string `json:"base_url"`
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
Models Models `json:"models"` Models Models `json:"models"`
Protocol string `json:"protocol,omitempty"`
APIFormat string `json:"api_format,omitempty"`
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"` Target string `json:"target,omitempty"`
@@ -170,6 +180,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()
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 { switch c.Providers[i].Target {
case TargetClaude, TargetCodex: case TargetClaude, TargetCodex:
default: default:
@@ -206,7 +223,7 @@ func (c *Config) clone() *Config {
return &out 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) name = strings.TrimSpace(name)
if name == "" { if name == "" {
return nil, errors.New("name is required") return nil, errors.New("name is required")
@@ -231,6 +248,13 @@ func newProvider(name, preset, baseURL, apiKey string, models Models, target str
default: default:
return nil, fmt.Errorf("target must be %q or %q", TargetClaude, TargetCodex) 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{ return &Provider{
ID: newID("p_"), ID: newID("p_"),
Name: name, Name: name,
@@ -238,6 +262,8 @@ func newProvider(name, preset, baseURL, apiKey string, models Models, target str
BaseURL: strings.TrimRight(baseURL, "/"), BaseURL: strings.TrimRight(baseURL, "/"),
APIKey: apiKey, APIKey: apiKey,
Models: models, Models: models,
Protocol: proto,
APIFormat: proto,
Target: target, Target: target,
CreatedAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
}, nil }, nil
+45
View File
@@ -337,3 +337,48 @@ func TestSetGatewayValidation(t *testing.T) {
t.Error("unknown language accepted") 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)
}
}
+8 -4
View File
@@ -104,10 +104,10 @@ func (s *Store) ActiveCodex() *Provider {
} }
// AddProvider validates and appends a provider for the given target. // 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() s.mu.Lock()
defer s.mu.Unlock() 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 { if err != nil {
return nil, err 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 // UpdateProvider replaces a provider's editable fields. An empty apiKey
// keeps the stored key so edits never wipe it by accident; an empty // keeps the stored key so edits never wipe it by accident; an empty
// target keeps the stored one. // 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() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
idx := -1 idx := -1
@@ -141,7 +141,11 @@ func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models
if target == "" { if target == "" {
target = existing.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 { if err != nil {
return nil, err return nil, err
} }
+20 -8
View File
@@ -17,12 +17,14 @@ import (
// providerInput is the provider payload accepted by the management API. // providerInput is the provider payload accepted by the management API.
type providerInput struct { type providerInput struct {
Name string `json:"name"` Name string `json:"name"`
Preset string `json:"preset"` Preset string `json:"preset"`
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"` Target string `json:"target"`
Protocol string `json:"protocol"`
APIFormat string `json:"api_format"`
} }
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { 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, "name": p.Name,
"base_url": p.BaseURL, "base_url": p.BaseURL,
"models": p.Models, "models": p.Models,
"protocol": p.Protocol,
} }
} }
var activeCodex any var activeCodex any
@@ -43,6 +46,7 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
"name": p.Name, "name": p.Name,
"base_url": p.BaseURL, "base_url": p.BaseURL,
"models": p.Models, "models": p.Models,
"protocol": p.Protocol,
} }
} }
writeJSON(w, http.StatusOK, map[string]any{ 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()) 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, 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 { if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error()) writeAPIError(w, http.StatusBadRequest, err.Error())
return return
@@ -139,7 +147,11 @@ 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, 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 { if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error()) writeAPIError(w, http.StatusBadRequest, err.Error())
return return
+13 -9
View File
@@ -6,16 +6,18 @@ import "gitea.rutua.cn/kate522/CrossRelay/internal/config"
// Preset is a ready-made provider template. Model names reflect each // Preset is a ready-made provider template. Model names reflect each
// vendor's catalog as of 2026-10 and can be edited per provider. // vendor's catalog as of 2026-10 and can be edited per provider.
type Preset struct { type Preset struct {
Key string `json:"key"` Key string `json:"key"`
BaseURL string `json:"base_url"` BaseURL string `json:"base_url"`
Models config.Models `json:"models"` Protocol string `json:"protocol,omitempty"`
Models config.Models `json:"models"`
} }
// All lists the built-in presets. // All lists the built-in presets.
var All = []Preset{ var All = []Preset{
{ {
Key: "deepseek", Key: "deepseek",
BaseURL: "https://api.deepseek.com/v1", BaseURL: "https://api.deepseek.com/v1",
Protocol: config.ProtocolOpenAIChat,
Models: config.Models{ Models: config.Models{
Default: "deepseek-chat", Default: "deepseek-chat",
Sonnet: "deepseek-chat", Sonnet: "deepseek-chat",
@@ -25,8 +27,9 @@ var All = []Preset{
}, },
}, },
{ {
Key: "moonshot", Key: "moonshot",
BaseURL: "https://api.moonshot.cn/v1", BaseURL: "https://api.moonshot.cn/v1",
Protocol: config.ProtocolOpenAIChat,
Models: config.Models{ Models: config.Models{
Default: "kimi-k2-0905-preview", Default: "kimi-k2-0905-preview",
Sonnet: "kimi-k2-0905-preview", Sonnet: "kimi-k2-0905-preview",
@@ -36,8 +39,9 @@ var All = []Preset{
}, },
}, },
{ {
Key: "zhipu", Key: "zhipu",
BaseURL: "https://open.bigmodel.cn/api/paas/v4", BaseURL: "https://open.bigmodel.cn/api/paas/v4",
Protocol: config.ProtocolOpenAIChat,
Models: config.Models{ Models: config.Models{
Default: "glm-4.6", Default: "glm-4.6",
Sonnet: "glm-4.6", Sonnet: "glm-4.6",
+46 -2
View File
@@ -14,6 +14,7 @@ import { useToast } from "@/components/toast";
export interface ProviderSubmitInput { export interface ProviderSubmitInput {
name: string; name: string;
preset: string; preset: string;
protocol: string;
base_url: string; base_url: string;
api_key: string; api_key: string;
models: Models; models: Models;
@@ -23,6 +24,7 @@ export interface ProviderSubmitInput {
interface FormState { interface FormState {
name: string; name: string;
preset: string; preset: string;
protocol: string;
base_url: string; base_url: string;
api_key: string; api_key: string;
models: Omit<Models, "extra"> & { extra: { key: string; value: string }[] }; models: Omit<Models, "extra"> & { extra: { key: string; value: string }[] };
@@ -31,6 +33,7 @@ interface FormState {
const emptyForm: FormState = { const emptyForm: FormState = {
name: "", name: "",
preset: "", preset: "",
protocol: "openai_chat",
base_url: "", base_url: "",
api_key: "", api_key: "",
models: { default: "", sonnet: "", haiku: "", opus: "", extra: [] }, models: { default: "", sonnet: "", haiku: "", opus: "", extra: [] },
@@ -54,15 +57,31 @@ interface ProviderFormProps {
onCancel: () => void; 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) { export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: ProviderFormProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { show } = useToast(); const { show } = useToast();
const uid = useId(); const uid = useId();
const defaultProtocol = "openai_chat";
const [form, setForm] = useState<FormState>(() => const [form, setForm] = useState<FormState>(() =>
editing editing
? { ? {
name: editing.name, name: editing.name,
preset: editing.preset ?? "", preset: editing.preset ?? "",
protocol: editing.protocol ?? editing.api_format ?? defaultProtocol,
base_url: editing.base_url, base_url: editing.base_url,
api_key: "", api_key: "",
models: { models: {
@@ -73,7 +92,7 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
extra: toRows(editing.models.extra), extra: toRows(editing.models.extra),
}, },
} }
: { ...emptyForm }, : { ...emptyForm, protocol: defaultProtocol },
); );
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({}); const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const [modelList, setModelList] = useState<{ id: string }[]>([]); 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 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 fetchModels = async () => {
const baseUrl = form.base_url.trim(); const baseUrl = form.base_url.trim();
const apiKey = form.api_key.trim() || editing?.api_key || ""; const apiKey = form.api_key.trim() || editing?.api_key || "";
@@ -114,6 +139,7 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
onSubmit({ onSubmit({
name: form.name.trim(), name: form.name.trim(),
preset: form.preset, preset: form.preset,
protocol: form.protocol,
base_url: form.base_url.trim(), base_url: form.base_url.trim(),
api_key: form.api_key.trim(), api_key: form.api_key.trim(),
models: { models: {
@@ -153,6 +179,7 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
setForm({ setForm({
...form, ...form,
preset: key, preset: key,
protocol: preset?.protocol ?? form.protocol,
base_url: preset?.base_url ?? form.base_url, base_url: preset?.base_url ?? form.base_url,
models: preset models: preset
? { ...preset.models, extra: toRows(preset.models.extra) } ? { ...preset.models, extra: toRows(preset.models.extra) }
@@ -168,6 +195,20 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
))} ))}
</Select> </Select>
</label> </label>
<label className="flex flex-col gap-1">
<Label>{t("providers.protocol")}</Label>
<Select
aria-label={t("providers.protocol")}
value={form.protocol}
onChange={(e) => setForm({ ...form, protocol: e.target.value })}
>
{finalProtocolOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.labelKey.startsWith("providers.") ? t(opt.labelKey) : opt.labelKey}
</option>
))}
</Select>
</label>
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
<Label>{t("providers.baseUrl")}</Label> <Label>{t("providers.baseUrl")}</Label>
<Input <Input
@@ -177,7 +218,7 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
/> />
{errors.base_url && <span className="text-xs text-red-600">{errors.base_url}</span>} {errors.base_url && <span className="text-xs text-red-600">{errors.base_url}</span>}
</label> </label>
<label className="flex flex-col gap-1"> <label className="col-span-2 flex flex-col gap-1">
<Label> <Label>
{t("providers.apiKey")} {t("providers.apiKey")}
{editing && <span className="ml-1 text-zinc-400">({t("providers.keyHint")})</span>} {editing && <span className="ml-1 text-zinc-400">({t("providers.keyHint")})</span>}
@@ -191,6 +232,9 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
{errors.api_key && <span className="text-xs text-red-600">{errors.api_key}</span>} {errors.api_key && <span className="text-xs text-red-600">{errors.api_key}</span>}
</label> </label>
</div> </div>
<p className="text-xs text-zinc-500">
{target === "claude" ? t("providers.protocolHintClaude") : t("providers.protocolHintCodex")}
</p>
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2">
<legend className="text-xs font-medium text-zinc-600">{t("providers.models")}</legend> <legend className="text-xs font-medium text-zinc-600">{t("providers.models")}</legend>
+20
View File
@@ -65,6 +65,16 @@ export const zhCN = {
apiKey: "API Key", apiKey: "API Key",
preset: "预设", preset: "预设",
presetCustom: "自定义", presetCustom: "自定义",
protocol: "协议选择",
protocolHintClaude: "供应商原生支持的协议:Anthropic Messages 原生无需转换;其他协议经由网关转换。",
protocolHintCodex: "供应商原生支持的协议:Responses 原生无转换;Chat 与 Anthropic 经网关转换为 Responses。",
protocolAnthropicClaude: "Anthropic Messages (原生)",
protocolOpenAIChatClaude: "OpenAI Chat Completions (需转换)",
protocolOpenAIResponsesClaude: "OpenAI Responses API (需转换)",
protocolGeminiNativeClaude: "Gemini Native generateContent (需转换)",
protocolOpenAIResponsesCodex: "OpenAI Responses API (原生)",
protocolOpenAIChatCodex: "OpenAI Chat Completions (需转换)",
protocolAnthropicCodex: "Anthropic Messages (需转换)",
models: "模型映射", models: "模型映射",
defaultModel: "默认模型", defaultModel: "默认模型",
sonnetModel: "Sonnet 模型", sonnetModel: "Sonnet 模型",
@@ -198,6 +208,16 @@ export const enUS = {
apiKey: "API key", apiKey: "API key",
preset: "Preset", preset: "Preset",
presetCustom: "Custom", presetCustom: "Custom",
protocol: "Protocol",
protocolHintClaude: "Native protocol supported by provider: Anthropic Messages is native without conversion; other protocols are converted by the gateway.",
protocolHintCodex: "Native protocol supported by provider: Responses is native without conversion; Chat and Anthropic are converted to Responses by the gateway.",
protocolAnthropicClaude: "Anthropic Messages (Native)",
protocolOpenAIChatClaude: "OpenAI Chat Completions (Routing required)",
protocolOpenAIResponsesClaude: "OpenAI Responses API (Routing required)",
protocolGeminiNativeClaude: "Gemini Native generateContent (Routing required)",
protocolOpenAIResponsesCodex: "OpenAI Responses API (Native)",
protocolOpenAIChatCodex: "OpenAI Chat Completions (Routing required)",
protocolAnthropicCodex: "Anthropic Messages (Routing required)",
models: "Model mapping", models: "Model mapping",
defaultModel: "Default model", defaultModel: "Default model",
sonnetModel: "Sonnet model", sonnetModel: "Sonnet model",
+2
View File
@@ -48,6 +48,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
export interface ProviderInput { export interface ProviderInput {
name: string; name: string;
preset?: string; preset?: string;
protocol?: string;
api_format?: string;
base_url: string; base_url: string;
api_key: string; api_key: string;
models: Models; models: Models;
+20
View File
@@ -6,6 +6,19 @@ export interface Models {
extra?: Record<string, string>; extra?: Record<string, string>;
} }
export type ClaudeProtocol =
| "anthropic"
| "openai_chat"
| "openai_responses"
| "gemini_native";
export type CodexProtocol =
| "openai_responses"
| "openai_chat"
| "anthropic";
export type ProviderProtocol = ClaudeProtocol | CodexProtocol;
export interface Provider { export interface Provider {
id: string; id: string;
name: string; name: string;
@@ -13,6 +26,8 @@ export interface Provider {
base_url: string; base_url: string;
api_key: string; api_key: string;
models: Models; models: Models;
protocol?: string;
api_format?: string;
enabled_for_claude: boolean; enabled_for_claude: boolean;
enabled_for_codex: boolean; enabled_for_codex: boolean;
target: "claude" | "codex"; target: "claude" | "codex";
@@ -31,6 +46,7 @@ export interface ActiveProvider {
name: string; name: string;
base_url: string; base_url: string;
models: Models; models: Models;
protocol?: string;
} }
export interface Status { export interface Status {
@@ -91,6 +107,7 @@ export interface Injection {
export interface Preset { export interface Preset {
key: string; key: string;
base_url: string; base_url: string;
protocol?: string;
models: Models; models: Models;
} }
@@ -100,6 +117,7 @@ export const PRESETS: Preset[] = [
{ {
key: "deepseek", key: "deepseek",
base_url: "https://api.deepseek.com/v1", base_url: "https://api.deepseek.com/v1",
protocol: "openai_chat",
models: withCodexExtras({ models: withCodexExtras({
default: "deepseek-chat", default: "deepseek-chat",
sonnet: "deepseek-chat", sonnet: "deepseek-chat",
@@ -110,6 +128,7 @@ export const PRESETS: Preset[] = [
{ {
key: "moonshot", key: "moonshot",
base_url: "https://api.moonshot.cn/v1", base_url: "https://api.moonshot.cn/v1",
protocol: "openai_chat",
models: withCodexExtras( models: withCodexExtras(
{ {
default: "kimi-k2-0905-preview", default: "kimi-k2-0905-preview",
@@ -123,6 +142,7 @@ export const PRESETS: Preset[] = [
{ {
key: "zhipu", key: "zhipu",
base_url: "https://open.bigmodel.cn/api/paas/v4", base_url: "https://open.bigmodel.cn/api/paas/v4",
protocol: "openai_chat",
models: withCodexExtras( models: withCodexExtras(
{ {
default: "glm-4.6", default: "glm-4.6",
+15
View File
@@ -4,3 +4,18 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
export function formatProtocolBadge(protocol?: string): string {
switch (protocol) {
case "anthropic":
return "Anthropic";
case "openai_chat":
return "Chat";
case "openai_responses":
return "Responses";
case "gemini_native":
return "Gemini";
default:
return protocol ?? "";
}
}
+24
View File
@@ -139,4 +139,28 @@ describe("Codex page", () => {
await user.click(await screen.findByRole("button", { name: /还原官方配置|Restore official config/ })); await user.click(await screen.findByRole("button", { name: /还原官方配置|Restore official config/ }));
await waitFor(() => expect(restore).toHaveBeenCalledWith({ target: "codex" })); await waitFor(() => expect(restore).toHaveBeenCalledWith({ target: "codex" }));
}); });
it("renders protocol options and submits selected protocol for Codex", async () => {
const user = userEvent.setup();
renderWithProviders(<Codex />);
await user.click(await screen.findByRole("button", { name: /新增供应商|Add provider/ }));
const select = screen.getByLabelText(/协议选择|Protocol/);
expect(select).toBeInTheDocument();
expect(screen.getByRole("option", { name: /OpenAI Responses API/ })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /OpenAI Chat Completions/ })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /Anthropic Messages/ })).toBeInTheDocument();
await user.selectOptions(select, "openai_responses");
await user.type(screen.getByLabelText(/名称|Name/), "OpenAI-Direct");
await user.type(screen.getByLabelText(/Base URL/), "https://api.openai.com/v1");
await user.type(screen.getByLabelText(/API Key/), "sk-oa");
await user.click(screen.getByRole("button", { name: /保存|Save/ }));
await waitFor(() => expect(createProvider).toHaveBeenCalledTimes(1));
const input = createProvider.mock.calls[0][0];
expect(input).toMatchObject({
name: "OpenAI-Direct",
protocol: "openai_responses",
target: "codex",
});
});
}); });
+4
View File
@@ -8,6 +8,7 @@ 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"; import { ProviderForm, type ProviderSubmitInput } from "@/components/ProviderForm";
import { formatProtocolBadge } from "@/lib/utils";
export default function Codex() { export default function Codex() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -151,6 +152,9 @@ export default function Codex() {
<span className="text-sm font-medium">{provider.name}</span> <span className="text-sm font-medium">{provider.name}</span>
{provider.enabled_for_codex && <Badge tone="success">{t("codex.activated")}</Badge>} {provider.enabled_for_codex && <Badge tone="success">{t("codex.activated")}</Badge>}
{provider.preset && <Badge>{provider.preset}</Badge>} {provider.preset && <Badge>{provider.preset}</Badge>}
{provider.protocol && (
<Badge>{formatProtocolBadge(provider.protocol)}</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>
</div> </div>
+25
View File
@@ -143,4 +143,29 @@ describe("Providers page", () => {
const input = createProvider.mock.calls[0][0]; const input = createProvider.mock.calls[0][0];
expect(input.models.extra).toEqual({ "gpt-5-codex": "deepseek-chat" }); expect(input.models.extra).toEqual({ "gpt-5-codex": "deepseek-chat" });
}); });
it("renders protocol options and submits selected protocol for Claude", async () => {
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole("button", { name: /新增供应商|Add provider/ }));
const select = screen.getByLabelText(/协议选择|Protocol/);
expect(select).toBeInTheDocument();
expect(screen.getByRole("option", { name: /Anthropic Messages/ })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /OpenAI Chat Completions/ })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /OpenAI Responses API/ })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /Gemini Native/ })).toBeInTheDocument();
await user.selectOptions(select, "anthropic");
await user.type(screen.getByLabelText(/名称|Name/), "Claude-Native");
await user.type(screen.getByLabelText(/Base URL/), "https://api.anthropic.com/v1");
await user.type(screen.getByLabelText(/API Key/), "sk-ant");
await user.click(screen.getByRole("button", { name: /保存|Save/ }));
await waitFor(() => expect(createProvider).toHaveBeenCalledTimes(1));
const input = createProvider.mock.calls[0][0];
expect(input).toMatchObject({
name: "Claude-Native",
protocol: "anthropic",
target: "claude",
});
});
}); });
+4
View File
@@ -8,6 +8,7 @@ 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"; import { ProviderForm, type ProviderSubmitInput } from "@/components/ProviderForm";
import { formatProtocolBadge } from "@/lib/utils";
export default function Providers() { export default function Providers() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -107,6 +108,9 @@ export default function Providers() {
<Badge tone="success">{t("providers.activated")}</Badge> <Badge tone="success">{t("providers.activated")}</Badge>
)} )}
{provider.preset && <Badge>{provider.preset}</Badge>} {provider.preset && <Badge>{provider.preset}</Badge>}
{provider.protocol && (
<Badge>{formatProtocolBadge(provider.protocol)}</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>
</div> </div>