Align docs and adapters with unified OpenAI proxy architecture

This commit is contained in:
CJACK.
2026-04-03 01:49:33 +08:00
parent ca3c16c424
commit 5722f21cdd
7 changed files with 185 additions and 232 deletions

View File

@@ -31,7 +31,7 @@ flowchart LR
Client["🖥️ 客户端 / SDK\n(OpenAI / Claude / Gemini)"]
Upstream["☁️ DeepSeek API"]
subgraph DS2API["DS2API 3.x统一 Go 路由内核)"]
subgraph DS2API["DS2API 3.x统一 OpenAI 内核)"]
Router["chi Router + 中间件\n(RequestID / RealIP / Logger / Recoverer / CORS)"]
subgraph Adapters["协议适配层"]
@@ -43,13 +43,13 @@ flowchart LR
end
subgraph Runtime["运行时核心能力"]
Bridge["CLIProxy 转换桥\n(多协议 <-> OpenAI)"]
OAEngine["OpenAI ChatCompletions\n(统一工具调用与流式语义)"]
Auth["Auth Resolver\n(API key / bearer / x-goog-api-key)"]
Pool["Account Pool + Queue\n(并发槽位 + 等待队列)"]
DSClient["DeepSeek Client\n(Session / Auth / HTTP)"]
Pow["PoW WASM\n(wazero 预加载)"]
SSE["SSE/Stream Engine\n(统一流式消费)"]
Tool["Tool Sieve\n(Go/Node 语义对齐)"]
Render["Formatter\n(OpenAI/Claude/Gemini 输出)"]
end
end
@@ -58,17 +58,18 @@ flowchart LR
Router --> Admin
Router --> WebUI
OA & CA & GA --> Auth
OA & CA & GA -.账号轮询.-> Pool
OA & CA & GA -.工具调用解析.-> Tool
OA & CA & GA -.流式处理.-> SSE
OA & CA & GA -.PoW 计算.-> Pow
OA --> OAEngine
CA & GA --> Bridge
Bridge --> OAEngine
OAEngine --> Auth
OAEngine -.账号轮询.-> Pool
OAEngine -.工具调用解析.-> Tool
OAEngine -.PoW 计算.-> Pow
Auth --> DSClient
DSClient --> Upstream
Upstream --> DSClient
DSClient --> Render
Render --> Client
OAEngine --> Bridge
Bridge --> Client
```
- **后端**Go`cmd/ds2api/`、`api/`、`internal/`),不依赖 Python 运行时
@@ -78,7 +79,8 @@ flowchart LR
### 3.0 底层架构调整(相较旧版本)
- **统一路由内核**:所有协议入口统一汇聚到 `internal/server/router.go`,并在同一路由树中注册 OpenAI / Claude / Gemini / Admin / WebUI 路由,避免多入口行为漂移。
- **适配器分层更清晰**`internal/adapter/{openai,claude,gemini}` 只负责协议形态、错误格式和流式事件语义DeepSeek 侧调用统一收敛到共享调用层
- **统一执行链路**Claude / Gemini 入口先经 `internal/translatorcliproxy` 做协议转换,再进入 `openai.ChatCompletions` 统一处理工具调用与流式语义,最后再转换回原协议响应
- **适配器分层更清晰**`internal/adapter/{claude,gemini}` 负责入口/出口协议封装,`internal/adapter/openai` 负责核心执行DeepSeek 侧调用只保留在 OpenAI 内核中。
- **Tool Calling 双运行时对齐**Go 侧(`internal/util`)与 Vercel Node 侧(`internal/js/helpers/stream-tool-sieve`)保持一致的解析/防泄漏语义,覆盖 JSON / XML / invoke / text-kv 多风格输入。
- **配置与运行时设置解耦**:静态配置(`config`)与运行时策略(`settings`)通过 Admin API 分离管理,支持热更新和密码轮换失效旧 JWT。
- **流式能力升级**`/v1/responses` 与 `/v1/chat/completions` 共享更一致的工具调用增量输出策略,降低不同 SDK 下的行为差异。

View File

@@ -31,7 +31,7 @@ flowchart LR
Client["🖥️ Clients / SDKs\n(OpenAI / Claude / Gemini)"]
Upstream["☁️ DeepSeek API"]
subgraph DS2API["DS2API 3.x (Unified Go Routing Core)"]
subgraph DS2API["DS2API 3.x (Unified OpenAI Core)"]
Router["chi Router + Middleware\n(RequestID / RealIP / Logger / Recoverer / CORS)"]
subgraph Adapters["Protocol Adapters"]
@@ -43,13 +43,13 @@ flowchart LR
end
subgraph Runtime["Runtime + Core Capabilities"]
Bridge["CLIProxy Bridge\n(multi-protocol <-> OpenAI)"]
OAEngine["OpenAI ChatCompletions\n(unified tools + stream semantics)"]
Auth["Auth Resolver\n(API key / bearer / x-goog-api-key)"]
Pool["Account Pool + Queue\n(in-flight slots + wait queue)"]
DSClient["DeepSeek Client\n(session / auth / HTTP)"]
Pow["PoW WASM\n(wazero preload)"]
SSE["SSE/Stream Engine\n(unified streaming consumption)"]
Tool["Tool Sieve\n(Go/Node semantic parity)"]
Render["Formatter\n(OpenAI/Claude/Gemini output)"]
end
end
@@ -58,17 +58,18 @@ flowchart LR
Router --> Admin
Router --> WebUI
OA & CA & GA --> Auth
OA & CA & GA -.account rotation.-> Pool
OA & CA & GA -.tool-call parsing.-> Tool
OA & CA & GA -.stream handling.-> SSE
OA & CA & GA -.PoW solving.-> Pow
OA --> OAEngine
CA & GA --> Bridge
Bridge --> OAEngine
OAEngine --> Auth
OAEngine -.account rotation.-> Pool
OAEngine -.tool-call parsing.-> Tool
OAEngine -.PoW solving.-> Pow
Auth --> DSClient
DSClient --> Upstream
Upstream --> DSClient
DSClient --> Render
Render --> Client
OAEngine --> Bridge
Bridge --> Client
```
- **Backend**: Go (`cmd/ds2api/`, `api/`, `internal/`), no Python runtime
@@ -78,7 +79,8 @@ flowchart LR
### 3.0 Architecture Changes (vs older releases)
- **Unified routing core**: all protocol entries are now centralized through `internal/server/router.go`, with OpenAI / Claude / Gemini / Admin / WebUI routes registered in one tree to avoid multi-entry drift.
- **Cleaner adapter boundaries**: `internal/adapter/{openai,claude,gemini}` focuses on protocol shapes, error contracts, and streaming semantics, while upstream DeepSeek invocation stays shared.
- **Unified execution chain**: Claude/Gemini entries are translated by `internal/translatorcliproxy`, then executed through `openai.ChatCompletions` for shared tool-calling and stream semantics, then translated back to the client protocol.
- **Cleaner adapter boundaries**: `internal/adapter/{claude,gemini}` handles protocol wrappers, while `internal/adapter/openai` remains the execution core; upstream DeepSeek calls are retained only in the OpenAI core.
- **Tool-calling parity across runtimes**: Go (`internal/util`) and Vercel Node (`internal/js/helpers/stream-tool-sieve`) follow aligned parsing/anti-leak semantics across JSON / XML / invoke / text-kv inputs.
- **Config/runtime separation**: static config (`config`) and runtime policy (`settings`) are managed independently via Admin APIs, enabling hot updates and password rotation with JWT invalidation.
- **Streaming behavior upgrade**: `/v1/responses` and `/v1/chat/completions` now share a more consistent incremental tool-call emission strategy across SDK ecosystems.

View File

@@ -3,17 +3,12 @@ package claude
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"time"
"ds2api/internal/auth"
"ds2api/internal/config"
claudefmt "ds2api/internal/format/claude"
"ds2api/internal/sse"
streamengine "ds2api/internal/stream"
"ds2api/internal/translatorcliproxy"
"ds2api/internal/util"
@@ -25,80 +20,17 @@ func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(r.Header.Get("anthropic-version")) == "" {
r.Header.Set("anthropic-version", "2023-06-01")
}
if h.OpenAI != nil {
if h.proxyViaOpenAI(w, r) {
return
}
}
a, err := h.Auth.Determine(r)
if err != nil {
status := http.StatusUnauthorized
detail := err.Error()
if err == auth.ErrNoAccount {
status = http.StatusTooManyRequests
}
writeClaudeError(w, status, detail)
if h.OpenAI == nil {
writeClaudeError(w, http.StatusInternalServerError, "OpenAI proxy backend unavailable.")
return
}
defer h.Auth.Release(a)
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeClaudeError(w, http.StatusBadRequest, "invalid json")
if h.proxyViaOpenAI(w, r, h.Store) {
return
}
norm, err := normalizeClaudeRequest(h.Store, req)
if err != nil {
writeClaudeError(w, http.StatusBadRequest, err.Error())
return
}
stdReq := norm.Standard
sessionID, err := h.DS.CreateSession(r.Context(), a, 3)
if err != nil {
writeClaudeError(w, http.StatusUnauthorized, "invalid token.")
return
}
pow, err := h.DS.GetPow(r.Context(), a, 3)
if err != nil {
writeClaudeError(w, http.StatusUnauthorized, "Failed to get PoW")
return
}
requestPayload := stdReq.CompletionPayload(sessionID)
resp, err := h.DS.CallCompletion(r.Context(), a, requestPayload, pow, 3)
if err != nil {
writeClaudeError(w, http.StatusInternalServerError, "Failed to get Claude response.")
return
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
writeClaudeError(w, http.StatusInternalServerError, string(body))
return
}
if stdReq.Stream {
h.handleClaudeStreamRealtime(w, r, resp, stdReq.ResponseModel, norm.NormalizedMessages, stdReq.Thinking, stdReq.Search, stdReq.ToolNames)
return
}
result := sse.CollectStream(resp, stdReq.Thinking, true)
respBody := claudefmt.BuildMessageResponse(
fmt.Sprintf("msg_%d", time.Now().UnixNano()),
stdReq.ResponseModel,
norm.NormalizedMessages,
result.Thinking,
result.Text,
stdReq.ToolNames,
)
if result.OutputTokens > 0 {
if usage, ok := respBody["usage"].(map[string]any); ok {
usage["output_tokens"] = result.OutputTokens
}
}
writeJSON(w, http.StatusOK, respBody)
writeClaudeError(w, http.StatusBadGateway, "Failed to proxy Claude request.")
}
func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request) bool {
func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, store ConfigReader) bool {
raw, err := io.ReadAll(r.Body)
if err != nil {
writeClaudeError(w, http.StatusBadRequest, "invalid body")
@@ -111,7 +43,15 @@ func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request) bool {
}
model, _ := req["model"].(string)
stream := util.ToBool(req["stream"])
translatedReq := translatorcliproxy.ToOpenAI(sdktranslator.FormatClaude, model, raw, stream)
// Preserve claude_mapping (fast/slow/opus routing) while proxying via OpenAI.
translateModel := model
if store != nil {
if norm, normErr := normalizeClaudeRequest(store, cloneMap(req)); normErr == nil && strings.TrimSpace(norm.Standard.ResolvedModel) != "" {
translateModel = strings.TrimSpace(norm.Standard.ResolvedModel)
}
}
translatedReq := translatorcliproxy.ToOpenAI(sdktranslator.FormatClaude, translateModel, raw, stream)
isVercelPrepare := strings.TrimSpace(r.URL.Query().Get("__stream_prepare")) == "1"
isVercelRelease := strings.TrimSpace(r.URL.Query().Get("__stream_release")) == "1"

View File

@@ -8,6 +8,14 @@ import (
"testing"
)
type claudeProxyStoreStub struct {
mapping map[string]string
}
func (s claudeProxyStoreStub) ClaudeMapping() map[string]string {
return s.mapping
}
type openAIProxyStub struct {
status int
body string
@@ -22,6 +30,21 @@ func (s openAIProxyStub) ChatCompletions(w http.ResponseWriter, _ *http.Request)
_, _ = w.Write([]byte(s.body))
}
type openAIProxyCaptureStub struct {
seenModel string
}
func (s *openAIProxyCaptureStub) ChatCompletions(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
if m, ok := req["model"].(string); ok {
s.seenModel = m
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":"ok","choices":[{"message":{"role":"assistant","content":"ok"}}]}`))
}
func TestClaudeProxyViaOpenAIVercelPreparePassthrough(t *testing.T) {
h := &Handler{OpenAI: openAIProxyStub{status: 200, body: `{"lease_id":"lease_123","payload":{"a":1}}`}}
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages?__stream_prepare=1", strings.NewReader(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hi"}],"stream":true}`))
@@ -40,3 +63,22 @@ func TestClaudeProxyViaOpenAIVercelPreparePassthrough(t *testing.T) {
t.Fatalf("expected lease_id in prepare passthrough, got=%v", out)
}
}
func TestClaudeProxyViaOpenAIPreservesClaudeMapping(t *testing.T) {
openAI := &openAIProxyCaptureStub{}
h := &Handler{
Store: claudeProxyStoreStub{mapping: map[string]string{"fast": "deepseek-chat", "slow": "deepseek-reasoner"}},
OpenAI: openAI,
}
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(`{"model":"claude-3-opus","messages":[{"role":"user","content":"hi"}],"stream":false}`))
rec := httptest.NewRecorder()
h.Messages(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if got := strings.TrimSpace(openAI.seenModel); got != "deepseek-reasoner" {
t.Fatalf("expected mapped proxy model deepseek-reasoner, got %q", got)
}
}

View File

@@ -1,7 +1,6 @@
package claude
import (
"context"
"net/http"
"net/http/httptest"
"strings"
@@ -9,48 +8,17 @@ import (
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"ds2api/internal/auth"
)
type streamStatusClaudeAuthStub struct{}
type streamStatusClaudeOpenAIStub struct{}
func (streamStatusClaudeAuthStub) Determine(_ *http.Request) (*auth.RequestAuth, error) {
return &auth.RequestAuth{
UseConfigToken: false,
DeepSeekToken: "direct-token",
CallerID: "caller:test",
TriedAccounts: map[string]bool{},
}, nil
func (streamStatusClaudeOpenAIStub) ChatCompletions(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"},\"finish_reason\":null}]}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}
func (streamStatusClaudeAuthStub) Release(_ *auth.RequestAuth) {}
type streamStatusClaudeDSStub struct{}
func (streamStatusClaudeDSStub) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
return "session-id", nil
}
func (streamStatusClaudeDSStub) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
return "pow", nil
}
func (streamStatusClaudeDSStub) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) {
body := "data: {\"p\":\"response/content\",\"v\":\"hello\"}\n" + "data: [DONE]\n"
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: ioNopCloser{strings.NewReader(body)},
}, nil
}
type ioNopCloser struct {
*strings.Reader
}
func (ioNopCloser) Close() error { return nil }
type streamStatusClaudeStoreStub struct{}
func (streamStatusClaudeStoreStub) ClaudeMapping() map[string]string {
@@ -73,9 +41,8 @@ func captureClaudeStatusMiddleware(statuses *[]int) func(http.Handler) http.Hand
func TestClaudeMessagesStreamStatusCapturedAs200(t *testing.T) {
statuses := make([]int, 0, 1)
h := &Handler{
Store: streamStatusClaudeStoreStub{},
Auth: streamStatusClaudeAuthStub{},
DS: streamStatusClaudeDSStub{},
Store: streamStatusClaudeStoreStub{},
OpenAI: streamStatusClaudeOpenAIStub{},
}
r := chi.NewRouter()
r.Use(captureClaudeStatusMiddleware(&statuses))
@@ -83,7 +50,6 @@ func TestClaudeMessagesStreamStatusCapturedAs200(t *testing.T) {
reqBody := `{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hi"}],"stream":true}`
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(reqBody))
req.Header.Set("Authorization", "Bearer direct-token")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)

View File

@@ -10,7 +10,6 @@ import (
"github.com/go-chi/chi/v5"
"ds2api/internal/auth"
"ds2api/internal/sse"
"ds2api/internal/translatorcliproxy"
"ds2api/internal/util"
@@ -19,62 +18,14 @@ import (
)
func (h *Handler) handleGenerateContent(w http.ResponseWriter, r *http.Request, stream bool) {
if h.OpenAI != nil {
if h.proxyViaOpenAI(w, r, stream) {
return
}
}
a, err := h.Auth.Determine(r)
if err != nil {
status := http.StatusUnauthorized
detail := err.Error()
if err == auth.ErrNoAccount {
status = http.StatusTooManyRequests
}
writeGeminiError(w, status, detail)
if h.OpenAI == nil {
writeGeminiError(w, http.StatusInternalServerError, "OpenAI proxy backend unavailable.")
return
}
defer h.Auth.Release(a)
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeGeminiError(w, http.StatusBadRequest, "invalid json")
if h.proxyViaOpenAI(w, r, stream) {
return
}
routeModel := strings.TrimSpace(chi.URLParam(r, "model"))
stdReq, err := normalizeGeminiRequest(h.Store, routeModel, req, stream)
if err != nil {
writeGeminiError(w, http.StatusBadRequest, err.Error())
return
}
sessionID, err := h.DS.CreateSession(r.Context(), a, 3)
if err != nil {
if a.UseConfigToken {
writeGeminiError(w, http.StatusUnauthorized, "Account token is invalid. Please re-login the account in admin.")
} else {
writeGeminiError(w, http.StatusUnauthorized, "Invalid token.")
}
return
}
pow, err := h.DS.GetPow(r.Context(), a, 3)
if err != nil {
writeGeminiError(w, http.StatusUnauthorized, "Failed to get PoW (invalid token or unknown error).")
return
}
payload := stdReq.CompletionPayload(sessionID)
resp, err := h.DS.CallCompletion(r.Context(), a, payload, pow, 3)
if err != nil {
writeGeminiError(w, http.StatusInternalServerError, "Failed to get completion.")
return
}
if stream {
h.handleStreamGenerateContent(w, r, resp, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames)
return
}
h.handleNonStreamGenerateContent(w, resp, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.ToolNames)
writeGeminiError(w, http.StatusBadGateway, "Failed to proxy Gemini request.")
}
func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, stream bool) bool {
@@ -139,13 +90,7 @@ func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, stream
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if res.StatusCode < 200 || res.StatusCode >= 300 {
for k, vv := range res.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(res.StatusCode)
_, _ = w.Write(body)
writeGeminiErrorFromOpenAI(w, res.StatusCode, body)
return true
}
if isVercelPrepare {
@@ -165,6 +110,22 @@ func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, stream
return true
}
func writeGeminiErrorFromOpenAI(w http.ResponseWriter, status int, raw []byte) {
message := strings.TrimSpace(string(raw))
var parsed map[string]any
if err := json.Unmarshal(raw, &parsed); err == nil {
if errObj, ok := parsed["error"].(map[string]any); ok {
if msg, ok := errObj["message"].(string); ok && strings.TrimSpace(msg) != "" {
message = strings.TrimSpace(msg)
}
}
}
if message == "" {
message = http.StatusText(status)
}
writeGeminiError(w, status, message)
}
func (h *Handler) handleNonStreamGenerateContent(w http.ResponseWriter, resp *http.Response, model, finalPrompt string, thinkingEnabled bool, toolNames []string) {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {

View File

@@ -61,6 +61,40 @@ func (m testGeminiDS) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ m
return m.resp, nil
}
type geminiOpenAIErrorStub struct {
status int
body string
}
func (s geminiOpenAIErrorStub) ChatCompletions(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(s.status)
_, _ = w.Write([]byte(s.body))
}
type geminiOpenAISuccessStub struct {
stream bool
body string
}
func (s geminiOpenAISuccessStub) ChatCompletions(w http.ResponseWriter, _ *http.Request) {
if s.stream {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello \"},\"finish_reason\":null}]}\n\n"))
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"world\"},\"finish_reason\":\"stop\"}]}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
return
}
out := s.body
if strings.TrimSpace(out) == "" {
out = `{"id":"chatcmpl-1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"eval_javascript","arguments":"{\"code\":\"1+1\"}"}}]},"finish_reason":"tool_calls"}]}`
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(out))
}
func makeGeminiUpstreamResponse(lines ...string) *http.Response {
body := strings.Join(lines, "\n")
if !strings.HasSuffix(body, "\n") {
@@ -98,14 +132,11 @@ func TestGeminiRoutesRegistered(t *testing.T) {
}
func TestGenerateContentReturnsFunctionCallParts(t *testing.T) {
upstream := makeGeminiUpstreamResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"eval_javascript\",\"input\":{\"code\":\"1+1\"}}]}"}`,
`data: [DONE]`,
)
h := &Handler{
Store: testGeminiConfig{},
Auth: testGeminiAuth{},
DS: testGeminiDS{resp: upstream},
OpenAI: geminiOpenAISuccessStub{
body: `{"id":"chatcmpl-1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"eval_javascript","arguments":"{\"code\":\"1+1\"}"}}]},"finish_reason":"tool_calls"}]}`,
},
}
r := chi.NewRouter()
RegisterRoutes(r, h)
@@ -115,7 +146,6 @@ func TestGenerateContentReturnsFunctionCallParts(t *testing.T) {
"tools":[{"functionDeclarations":[{"name":"eval_javascript","description":"eval","parameters":{"type":"object","properties":{"code":{"type":"string"}}}}]}]
}`
req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-pro:generateContent", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer direct-token")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
@@ -144,11 +174,7 @@ func TestGenerateContentReturnsFunctionCallParts(t *testing.T) {
}
func TestGenerateContentMixedToolSnippetAlsoTriggersFunctionCall(t *testing.T) {
upstream := makeGeminiUpstreamResponse(
`data: {"p":"response/content","v":"我来调用工具\n{\"tool_calls\":[{\"name\":\"eval_javascript\",\"input\":{\"code\":\"1+1\"}}]}"}`,
`data: [DONE]`,
)
h := &Handler{Store: testGeminiConfig{}, Auth: testGeminiAuth{}, DS: testGeminiDS{resp: upstream}}
h := &Handler{Store: testGeminiConfig{}, OpenAI: geminiOpenAISuccessStub{}}
r := chi.NewRouter()
RegisterRoutes(r, h)
@@ -157,7 +183,6 @@ func TestGenerateContentMixedToolSnippetAlsoTriggersFunctionCall(t *testing.T) {
"tools":[{"functionDeclarations":[{"name":"eval_javascript","description":"eval","parameters":{"type":"object","properties":{"code":{"type":"string"}}}}]}]
}`
req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-pro:generateContent", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer direct-token")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
@@ -180,38 +205,25 @@ func TestGenerateContentMixedToolSnippetAlsoTriggersFunctionCall(t *testing.T) {
}
func TestStreamGenerateContentEmitsSSE(t *testing.T) {
upstream := makeGeminiUpstreamResponse(
`data: {"p":"response/content","v":"hello "}`,
`data: {"p":"response/content","v":"world"}`,
`data: [DONE]`,
)
h := &Handler{
Store: testGeminiConfig{},
Auth: testGeminiAuth{},
DS: testGeminiDS{resp: upstream},
Store: testGeminiConfig{},
OpenAI: geminiOpenAISuccessStub{stream: true},
}
r := chi.NewRouter()
RegisterRoutes(r, h)
body := `{"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/models/gemini-2.5-pro:streamGenerateContent?alt=sse", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer direct-token")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "data: ") {
t.Fatalf("expected SSE data frames, got body=%s", rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"finishReason":"STOP"`) {
t.Fatalf("expected stream finish frame, got body=%s", rec.Body.String())
}
frames := extractGeminiSSEFrames(t, rec.Body.String())
if len(frames) == 0 {
t.Fatalf("expected non-empty sse frames, body=%s", rec.Body.String())
t.Fatalf("expected non-empty stream frames, body=%s", rec.Body.String())
}
last := frames[len(frames)-1]
candidates, _ := last["candidates"].([]any)
@@ -229,16 +241,44 @@ func TestStreamGenerateContentEmitsSSE(t *testing.T) {
}
}
func TestGenerateContentOpenAIProxyErrorUsesGeminiEnvelope(t *testing.T) {
h := &Handler{
Store: testGeminiConfig{},
OpenAI: geminiOpenAIErrorStub{status: http.StatusUnauthorized, body: `{"error":{"message":"invalid api key"}}`},
}
r := chi.NewRouter()
RegisterRoutes(r, h)
req := httptest.NewRequest(http.MethodPost, "/v1/models/gemini-2.5-pro:generateContent", strings.NewReader(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d body=%s", rec.Code, rec.Body.String())
}
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("expected json body: %v", err)
}
errObj, _ := out["error"].(map[string]any)
if errObj["status"] != "UNAUTHENTICATED" {
t.Fatalf("expected Gemini status UNAUTHENTICATED, got=%v", errObj["status"])
}
if errObj["message"] != "invalid api key" {
t.Fatalf("expected parsed error message, got=%v", errObj["message"])
}
}
func extractGeminiSSEFrames(t *testing.T, body string) []map[string]any {
t.Helper()
scanner := bufio.NewScanner(strings.NewReader(body))
out := make([]map[string]any, 0, 4)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "data: ") {
continue
raw := line
if strings.HasPrefix(line, "data: ") {
raw = strings.TrimSpace(strings.TrimPrefix(line, "data: "))
}
raw := strings.TrimSpace(strings.TrimPrefix(line, "data: "))
if raw == "" {
continue
}