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
@@ -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