CrossRelay stage 2: Codex /v1/responses relay, config.toml manager, Codex page
This commit is contained in:
1 parent
404704171d
commit
5112f7ca06
30 files changed
+3539
-72
No files matched your search
@@ -7,6 +7,7 @@ import { ToastProvider } from "./components/toast";
|
||||
import { Layout } from "./components/Layout";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Providers from "./pages/Providers";
|
||||
import Codex from "./pages/Codex";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -31,6 +32,7 @@ export default function App({ initialLanguage }: { initialLanguage?: string }) {
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/providers" element={<Providers />} />
|
||||
<Route path="/codex" element={<Codex />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Gauge, Server, Settings as SettingsIcon } from "lucide-react";
|
||||
import { Gauge, Server, Settings as SettingsIcon, Terminal } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettingsMutation, useSettings } from "@/lib/queries";
|
||||
import i18n from "@/i18n";
|
||||
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
|
||||
const navItems = [
|
||||
{ to: "/", key: "nav.dashboard", icon: Gauge },
|
||||
{ to: "/providers", key: "nav.providers", icon: Server },
|
||||
{ to: "/codex", key: "nav.codex", icon: Terminal },
|
||||
{ to: "/settings", key: "nav.settings", icon: SettingsIcon },
|
||||
];
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export const zhCN = {
|
||||
dashboard: "仪表盘",
|
||||
providers: "供应商",
|
||||
settings: "设置",
|
||||
codex: "Codex",
|
||||
},
|
||||
common: {
|
||||
save: "保存",
|
||||
@@ -73,6 +74,7 @@ export const zhCN = {
|
||||
extraKey: "请求的模型名",
|
||||
extraValue: "上游模型名",
|
||||
addMapping: "添加一行",
|
||||
extraHint: "Codex 等客户端会把模型名原样发给网关,在此把入站模型名映射到上游模型名。",
|
||||
activated: "已激活",
|
||||
activate: "激活并写入",
|
||||
empty: "还没有供应商,先添加一个",
|
||||
@@ -87,6 +89,20 @@ export const zhCN = {
|
||||
keyHint: "留空表示保持原有 Key 不变",
|
||||
enabledClaude: "对 Claude Code 启用",
|
||||
},
|
||||
codex: {
|
||||
title: "Codex",
|
||||
status: "注入状态",
|
||||
injected: "已注入",
|
||||
notInjected: "未注入",
|
||||
configPath: "配置文件",
|
||||
portHint: "auto 端口模式下网关重启后端口会变,需重新激活",
|
||||
preview: "已写入 config.toml 的托管配置",
|
||||
activate: "激活并写入",
|
||||
activated: "已激活",
|
||||
restore: "还原官方配置",
|
||||
restoreDone: "已还原 Codex 官方配置",
|
||||
empty: "还没有供应商,请先到供应商页添加",
|
||||
},
|
||||
settings: {
|
||||
title: "设置",
|
||||
gateway: "网关",
|
||||
@@ -100,6 +116,11 @@ export const zhCN = {
|
||||
backupsEmpty: "暂无备份",
|
||||
restoreBackup: "还原",
|
||||
restored: "已还原",
|
||||
backupTarget: "备份目标",
|
||||
targetClaude: "Claude Code",
|
||||
targetCodex: "Codex",
|
||||
rollback: "回滚",
|
||||
rolledBack: "已回滚",
|
||||
saveFailed: "保存失败",
|
||||
},
|
||||
},
|
||||
@@ -115,6 +136,7 @@ export const enUS = {
|
||||
dashboard: "Dashboard",
|
||||
providers: "Providers",
|
||||
settings: "Settings",
|
||||
codex: "Codex",
|
||||
},
|
||||
common: {
|
||||
save: "Save",
|
||||
@@ -180,6 +202,7 @@ export const enUS = {
|
||||
extraKey: "Requested model",
|
||||
extraValue: "Upstream model",
|
||||
addMapping: "Add row",
|
||||
extraHint: "Clients like Codex send their model names as-is; map inbound names onto upstream models here.",
|
||||
activated: "Active",
|
||||
activate: "Activate & apply",
|
||||
empty: "No providers yet",
|
||||
@@ -194,6 +217,20 @@ export const enUS = {
|
||||
keyHint: "Leave empty to keep the current key",
|
||||
enabledClaude: "Enabled for Claude Code",
|
||||
},
|
||||
codex: {
|
||||
title: "Codex",
|
||||
status: "Injection status",
|
||||
injected: "Injected",
|
||||
notInjected: "Not injected",
|
||||
configPath: "Config file",
|
||||
portHint: "With an automatic port the gateway picks a new port on restart; activate again afterwards.",
|
||||
preview: "Managed keys written to config.toml",
|
||||
activate: "Activate & apply",
|
||||
activated: "Active",
|
||||
restore: "Restore official config",
|
||||
restoreDone: "Codex official config restored",
|
||||
empty: "No providers yet, add one on the Providers page",
|
||||
},
|
||||
settings: {
|
||||
title: "Settings",
|
||||
gateway: "Gateway",
|
||||
@@ -207,6 +244,11 @@ export const enUS = {
|
||||
backupsEmpty: "No backups yet",
|
||||
restoreBackup: "Restore",
|
||||
restored: "Restored",
|
||||
backupTarget: "Backup target",
|
||||
targetClaude: "Claude Code",
|
||||
targetCodex: "Codex",
|
||||
rollback: "Roll back",
|
||||
rolledBack: "Rolled back",
|
||||
saveFailed: "Save failed",
|
||||
},
|
||||
},
|
||||
|
||||
+19
-4
@@ -61,12 +61,27 @@ export const api = {
|
||||
request<Provider>(`/api/providers/${id}`, { method: "PUT", body: JSON.stringify(input) }),
|
||||
deleteProvider: (id: string) =>
|
||||
request<{ deleted: string }>(`/api/providers/${id}`, { method: "DELETE" }),
|
||||
applyProvider: (id: string) =>
|
||||
request<ApplyResult>(`/api/providers/${id}/apply`, { method: "POST", body: "{}" }),
|
||||
restore: () => request<{ restored_from: string }>("/api/restore", { method: "POST", body: "{}" }),
|
||||
listBackups: () => request<{ items: BackupInfo[] }>("/api/backups"),
|
||||
applyProvider: (id: string, target: ApplyTarget = "claude") =>
|
||||
request<ApplyResult>(`/api/providers/${id}/apply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ target }),
|
||||
}),
|
||||
restore: (input: RestoreInput = {}) =>
|
||||
request<{ restored_from: string }>("/api/restore", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
listBackups: (target: ApplyTarget = "claude") =>
|
||||
request<{ items: BackupInfo[] }>(`/api/backups?target=${target}`),
|
||||
getSettings: () => request<Settings>("/api/settings"),
|
||||
updateSettings: (patch: Partial<Settings>) =>
|
||||
request<Settings>("/api/settings", { method: "PUT", body: JSON.stringify(patch) }),
|
||||
listLogs: () => request<{ items: LogEntry[] }>("/api/logs"),
|
||||
};
|
||||
|
||||
export type ApplyTarget = "claude" | "codex";
|
||||
|
||||
export interface RestoreInput {
|
||||
target?: ApplyTarget;
|
||||
backup?: string;
|
||||
}
|
||||
+7
-6
@@ -5,7 +5,7 @@ export const queryKeys = {
|
||||
status: ["status"] as const,
|
||||
providers: ["providers"] as const,
|
||||
logs: ["logs"] as const,
|
||||
backups: ["backups"] as const,
|
||||
backups: (target: string) => ["backups", target] as const,
|
||||
settings: ["settings"] as const,
|
||||
};
|
||||
|
||||
@@ -21,8 +21,8 @@ export function useLogs() {
|
||||
return useQuery({ queryKey: queryKeys.logs, queryFn: api.listLogs, refetchInterval: 5000 });
|
||||
}
|
||||
|
||||
export function useBackups() {
|
||||
return useQuery({ queryKey: queryKeys.backups, queryFn: api.listBackups });
|
||||
export function useBackups(target: "claude" | "codex") {
|
||||
return useQuery({ queryKey: queryKeys.backups(target), queryFn: () => api.listBackups(target) });
|
||||
}
|
||||
|
||||
export function useSettings() {
|
||||
@@ -33,7 +33,7 @@ function invalidateAll(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.providers }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.status }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.backups }),
|
||||
queryClient.invalidateQueries({ queryKey: ["backups"] }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.settings }),
|
||||
]);
|
||||
}
|
||||
@@ -53,7 +53,8 @@ export function useProviderMutations() {
|
||||
onSuccess: () => invalidateAll(queryClient),
|
||||
});
|
||||
const apply = useMutation({
|
||||
mutationFn: (id: string) => api.applyProvider(id),
|
||||
mutationFn: ({ id, target }: { id: string; target: "claude" | "codex" }) =>
|
||||
api.applyProvider(id, target),
|
||||
onSuccess: () => invalidateAll(queryClient),
|
||||
});
|
||||
return { create, update, remove, apply };
|
||||
@@ -62,7 +63,7 @@ export function useProviderMutations() {
|
||||
export function useRestore() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => api.restore(),
|
||||
mutationFn: (input: Parameters<typeof api.restore>[0]) => api.restore(input),
|
||||
onSuccess: () => invalidateAll(queryClient),
|
||||
});
|
||||
}
|
||||
|
||||
+40
-9
@@ -14,6 +14,7 @@ export interface Provider {
|
||||
api_key: string;
|
||||
models: Models;
|
||||
enabled_for_claude: boolean;
|
||||
enabled_for_codex: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -37,6 +38,7 @@ export interface Status {
|
||||
port: number;
|
||||
uptime_sec: number;
|
||||
active_claude_provider: ActiveProvider | null;
|
||||
active_codex_provider: ActiveProvider | null;
|
||||
stats: Stats;
|
||||
language: string;
|
||||
port_mode: string;
|
||||
@@ -69,7 +71,14 @@ export interface Settings {
|
||||
|
||||
export interface ApplyResult {
|
||||
provider: Provider;
|
||||
env: Record<string, string>;
|
||||
env?: Record<string, string>;
|
||||
injection?: Injection;
|
||||
}
|
||||
|
||||
export interface Injection {
|
||||
model_provider: string;
|
||||
table: Record<string, string>;
|
||||
prior_provider?: string;
|
||||
}
|
||||
|
||||
export interface Preset {
|
||||
@@ -78,25 +87,47 @@ export interface Preset {
|
||||
models: Models;
|
||||
}
|
||||
|
||||
const CODEX_EXTRAS = ["gpt-5-codex", "gpt-5", "codex-mini-latest"];
|
||||
|
||||
export const PRESETS: Preset[] = [
|
||||
{
|
||||
key: "deepseek",
|
||||
base_url: "https://api.deepseek.com/v1",
|
||||
models: { default: "deepseek-chat", sonnet: "deepseek-chat", haiku: "deepseek-chat", opus: "deepseek-chat" },
|
||||
models: withCodexExtras({
|
||||
default: "deepseek-chat",
|
||||
sonnet: "deepseek-chat",
|
||||
haiku: "deepseek-chat",
|
||||
opus: "deepseek-chat",
|
||||
}, "deepseek-chat"),
|
||||
},
|
||||
{
|
||||
key: "moonshot",
|
||||
base_url: "https://api.moonshot.cn/v1",
|
||||
models: {
|
||||
default: "kimi-k2-0905-preview",
|
||||
sonnet: "kimi-k2-0905-preview",
|
||||
haiku: "kimi-k2-0905-preview",
|
||||
opus: "kimi-k2-0905-preview",
|
||||
},
|
||||
models: withCodexExtras(
|
||||
{
|
||||
default: "kimi-k2-0905-preview",
|
||||
sonnet: "kimi-k2-0905-preview",
|
||||
haiku: "kimi-k2-0905-preview",
|
||||
opus: "kimi-k2-0905-preview",
|
||||
},
|
||||
"kimi-k2-0905-preview",
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "zhipu",
|
||||
base_url: "https://open.bigmodel.cn/api/paas/v4",
|
||||
models: { default: "glm-4.6", sonnet: "glm-4.6", haiku: "glm-4.6", opus: "glm-4.6" },
|
||||
models: withCodexExtras(
|
||||
{
|
||||
default: "glm-4.6",
|
||||
sonnet: "glm-4.6",
|
||||
haiku: "glm-4.6",
|
||||
opus: "glm-4.6",
|
||||
},
|
||||
"glm-4.6",
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function withCodexExtras(models: Models, upstream: string): Models {
|
||||
return { ...models, extra: Object.fromEntries(CODEX_EXTRAS.map((m) => [m, upstream])) };
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { renderWithProviders } from "@/test/utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const listProviders = vi.fn();
|
||||
const applyProvider = vi.fn();
|
||||
const restore = vi.fn();
|
||||
const getStatus = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
listProviders: () => listProviders(),
|
||||
createProvider: vi.fn(),
|
||||
updateProvider: vi.fn(),
|
||||
deleteProvider: vi.fn(),
|
||||
applyProvider: (id: string, target: string) => applyProvider(id, target),
|
||||
restore: (input: unknown) => restore(input),
|
||||
getStatus: () => getStatus(),
|
||||
getSettings: vi.fn().mockResolvedValue({ language: "zh-CN", port_mode: "auto", fixed_port: 0 }),
|
||||
updateSettings: vi.fn(),
|
||||
listLogs: vi.fn().mockResolvedValue({ items: [] }),
|
||||
listBackups: vi.fn().mockResolvedValue({ items: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
import Codex from "./Codex";
|
||||
|
||||
const provider = {
|
||||
id: "p_1",
|
||||
name: "DeepSeek",
|
||||
preset: "deepseek",
|
||||
base_url: "https://api.deepseek.com/v1",
|
||||
api_key: "sk-x",
|
||||
models: { default: "deepseek-chat", sonnet: "deepseek-chat", haiku: "deepseek-chat", opus: "deepseek-chat" },
|
||||
enabled_for_claude: false,
|
||||
enabled_for_codex: false,
|
||||
created_at: "2026-10-18T00:00:00Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listProviders.mockResolvedValue({ items: [provider] });
|
||||
getStatus.mockResolvedValue({
|
||||
running: true,
|
||||
version: "0.1.0",
|
||||
port: 52917,
|
||||
uptime_sec: 95,
|
||||
active_claude_provider: null,
|
||||
active_codex_provider: null,
|
||||
stats: { requests_total: 0, requests_failed: 0, input_tokens: 0, output_tokens: 0 },
|
||||
language: "zh-CN",
|
||||
port_mode: "auto",
|
||||
fixed_port: 0,
|
||||
});
|
||||
applyProvider.mockResolvedValue({
|
||||
provider,
|
||||
injection: {
|
||||
model_provider: "crossrelay",
|
||||
table: {
|
||||
name: "crossrelay",
|
||||
wire_api: "responses",
|
||||
base_url: "http://127.0.0.1:52917",
|
||||
experimental_bearer_token: "crl_x",
|
||||
requires_openai_auth: "false",
|
||||
},
|
||||
},
|
||||
});
|
||||
restore.mockResolvedValue({ restored_from: "" });
|
||||
});
|
||||
|
||||
describe("Codex page", () => {
|
||||
it("shows the injection status, config path and provider list", async () => {
|
||||
renderWithProviders(<Codex />);
|
||||
expect(await screen.findByText("未注入")).toBeInTheDocument();
|
||||
expect(screen.getByText(/config\.toml/)).toBeInTheDocument();
|
||||
expect(screen.getByText("DeepSeek")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("activates a provider for Codex and previews the injected keys", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<Codex />);
|
||||
await user.click(await screen.findByRole("button", { name: /激活并写入|Activate & apply/ }));
|
||||
await waitFor(() => expect(applyProvider).toHaveBeenCalledWith("p_1", "codex"));
|
||||
expect(await screen.findByText(/experimental_bearer_token/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/wire_api/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores the official config", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<Codex />);
|
||||
await user.click(await screen.findByRole("button", { name: /还原官方配置|Restore official config/ }));
|
||||
await waitFor(() => expect(restore).toHaveBeenCalledWith({ target: "codex" }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RotateCcw, Zap } from "lucide-react";
|
||||
import type { Provider } from "@/lib/types";
|
||||
import { useProviders, useProviderMutations, useRestore, useStatus } from "@/lib/queries";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function Codex() {
|
||||
const { t } = useTranslation();
|
||||
const status = useStatus();
|
||||
const { data, isLoading, isError } = useProviders();
|
||||
const mutations = useProviderMutations();
|
||||
const restore = useRestore();
|
||||
const { show } = useToast();
|
||||
const [injection, setInjection] = useState<Record<string, string> | null>(null);
|
||||
|
||||
const providers = data?.items ?? [];
|
||||
const active = status.data?.active_codex_provider ?? null;
|
||||
|
||||
const apply = (provider: Provider) => {
|
||||
mutations.apply.mutate(
|
||||
{ id: provider.id, target: "codex" },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
setInjection(result.injection?.table ?? null);
|
||||
show(`${t("codex.activated")}: ${provider.name}`);
|
||||
},
|
||||
onError: (err: Error) => show(err.message, "error"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const restoreConfig = () => {
|
||||
restore.mutate(
|
||||
{ target: "codex" },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setInjection(null);
|
||||
show(t("codex.restoreDone"));
|
||||
},
|
||||
onError: (err: Error) => show(err.message, "error"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="text-sm text-zinc-500">{t("common.loading")}</div>;
|
||||
if (isError) return <div className="text-sm text-red-600">{t("common.error")}</div>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{t("codex.title")}</h2>
|
||||
<Button variant="outline" size="sm" disabled={restore.isPending} onClick={restoreConfig}>
|
||||
{restore.isPending ? (
|
||||
<RotateCcw size={14} className="animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Zap size={14} aria-hidden />
|
||||
)}
|
||||
{restore.isPending ? t("common.restoring") : t("codex.restore")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("codex.status")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone={active ? "success" : "danger"}>
|
||||
{active ? t("codex.injected") : t("codex.notInjected")}
|
||||
</Badge>
|
||||
{active && <span className="text-sm font-medium">{active.name}</span>}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{t("codex.configPath")}: ~/.codex/config.toml
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">{t("codex.portHint")}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{injection && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("codex.preview")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="grid grid-cols-2 gap-1 font-mono text-xs text-zinc-600">
|
||||
<li>
|
||||
<span className="text-zinc-400">model_provider</span> = crossrelay
|
||||
</li>
|
||||
{Object.entries(injection)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => (
|
||||
<li key={key}>
|
||||
<span className="text-zinc-400">{key}</span> = {value}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{providers.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-zinc-500">
|
||||
{t("codex.empty")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{providers.map((provider) => (
|
||||
<Card key={provider.id}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>}
|
||||
</div>
|
||||
<div className="truncate text-xs text-zinc-500">{provider.base_url}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => apply(provider)}>
|
||||
{t("codex.activate")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -118,10 +118,13 @@ export default function Dashboard() {
|
||||
size="sm"
|
||||
disabled={restore.isPending}
|
||||
onClick={() =>
|
||||
restore.mutate(undefined, {
|
||||
restore.mutate(
|
||||
{ target: "claude" },
|
||||
{
|
||||
onSuccess: () => show(t("dashboard.restoreDone")),
|
||||
onError: (err) => show(err.message, "error"),
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{restore.isPending ? (
|
||||
|
||||
@@ -21,8 +21,8 @@ vi.mock("@/lib/api", () => ({
|
||||
createProvider: (input: unknown) => createProvider(input),
|
||||
updateProvider: (id: string, input: unknown) => updateProvider(id, input),
|
||||
deleteProvider: (id: string) => deleteProvider(id),
|
||||
applyProvider: (id: string) => applyProvider(id),
|
||||
restore: () => restore(),
|
||||
applyProvider: (id: string, target: string) => applyProvider(id, target),
|
||||
restore: (input: unknown) => restore(input),
|
||||
getStatus: () => getStatus(),
|
||||
getSettings: () => getSettings(),
|
||||
updateSettings: (patch: unknown) => updateSettings(patch),
|
||||
@@ -94,7 +94,23 @@ describe("Providers page", () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await user.click(await screen.findByRole("button", { name: /激活并写入|Activate & apply/ }));
|
||||
await waitFor(() => expect(applyProvider).toHaveBeenCalledWith("p_1"));
|
||||
await waitFor(() => expect(applyProvider).toHaveBeenCalledWith("p_1", "claude"));
|
||||
expect(await screen.findByText(/ANTHROPIC_BASE_URL/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits exact model mappings from the mapping editor", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await user.click(await screen.findByRole("button", { name: /新增供应商|Add provider/ }));
|
||||
await user.click(screen.getByRole("button", { name: /添加一行|Add row/ }));
|
||||
await user.type(screen.getByPlaceholderText(/请求的模型名|Requested model/), "gpt-5-codex");
|
||||
await user.type(screen.getByPlaceholderText(/上游模型名|Upstream model/), "deepseek-chat");
|
||||
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));
|
||||
const input = createProvider.mock.calls[0][0];
|
||||
expect(input.models.extra).toEqual({ "gpt-5-codex": "deepseek-chat" });
|
||||
});
|
||||
});
|
||||
+91
-14
@@ -17,7 +17,7 @@ interface FormState {
|
||||
preset: string;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
models: Models;
|
||||
models: Omit<Models, "extra"> & { extra: { key: string; value: string }[] };
|
||||
}
|
||||
|
||||
const emptyForm: FormState = {
|
||||
@@ -25,9 +25,19 @@ const emptyForm: FormState = {
|
||||
preset: "",
|
||||
base_url: "",
|
||||
api_key: "",
|
||||
models: { default: "", sonnet: "", haiku: "", opus: "", extra: {} },
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError } = useProviders();
|
||||
@@ -47,7 +57,9 @@ export default function Providers() {
|
||||
preset: presetKey,
|
||||
name: presetKey ? presetKey : "",
|
||||
base_url: preset?.base_url ?? "",
|
||||
models: preset ? { ...preset.models } : { ...emptyForm.models },
|
||||
models: preset
|
||||
? { ...preset.models, extra: toRows(preset.models.extra) }
|
||||
: { ...emptyForm.models },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -64,7 +76,7 @@ export default function Providers() {
|
||||
sonnet: provider.models.sonnet ?? "",
|
||||
haiku: provider.models.haiku ?? "",
|
||||
opus: provider.models.opus ?? "",
|
||||
extra: { ...(provider.models.extra ?? {}) },
|
||||
extra: toRows(provider.models.extra),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -91,9 +103,7 @@ export default function Providers() {
|
||||
sonnet: form.models.sonnet?.trim(),
|
||||
haiku: form.models.haiku?.trim(),
|
||||
opus: form.models.opus?.trim(),
|
||||
extra: Object.fromEntries(
|
||||
Object.entries(form.models.extra ?? {}).filter(([k, v]) => k.trim() && v.trim()),
|
||||
),
|
||||
extra: toMap(form.models.extra),
|
||||
},
|
||||
};
|
||||
const onSuccess = () => {
|
||||
@@ -109,13 +119,16 @@ export default function Providers() {
|
||||
};
|
||||
|
||||
const apply = (provider: Provider) => {
|
||||
mutations.apply.mutate(provider.id, {
|
||||
onSuccess: (result) => {
|
||||
setLastAppliedEnv(result.env);
|
||||
show(`${t("providers.activated")}: ${provider.name}`);
|
||||
mutations.apply.mutate(
|
||||
{ id: provider.id, target: "claude" },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
setLastAppliedEnv(result.env ?? null);
|
||||
show(`${t("providers.activated")}: ${provider.name}`);
|
||||
},
|
||||
onError: (err) => show(err.message, "error"),
|
||||
},
|
||||
onError: (err) => show(err.message, "error"),
|
||||
});
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="text-sm text-zinc-500">{t("common.loading")}</div>;
|
||||
@@ -158,7 +171,9 @@ export default function Providers() {
|
||||
...form,
|
||||
preset: key,
|
||||
base_url: preset?.base_url ?? form.base_url,
|
||||
models: preset ? { ...preset.models } : form.models,
|
||||
models: preset
|
||||
? { ...preset.models, extra: toRows(preset.models.extra) }
|
||||
: form.models,
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -209,6 +224,68 @@ export default function Providers() {
|
||||
</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">
|
||||
|
||||
@@ -11,7 +11,8 @@ import { Table, TBody, TD, TH, THead, TR } from "@/components/ui/table";
|
||||
export default function SettingsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { data: settings } = useSettings();
|
||||
const { data: backups } = useBackups();
|
||||
const [backupTarget, setBackupTarget] = useState<"claude" | "codex">("claude");
|
||||
const { data: backups } = useBackups(backupTarget);
|
||||
const mutation = useSettingsMutation();
|
||||
const restore = useRestore();
|
||||
const { show } = useToast();
|
||||
@@ -93,16 +94,29 @@ export default function SettingsPage() {
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle>{t("settings.backups")}</CardTitle>
|
||||
<Select
|
||||
value={backupTarget}
|
||||
onChange={(e) => setBackupTarget(e.target.value as "claude" | "codex")}
|
||||
className="max-w-40"
|
||||
>
|
||||
<option value="claude">{t("settings.targetClaude")}</option>
|
||||
<option value="codex">{t("settings.targetCodex")}</option>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={restore.isPending}
|
||||
onClick={() =>
|
||||
restore.mutate(undefined, {
|
||||
onSuccess: (result) => show(`${t("settings.restored")}: ${result.restored_from}`),
|
||||
onError: (err) => show(err.message, "error"),
|
||||
})
|
||||
}
|
||||
disabled={restore.isPending || !backups?.items?.length}
|
||||
onClick={() => {
|
||||
const newest = backups?.items?.[0]?.name;
|
||||
if (!newest) return;
|
||||
restore.mutate(
|
||||
{ target: backupTarget, backup: newest },
|
||||
{
|
||||
onSuccess: () => show(`${t("settings.restored")}: ${newest}`),
|
||||
onError: (err) => show(err.message, "error"),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("settings.restoreBackup")}
|
||||
</Button>
|
||||
@@ -115,6 +129,7 @@ export default function SettingsPage() {
|
||||
<TH>{t("settings.backups")}</TH>
|
||||
<TH>Size</TH>
|
||||
<TH>Modified</TH>
|
||||
<TH>{t("common.actions")}</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
@@ -123,6 +138,24 @@ export default function SettingsPage() {
|
||||
<TD className="font-mono text-xs">{item.name}</TD>
|
||||
<TD className="text-xs">{item.size} B</TD>
|
||||
<TD className="text-xs">{new Date(item.modified).toLocaleString()}</TD>
|
||||
<TD>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={restore.isPending}
|
||||
onClick={() =>
|
||||
restore.mutate(
|
||||
{ target: backupTarget, backup: item.name },
|
||||
{
|
||||
onSuccess: () => show(`${t("settings.rolledBack")}: ${item.name}`),
|
||||
onError: (err) => show(err.message, "error"),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("settings.rollback")}
|
||||
</Button>
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
|
||||
Reference in new issue
Block a user