Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}` |
Expand Down
2 changes: 1 addition & 1 deletion README_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}` |
Expand Down
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
8 changes: 4 additions & 4 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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 文件。

Expand Down Expand Up @@ -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/<ID>` |
| 多账户同模型 | generation methods 与能力选项取并集 |
| 多账户 token limit | 输入和输出上限分别取正数最小值 |
Expand Down
9 changes: 9 additions & 0 deletions internal/api/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
240 changes: 240 additions & 0 deletions internal/api/models_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
Loading