From 97a1ef93d7551651aea0286f734d551081ce65ab Mon Sep 17 00:00:00 2001 From: Sergey Skorokhod Date: Sun, 4 Oct 2026 10:55:36 +0300 Subject: [PATCH] feat(api): implement GET /v1/models/{model} retrieve model endpoint - Register GET /v1/models/{model...} route to support retrieving single model details - Support model lookup by canonical ID, 'models/' prefix, and configured aliases - Return Anthropic model object when Anthropic-Version header is present - Return standard 404 model_not_found error when model does not exist - Add comprehensive unit tests in internal/api/models_test.go - Update API endpoint documentation in README and docs --- README.md | 2 +- README_en.md | 2 +- docs/development.md | 2 +- docs/protocol.md | 8 +- internal/api/anthropic.go | 9 ++ internal/api/models_test.go | 240 ++++++++++++++++++++++++++++++++++++ internal/api/openai.go | 96 +++++++++++---- internal/api/router.go | 1 + 8 files changed, 328 insertions(+), 32 deletions(-) create mode 100644 internal/api/models_test.go diff --git a/README.md b/README.md index c3a0439..6f69378 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ providers: | 能力 | 端点 | | --- | --- | -| 模型 | `GET /v1/models`、`GET /v1beta/models` | +| 模型 | `GET /v1/models`、`GET /v1/models/{model}`、`GET /v1beta/models`、`GET /v1beta/models/{model}` | | OpenAI Chat | `POST /v1/chat/completions` | | OpenAI Responses | `POST /v1/responses` | | Files | `POST /v1/files`、`GET /v1/files/{id}`、`GET /v1/files/{id}/content`、`DELETE /v1/files/{id}` | diff --git a/README_en.md b/README_en.md index 8090434..b1d74cc 100644 --- a/README_en.md +++ b/README_en.md @@ -248,7 +248,7 @@ Main endpoints: | Capability | Endpoint | | --- | --- | -| Models | `GET /v1/models`, `GET /v1beta/models` | +| Models | `GET /v1/models`, `GET /v1/models/{model}`, `GET /v1beta/models`, `GET /v1beta/models/{model}` | | OpenAI Chat | `POST /v1/chat/completions` | | OpenAI Responses | `POST /v1/responses` | | Files | `POST /v1/files`, `GET /v1/files/{id}`, `GET /v1/files/{id}/content`, `DELETE /v1/files/{id}` | diff --git a/docs/development.md b/docs/development.md index 8e3c7c8..ea4aca4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -269,7 +269,7 @@ Worker 容量由热池目标、活动上限和单账户并发共同约束。活 | 协议 | 端点 | | --- | --- | -| OpenAI Chat | `GET /v1/models`、`POST /v1/chat/completions` | +| OpenAI Chat | `GET /v1/models`、`GET /v1/models/{model}`、`POST /v1/chat/completions` | | OpenAI Responses | `POST /v1/responses` | | Gemini Interactions | `POST /v1beta/interactions`、`POST /v1/interactions` | | OpenAI Files | `POST /v1/files`、`GET/DELETE /v1/files/{file}`、`GET /v1/files/{file}/content` | diff --git a/docs/protocol.md b/docs/protocol.md index a25c1ed..7e3b2f3 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1060,7 +1060,7 @@ server content 的 index `0/1/2/4/5/6` 分别为 model content、turn complete | 协议 | 端点 | | --- | --- | -| OpenAI Chat | `GET /v1/models`、`POST /v1/chat/completions` | +| OpenAI Chat | `GET /v1/models`、`GET /v1/models/{id}`、`POST /v1/chat/completions` | | OpenAI Responses | `POST /v1/responses` | | OpenAI 媒体 | `POST /v1/images/generations`、`POST /v1/audio/speech`、`POST /v1/videos`、`GET /v1/videos/{id}`、`GET /v1/videos/{id}/content` | | Anthropic | `POST /v1/messages`、`POST /v1/messages/count_tokens` | @@ -1074,7 +1074,7 @@ server content 的 index `0/1/2/4/5/6` 分别为 model content、turn complete | OpenAI 转录 | `POST /v1/audio/transcriptions` | | 实时 WebSocket | `GET /v1/live`、`GET /v1/robotics/stream` | -动态路由的注册形状为 `GET /v1/files/{file}`、`GET /v1/files/{file}/content`、`DELETE /v1/files/{file}`、`GET /v1/videos/{video}`、`GET /v1/videos/{video}/content`、`POST /v1beta/models/{action}` 与 `GET /v1beta/operations/{operation}`;端点表中的 `{id}` 表示对应资源标识。 +动态路由的注册形状为 `GET /v1/models/{model...}`、`GET /v1/files/{file}`、`GET /v1/files/{file}/content`、`DELETE /v1/files/{file}`、`GET /v1/videos/{video}`、`GET /v1/videos/{video}/content`、`POST /v1beta/models/{action}` 与 `GET /v1beta/operations/{operation}`;端点表中的 `{id}` 表示对应资源标识。 公开 `/v1` 与 `/v1beta` 接受 `Authorization: Bearer`、`X-API-Key`、`X-Goog-API-Key` 或 `?key=`,读取优先级为 `?key=`、`X-Goog-API-Key`、`X-API-Key`、`Authorization: Bearer`;配置为空时关闭本地 API key 校验,此时 `Origin` 为 `null` 或非 localhost、非回环地址的 http/https 页面请求返回 401,不带 `Origin` 的客户端与其他 scheme 不受限制。`/v1*` 响应允许任意 origin,允许 `GET/POST/PUT/DELETE/OPTIONS` 与 `Authorization`、`Content-Type`、`X-API-Key`、`X-Goog-API-Key`、`Anthropic-Version`、`Anthropic-Beta` headers。`/v1*` 请求体上限约为 684 MiB,可容纳 Base64 编码的 512 MiB 文件。 @@ -1266,8 +1266,8 @@ Bidi setup 成功使用 lease(本次会话持有的账户租约)的 `checked | 规则 | 结果 | | --- | --- | -| OpenAI | `GET /v1/models` 返回 OpenAI model list | -| Anthropic | `GET /v1/models` 携带 `Anthropic-Version` 时返回 Anthropic model list | +| OpenAI | `GET /v1/models` 返回 OpenAI model list,`GET /v1/models/{model}` 返回单个 model 对象 | +| Anthropic | `GET /v1/models` 与 `GET /v1/models/{model}` 携带 `Anthropic-Version` 时返回 Anthropic 格式 | | Gemini | 模型名称使用 `models/` | | 多账户同模型 | generation methods 与能力选项取并集 | | 多账户 token limit | 输入和输出上限分别取正数最小值 | diff --git a/internal/api/anthropic.go b/internal/api/anthropic.go index 14af2d4..67af7b4 100644 --- a/internal/api/anthropic.go +++ b/internal/api/anthropic.go @@ -663,6 +663,15 @@ func writeAnthropicModels(w http.ResponseWriter, models []aistudio.Model) { writeJSON(w, http.StatusOK, response) } +func writeAnthropicModel(w http.ResponseWriter, model aistudio.Model) { + writeJSON(w, http.StatusOK, map[string]any{ + "id": model.ID, + "type": "model", + "display_name": model.Name, + "created_at": "1970-01-01T00:00:00Z", + }) +} + type anthropicStreamWriter struct { w http.ResponseWriter id string diff --git a/internal/api/models_test.go b/internal/api/models_test.go new file mode 100644 index 0000000..6dac6ba --- /dev/null +++ b/internal/api/models_test.go @@ -0,0 +1,240 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Mag1cFall/AIStudio2API/internal/aistudio" +) + +type mockModelService struct { + models []aistudio.Model + err error +} + +func (m *mockModelService) Models(ctx context.Context) ([]aistudio.Model, error) { + return m.models, m.err +} + +func (m *mockModelService) CountTokens(ctx context.Context, req aistudio.TokenCountRequest) (aistudio.TokenCount, error) { + return aistudio.TokenCount{}, nil +} + +func (m *mockModelService) Generate(ctx context.Context, req aistudio.GenerateRequest) (<-chan aistudio.Event, error) { + ch := make(chan aistudio.Event) + close(ch) + return ch, nil +} + +func TestOpenAIModelsEndpoint(t *testing.T) { + service := &mockModelService{ + models: []aistudio.Model{ + { + ID: "gemini-2.5-flash", + Name: "Gemini 2.5 Flash", + Description: "Fast and versatile multimodal model", + Methods: []string{"generateContent", "countTokens"}, + InputTokenLimit: 1048576, + OutputTokenLimit: 8192, + Capabilities: map[string]bool{"multimodal": true}, + CapabilityOptions: map[string][]string{ + "aliases": {"gemini-flash"}, + }, + Channels: []string{"webchannel"}, + Paid: false, + }, + { + ID: "gemini-2.5-pro", + Name: "Gemini 2.5 Pro", + Description: "Complex reasoning model", + Methods: []string{"generateContent"}, + InputTokenLimit: 2097152, + OutputTokenLimit: 8192, + Paid: true, + }, + }, + } + handler := NewHandler(service, Config{}) + + t.Run("list models openai", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp struct { + Object string `json:"object"` + Data []map[string]any `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Object != "list" || len(resp.Data) != 2 { + t.Fatalf("unexpected response: %+v", resp) + } + if resp.Data[0]["id"] != "gemini-2.5-flash" || resp.Data[0]["object"] != "model" { + t.Fatalf("unexpected model data: %+v", resp.Data[0]) + } + }) + + t.Run("list models anthropic", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + req.Header.Set("Anthropic-Version", "2023-06-01") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp struct { + Data []map[string]any `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if len(resp.Data) != 2 || resp.Data[0]["type"] != "model" { + t.Fatalf("unexpected anthropic response: %+v", resp) + } + }) + + t.Run("retrieve model by id", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v1/models/gemini-2.5-flash", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var model map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &model); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if model["id"] != "gemini-2.5-flash" { + t.Fatalf("expected id gemini-2.5-flash, got %v", model["id"]) + } + if model["object"] != "model" { + t.Fatalf("expected object model, got %v", model["object"]) + } + if model["owned_by"] != "google" { + t.Fatalf("expected owned_by google, got %v", model["owned_by"]) + } + if model["name"] != "Gemini 2.5 Flash" { + t.Fatalf("expected name Gemini 2.5 Flash, got %v", model["name"]) + } + }) + + t.Run("retrieve model with prefix models/", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v1/models/models/gemini-2.5-flash", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var model map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &model); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if model["id"] != "gemini-2.5-flash" { + t.Fatalf("expected id gemini-2.5-flash, got %v", model["id"]) + } + }) + + t.Run("retrieve model by alias", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v1/models/gemini-flash", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var model map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &model); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if model["id"] != "gemini-2.5-flash" { + t.Fatalf("expected canonical id gemini-2.5-flash, got %v", model["id"]) + } + }) + + t.Run("retrieve model not found", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v1/models/non-existent-model", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", rec.Code, rec.Body.String()) + } + + var errResp struct { + Error struct { + Message string `json:"message"` + Type string `json:"type"` + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &errResp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if errResp.Error.Code != "model_not_found" { + t.Fatalf("expected code model_not_found, got %s", errResp.Error.Code) + } + if errResp.Error.Type != "invalid_request_error" { + t.Fatalf("expected type invalid_request_error, got %s", errResp.Error.Type) + } + }) + + t.Run("retrieve model anthropic found", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v1/models/gemini-2.5-flash", nil) + req.Header.Set("Anthropic-Version", "2023-06-01") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var model map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &model); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if model["type"] != "model" || model["id"] != "gemini-2.5-flash" || model["display_name"] != "Gemini 2.5 Flash" { + t.Fatalf("unexpected anthropic model: %+v", model) + } + }) + + t.Run("retrieve model anthropic not found", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v1/models/non-existent-model", nil) + req.Header.Set("Anthropic-Version", "2023-06-01") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", rec.Code, rec.Body.String()) + } + + var errResp struct { + Type string `json:"type"` + Error struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &errResp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if errResp.Type != "error" || errResp.Error.Type != "not_found_error" { + t.Fatalf("unexpected anthropic error: %+v", errResp) + } + }) +} diff --git a/internal/api/openai.go b/internal/api/openai.go index 15cc6f5..4240421 100644 --- a/internal/api/openai.go +++ b/internal/api/openai.go @@ -74,6 +74,36 @@ type openAITool struct { var assistantImagePattern = regexp.MustCompile(`!\[[^\]]*\]\((data:image/[A-Za-z0-9.+-]+;base64,[A-Za-z0-9+/_=\r\n-]+)\)`) +func openAIModelObject(model aistudio.Model) map[string]any { + item := map[string]any{ + "id": model.ID, + "object": "model", + "created": 0, + "owned_by": "google", + "name": model.Name, + "description": model.Description, + "supported_generation_methods": model.Methods, + "input_token_limit": model.InputTokenLimit, + "output_token_limit": model.OutputTokenLimit, + } + if len(model.Capabilities) > 0 { + item["capabilities"] = model.Capabilities + } + if len(model.CapabilityOptions) > 0 { + item["capability_options"] = model.CapabilityOptions + } + if len(model.AccessModes) > 0 { + item["access_modes"] = model.AccessModes + } + if len(model.Channels) > 0 { + item["channels"] = model.Channels + } + if model.Paid { + item["paid"] = true + } + return item +} + func (s *server) handleOpenAIModels(w http.ResponseWriter, r *http.Request) { models, err := s.service.Models(r.Context()) if err != nil { @@ -88,35 +118,51 @@ func (s *server) handleOpenAIModels(w http.ResponseWriter, r *http.Request) { } data := make([]map[string]any, 0, len(models)) for _, model := range models { - item := map[string]any{ - "id": model.ID, - "object": "model", - "created": 0, - "owned_by": "google", - "name": model.Name, - "description": model.Description, - "supported_generation_methods": model.Methods, - "input_token_limit": model.InputTokenLimit, - "output_token_limit": model.OutputTokenLimit, - } - if len(model.Capabilities) > 0 { - item["capabilities"] = model.Capabilities - } - if len(model.CapabilityOptions) > 0 { - item["capability_options"] = model.CapabilityOptions - } - if len(model.AccessModes) > 0 { - item["access_modes"] = model.AccessModes + data = append(data, openAIModelObject(model)) + } + writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": data}) +} + +func (s *server) handleOpenAIModel(w http.ResponseWriter, r *http.Request) { + modelID := strings.TrimPrefix(r.PathValue("model"), "models/") + models, err := s.service.Models(r.Context()) + if err != nil { + if shouldWriteRequestError(r, err) { + if r.Header.Get("Anthropic-Version") != "" { + writeAnthropicError(w, statusFromError(err), anthropicErrorType(err), err.Error()) + } else { + writeOpenAIError(w, statusFromError(err), openAIErrorCode(err), err.Error()) + } } - if len(model.Channels) > 0 { - item["channels"] = model.Channels + return + } + for _, model := range models { + if model.ID == modelID { + if r.Header.Get("Anthropic-Version") != "" { + writeAnthropicModel(w, model) + } else { + writeJSON(w, http.StatusOK, openAIModelObject(model)) + } + return } - if model.Paid { - item["paid"] = true + } + for _, model := range models { + for _, alias := range model.CapabilityOptions["aliases"] { + if alias == modelID { + if r.Header.Get("Anthropic-Version") != "" { + writeAnthropicModel(w, model) + } else { + writeJSON(w, http.StatusOK, openAIModelObject(model)) + } + return + } } - data = append(data, item) } - writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": data}) + if r.Header.Get("Anthropic-Version") != "" { + writeAnthropicError(w, http.StatusNotFound, "not_found_error", fmt.Sprintf("model not found: %s", modelID)) + } else { + writeOpenAIError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("The model '%s' does not exist", modelID)) + } } func (s *server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/router.go b/internal/api/router.go index e0c707b..b5ee62e 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -32,6 +32,7 @@ func NewHandler(service aistudio.Service, config Config) http.Handler { s := &server{service: service, config: config, responseStates: newResponseStateStore(), thoughtSignatures: newThoughtSignatureStore()} public := http.NewServeMux() public.HandleFunc("GET /v1/models", s.handleOpenAIModels) + public.HandleFunc("GET /v1/models/{model...}", s.handleOpenAIModel) public.HandleFunc("POST /v1/chat/completions", s.handleChatCompletions) public.HandleFunc("POST /v1/responses", s.handleResponses) public.HandleFunc("POST /v1/interactions", s.handleInteraction)