From f3bf1472e7a037764fdbb6b5ac6aa54cfece3578 Mon Sep 17 00:00:00 2001 From: kate522 Date: Sun, 18 Oct 2026 19:33:32 +0800 Subject: [PATCH] Add upstream model list fetching and a Codex-specific model section --- internal/chatcmpl/client.go | 27 ++++++++++ internal/chatcmpl/client_test.go | 43 +++++++++++++++ internal/chatcmpl/types.go | 12 +++++ internal/gateway/gateway_test.go | 60 +++++++++++++++++++++ internal/gateway/handlers.go | 47 ++++++++++++++++ internal/gateway/server.go | 1 + src/components/ProviderForm.tsx | 93 ++++++++++++++++++++++++++------ src/i18n/resources.ts | 10 ++++ src/lib/api.ts | 6 +++ src/lib/types.ts | 6 +++ src/pages/Codex.test.tsx | 22 ++++++++ src/pages/Providers.test.tsx | 28 ++++++++++ 12 files changed, 339 insertions(+), 16 deletions(-) diff --git a/internal/chatcmpl/client.go b/internal/chatcmpl/client.go index e8badf5..8cfe551 100644 --- a/internal/chatcmpl/client.go +++ b/internal/chatcmpl/client.go @@ -42,6 +42,33 @@ func (c *Client) endpoint() string { 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) { body, err := json.Marshal(req) if err != nil { diff --git a/internal/chatcmpl/client_test.go b/internal/chatcmpl/client_test.go index 93483a8..63627de 100644 --- a/internal/chatcmpl/client_test.go +++ b/internal/chatcmpl/client_test.go @@ -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) { sse := strings.Join([]string{ `data: {"id":"1","choices":[{"index":0,"delta":{"content":"Hel"}}]}`, diff --git a/internal/chatcmpl/types.go b/internal/chatcmpl/types.go index 30b82a9..3ba8fe4 100644 --- a/internal/chatcmpl/types.go +++ b/internal/chatcmpl/types.go @@ -102,6 +102,18 @@ type Error struct { 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. type StreamChunk struct { ID string `json:"id"` diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 2e80822..8d85312 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -23,6 +23,11 @@ type fakeUpstream struct { } 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" { w.WriteHeader(http.StatusNotFound) 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) { env := newCodexTestEnv(t) codexItems := env.get(t, "/api/providers?target=codex")["items"].([]any) diff --git a/internal/gateway/handlers.go b/internal/gateway/handlers.go index c459243..4068a3d 100644 --- a/internal/gateway/handlers.go +++ b/internal/gateway/handlers.go @@ -1,10 +1,15 @@ package gateway import ( + "context" + "errors" "fmt" "net/http" + "net/url" + "strings" "time" + "gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl" "gitea.rutua.cn/kate522/CrossRelay/internal/claudecfg" "gitea.rutua.cn/kate522/CrossRelay/internal/codexcfg" "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}) } +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) { var in providerInput if err := decodeJSON(r, &in, 1<<20); err != nil { diff --git a/internal/gateway/server.go b/internal/gateway/server.go index a01ca93..ec67e98 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -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/responses", s.withAuth(s.handleResponses)) mux.HandleFunc("GET /api/status", s.handleStatus) + mux.HandleFunc("POST /api/fetch-models", s.handleFetchModels) mux.HandleFunc("GET /api/providers", s.handleListProviders) mux.HandleFunc("POST /api/providers", s.handleCreateProvider) mux.HandleFunc("PUT /api/providers/{id}", s.handleUpdateProvider) diff --git a/src/components/ProviderForm.tsx b/src/components/ProviderForm.tsx index 65ea9c2..c9792d7 100644 --- a/src/components/ProviderForm.tsx +++ b/src/components/ProviderForm.tsx @@ -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 { 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(() => editing ? { @@ -72,6 +76,29 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P : { ...emptyForm }, ); const [errors, setErrors] = useState>>({}); + 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> = {}; @@ -167,19 +194,47 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P
{t("providers.models")} -
- {(["default", "sonnet", "haiku", "opus"] as const).map((role) => ( - - ))} +
+ +
+ {target === "claude" && ( +
+ {(["sonnet", "haiku", "opus"] as const).map((role) => ( + + ))} +
+ )} +

{t("providers.modelsFetchHint")}

{t("providers.extraMappings")} @@ -215,6 +270,7 @@ export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: P { 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 } }); }} /> + + {modelList.map((m) => ( +