Add protocol selection to Claude and Codex provider forms referencing CCSwitch
This commit is contained in:
1 parent
bae24cc439
commit
16957decb2
14 files changed
+274
-25
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Models, "extra"> & { 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<FormState>(() =>
|
||||
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<Partial<Record<keyof FormState, 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 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
|
||||
))}
|
||||
</Select>
|
||||
</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>{t("providers.baseUrl")}</Label>
|
||||
<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>}
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<label className="col-span-2 flex flex-col gap-1">
|
||||
<Label>
|
||||
{t("providers.apiKey")}
|
||||
{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>}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{target === "claude" ? t("providers.protocolHintClaude") : t("providers.protocolHintCodex")}
|
||||
</p>
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-xs font-medium text-zinc-600">{t("providers.models")}</legend>
|
||||
|
||||
@@ -65,6 +65,16 @@ export const zhCN = {
|
||||
apiKey: "API Key",
|
||||
preset: "预设",
|
||||
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: "模型映射",
|
||||
defaultModel: "默认模型",
|
||||
sonnetModel: "Sonnet 模型",
|
||||
@@ -198,6 +208,16 @@ export const enUS = {
|
||||
apiKey: "API key",
|
||||
preset: "Preset",
|
||||
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",
|
||||
defaultModel: "Default model",
|
||||
sonnetModel: "Sonnet model",
|
||||
|
||||
@@ -48,6 +48,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
export interface ProviderInput {
|
||||
name: string;
|
||||
preset?: string;
|
||||
protocol?: string;
|
||||
api_format?: string;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
models: Models;
|
||||
|
||||
@@ -6,6 +6,19 @@ export interface Models {
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -13,6 +26,8 @@ export interface Provider {
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
models: Models;
|
||||
protocol?: string;
|
||||
api_format?: string;
|
||||
enabled_for_claude: boolean;
|
||||
enabled_for_codex: boolean;
|
||||
target: "claude" | "codex";
|
||||
@@ -31,6 +46,7 @@ export interface ActiveProvider {
|
||||
name: string;
|
||||
base_url: string;
|
||||
models: Models;
|
||||
protocol?: string;
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
@@ -91,6 +107,7 @@ export interface Injection {
|
||||
export interface Preset {
|
||||
key: string;
|
||||
base_url: string;
|
||||
protocol?: string;
|
||||
models: Models;
|
||||
}
|
||||
|
||||
@@ -100,6 +117,7 @@ export const PRESETS: Preset[] = [
|
||||
{
|
||||
key: "deepseek",
|
||||
base_url: "https://api.deepseek.com/v1",
|
||||
protocol: "openai_chat",
|
||||
models: withCodexExtras({
|
||||
default: "deepseek-chat",
|
||||
sonnet: "deepseek-chat",
|
||||
@@ -110,6 +128,7 @@ export const PRESETS: Preset[] = [
|
||||
{
|
||||
key: "moonshot",
|
||||
base_url: "https://api.moonshot.cn/v1",
|
||||
protocol: "openai_chat",
|
||||
models: withCodexExtras(
|
||||
{
|
||||
default: "kimi-k2-0905-preview",
|
||||
@@ -123,6 +142,7 @@ export const PRESETS: Preset[] = [
|
||||
{
|
||||
key: "zhipu",
|
||||
base_url: "https://open.bigmodel.cn/api/paas/v4",
|
||||
protocol: "openai_chat",
|
||||
models: withCodexExtras(
|
||||
{
|
||||
default: "glm-4.6",
|
||||
|
||||
@@ -4,3 +4,18 @@ import { twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
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 ?? "";
|
||||
}
|
||||
}
|
||||
@@ -139,4 +139,28 @@ describe("Codex page", () => {
|
||||
await user.click(await screen.findByRole("button", { name: /还原官方配置|Restore official config/ }));
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ProviderForm, type ProviderSubmitInput } from "@/components/ProviderForm";
|
||||
import { formatProtocolBadge } from "@/lib/utils";
|
||||
|
||||
export default function Codex() {
|
||||
const { t } = useTranslation();
|
||||
@@ -151,6 +152,9 @@ export default function Codex() {
|
||||
<span className="text-sm font-medium">{provider.name}</span>
|
||||
{provider.enabled_for_codex && <Badge tone="success">{t("codex.activated")}</Badge>}
|
||||
{provider.preset && <Badge>{provider.preset}</Badge>}
|
||||
{provider.protocol && (
|
||||
<Badge>{formatProtocolBadge(provider.protocol)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-zinc-500">{provider.base_url}</div>
|
||||
</div>
|
||||
|
||||
@@ -143,4 +143,29 @@ describe("Providers page", () => {
|
||||
const input = createProvider.mock.calls[0][0];
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ProviderForm, type ProviderSubmitInput } from "@/components/ProviderForm";
|
||||
import { formatProtocolBadge } from "@/lib/utils";
|
||||
|
||||
export default function Providers() {
|
||||
const { t } = useTranslation();
|
||||
@@ -107,6 +108,9 @@ export default function Providers() {
|
||||
<Badge tone="success">{t("providers.activated")}</Badge>
|
||||
)}
|
||||
{provider.preset && <Badge>{provider.preset}</Badge>}
|
||||
{provider.protocol && (
|
||||
<Badge>{formatProtocolBadge(provider.protocol)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-zinc-500">{provider.base_url}</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user