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

+60
View File
@@ -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)
+47
View File
@@ -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 {
+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/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)