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
@@ -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 {
|
||||
|
||||
@@ -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"}}]}`,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user