Add upstream model list fetching and a Codex-specific model section
This commit is contained in:
1 parent
d759fe5e5d
commit
f3bf1472e7
12 files changed
+339
-16
No files matched your search
@@ -1,13 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { PRESETS } from "@/lib/types";
|
||||
import type { Models } from "@/lib/types";
|
||||
import type { Models, Provider } from "@/lib/types";
|
||||
import { api } from "@/lib/api";
|
||||
import type { ApplyTarget } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input, Label } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { useToast } from "@/components/toast";
|
||||
|
||||
export interface ProviderSubmitInput {
|
||||
name: string;
|
||||
@@ -46,7 +48,7 @@ function toMap(rows: { key: string; value: string }[]): Record<string, string> {
|
||||
|
||||
interface ProviderFormProps {
|
||||
target: ApplyTarget;
|
||||
editing: { id: string; name: string; preset?: string; base_url: string; models: Models } | null;
|
||||
editing: Provider | null;
|
||||
pending: boolean;
|
||||
onSubmit: (input: ProviderSubmitInput) => void;
|
||||
onCancel: () => void;
|
||||
@@ -54,6 +56,8 @@ interface ProviderFormProps {
|
||||
|
||||
export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: ProviderFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const { show } = useToast();
|
||||
const uid = useId();
|
||||
const [form, setForm] = useState<FormState>(() =>
|
||||
editing
|
||||
? {
|
||||
@@ -72,6 +76,29 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
|
||||
: { ...emptyForm },
|
||||
);
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
||||
const [modelList, setModelList] = useState<{ id: string }[]>([]);
|
||||
const [fetchingModels, setFetchingModels] = useState(false);
|
||||
|
||||
const listId = (name: string) => `${uid}-${name}`;
|
||||
|
||||
const fetchModels = async () => {
|
||||
const baseUrl = form.base_url.trim();
|
||||
const apiKey = form.api_key.trim() || editing?.api_key || "";
|
||||
if (!baseUrl || !apiKey) {
|
||||
show(t("providers.fetchModelsNeedKey"), "error");
|
||||
return;
|
||||
}
|
||||
setFetchingModels(true);
|
||||
try {
|
||||
const result = await api.fetchModels({ base_url: baseUrl, api_key: apiKey });
|
||||
setModelList(result.items);
|
||||
show(t("providers.modelsFetched", { count: result.items.length }));
|
||||
} catch (err) {
|
||||
show(err instanceof Error ? err.message : String(err), "error");
|
||||
} finally {
|
||||
setFetchingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validate = (): boolean => {
|
||||
const next: Partial<Record<keyof FormState, string>> = {};
|
||||
@@ -167,19 +194,47 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-xs font-medium text-zinc-600">{t("providers.models")}</legend>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(["default", "sonnet", "haiku", "opus"] as const).map((role) => (
|
||||
<label key={role} className="flex flex-col gap-1">
|
||||
<Label>{t(`providers.${role}Model`)}</Label>
|
||||
<Input
|
||||
value={form.models[role] ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, models: { ...form.models, [role]: e.target.value } })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<div className="flex items-end gap-2">
|
||||
<label className="flex flex-1 flex-col gap-1">
|
||||
<Label>{t("providers.defaultModel")}</Label>
|
||||
<Input
|
||||
value={form.models.default ?? ""}
|
||||
list={listId("default")}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, models: { ...form.models, default: e.target.value } })
|
||||
}
|
||||
/>
|
||||
<datalist id={listId("default")}>
|
||||
{modelList.map((m) => (
|
||||
<option key={m.id} value={m.id} />
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
<Button variant="outline" size="sm" disabled={fetchingModels} onClick={fetchModels}>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={fetchingModels ? "animate-spin" : undefined}
|
||||
aria-hidden
|
||||
/>
|
||||
{fetchingModels ? t("providers.fetchingModels") : t("providers.fetchModels")}
|
||||
</Button>
|
||||
</div>
|
||||
{target === "claude" && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(["sonnet", "haiku", "opus"] as const).map((role) => (
|
||||
<label key={role} className="flex flex-col gap-1">
|
||||
<Label>{t(`providers.${role}Model`)}</Label>
|
||||
<Input
|
||||
value={form.models[role] ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, models: { ...form.models, [role]: e.target.value } })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-zinc-500">{t("providers.modelsFetchHint")}</p>
|
||||
<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>
|
||||
@@ -215,6 +270,7 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
|
||||
<Input
|
||||
value={row.value}
|
||||
placeholder={t("providers.extraValue")}
|
||||
list={listId(`extra-${index}`)}
|
||||
onChange={(e) => {
|
||||
const extra = form.models.extra.map((r, i) =>
|
||||
i === index ? { ...r, value: e.target.value } : r,
|
||||
@@ -222,6 +278,11 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
|
||||
setForm({ ...form, models: { ...form.models, extra } });
|
||||
}}
|
||||
/>
|
||||
<datalist id={listId(`extra-${index}`)}>
|
||||
{modelList.map((m) => (
|
||||
<option key={m.id} value={m.id} />
|
||||
))}
|
||||
</datalist>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -74,6 +74,11 @@ export const zhCN = {
|
||||
extraKey: "请求的模型名",
|
||||
extraValue: "上游模型名",
|
||||
addMapping: "添加一行",
|
||||
fetchModels: "获取模型列表",
|
||||
fetchingModels: "获取中",
|
||||
modelsFetched: "已获取 {{count}} 个模型",
|
||||
modelsFetchHint: "点「获取模型列表」从供应商拉取可用模型,下方输入可从列表选择;不支持列表的供应商可手动输入。",
|
||||
fetchModelsNeedKey: "请先填写 Base URL 和 API Key",
|
||||
extraHint: "Codex 等客户端会把模型名原样发给网关,在此把入站模型名映射到上游模型名。",
|
||||
activated: "已激活",
|
||||
activate: "激活并写入",
|
||||
@@ -202,6 +207,11 @@ export const enUS = {
|
||||
extraKey: "Requested model",
|
||||
extraValue: "Upstream model",
|
||||
addMapping: "Add row",
|
||||
fetchModels: "Fetch models",
|
||||
fetchingModels: "Fetching",
|
||||
modelsFetched: "Fetched {{count}} models",
|
||||
modelsFetchHint: "Fetch the provider's model list to pick from the inputs below; providers without a list can be filled in manually.",
|
||||
fetchModelsNeedKey: "Base URL and API key are required first",
|
||||
extraHint: "Clients like Codex send their model names as-is; map inbound names onto upstream models here.",
|
||||
activated: "Active",
|
||||
activate: "Activate & apply",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ApplyResult,
|
||||
BackupInfo,
|
||||
LogEntry,
|
||||
ModelInfo,
|
||||
Models,
|
||||
Provider,
|
||||
Settings,
|
||||
@@ -73,6 +74,11 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
fetchModels: (input: { base_url: string; api_key: string }) =>
|
||||
request<{ items: ModelInfo[] }>("/api/fetch-models", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
listBackups: (target: ApplyTarget = "claude") =>
|
||||
request<{ items: BackupInfo[] }>(`/api/backups?target=${target}`),
|
||||
getSettings: () => request<Settings>("/api/settings"),
|
||||
|
||||
@@ -76,6 +76,12 @@ export interface ApplyResult {
|
||||
injection?: Injection;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
object?: string;
|
||||
owned_by?: string;
|
||||
}
|
||||
|
||||
export interface Injection {
|
||||
model_provider: string;
|
||||
table: Record<string, string>;
|
||||
|
||||
@@ -8,6 +8,7 @@ const createProvider = vi.fn();
|
||||
const applyProvider = vi.fn();
|
||||
const restore = vi.fn();
|
||||
const getStatus = vi.fn();
|
||||
const fetchModels = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
@@ -22,6 +23,7 @@ vi.mock("@/lib/api", () => ({
|
||||
updateSettings: vi.fn(),
|
||||
listLogs: vi.fn().mockResolvedValue({ items: [] }),
|
||||
listBackups: vi.fn().mockResolvedValue({ items: [] }),
|
||||
fetchModels: (input: unknown) => fetchModels(input),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -70,6 +72,7 @@ beforeEach(() => {
|
||||
},
|
||||
});
|
||||
restore.mockResolvedValue({ restored_from: "" });
|
||||
fetchModels.mockResolvedValue({ items: [{ id: "deepseek-chat" }, { id: "deepseek-reasoner" }] });
|
||||
});
|
||||
|
||||
describe("Codex page", () => {
|
||||
@@ -102,6 +105,25 @@ describe("Codex page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("offers model fetching instead of Claude role fields", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<Codex />);
|
||||
await user.click(await screen.findByRole("button", { name: /新增供应商|Add provider/ }));
|
||||
expect(screen.queryByLabelText(/Sonnet 模型|Sonnet model/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Haiku 模型|Haiku model/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Opus 模型|Opus model/)).not.toBeInTheDocument();
|
||||
await user.type(screen.getByLabelText(/Base URL/), "https://api.deepseek.com/v1");
|
||||
await user.type(screen.getByLabelText(/API Key/), "sk-x");
|
||||
await user.click(screen.getByRole("button", { name: /获取模型列表|Fetch models/ }));
|
||||
await waitFor(() =>
|
||||
expect(fetchModels).toHaveBeenCalledWith({
|
||||
base_url: "https://api.deepseek.com/v1",
|
||||
api_key: "sk-x",
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText(/已获取 2 个模型|Fetched 2 models/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("activates a provider for Codex and previews the injected keys", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<Codex />);
|
||||
|
||||
@@ -14,6 +14,7 @@ const listLogs = vi.fn();
|
||||
const listBackups = vi.fn();
|
||||
const updateProvider = vi.fn();
|
||||
const updateSettings = vi.fn();
|
||||
const fetchModels = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
@@ -28,6 +29,7 @@ vi.mock("@/lib/api", () => ({
|
||||
updateSettings: (patch: unknown) => updateSettings(patch),
|
||||
listLogs: () => listLogs(),
|
||||
listBackups: () => listBackups(),
|
||||
fetchModels: (input: unknown) => fetchModels(input),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -55,6 +57,7 @@ beforeEach(() => {
|
||||
createProvider.mockResolvedValue({ ...provider, id: "p_2" });
|
||||
deleteProvider.mockResolvedValue({ deleted: "p_1" });
|
||||
applyProvider.mockResolvedValue({ provider, env: { ANTHROPIC_BASE_URL: "http://127.0.0.1:1234" } });
|
||||
fetchModels.mockResolvedValue({ items: [{ id: "deepseek-chat" }, { id: "deepseek-reasoner" }] });
|
||||
});
|
||||
|
||||
describe("Providers page", () => {
|
||||
@@ -92,6 +95,31 @@ describe("Providers page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the Claude model role fields", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await user.click(await screen.findByRole("button", { name: /新增供应商|Add provider/ }));
|
||||
expect(screen.getByLabelText(/Sonnet 模型|Sonnet model/)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Haiku 模型|Haiku model/)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Opus 模型|Opus model/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fetches the upstream model list", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await user.click(await screen.findByRole("button", { name: /新增供应商|Add provider/ }));
|
||||
await user.type(screen.getByLabelText(/Base URL/), "https://api.deepseek.com/v1");
|
||||
await user.type(screen.getByLabelText(/API Key/), "sk-x");
|
||||
await user.click(screen.getByRole("button", { name: /获取模型列表|Fetch models/ }));
|
||||
await waitFor(() =>
|
||||
expect(fetchModels).toHaveBeenCalledWith({
|
||||
base_url: "https://api.deepseek.com/v1",
|
||||
api_key: "sk-x",
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText(/已获取 2 个模型|Fetched 2 models/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("activates a provider and shows the applied env", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
Reference in new issue
Block a user