Add upstream model list fetching and a Codex-specific model section

This commit is contained in:
kate522 committed 2026-10-18 19:33:32 +08:00
1 parent d759fe5e5d
commit f3bf1472e7
12 files changed
+339 -16

No files matched your search

+27
View File
@@ -42,6 +42,33 @@ func (c *Client) endpoint() string {
return c.BaseURL + "/chat/completions" return c.BaseURL + "/chat/completions"
} }
// ListModels fetches the upstream model catalog. Not every provider
// implements it; callers should treat an error as "list unavailable"
// and fall back to manual entry.
func (c *Client) ListModels(ctx context.Context) (*ModelList, error) {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/models", nil)
if err != nil {
return nil, err
}
httpReq.Header.Set("Accept", "application/json")
if c.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+c.APIKey)
}
resp, err := c.HTTP.Do(httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, &UpstreamError{StatusCode: resp.StatusCode, Message: readErrorBody(resp)}
}
var out ModelList
if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&out); err != nil {
return nil, fmt.Errorf("decode upstream model list: %w", err)
}
return &out, nil
}
func (c *Client) do(ctx context.Context, req *Request) (*http.Response, error) { func (c *Client) do(ctx context.Context, req *Request) (*http.Response, error) {
body, err := json.Marshal(req) body, err := json.Marshal(req)
if err != nil { if err != nil {
+43
View File
@@ -73,6 +73,49 @@ func TestCreateStreamRejectsNon2xx(t *testing.T) {
} }
} }
func TestListModels(t *testing.T) {
var gotPath, gotAuth string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"object":"list","data":[{"id":"deepseek-chat","object":"model","owned_by":"deepseek"},{"id":"deepseek-reasoner"}]}`))
}))
defer upstream.Close()
client := NewClient(upstream.URL, "sk-up")
list, err := client.ListModels(context.Background())
if err != nil {
t.Fatalf("ListModels: %v", err)
}
if gotPath != "/models" {
t.Errorf("path = %q, want /models", gotPath)
}
if gotAuth != "Bearer sk-up" {
t.Errorf("authorization = %q", gotAuth)
}
if len(list.Data) != 2 || list.Data[0].ID != "deepseek-chat" {
t.Fatalf("models = %+v", list.Data)
}
}
func TestListModelsUpstreamError(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":{"message":"bad key"}}`))
}))
defer upstream.Close()
_, err := NewClient(upstream.URL, "sk-bad").ListModels(context.Background())
var ue *UpstreamError
if !errors.As(err, &ue) {
t.Fatalf("error = %v, want *UpstreamError", err)
}
if ue.StatusCode != http.StatusUnauthorized || ue.Message != "bad key" {
t.Errorf("upstream error = %+v", ue)
}
}
func TestStreamNext(t *testing.T) { func TestStreamNext(t *testing.T) {
sse := strings.Join([]string{ sse := strings.Join([]string{
`data: {"id":"1","choices":[{"index":0,"delta":{"content":"Hel"}}]}`, `data: {"id":"1","choices":[{"index":0,"delta":{"content":"Hel"}}]}`,
+12
View File
@@ -102,6 +102,18 @@ type Error struct {
Code any `json:"code,omitempty"` Code any `json:"code,omitempty"`
} }
// ModelInfo is one entry of an upstream model catalog.
type ModelInfo struct {
ID string `json:"id"`
Object string `json:"object,omitempty"`
OwnedBy string `json:"owned_by,omitempty"`
}
// ModelList is the upstream /models response body.
type ModelList struct {
Data []ModelInfo `json:"data"`
}
// StreamChunk is one SSE data payload of a streaming response. // StreamChunk is one SSE data payload of a streaming response.
type StreamChunk struct { type StreamChunk struct {
ID string `json:"id"` ID string `json:"id"`
+60
View File
@@ -23,6 +23,11 @@ type fakeUpstream struct {
} }
func (f *fakeUpstream) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (f *fakeUpstream) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/models" {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"object":"list","data":[{"id":"ds-chat"},{"id":"ds-haiku"}]}`)
return
}
if r.URL.Path != "/chat/completions" { if r.URL.Path != "/chat/completions" {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
return return
@@ -683,6 +688,61 @@ func TestApplyRejectsWrongTarget(t *testing.T) {
} }
} }
func TestFetchModels(t *testing.T) {
env := newTestEnv(t)
resp := env.post(t, "/api/fetch-models", map[string]any{
"base_url": env.upstreamURL,
"api_key": "up-key",
}, "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
var out struct {
Items []struct {
ID string `json:"id"`
} `json:"items"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out.Items) != 2 || out.Items[0].ID != "ds-chat" {
t.Errorf("items = %+v", out.Items)
}
}
func TestFetchModelsRejectsBadInput(t *testing.T) {
env := newTestEnv(t)
for _, body := range []map[string]any{
{"base_url": env.upstreamURL},
{"api_key": "up-key"},
{"base_url": "ftp://x", "api_key": "k"},
} {
resp := env.post(t, "/api/fetch-models", body, "")
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("body %v status = %d, want 400", body, resp.StatusCode)
}
}
}
func TestFetchModelsUpstreamFailure(t *testing.T) {
env := newTestEnv(t)
broken := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, `{"error":{"message":"bad key"}}`)
}))
defer broken.Close()
resp := env.post(t, "/api/fetch-models", map[string]any{
"base_url": broken.URL,
"api_key": "sk-bad",
}, "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadGateway {
t.Errorf("status = %d, want 502", resp.StatusCode)
}
}
func TestListProvidersFiltersByTarget(t *testing.T) { func TestListProvidersFiltersByTarget(t *testing.T) {
env := newCodexTestEnv(t) env := newCodexTestEnv(t)
codexItems := env.get(t, "/api/providers?target=codex")["items"].([]any) codexItems := env.get(t, "/api/providers?target=codex")["items"].([]any)
+47
View File
@@ -1,10 +1,15 @@
package gateway package gateway
import ( import (
"context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"strings"
"time" "time"
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
"gitea.rutua.cn/kate522/CrossRelay/internal/claudecfg" "gitea.rutua.cn/kate522/CrossRelay/internal/claudecfg"
"gitea.rutua.cn/kate522/CrossRelay/internal/codexcfg" "gitea.rutua.cn/kate522/CrossRelay/internal/codexcfg"
"gitea.rutua.cn/kate522/CrossRelay/internal/config" "gitea.rutua.cn/kate522/CrossRelay/internal/config"
@@ -71,6 +76,48 @@ func (s *Server) handleListProviders(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"items": items}) writeJSON(w, http.StatusOK, map[string]any{"items": items})
} }
type fetchModelsInput struct {
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
}
// handleFetchModels proxies the upstream model catalog so the UI can
// offer a picker without talking to the provider directly.
func (s *Server) handleFetchModels(w http.ResponseWriter, r *http.Request) {
var in fetchModelsInput
if err := decodeJSON(r, &in, 1<<20); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
in.BaseURL = strings.TrimSpace(in.BaseURL)
in.APIKey = strings.TrimSpace(in.APIKey)
if in.BaseURL == "" || in.APIKey == "" {
writeAPIError(w, http.StatusBadRequest, "base URL and API key are required")
return
}
if u, err := url.Parse(in.BaseURL); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
writeAPIError(w, http.StatusBadRequest, "base URL must be an http(s) URL")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
list, err := chatcmpl.NewClient(in.BaseURL, in.APIKey).ListModels(ctx)
if err != nil {
var ue *chatcmpl.UpstreamError
if errors.As(err, &ue) {
writeAPIError(w, http.StatusBadGateway, fmt.Sprintf("upstream %d: %s", ue.StatusCode, ue.Message))
return
}
writeAPIError(w, http.StatusBadGateway, fmt.Sprintf("upstream request failed: %v", err))
return
}
items := []chatcmpl.ModelInfo{}
if list != nil {
items = list.Data
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) handleCreateProvider(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCreateProvider(w http.ResponseWriter, r *http.Request) {
var in providerInput var in providerInput
if err := decodeJSON(r, &in, 1<<20); err != nil { if err := decodeJSON(r, &in, 1<<20); err != nil {
+1
View File
@@ -75,6 +75,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /v1/messages/count_tokens", s.withAuth(s.handleCountTokens)) mux.HandleFunc("POST /v1/messages/count_tokens", s.withAuth(s.handleCountTokens))
mux.HandleFunc("POST /v1/responses", s.withAuth(s.handleResponses)) mux.HandleFunc("POST /v1/responses", s.withAuth(s.handleResponses))
mux.HandleFunc("GET /api/status", s.handleStatus) mux.HandleFunc("GET /api/status", s.handleStatus)
mux.HandleFunc("POST /api/fetch-models", s.handleFetchModels)
mux.HandleFunc("GET /api/providers", s.handleListProviders) mux.HandleFunc("GET /api/providers", s.handleListProviders)
mux.HandleFunc("POST /api/providers", s.handleCreateProvider) mux.HandleFunc("POST /api/providers", s.handleCreateProvider)
mux.HandleFunc("PUT /api/providers/{id}", s.handleUpdateProvider) mux.HandleFunc("PUT /api/providers/{id}", s.handleUpdateProvider)
+77 -16
View File
@@ -1,13 +1,15 @@
import { useState } from "react"; import { useId, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Plus, Trash2 } from "lucide-react"; import { Plus, RefreshCw, Trash2 } from "lucide-react";
import { PRESETS } from "@/lib/types"; 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 type { ApplyTarget } from "@/lib/api";
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 { Input, Label } from "@/components/ui/input"; import { Input, Label } from "@/components/ui/input";
import { Select } from "@/components/ui/select"; import { Select } from "@/components/ui/select";
import { useToast } from "@/components/toast";
export interface ProviderSubmitInput { export interface ProviderSubmitInput {
name: string; name: string;
@@ -46,7 +48,7 @@ function toMap(rows: { key: string; value: string }[]): Record<string, string> {
interface ProviderFormProps { interface ProviderFormProps {
target: ApplyTarget; target: ApplyTarget;
editing: { id: string; name: string; preset?: string; base_url: string; models: Models } | null; editing: Provider | null;
pending: boolean; pending: boolean;
onSubmit: (input: ProviderSubmitInput) => void; onSubmit: (input: ProviderSubmitInput) => void;
onCancel: () => void; onCancel: () => void;
@@ -54,6 +56,8 @@ interface ProviderFormProps {
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 uid = useId();
const [form, setForm] = useState<FormState>(() => const [form, setForm] = useState<FormState>(() =>
editing editing
? { ? {
@@ -72,6 +76,29 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
: { ...emptyForm }, : { ...emptyForm },
); );
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 [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 validate = (): boolean => {
const next: Partial<Record<keyof FormState, string>> = {}; 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"> <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>
<div className="grid grid-cols-2 gap-3"> <div className="flex items-end gap-2">
{(["default", "sonnet", "haiku", "opus"] as const).map((role) => ( <label className="flex flex-1 flex-col gap-1">
<label key={role} className="flex flex-col gap-1"> <Label>{t("providers.defaultModel")}</Label>
<Label>{t(`providers.${role}Model`)}</Label> <Input
<Input value={form.models.default ?? ""}
value={form.models[role] ?? ""} list={listId("default")}
onChange={(e) => onChange={(e) =>
setForm({ ...form, models: { ...form.models, [role]: e.target.value } }) setForm({ ...form, models: { ...form.models, default: e.target.value } })
} }
/> />
</label> <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> </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 flex-col gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-medium text-zinc-600">{t("providers.extraMappings")}</span> <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 <Input
value={row.value} value={row.value}
placeholder={t("providers.extraValue")} placeholder={t("providers.extraValue")}
list={listId(`extra-${index}`)}
onChange={(e) => { onChange={(e) => {
const extra = form.models.extra.map((r, i) => const extra = form.models.extra.map((r, i) =>
i === index ? { ...r, value: e.target.value } : r, 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 } }); setForm({ ...form, models: { ...form.models, extra } });
}} }}
/> />
<datalist id={listId(`extra-${index}`)}>
{modelList.map((m) => (
<option key={m.id} value={m.id} />
))}
</datalist>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
+10
View File
@@ -74,6 +74,11 @@ export const zhCN = {
extraKey: "请求的模型名", extraKey: "请求的模型名",
extraValue: "上游模型名", extraValue: "上游模型名",
addMapping: "添加一行", addMapping: "添加一行",
fetchModels: "获取模型列表",
fetchingModels: "获取中",
modelsFetched: "已获取 {{count}} 个模型",
modelsFetchHint: "点「获取模型列表」从供应商拉取可用模型,下方输入可从列表选择;不支持列表的供应商可手动输入。",
fetchModelsNeedKey: "请先填写 Base URL 和 API Key",
extraHint: "Codex 等客户端会把模型名原样发给网关,在此把入站模型名映射到上游模型名。", extraHint: "Codex 等客户端会把模型名原样发给网关,在此把入站模型名映射到上游模型名。",
activated: "已激活", activated: "已激活",
activate: "激活并写入", activate: "激活并写入",
@@ -202,6 +207,11 @@ export const enUS = {
extraKey: "Requested model", extraKey: "Requested model",
extraValue: "Upstream model", extraValue: "Upstream model",
addMapping: "Add row", 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.", extraHint: "Clients like Codex send their model names as-is; map inbound names onto upstream models here.",
activated: "Active", activated: "Active",
activate: "Activate & apply", activate: "Activate & apply",
+6
View File
@@ -3,6 +3,7 @@ import type {
ApplyResult, ApplyResult,
BackupInfo, BackupInfo,
LogEntry, LogEntry,
ModelInfo,
Models, Models,
Provider, Provider,
Settings, Settings,
@@ -73,6 +74,11 @@ export const api = {
method: "POST", method: "POST",
body: JSON.stringify(input), 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") => listBackups: (target: ApplyTarget = "claude") =>
request<{ items: BackupInfo[] }>(`/api/backups?target=${target}`), request<{ items: BackupInfo[] }>(`/api/backups?target=${target}`),
getSettings: () => request<Settings>("/api/settings"), getSettings: () => request<Settings>("/api/settings"),
+6
View File
@@ -76,6 +76,12 @@ export interface ApplyResult {
injection?: Injection; injection?: Injection;
} }
export interface ModelInfo {
id: string;
object?: string;
owned_by?: string;
}
export interface Injection { export interface Injection {
model_provider: string; model_provider: string;
table: Record<string, string>; table: Record<string, string>;
+22
View File
@@ -8,6 +8,7 @@ const createProvider = vi.fn();
const applyProvider = vi.fn(); const applyProvider = vi.fn();
const restore = vi.fn(); const restore = vi.fn();
const getStatus = vi.fn(); const getStatus = vi.fn();
const fetchModels = vi.fn();
vi.mock("@/lib/api", () => ({ vi.mock("@/lib/api", () => ({
api: { api: {
@@ -22,6 +23,7 @@ vi.mock("@/lib/api", () => ({
updateSettings: vi.fn(), updateSettings: vi.fn(),
listLogs: vi.fn().mockResolvedValue({ items: [] }), listLogs: vi.fn().mockResolvedValue({ items: [] }),
listBackups: vi.fn().mockResolvedValue({ items: [] }), listBackups: vi.fn().mockResolvedValue({ items: [] }),
fetchModels: (input: unknown) => fetchModels(input),
}, },
})); }));
@@ -70,6 +72,7 @@ beforeEach(() => {
}, },
}); });
restore.mockResolvedValue({ restored_from: "" }); restore.mockResolvedValue({ restored_from: "" });
fetchModels.mockResolvedValue({ items: [{ id: "deepseek-chat" }, { id: "deepseek-reasoner" }] });
}); });
describe("Codex page", () => { 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 () => { it("activates a provider for Codex and previews the injected keys", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderWithProviders(<Codex />); renderWithProviders(<Codex />);
+28
View File
@@ -14,6 +14,7 @@ const listLogs = vi.fn();
const listBackups = vi.fn(); const listBackups = vi.fn();
const updateProvider = vi.fn(); const updateProvider = vi.fn();
const updateSettings = vi.fn(); const updateSettings = vi.fn();
const fetchModels = vi.fn();
vi.mock("@/lib/api", () => ({ vi.mock("@/lib/api", () => ({
api: { api: {
@@ -28,6 +29,7 @@ vi.mock("@/lib/api", () => ({
updateSettings: (patch: unknown) => updateSettings(patch), updateSettings: (patch: unknown) => updateSettings(patch),
listLogs: () => listLogs(), listLogs: () => listLogs(),
listBackups: () => listBackups(), listBackups: () => listBackups(),
fetchModels: (input: unknown) => fetchModels(input),
}, },
})); }));
@@ -55,6 +57,7 @@ beforeEach(() => {
createProvider.mockResolvedValue({ ...provider, id: "p_2" }); createProvider.mockResolvedValue({ ...provider, id: "p_2" });
deleteProvider.mockResolvedValue({ deleted: "p_1" }); deleteProvider.mockResolvedValue({ deleted: "p_1" });
applyProvider.mockResolvedValue({ provider, env: { ANTHROPIC_BASE_URL: "http://127.0.0.1:1234" } }); 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", () => { 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 () => { it("activates a provider and shows the applied env", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderPage(); renderPage();