From 443fa4ad8e48eeae12b155b54dc9a02adf2dacac Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Thu, 2 Apr 2026 22:28:36 +0800 Subject: [PATCH 1/9] fix: handle vercel prepare/release passthrough in translated proxy paths --- go.mod | 16 ++- go.sum | 31 +++++ internal/adapter/claude/deps.go | 4 + internal/adapter/claude/handler_messages.go | 96 ++++++++++++++++ internal/adapter/claude/handler_routes.go | 7 +- internal/adapter/claude/proxy_vercel_test.go | 42 +++++++ internal/adapter/gemini/deps.go | 4 + internal/adapter/gemini/handler_generate.go | 98 ++++++++++++++++ internal/adapter/gemini/handler_routes.go | 7 +- internal/adapter/gemini/proxy_vercel_test.go | 42 +++++++ internal/server/router.go | 4 +- internal/translatorcliproxy/bridge.go | 67 +++++++++++ internal/translatorcliproxy/bridge_test.go | 72 ++++++++++++ internal/translatorcliproxy/stream_writer.go | 108 ++++++++++++++++++ .../translatorcliproxy/stream_writer_test.go | 44 +++++++ 15 files changed, 630 insertions(+), 12 deletions(-) create mode 100644 internal/adapter/claude/proxy_vercel_test.go create mode 100644 internal/adapter/gemini/proxy_vercel_test.go create mode 100644 internal/translatorcliproxy/bridge.go create mode 100644 internal/translatorcliproxy/bridge_test.go create mode 100644 internal/translatorcliproxy/stream_writer.go create mode 100644 internal/translatorcliproxy/stream_writer_test.go diff --git a/go.mod b/go.mod index 060a56a..0d3452e 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,25 @@ module ds2api -go 1.24 +go 1.26.0 require ( github.com/andybalholm/brotli v1.0.6 github.com/go-chi/chi/v5 v5.2.3 github.com/google/uuid v1.6.0 - github.com/refraction-networking/utls v1.8.1 + github.com/refraction-networking/utls v1.8.2 github.com/tetratelabs/wazero v1.9.0 ) require ( github.com/klauspost/compress v1.17.4 // indirect - golang.org/x/crypto v0.36.0 // indirect - golang.org/x/sys v0.31.0 // indirect + github.com/router-for-me/CLIProxyAPI/v6 v6.9.8 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.38.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 03b6d07..09822f7 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,47 @@ github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo= github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/router-for-me/CLIProxyAPI/v6 v6.9.8 h1:O65R38THenp8E1IK0paQlOfop3Y6UYlfqSdLlepidSY= +github.com/router-for-me/CLIProxyAPI/v6 v6.9.8/go.mod h1:P1jsIPFXorYGuS2N/3BlZYkpRKi/z7+oR3+1tdG0u4k= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/adapter/claude/deps.go b/internal/adapter/claude/deps.go index 73203b2..a6398b3 100644 --- a/internal/adapter/claude/deps.go +++ b/internal/adapter/claude/deps.go @@ -24,6 +24,10 @@ type ConfigReader interface { ClaudeMapping() map[string]string } +type OpenAIChatRunner interface { + ChatCompletions(w http.ResponseWriter, r *http.Request) +} + var _ AuthResolver = (*auth.Resolver)(nil) var _ DeepSeekCaller = (*deepseek.Client)(nil) var _ ConfigReader = (*config.Store)(nil) diff --git a/internal/adapter/claude/handler_messages.go b/internal/adapter/claude/handler_messages.go index 1c4272b..5b553dc 100644 --- a/internal/adapter/claude/handler_messages.go +++ b/internal/adapter/claude/handler_messages.go @@ -1,10 +1,12 @@ package claude import ( + "bytes" "encoding/json" "fmt" "io" "net/http" + "net/http/httptest" "strings" "time" @@ -13,12 +15,21 @@ import ( claudefmt "ds2api/internal/format/claude" "ds2api/internal/sse" streamengine "ds2api/internal/stream" + "ds2api/internal/translatorcliproxy" + "ds2api/internal/util" + + sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" ) 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 @@ -82,6 +93,91 @@ func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, respBody) } +func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request) bool { + raw, err := io.ReadAll(r.Body) + if err != nil { + writeClaudeError(w, http.StatusBadRequest, "invalid body") + return true + } + var req map[string]any + if err := json.Unmarshal(raw, &req); err != nil { + writeClaudeError(w, http.StatusBadRequest, "invalid json") + return true + } + model, _ := req["model"].(string) + stream := util.ToBool(req["stream"]) + translatedReq := translatorcliproxy.ToOpenAI(sdktranslator.FormatClaude, model, raw, stream) + + isVercelPrepare := strings.TrimSpace(r.URL.Query().Get("__stream_prepare")) == "1" + isVercelRelease := strings.TrimSpace(r.URL.Query().Get("__stream_release")) == "1" + + if isVercelRelease { + proxyReq := r.Clone(r.Context()) + proxyReq.URL.Path = "/v1/chat/completions" + proxyReq.Body = io.NopCloser(bytes.NewReader(raw)) + proxyReq.ContentLength = int64(len(raw)) + rec := httptest.NewRecorder() + h.OpenAI.ChatCompletions(rec, proxyReq) + res := rec.Result() + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + for k, vv := range res.Header { + for _, v := range vv { + w.Header().Add(k, v) + } + } + w.WriteHeader(res.StatusCode) + _, _ = w.Write(body) + return true + } + + proxyReq := r.Clone(r.Context()) + proxyReq.URL.Path = "/v1/chat/completions" + proxyReq.Body = io.NopCloser(bytes.NewReader(translatedReq)) + proxyReq.ContentLength = int64(len(translatedReq)) + + if stream && !isVercelPrepare { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache, no-transform") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + streamWriter := translatorcliproxy.NewOpenAIStreamTranslatorWriter(w, sdktranslator.FormatClaude, model, raw, translatedReq) + h.OpenAI.ChatCompletions(streamWriter, proxyReq) + return true + } + + rec := httptest.NewRecorder() + h.OpenAI.ChatCompletions(rec, proxyReq) + res := rec.Result() + 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) + return true + } + if isVercelPrepare { + for k, vv := range res.Header { + for _, v := range vv { + w.Header().Add(k, v) + } + } + w.WriteHeader(res.StatusCode) + _, _ = w.Write(body) + return true + } + converted := translatorcliproxy.FromOpenAINonStream(sdktranslator.FormatClaude, model, raw, translatedReq, body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(converted) + return true +} + func (h *Handler) handleClaudeStreamRealtime(w http.ResponseWriter, r *http.Request, resp *http.Response, model string, messages []any, thinkingEnabled, searchEnabled bool, toolNames []string) { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { diff --git a/internal/adapter/claude/handler_routes.go b/internal/adapter/claude/handler_routes.go index 0376b2c..483ade7 100644 --- a/internal/adapter/claude/handler_routes.go +++ b/internal/adapter/claude/handler_routes.go @@ -15,9 +15,10 @@ import ( var writeJSON = util.WriteJSON type Handler struct { - Store ConfigReader - Auth AuthResolver - DS DeepSeekCaller + Store ConfigReader + Auth AuthResolver + DS DeepSeekCaller + OpenAI OpenAIChatRunner } var ( diff --git a/internal/adapter/claude/proxy_vercel_test.go b/internal/adapter/claude/proxy_vercel_test.go new file mode 100644 index 0000000..0439641 --- /dev/null +++ b/internal/adapter/claude/proxy_vercel_test.go @@ -0,0 +1,42 @@ +package claude + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type openAIProxyStub struct { + status int + body string +} + +func (s openAIProxyStub) ChatCompletions(w http.ResponseWriter, _ *http.Request) { + if s.status == 0 { + s.status = http.StatusOK + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(s.status) + _, _ = w.Write([]byte(s.body)) +} + +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}`)) + rec := httptest.NewRecorder() + + h.Messages(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: %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 response, got err=%v body=%s", err, rec.Body.String()) + } + if _, ok := out["lease_id"]; !ok { + t.Fatalf("expected lease_id in prepare passthrough, got=%v", out) + } +} diff --git a/internal/adapter/gemini/deps.go b/internal/adapter/gemini/deps.go index 312114a..2d12249 100644 --- a/internal/adapter/gemini/deps.go +++ b/internal/adapter/gemini/deps.go @@ -24,6 +24,10 @@ type ConfigReader interface { ModelAliases() map[string]string } +type OpenAIChatRunner interface { + ChatCompletions(w http.ResponseWriter, r *http.Request) +} + var _ AuthResolver = (*auth.Resolver)(nil) var _ DeepSeekCaller = (*deepseek.Client)(nil) var _ ConfigReader = (*config.Store)(nil) diff --git a/internal/adapter/gemini/handler_generate.go b/internal/adapter/gemini/handler_generate.go index 9144a42..a6d85b5 100644 --- a/internal/adapter/gemini/handler_generate.go +++ b/internal/adapter/gemini/handler_generate.go @@ -1,19 +1,29 @@ package gemini import ( + "bytes" "encoding/json" "io" "net/http" + "net/http/httptest" "strings" "github.com/go-chi/chi/v5" "ds2api/internal/auth" "ds2api/internal/sse" + "ds2api/internal/translatorcliproxy" "ds2api/internal/util" + + sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" ) 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 @@ -67,6 +77,94 @@ func (h *Handler) handleGenerateContent(w http.ResponseWriter, r *http.Request, h.handleNonStreamGenerateContent(w, resp, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.ToolNames) } +func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, stream bool) bool { + raw, err := io.ReadAll(r.Body) + if err != nil { + writeGeminiError(w, http.StatusBadRequest, "invalid body") + return true + } + routeModel := strings.TrimSpace(chi.URLParam(r, "model")) + translatedReq := translatorcliproxy.ToOpenAI(sdktranslator.FormatGemini, routeModel, raw, stream) + if !strings.Contains(string(translatedReq), `"stream"`) { + var reqMap map[string]any + if json.Unmarshal(translatedReq, &reqMap) == nil { + reqMap["stream"] = stream + if b, e := json.Marshal(reqMap); e == nil { + translatedReq = b + } + } + } + + isVercelPrepare := strings.TrimSpace(r.URL.Query().Get("__stream_prepare")) == "1" + isVercelRelease := strings.TrimSpace(r.URL.Query().Get("__stream_release")) == "1" + + if isVercelRelease { + proxyReq := r.Clone(r.Context()) + proxyReq.URL.Path = "/v1/chat/completions" + proxyReq.Body = io.NopCloser(bytes.NewReader(raw)) + proxyReq.ContentLength = int64(len(raw)) + rec := httptest.NewRecorder() + h.OpenAI.ChatCompletions(rec, proxyReq) + res := rec.Result() + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + for k, vv := range res.Header { + for _, v := range vv { + w.Header().Add(k, v) + } + } + w.WriteHeader(res.StatusCode) + _, _ = w.Write(body) + return true + } + + proxyReq := r.Clone(r.Context()) + proxyReq.URL.Path = "/v1/chat/completions" + proxyReq.Body = io.NopCloser(bytes.NewReader(translatedReq)) + proxyReq.ContentLength = int64(len(translatedReq)) + + if stream && !isVercelPrepare { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache, no-transform") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + streamWriter := translatorcliproxy.NewOpenAIStreamTranslatorWriter(w, sdktranslator.FormatGemini, routeModel, raw, translatedReq) + h.OpenAI.ChatCompletions(streamWriter, proxyReq) + return true + } + + rec := httptest.NewRecorder() + h.OpenAI.ChatCompletions(rec, proxyReq) + res := rec.Result() + 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) + return true + } + if isVercelPrepare { + for k, vv := range res.Header { + for _, v := range vv { + w.Header().Add(k, v) + } + } + w.WriteHeader(res.StatusCode) + _, _ = w.Write(body) + return true + } + converted := translatorcliproxy.FromOpenAINonStream(sdktranslator.FormatGemini, routeModel, raw, translatedReq, body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(converted) + return true +} + 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 { diff --git a/internal/adapter/gemini/handler_routes.go b/internal/adapter/gemini/handler_routes.go index 6850b51..1355689 100644 --- a/internal/adapter/gemini/handler_routes.go +++ b/internal/adapter/gemini/handler_routes.go @@ -11,9 +11,10 @@ import ( var writeJSON = util.WriteJSON type Handler struct { - Store ConfigReader - Auth AuthResolver - DS DeepSeekCaller + Store ConfigReader + Auth AuthResolver + DS DeepSeekCaller + OpenAI OpenAIChatRunner } func RegisterRoutes(r chi.Router, h *Handler) { diff --git a/internal/adapter/gemini/proxy_vercel_test.go b/internal/adapter/gemini/proxy_vercel_test.go new file mode 100644 index 0000000..4b146bc --- /dev/null +++ b/internal/adapter/gemini/proxy_vercel_test.go @@ -0,0 +1,42 @@ +package gemini + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type openAIProxyStub struct { + status int + body string +} + +func (s openAIProxyStub) ChatCompletions(w http.ResponseWriter, _ *http.Request) { + if s.status == 0 { + s.status = http.StatusOK + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(s.status) + _, _ = w.Write([]byte(s.body)) +} + +func TestGeminiProxyViaOpenAIVercelReleasePassthrough(t *testing.T) { + h := &Handler{OpenAI: openAIProxyStub{status: 200, body: `{"success":true}`}} + req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-pro:streamGenerateContent?__stream_release=1", strings.NewReader(`{"lease_id":"lease_123"}`)) + rec := httptest.NewRecorder() + + h.StreamGenerateContent(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: %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 response, got err=%v body=%s", err, rec.Body.String()) + } + if v, ok := out["success"].(bool); !ok || !v { + t.Fatalf("expected success=true passthrough, got=%v", out) + } +} diff --git a/internal/server/router.go b/internal/server/router.go index 6672ad6..a6c71f3 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -44,8 +44,8 @@ func NewApp() *App { } openaiHandler := &openai.Handler{Store: store, Auth: resolver, DS: dsClient} - claudeHandler := &claude.Handler{Store: store, Auth: resolver, DS: dsClient} - geminiHandler := &gemini.Handler{Store: store, Auth: resolver, DS: dsClient} + claudeHandler := &claude.Handler{Store: store, Auth: resolver, DS: dsClient, OpenAI: openaiHandler} + geminiHandler := &gemini.Handler{Store: store, Auth: resolver, DS: dsClient, OpenAI: openaiHandler} adminHandler := &admin.Handler{Store: store, Pool: pool, DS: dsClient} webuiHandler := webui.NewHandler() diff --git a/internal/translatorcliproxy/bridge.go b/internal/translatorcliproxy/bridge.go new file mode 100644 index 0000000..e5dc5ac --- /dev/null +++ b/internal/translatorcliproxy/bridge.go @@ -0,0 +1,67 @@ +package translatorcliproxy + +import ( + "bytes" + "context" + "strings" + + sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + _ "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator/builtin" +) + +func ToOpenAI(from sdktranslator.Format, model string, raw []byte, stream bool) []byte { + return sdktranslator.TranslateRequest(from, sdktranslator.FormatOpenAI, model, raw, stream) +} + +func FromOpenAINonStream(to sdktranslator.Format, model string, originalReq, translatedReq, raw []byte) []byte { + var param any + return sdktranslator.TranslateNonStream(context.Background(), sdktranslator.FormatOpenAI, to, model, originalReq, translatedReq, raw, ¶m) +} + +func FromOpenAIStream(to sdktranslator.Format, model string, originalReq, translatedReq, streamBody []byte) []byte { + var out bytes.Buffer + var param any + for _, line := range bytes.Split(streamBody, []byte("\n")) { + trimmed := strings.TrimSpace(string(line)) + if trimmed == "" { + continue + } + payload := append([]byte(nil), line...) + if !bytes.HasPrefix(payload, []byte("data:")) { + continue + } + chunks := sdktranslator.TranslateStream(context.Background(), sdktranslator.FormatOpenAI, to, model, originalReq, translatedReq, payload, ¶m) + for i := range chunks { + out.Write(chunks[i]) + if !bytes.HasSuffix(chunks[i], []byte("\n")) { + out.WriteByte('\n') + } + } + } + return out.Bytes() +} + +func ParseFormat(name string) sdktranslator.Format { + switch strings.ToLower(strings.TrimSpace(name)) { + case "openai", "openai-chat", "chat", "chat-completions": + return sdktranslator.FormatOpenAI + case "openai-response", "responses", "openai-responses": + return sdktranslator.FormatOpenAIResponse + case "claude", "anthropic": + return sdktranslator.FormatClaude + case "gemini", "google": + return sdktranslator.FormatGemini + case "gemini-cli", "geminicli": + return sdktranslator.FormatGeminiCLI + case "codex", "openai-codex": + return sdktranslator.FormatCodex + case "antigravity": + return sdktranslator.FormatAntigravity + default: + return sdktranslator.FromString(name) + } +} + +func ToOpenAIByName(formatName, model string, raw []byte, stream bool) []byte { + return ToOpenAI(ParseFormat(formatName), model, raw, stream) +} diff --git a/internal/translatorcliproxy/bridge_test.go b/internal/translatorcliproxy/bridge_test.go new file mode 100644 index 0000000..5f0979f --- /dev/null +++ b/internal/translatorcliproxy/bridge_test.go @@ -0,0 +1,72 @@ +package translatorcliproxy + +import ( + "strings" + "testing" + + sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" +) + +func TestToOpenAIClaude(t *testing.T) { + raw := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hi"}],"stream":false}`) + got := ToOpenAI(sdktranslator.FormatClaude, "claude-sonnet-4-5", raw, false) + s := string(got) + if !strings.Contains(s, `"messages"`) || !strings.Contains(s, `"model"`) { + t.Fatalf("unexpected translated request: %s", s) + } +} + +func TestFromOpenAINonStreamClaude(t *testing.T) { + original := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hi"}],"stream":false}`) + translatedReq := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hi"}],"stream":false}`) + openaibody := []byte(`{"id":"chatcmpl_1","object":"chat.completion","created":1,"model":"claude-sonnet-4-5","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`) + got := FromOpenAINonStream(sdktranslator.FormatClaude, "claude-sonnet-4-5", original, translatedReq, openaibody) + if !strings.Contains(string(got), `"type":"message"`) { + t.Fatalf("expected claude response format, got: %s", string(got)) + } +} + +func TestParseFormatAliases(t *testing.T) { + cases := map[string]sdktranslator.Format{ + "responses": sdktranslator.FormatOpenAIResponse, + "anthropic": sdktranslator.FormatClaude, + "geminicli": sdktranslator.FormatGeminiCLI, + "openai-codex": sdktranslator.FormatCodex, + "antigravity": sdktranslator.FormatAntigravity, + "chat-completions": sdktranslator.FormatOpenAI, + } + for in, want := range cases { + if got := ParseFormat(in); got != want { + t.Fatalf("ParseFormat(%q)=%q want %q", in, got, want) + } + } +} + +func TestToOpenAIByNameAllSupportedFormats(t *testing.T) { + tests := []struct { + name string + format string + model string + body string + }{ + {name: "openai", format: "openai", model: "gpt-4.1", body: `{"model":"gpt-4.1","messages":[{"role":"user","content":"hi"}],"stream":false}`}, + {name: "responses", format: "responses", model: "gpt-4.1", body: `{"model":"gpt-4.1","input":"hello","stream":false}`}, + {name: "claude", format: "claude", model: "claude-sonnet-4-5", body: `{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hello"}],"stream":false}`}, + {name: "gemini", format: "gemini", model: "gemini-2.5-pro", body: `{"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`}, + {name: "gemini-cli", format: "gemini-cli", model: "gemini-2.5-pro", body: `{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"hello"}],"stream":false}`}, + {name: "codex", format: "codex", model: "gpt-5-codex", body: `{"model":"gpt-5-codex","messages":[{"role":"user","content":"hello"}],"stream":false}`}, + {name: "antigravity", format: "antigravity", model: "gpt-4.1", body: `{"model":"gpt-4.1","messages":[{"role":"user","content":"hello"}],"stream":false}`}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := ToOpenAIByName(tc.format, tc.model, []byte(tc.body), false) + if len(got) == 0 { + t.Fatalf("expected non-empty conversion result for format=%s", tc.format) + } + if !strings.Contains(string(got), `"model"`) { + t.Fatalf("expected model field in converted payload, got=%s", string(got)) + } + }) + } +} diff --git a/internal/translatorcliproxy/stream_writer.go b/internal/translatorcliproxy/stream_writer.go new file mode 100644 index 0000000..b1285b1 --- /dev/null +++ b/internal/translatorcliproxy/stream_writer.go @@ -0,0 +1,108 @@ +package translatorcliproxy + +import ( + "bytes" + "context" + "net/http" + + sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" +) + +// OpenAIStreamTranslatorWriter translates OpenAI SSE output to another client format in real-time. +type OpenAIStreamTranslatorWriter struct { + dst http.ResponseWriter + target sdktranslator.Format + model string + originalReq []byte + translatedReq []byte + param any + statusCode int + headersSent bool + lineBuf bytes.Buffer +} + +func NewOpenAIStreamTranslatorWriter(dst http.ResponseWriter, target sdktranslator.Format, model string, originalReq, translatedReq []byte) *OpenAIStreamTranslatorWriter { + return &OpenAIStreamTranslatorWriter{ + dst: dst, + target: target, + model: model, + originalReq: originalReq, + translatedReq: translatedReq, + statusCode: http.StatusOK, + } +} + +func (w *OpenAIStreamTranslatorWriter) Header() http.Header { + return w.dst.Header() +} + +func (w *OpenAIStreamTranslatorWriter) WriteHeader(statusCode int) { + if w.headersSent { + return + } + w.statusCode = statusCode + w.headersSent = true + w.dst.WriteHeader(statusCode) +} + +func (w *OpenAIStreamTranslatorWriter) Write(p []byte) (int, error) { + if !w.headersSent { + w.WriteHeader(http.StatusOK) + } + if w.statusCode < 200 || w.statusCode >= 300 { + return w.dst.Write(p) + } + w.lineBuf.Write(p) + for { + line, ok := w.readOneLine() + if !ok { + break + } + trimmed := bytes.TrimSpace(line) + if len(trimmed) == 0 { + continue + } + if !bytes.HasPrefix(trimmed, []byte("data:")) { + continue + } + chunks := sdktranslator.TranslateStream(context.Background(), sdktranslator.FormatOpenAI, w.target, w.model, w.originalReq, w.translatedReq, trimmed, &w.param) + for i := range chunks { + if len(chunks[i]) == 0 { + continue + } + if _, err := w.dst.Write(chunks[i]); err != nil { + return len(p), err + } + if !bytes.HasSuffix(chunks[i], []byte("\n")) { + if _, err := w.dst.Write([]byte("\n")); err != nil { + return len(p), err + } + } + } + if f, ok := w.dst.(http.Flusher); ok { + f.Flush() + } + } + return len(p), nil +} + +func (w *OpenAIStreamTranslatorWriter) Flush() { + if f, ok := w.dst.(http.Flusher); ok { + f.Flush() + } +} + +func (w *OpenAIStreamTranslatorWriter) Unwrap() http.ResponseWriter { + return w.dst +} + +func (w *OpenAIStreamTranslatorWriter) readOneLine() ([]byte, bool) { + b := w.lineBuf.Bytes() + idx := bytes.IndexByte(b, '\n') + if idx < 0 { + return nil, false + } + line := append([]byte(nil), b[:idx]...) + w.lineBuf.Next(idx + 1) + return line, true +} diff --git a/internal/translatorcliproxy/stream_writer_test.go b/internal/translatorcliproxy/stream_writer_test.go new file mode 100644 index 0000000..31a4aa3 --- /dev/null +++ b/internal/translatorcliproxy/stream_writer_test.go @@ -0,0 +1,44 @@ +package translatorcliproxy + +import ( + "net/http/httptest" + "strings" + "testing" + + sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" +) + +func TestOpenAIStreamTranslatorWriterClaude(t *testing.T) { + original := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hi"}],"stream":true}`) + translated := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hi"}],"stream":true}`) + + rec := httptest.NewRecorder() + w := NewOpenAIStreamTranslatorWriter(rec, sdktranslator.FormatClaude, "claude-sonnet-4-5", original, translated) + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + _, _ = w.Write([]byte("data: {\"id\":\"chatcmpl_1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"claude-sonnet-4-5\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"finish_reason\":null}]}\n\n")) + _, _ = w.Write([]byte("data: {\"id\":\"chatcmpl_1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"claude-sonnet-4-5\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"},\"finish_reason\":null}]}\n\n")) + _, _ = w.Write([]byte("data: [DONE]\n\n")) + + body := rec.Body.String() + if !strings.Contains(body, "event: message_start") { + t.Fatalf("expected claude message_start event, got: %s", body) + } +} + +func TestOpenAIStreamTranslatorWriterGemini(t *testing.T) { + original := []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`) + translated := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"hi"}],"stream":true}`) + + rec := httptest.NewRecorder() + w := NewOpenAIStreamTranslatorWriter(rec, sdktranslator.FormatGemini, "gemini-2.5-pro", original, translated) + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + _, _ = w.Write([]byte("data: {\"id\":\"chatcmpl_1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gemini-2.5-pro\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"},\"finish_reason\":null}]}\n\n")) + _, _ = w.Write([]byte("data: [DONE]\n\n")) + + body := rec.Body.String() + if !strings.Contains(body, "candidates") { + t.Fatalf("expected gemini stream payload, got: %s", body) + } +} From e958bf7e40430aa069f396ff41c01d02ea2cf872 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Thu, 2 Apr 2026 23:58:36 +0800 Subject: [PATCH 2/9] Fix SSE keep-alive passthrough, content-filter stop, and usage token propagation --- internal/adapter/claude/handler_messages.go | 5 ++ .../adapter/claude/stream_runtime_core.go | 4 + .../adapter/claude/stream_runtime_finalize.go | 3 + internal/adapter/gemini/handler_generate.go | 12 ++- .../adapter/gemini/handler_stream_runtime.go | 6 +- .../adapter/openai/chat_stream_runtime.go | 13 ++- internal/adapter/openai/handler_chat.go | 8 ++ internal/adapter/openai/responses_handler.go | 8 ++ .../openai/responses_stream_runtime_core.go | 12 +++ internal/sse/consumer.go | 14 +++- internal/sse/consumer_edge_test.go | 12 +++ internal/sse/line.go | 12 +++ internal/sse/line_test.go | 14 ++++ internal/sse/parser.go | 84 +++++++++++++++++++ internal/translatorcliproxy/stream_writer.go | 12 +++ .../translatorcliproxy/stream_writer_test.go | 13 +++ 16 files changed, 223 insertions(+), 9 deletions(-) diff --git a/internal/adapter/claude/handler_messages.go b/internal/adapter/claude/handler_messages.go index 5b553dc..ced0dc1 100644 --- a/internal/adapter/claude/handler_messages.go +++ b/internal/adapter/claude/handler_messages.go @@ -90,6 +90,11 @@ func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) { 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) } diff --git a/internal/adapter/claude/stream_runtime_core.go b/internal/adapter/claude/stream_runtime_core.go index a3dd649..e5be865 100644 --- a/internal/adapter/claude/stream_runtime_core.go +++ b/internal/adapter/claude/stream_runtime_core.go @@ -26,6 +26,7 @@ type claudeStreamRuntime struct { messageID string thinking strings.Builder text strings.Builder + outputTokens int nextBlockIndex int thinkingBlockOpen bool @@ -66,6 +67,9 @@ func (s *claudeStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse if !parsed.Parsed { return streamengine.ParsedDecision{} } + if parsed.OutputTokens > 0 { + s.outputTokens = parsed.OutputTokens + } if parsed.ErrorMessage != "" { s.upstreamErr = parsed.ErrorMessage return streamengine.ParsedDecision{Stop: true, StopReason: streamengine.StopReason("upstream_error")} diff --git a/internal/adapter/claude/stream_runtime_finalize.go b/internal/adapter/claude/stream_runtime_finalize.go index 0aff357..6a020ef 100644 --- a/internal/adapter/claude/stream_runtime_finalize.go +++ b/internal/adapter/claude/stream_runtime_finalize.go @@ -108,6 +108,9 @@ func (s *claudeStreamRuntime) finalize(stopReason string) { } outputTokens := util.EstimateTokens(finalThinking) + util.EstimateTokens(finalText) + if s.outputTokens > 0 { + outputTokens = s.outputTokens + } s.send("message_delta", map[string]any{ "type": "message_delta", "delta": map[string]any{ diff --git a/internal/adapter/gemini/handler_generate.go b/internal/adapter/gemini/handler_generate.go index a6d85b5..d2f33f1 100644 --- a/internal/adapter/gemini/handler_generate.go +++ b/internal/adapter/gemini/handler_generate.go @@ -174,12 +174,12 @@ func (h *Handler) handleNonStreamGenerateContent(w http.ResponseWriter, resp *ht } result := sse.CollectStream(resp, thinkingEnabled, true) - writeJSON(w, http.StatusOK, buildGeminiGenerateContentResponse(model, finalPrompt, result.Thinking, result.Text, toolNames)) + writeJSON(w, http.StatusOK, buildGeminiGenerateContentResponse(model, finalPrompt, result.Thinking, result.Text, toolNames, result.OutputTokens)) } -func buildGeminiGenerateContentResponse(model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any { +func buildGeminiGenerateContentResponse(model, finalPrompt, finalThinking, finalText string, toolNames []string, outputTokens int) map[string]any { parts := buildGeminiPartsFromFinal(finalText, finalThinking, toolNames) - usage := buildGeminiUsage(finalPrompt, finalThinking, finalText) + usage := buildGeminiUsage(finalPrompt, finalThinking, finalText, outputTokens) return map[string]any{ "candidates": []map[string]any{ { @@ -196,10 +196,14 @@ func buildGeminiGenerateContentResponse(model, finalPrompt, finalThinking, final } } -func buildGeminiUsage(finalPrompt, finalThinking, finalText string) map[string]any { +func buildGeminiUsage(finalPrompt, finalThinking, finalText string, outputTokens int) map[string]any { promptTokens := util.EstimateTokens(finalPrompt) reasoningTokens := util.EstimateTokens(finalThinking) completionTokens := util.EstimateTokens(finalText) + if outputTokens > 0 { + completionTokens = outputTokens + reasoningTokens = 0 + } return map[string]any{ "promptTokenCount": promptTokens, "candidatesTokenCount": reasoningTokens + completionTokens, diff --git a/internal/adapter/gemini/handler_stream_runtime.go b/internal/adapter/gemini/handler_stream_runtime.go index c6a6bcd..1fd9021 100644 --- a/internal/adapter/gemini/handler_stream_runtime.go +++ b/internal/adapter/gemini/handler_stream_runtime.go @@ -64,6 +64,7 @@ type geminiStreamRuntime struct { thinking strings.Builder text strings.Builder + outputTokens int } func newGeminiStreamRuntime( @@ -103,6 +104,9 @@ func (s *geminiStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse if !parsed.Parsed { return streamengine.ParsedDecision{} } + if parsed.OutputTokens > 0 { + s.outputTokens = parsed.OutputTokens + } if parsed.ContentFilter || parsed.ErrorMessage != "" || parsed.Stop { return streamengine.ParsedDecision{Stop: true} } @@ -176,6 +180,6 @@ func (s *geminiStreamRuntime) finalize() { }, }, "modelVersion": s.model, - "usageMetadata": buildGeminiUsage(s.finalPrompt, finalThinking, finalText), + "usageMetadata": buildGeminiUsage(s.finalPrompt, finalThinking, finalText, s.outputTokens), }) } diff --git a/internal/adapter/openai/chat_stream_runtime.go b/internal/adapter/openai/chat_stream_runtime.go index 3a25f79..d59ea66 100644 --- a/internal/adapter/openai/chat_stream_runtime.go +++ b/internal/adapter/openai/chat_stream_runtime.go @@ -36,6 +36,7 @@ type chatStreamRuntime struct { streamToolNames map[int]string thinking strings.Builder text strings.Builder + outputTokens int } func newChatStreamRuntime( @@ -165,12 +166,19 @@ func (s *chatStreamRuntime) finalize(finishReason string) { if len(detected.Calls) > 0 || s.toolCallsEmitted { finishReason = "tool_calls" } + usage := openaifmt.BuildChatUsage(s.finalPrompt, finalThinking, finalText) + if s.outputTokens > 0 { + usage["completion_tokens"] = s.outputTokens + if prompt, ok := usage["prompt_tokens"].(int); ok { + usage["total_tokens"] = prompt + s.outputTokens + } + } s.sendChunk(openaifmt.BuildChatStreamChunk( s.completionID, s.created, s.model, []map[string]any{openaifmt.BuildChatStreamFinishChoice(0, finishReason)}, - openaifmt.BuildChatUsage(s.finalPrompt, finalThinking, finalText), + usage, )) s.sendDone() } @@ -179,6 +187,9 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD if !parsed.Parsed { return streamengine.ParsedDecision{} } + if parsed.OutputTokens > 0 { + s.outputTokens = parsed.OutputTokens + } if parsed.ContentFilter || parsed.ErrorMessage != "" { return streamengine.ParsedDecision{Stop: true, StopReason: streamengine.StopReason("content_filter")} } diff --git a/internal/adapter/openai/handler_chat.go b/internal/adapter/openai/handler_chat.go index 8847097..1b2fec4 100644 --- a/internal/adapter/openai/handler_chat.go +++ b/internal/adapter/openai/handler_chat.go @@ -107,6 +107,14 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, re finalThinking := result.Thinking finalText := sanitizeLeakedOutput(result.Text) respBody := openaifmt.BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText, toolNames) + if result.OutputTokens > 0 { + if usage, ok := respBody["usage"].(map[string]any); ok { + usage["completion_tokens"] = result.OutputTokens + if prompt, ok := usage["prompt_tokens"].(int); ok { + usage["total_tokens"] = prompt + result.OutputTokens + } + } + } writeJSON(w, http.StatusOK, respBody) } diff --git a/internal/adapter/openai/responses_handler.go b/internal/adapter/openai/responses_handler.go index a7d0828..ed2c715 100644 --- a/internal/adapter/openai/responses_handler.go +++ b/internal/adapter/openai/responses_handler.go @@ -124,6 +124,14 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res } responseObj := openaifmt.BuildResponseObject(responseID, model, finalPrompt, result.Thinking, sanitizedText, toolNames) + if result.OutputTokens > 0 { + if usage, ok := responseObj["usage"].(map[string]any); ok { + usage["output_tokens"] = result.OutputTokens + if input, ok := usage["input_tokens"].(int); ok { + usage["total_tokens"] = input + result.OutputTokens + } + } + } h.getResponseStore().put(owner, responseID, responseObj) writeJSON(w, http.StatusOK, responseObj) } diff --git a/internal/adapter/openai/responses_stream_runtime_core.go b/internal/adapter/openai/responses_stream_runtime_core.go index 460ce2a..eaae51b 100644 --- a/internal/adapter/openai/responses_stream_runtime_core.go +++ b/internal/adapter/openai/responses_stream_runtime_core.go @@ -49,6 +49,7 @@ type responsesStreamRuntime struct { messagePartAdded bool sequence int failed bool + outputTokens int persistResponse func(obj map[string]any) } @@ -144,6 +145,14 @@ func (s *responsesStreamRuntime) finalize() { s.closeIncompleteFunctionItems() obj := s.buildCompletedResponseObject(finalThinking, finalText, detected) + if s.outputTokens > 0 { + if usage, ok := obj["usage"].(map[string]any); ok { + usage["output_tokens"] = s.outputTokens + if input, ok := usage["input_tokens"].(int); ok { + usage["total_tokens"] = input + s.outputTokens + } + } + } if s.persistResponse != nil { s.persistResponse(obj) } @@ -172,6 +181,9 @@ func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Pa if !parsed.Parsed { return streamengine.ParsedDecision{} } + if parsed.OutputTokens > 0 { + s.outputTokens = parsed.OutputTokens + } if parsed.ContentFilter || parsed.ErrorMessage != "" || parsed.Stop { return streamengine.ParsedDecision{Stop: true} } diff --git a/internal/sse/consumer.go b/internal/sse/consumer.go index 9e0e180..c4a1e00 100644 --- a/internal/sse/consumer.go +++ b/internal/sse/consumer.go @@ -10,8 +10,9 @@ import ( // CollectResult holds the aggregated text and thinking content from a // DeepSeek SSE stream, consumed to completion (non-streaming use case). type CollectResult struct { - Text string - Thinking string + Text string + Thinking string + OutputTokens int } // CollectStream fully consumes a DeepSeek SSE response and separates @@ -26,6 +27,7 @@ func CollectStream(resp *http.Response, thinkingEnabled bool, closeBody bool) Co } text := strings.Builder{} thinking := strings.Builder{} + outputTokens := 0 currentType := "text" if thinkingEnabled { currentType = "thinking" @@ -37,8 +39,14 @@ func CollectStream(resp *http.Response, thinkingEnabled bool, closeBody bool) Co return true } if result.Stop { + if result.OutputTokens > 0 { + outputTokens = result.OutputTokens + } return false } + if result.OutputTokens > 0 { + outputTokens = result.OutputTokens + } for _, p := range result.Parts { if p.Type == "thinking" { thinking.WriteString(p.Text) @@ -48,5 +56,5 @@ func CollectStream(resp *http.Response, thinkingEnabled bool, closeBody bool) Co } return true }) - return CollectResult{Text: text.String(), Thinking: thinking.String()} + return CollectResult{Text: text.String(), Thinking: thinking.String(), OutputTokens: outputTokens} } diff --git a/internal/sse/consumer_edge_test.go b/internal/sse/consumer_edge_test.go index 8f78f01..54f841b 100644 --- a/internal/sse/consumer_edge_test.go +++ b/internal/sse/consumer_edge_test.go @@ -138,3 +138,15 @@ func TestCollectStreamStatusFinished(t *testing.T) { t.Fatalf("expected 'Hello', got %q", result.Text) } } + +func TestCollectStreamStopsOnContentFilterStatus(t *testing.T) { + resp := makeHTTPResponse( + "data: {\"p\":\"response/content\",\"v\":\"safe\"}\n" + + "data: {\"p\":\"response/status\",\"v\":\"CONTENT_FILTER\"}\n" + + "data: {\"p\":\"response/content\",\"v\":\"blocked\"}\n", + ) + result := CollectStream(resp, false, false) + if result.Text != "safe" { + t.Fatalf("expected stream to stop before blocked tail, got %q", result.Text) + } +} diff --git a/internal/sse/line.go b/internal/sse/line.go index e63f378..b91edc7 100644 --- a/internal/sse/line.go +++ b/internal/sse/line.go @@ -10,6 +10,7 @@ type LineResult struct { ErrorMessage string Parts []ContentPart NextType string + OutputTokens int } // ParseDeepSeekContentLine centralizes one-line DeepSeek SSE parsing for both @@ -39,6 +40,16 @@ func ParseDeepSeekContentLine(raw []byte, thinkingEnabled bool, currentType stri NextType: currentType, } } + if hasContentFilterStatus(chunk) { + return LineResult{ + Parsed: true, + Stop: true, + ContentFilter: true, + ErrorMessage: "content filtered by upstream", + NextType: currentType, + OutputTokens: extractAccumulatedTokenUsage(chunk), + } + } parts, finished, nextType := ParseSSEChunkForContent(chunk, thinkingEnabled, currentType) parts = filterLeakedContentFilterParts(parts) return LineResult{ @@ -46,5 +57,6 @@ func ParseDeepSeekContentLine(raw []byte, thinkingEnabled bool, currentType stri Stop: finished, Parts: parts, NextType: nextType, + OutputTokens: extractAccumulatedTokenUsage(chunk), } } diff --git a/internal/sse/line_test.go b/internal/sse/line_test.go index 4e1d22a..a226034 100644 --- a/internal/sse/line_test.go +++ b/internal/sse/line_test.go @@ -26,6 +26,20 @@ func TestParseDeepSeekContentLineContentFilter(t *testing.T) { } } +func TestParseDeepSeekContentLineContentFilterStatus(t *testing.T) { + res := ParseDeepSeekContentLine([]byte(`data: {"p":"response/status","v":"CONTENT_FILTER"}`), false, "text") + if !res.Parsed || !res.Stop || !res.ContentFilter { + t.Fatalf("expected status-based content-filter stop result: %#v", res) + } +} + +func TestParseDeepSeekContentLineCapturesAccumulatedTokenUsage(t *testing.T) { + res := ParseDeepSeekContentLine([]byte(`data: {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":1383},{"p":"quasi_status","v":"FINISHED"}]}`), false, "text") + if res.OutputTokens != 1383 { + t.Fatalf("expected output token usage 1383, got %d", res.OutputTokens) + } +} + func TestParseDeepSeekContentLineContent(t *testing.T) { res := ParseDeepSeekContentLine([]byte(`data: {"p":"response/content","v":"hi"}`), false, "text") if !res.Parsed || res.Stop { diff --git a/internal/sse/parser.go b/internal/sse/parser.go index c20bc79..725ac1f 100644 --- a/internal/sse/parser.go +++ b/internal/sse/parser.go @@ -3,6 +3,7 @@ package sse import ( "bytes" "encoding/json" + "math" "strings" "ds2api/internal/deepseek" @@ -287,3 +288,86 @@ func extractContentRecursive(items []any, defaultType string) ([]ContentPart, bo func IsCitation(text string) bool { return bytes.HasPrefix([]byte(strings.TrimSpace(text)), []byte("[citation:")) } + +func hasContentFilterStatus(chunk map[string]any) bool { + return hasContentFilterValue(chunk) +} + +func hasContentFilterValue(v any) bool { + switch x := v.(type) { + case string: + return strings.EqualFold(strings.TrimSpace(x), "content_filter") + case []any: + for _, item := range x { + if hasContentFilterValue(item) { + return true + } + } + case map[string]any: + if p, _ := x["p"].(string); strings.Contains(strings.ToLower(p), "status") { + if s, _ := x["v"].(string); strings.EqualFold(strings.TrimSpace(s), "content_filter") { + return true + } + } + for _, vv := range x { + if hasContentFilterValue(vv) { + return true + } + } + } + return false +} + +func extractAccumulatedTokenUsage(chunk map[string]any) int { + return findAccumulatedTokenUsage(chunk) +} + +func findAccumulatedTokenUsage(v any) int { + switch x := v.(type) { + case map[string]any: + if p, _ := x["p"].(string); strings.Contains(strings.ToLower(p), "accumulated_token_usage") { + if n, ok := toInt(x["v"]); ok && n > 0 { + return n + } + } + if n, ok := toInt(x["accumulated_token_usage"]); ok && n > 0 { + return n + } + for _, vv := range x { + if n := findAccumulatedTokenUsage(vv); n > 0 { + return n + } + } + case []any: + for _, item := range x { + if n := findAccumulatedTokenUsage(item); n > 0 { + return n + } + } + } + return 0 +} + +func toInt(v any) (int, bool) { + switch x := v.(type) { + case int: + return x, true + case int32: + return int(x), true + case int64: + return int(x), true + case float64: + if math.IsNaN(x) || math.IsInf(x, 0) { + return 0, false + } + return int(x), true + case json.Number: + i, err := x.Int64() + if err != nil { + return 0, false + } + return int(i), true + default: + return 0, false + } +} diff --git a/internal/translatorcliproxy/stream_writer.go b/internal/translatorcliproxy/stream_writer.go index b1285b1..07c4bcb 100644 --- a/internal/translatorcliproxy/stream_writer.go +++ b/internal/translatorcliproxy/stream_writer.go @@ -62,6 +62,18 @@ func (w *OpenAIStreamTranslatorWriter) Write(p []byte) (int, error) { if len(trimmed) == 0 { continue } + if bytes.HasPrefix(trimmed, []byte(":")) { + if _, err := w.dst.Write(trimmed); err != nil { + return len(p), err + } + if _, err := w.dst.Write([]byte("\n\n")); err != nil { + return len(p), err + } + if f, ok := w.dst.(http.Flusher); ok { + f.Flush() + } + continue + } if !bytes.HasPrefix(trimmed, []byte("data:")) { continue } diff --git a/internal/translatorcliproxy/stream_writer_test.go b/internal/translatorcliproxy/stream_writer_test.go index 31a4aa3..979d36e 100644 --- a/internal/translatorcliproxy/stream_writer_test.go +++ b/internal/translatorcliproxy/stream_writer_test.go @@ -42,3 +42,16 @@ func TestOpenAIStreamTranslatorWriterGemini(t *testing.T) { t.Fatalf("expected gemini stream payload, got: %s", body) } } + +func TestOpenAIStreamTranslatorWriterPreservesKeepAliveComment(t *testing.T) { + rec := httptest.NewRecorder() + w := NewOpenAIStreamTranslatorWriter(rec, sdktranslator.FormatGemini, "gemini-2.5-pro", []byte(`{}`), []byte(`{}`)) + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + _, _ = w.Write([]byte(": keep-alive\n\n")) + + body := rec.Body.String() + if !strings.Contains(body, ": keep-alive\n\n") { + t.Fatalf("expected keep-alive comment passthrough, got %q", body) + } +} From 8a2c500806b8080119b24f504aff64fe0ea17e9f Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Fri, 3 Apr 2026 00:47:11 +0800 Subject: [PATCH 3/9] treat content filter as normal stop and hide leaked suffix --- .../adapter/openai/chat_stream_runtime.go | 5 +- internal/adapter/openai/stream_status_test.go | 50 +++++++++++++++++++ internal/sse/line.go | 3 +- internal/sse/line_edge_test.go | 4 +- internal/sse/line_test.go | 23 +++++++++ internal/sse/parser.go | 16 +++--- 6 files changed, 90 insertions(+), 11 deletions(-) diff --git a/internal/adapter/openai/chat_stream_runtime.go b/internal/adapter/openai/chat_stream_runtime.go index d59ea66..563b0f2 100644 --- a/internal/adapter/openai/chat_stream_runtime.go +++ b/internal/adapter/openai/chat_stream_runtime.go @@ -190,7 +190,10 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD if parsed.OutputTokens > 0 { s.outputTokens = parsed.OutputTokens } - if parsed.ContentFilter || parsed.ErrorMessage != "" { + if parsed.ContentFilter { + return streamengine.ParsedDecision{Stop: true, StopReason: streamengine.StopReasonHandlerRequested} + } + if parsed.ErrorMessage != "" { return streamengine.ParsedDecision{Stop: true, StopReason: streamengine.StopReason("content_filter")} } if parsed.Stop { diff --git a/internal/adapter/openai/stream_status_test.go b/internal/adapter/openai/stream_status_test.go index c76d881..2a3584b 100644 --- a/internal/adapter/openai/stream_status_test.go +++ b/internal/adapter/openai/stream_status_test.go @@ -183,3 +183,53 @@ func TestResponsesNonStreamMixedProseToolPayloadHandlerPath(t *testing.T) { t.Fatalf("expected function_call output item, got %#v", output) } } + +func TestChatCompletionsStreamContentFilterStopsNormallyWithoutLeak(t *testing.T) { + statuses := make([]int, 0, 1) + h := &Handler{ + Store: mockOpenAIConfig{wideInput: true}, + Auth: streamStatusAuthStub{}, + DS: streamStatusDSStub{resp: makeOpenAISSEHTTPResponse( + `data: {"p":"response/content","v":"合法前缀"}`, + `data: {"p":"response/status","v":"CONTENT_FILTER","accumulated_token_usage":77}`, + `data: {"p":"response/content","v":"CONTENT_FILTER你好,这个问题我暂时无法回答,让我们换个话题再聊聊吧。"}`, + )}, + } + r := chi.NewRouter() + r.Use(captureStatusMiddleware(&statuses)) + RegisterRoutes(r, h) + + reqBody := `{"model":"deepseek-chat","messages":[{"role":"user","content":"hi"}],"stream":true}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody)) + req.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", "application/json") + 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 len(statuses) != 1 || statuses[0] != http.StatusOK { + t.Fatalf("expected captured status 200, got %#v", statuses) + } + if strings.Contains(rec.Body.String(), "这个问题我暂时无法回答") { + t.Fatalf("expected leaked content-filter suffix to be hidden, body=%s", rec.Body.String()) + } + + frames, done := parseSSEDataFrames(t, rec.Body.String()) + if !done { + t.Fatalf("expected [DONE], body=%s", rec.Body.String()) + } + if len(frames) == 0 { + t.Fatalf("expected at least one json frame, body=%s", rec.Body.String()) + } + last := frames[len(frames)-1] + choices, _ := last["choices"].([]any) + if len(choices) != 1 { + t.Fatalf("expected one choice in final frame, got %#v", last) + } + choice, _ := choices[0].(map[string]any) + if choice["finish_reason"] != "stop" { + t.Fatalf("expected finish_reason=stop for content-filter upstream stop, got %#v", choice["finish_reason"]) + } +} diff --git a/internal/sse/line.go b/internal/sse/line.go index b91edc7..1d9ddae 100644 --- a/internal/sse/line.go +++ b/internal/sse/line.go @@ -36,8 +36,8 @@ func ParseDeepSeekContentLine(raw []byte, thinkingEnabled bool, currentType stri Parsed: true, Stop: true, ContentFilter: true, - ErrorMessage: "content filtered by upstream", NextType: currentType, + OutputTokens: extractAccumulatedTokenUsage(chunk), } } if hasContentFilterStatus(chunk) { @@ -45,7 +45,6 @@ func ParseDeepSeekContentLine(raw []byte, thinkingEnabled bool, currentType stri Parsed: true, Stop: true, ContentFilter: true, - ErrorMessage: "content filtered by upstream", NextType: currentType, OutputTokens: extractAccumulatedTokenUsage(chunk), } diff --git a/internal/sse/line_edge_test.go b/internal/sse/line_edge_test.go index 2ae53a6..4d507fc 100644 --- a/internal/sse/line_edge_test.go +++ b/internal/sse/line_edge_test.go @@ -40,8 +40,8 @@ func TestParseDeepSeekContentLineContentFilterMessage(t *testing.T) { if !res.ContentFilter { t.Fatal("expected content filter flag") } - if res.ErrorMessage == "" { - t.Fatal("expected error message on content filter") + if res.ErrorMessage != "" { + t.Fatalf("expected empty error message on content filter, got %q", res.ErrorMessage) } } diff --git a/internal/sse/line_test.go b/internal/sse/line_test.go index a226034..7f2baa6 100644 --- a/internal/sse/line_test.go +++ b/internal/sse/line_test.go @@ -26,6 +26,19 @@ func TestParseDeepSeekContentLineContentFilter(t *testing.T) { } } +func TestParseDeepSeekContentLineContentFilterCodeIncludesOutputTokens(t *testing.T) { + res := ParseDeepSeekContentLine( + []byte(`data: {"code":"content_filter","accumulated_token_usage":99}`), + false, "text", + ) + if !res.Parsed || !res.Stop || !res.ContentFilter { + t.Fatalf("expected content-filter stop result: %#v", res) + } + if res.OutputTokens != 99 { + t.Fatalf("expected output token usage 99, got %d", res.OutputTokens) + } +} + func TestParseDeepSeekContentLineContentFilterStatus(t *testing.T) { res := ParseDeepSeekContentLine([]byte(`data: {"p":"response/status","v":"CONTENT_FILTER"}`), false, "text") if !res.Parsed || !res.Stop || !res.ContentFilter { @@ -79,3 +92,13 @@ func TestParseDeepSeekContentLineTrimsFromContentFilterKeyword(t *testing.T) { t.Fatalf("unexpected parts after filter: %#v", res.Parts) } } + +func TestParseDeepSeekContentLineContentTextEqualContentFilterDoesNotStop(t *testing.T) { + res := ParseDeepSeekContentLine([]byte(`data: {"p":"response/content","v":"content_filter"}`), false, "text") + if !res.Parsed { + t.Fatalf("expected parsed result: %#v", res) + } + if res.Stop || res.ContentFilter { + t.Fatalf("did not expect content-filter stop for content text: %#v", res) + } +} diff --git a/internal/sse/parser.go b/internal/sse/parser.go index 725ac1f..1074a34 100644 --- a/internal/sse/parser.go +++ b/internal/sse/parser.go @@ -290,16 +290,17 @@ func IsCitation(text string) bool { } func hasContentFilterStatus(chunk map[string]any) bool { - return hasContentFilterValue(chunk) + if code, _ := chunk["code"].(string); strings.EqualFold(strings.TrimSpace(code), "content_filter") { + return true + } + return hasContentFilterStatusValue(chunk) } -func hasContentFilterValue(v any) bool { +func hasContentFilterStatusValue(v any) bool { switch x := v.(type) { - case string: - return strings.EqualFold(strings.TrimSpace(x), "content_filter") case []any: for _, item := range x { - if hasContentFilterValue(item) { + if hasContentFilterStatusValue(item) { return true } } @@ -309,8 +310,11 @@ func hasContentFilterValue(v any) bool { return true } } + if code, _ := x["code"].(string); strings.EqualFold(strings.TrimSpace(code), "content_filter") { + return true + } for _, vv := range x { - if hasContentFilterValue(vv) { + if hasContentFilterStatusValue(vv) { return true } } From 2a05c96f5f4eea352b5f914a694fe91ab5ad9b53 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Fri, 3 Apr 2026 01:13:13 +0800 Subject: [PATCH 4/9] docs: refresh architecture diagram and structure sections --- API.en.md | 21 ++++++++++---- API.md | 21 ++++++++++---- README.MD | 62 ++++++++++++++++++++++++++--------------- README.en.md | 62 ++++++++++++++++++++++++++--------------- VERSION | 2 +- docs/CONTRIBUTING.en.md | 3 +- docs/CONTRIBUTING.md | 3 +- docs/DEPLOY.en.md | 4 +-- docs/DEPLOY.md | 4 +-- 9 files changed, 117 insertions(+), 65 deletions(-) diff --git a/API.en.md b/API.en.md index 967c258..73d6eaa 100644 --- a/API.en.md +++ b/API.en.md @@ -31,6 +31,13 @@ This document describes the actual behavior of the current Go codebase. | Health probes | `GET /healthz`, `GET /readyz` | | CORS | Enabled (`Access-Control-Allow-Origin: *`, allows `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Vercel-Protection-Bypass`) | +### 3.0 Adapter-Layer Notes + +- OpenAI / Claude / Gemini protocols are now mounted on one shared `chi` router tree assembled in `internal/server/router.go`. +- Adapter responsibilities are streamlined to: **request normalization → DeepSeek invocation → protocol-shaped rendering**, reducing legacy split-logic paths. +- Tool-calling semantics are aligned between Go and Node runtime: structured parsing first (JSON/XML/invoke/markup), plus stream-time anti-leak filtering. +- `Admin API` separates static config from runtime policy: `/admin/config*` for configuration state, `/admin/settings*` for runtime behavior. + --- ## Configuration Best Practice @@ -91,7 +98,9 @@ Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key= | Method | Path | Auth | Description | | --- | --- | --- | --- | | GET | `/healthz` | None | Liveness probe | +| HEAD | `/healthz` | None | Liveness probe (no body) | | GET | `/readyz` | None | Readiness probe | +| HEAD | `/readyz` | None | Readiness probe (no body) | | GET | `/v1/models` | None | OpenAI model list | | GET | `/v1/models/{id}` | None | OpenAI single-model query (alias accepted) | | POST | `/v1/chat/completions` | Business | OpenAI chat completions | @@ -931,15 +940,15 @@ Checks the current build version and the latest GitHub Release: ```json { "success": true, - "current_version": "2.3.5", - "current_tag": "v2.3.5", + "current_version": "3.0.0", + "current_tag": "v3.0.0", "source": "file:VERSION", "checked_at": "2026-03-29T00:00:00Z", - "latest_tag": "v2.3.6", - "latest_version": "2.3.6", - "release_url": "https://github.com/CJackHwang/ds2api/releases/tag/v2.3.6", + "latest_tag": "v3.0.0", + "latest_version": "3.0.0", + "release_url": "https://github.com/CJackHwang/ds2api/releases/tag/v3.0.0", "published_at": "2026-03-28T12:00:00Z", - "has_update": true + "has_update": false } ``` diff --git a/API.md b/API.md index aac0e3b..061529e 100644 --- a/API.md +++ b/API.md @@ -31,6 +31,13 @@ | 健康检查 | `GET /healthz`、`GET /readyz` | | CORS | 已启用(`Access-Control-Allow-Origin: *`,允许 `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Vercel-Protection-Bypass`) | +### 3.0 接口适配层说明 + +- OpenAI / Claude / Gemini 三套协议已统一挂在同一 `chi` 路由树上,由 `internal/server/router.go` 负责装配。 +- 适配器层职责收敛为:**请求归一化 → DeepSeek 调用 → 协议形态渲染**,减少历史版本中“同能力多处实现”的分叉。 +- Tool Calling 的解析策略在 Go 与 Node Runtime 间保持一致:优先结构化解析(JSON/XML/invoke/markup),并在流式场景执行防泄漏筛分。 +- `Admin API` 将配置与运行时策略分开:`/admin/config*` 管静态配置,`/admin/settings*` 管运行时行为。 + --- ## 配置最佳实践 @@ -91,7 +98,9 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=` | 方法 | 路径 | 鉴权 | 说明 | | --- | --- | --- | --- | | GET | `/healthz` | 无 | 存活探针 | +| HEAD | `/healthz` | 无 | 存活探针(无响应体) | | GET | `/readyz` | 无 | 就绪探针 | +| HEAD | `/readyz` | 无 | 就绪探针(无响应体) | | GET | `/v1/models` | 无 | OpenAI 模型列表 | | GET | `/v1/models/{id}` | 无 | OpenAI 单模型查询(支持 alias 入参) | | POST | `/v1/chat/completions` | 业务 | OpenAI 对话补全 | @@ -937,15 +946,15 @@ data: {"type":"message_stop"} ```json { "success": true, - "current_version": "2.3.5", - "current_tag": "v2.3.5", + "current_version": "3.0.0", + "current_tag": "v3.0.0", "source": "file:VERSION", "checked_at": "2026-03-29T00:00:00Z", - "latest_tag": "v2.3.6", - "latest_version": "2.3.6", - "release_url": "https://github.com/CJackHwang/ds2api/releases/tag/v2.3.6", + "latest_tag": "v3.0.0", + "latest_version": "3.0.0", + "release_url": "https://github.com/CJackHwang/ds2api/releases/tag/v3.0.0", "published_at": "2026-03-28T12:00:00Z", - "has_update": true + "has_update": false } ``` diff --git a/README.MD b/README.MD index 1c20ee6..e9002fd 100644 --- a/README.MD +++ b/README.MD @@ -28,43 +28,58 @@ ```mermaid flowchart LR - Client["🖥️ 客户端\n(OpenAI / Claude / Gemini 兼容)"] + Client["🖥️ 客户端 / SDK\n(OpenAI / Claude / Gemini)"] - subgraph DS2API["DS2API 服务"] - direction TB - CORS["CORS 中间件"] - Auth["🔐 鉴权中间件"] + subgraph DS2API["DS2API 3.0(统一 Go 路由内核)"] + Router["chi Router + 中间件\n(RequestID / Recoverer / CORS / Timeout)"] - subgraph Adapters["适配器层"] - OA["OpenAI 适配器\n/v1/*"] - CA["Claude 适配器\n/anthropic/*"] - GA["Gemini 适配器\n/v1beta/models/*"] + subgraph Adapters["协议适配层"] + OA["OpenAI\n/v1/*"] + CA["Claude\n/anthropic/* + /v1/messages"] + GA["Gemini\n/v1beta/models/* + /v1/models/*"] end - subgraph Support["支撑模块"] - Pool["📦 账号池 / 并发队列"] - PoW["⚙️ PoW WASM\n(wazero)"] + subgraph Runtime["运行时与核心能力"] + Auth["Auth Resolver\n(API key / bearer / x-goog-api-key)"] + Pool["Account Pool + Queue\n(并发与轮询)"] + DS["DeepSeek Client\n(Session / Auth / HTTP)"] + Pow["PoW WASM (wazero)"] + Tool["Tool Sieve\n(Go/Node 语义对齐)"] + Format["Response Render\n(OpenAI/Claude/Gemini)"] end - Admin["🛠️ Admin API\n/admin/*"] - WebUI["🌐 WebUI\n(/admin)"] + Admin["Admin API\n/admin/*"] + WebUI["WebUI Static\n/admin"] end - DS["☁️ DeepSeek API"] + Upstream["☁️ DeepSeek Upstream"] - Client -- "请求" --> CORS --> Auth - Auth --> OA & CA & GA - OA & CA & GA -- "调用" --> DS - Auth --> Admin - OA & CA & GA -. "轮询选账号" .-> Pool - OA & CA & GA -. "计算 PoW" .-> PoW - DS -- "响应" --> Client + Client --> Router + Router --> OA & CA & GA + Router --> Admin + Router --> WebUI + OA --> Auth --> Pool --> DS + CA --> Auth + GA --> Auth + DS --> Pow + DS --> Tool --> Format + DS --> Upstream + Upstream --> DS ``` - **后端**:Go(`cmd/ds2api/`、`api/`、`internal/`),不依赖 Python 运行时 - **前端**:React 管理台(`webui/`),运行时托管静态构建产物 - **部署**:本地运行、Docker、Vercel Serverless、Linux systemd +### 3.0 底层架构调整(相较旧版本) + +- **统一路由内核**:所有协议入口统一汇聚到 `internal/server/router.go`,并在同一路由树中注册 OpenAI / Claude / Gemini / Admin / WebUI 路由,避免多入口行为漂移。 +- **适配器分层更清晰**:`internal/adapter/{openai,claude,gemini}` 只负责协议形态、错误格式和流式事件语义,DeepSeek 侧调用统一收敛到共享调用层。 +- **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 下的行为差异。 +- **可观测与可运维增强**:`/healthz`、`/readyz`、`/admin/version`、`/admin/dev/captures` 形成排障闭环,便于发布后验证。 + ## 核心能力 | 能力 | 说明 | @@ -144,7 +159,7 @@ cp config.example.json config.json ### 方式一:本地运行 -**前置要求**:Go 1.24+,Node.js 20+(仅在需要构建 WebUI 时) +**前置要求**:Go 1.26+,Node.js 20+(仅在需要构建 WebUI 时) ```bash # 1. 克隆仓库 @@ -412,6 +427,7 @@ go run ./cmd/ds2api ```text ds2api/ +├── app/ # 统一 Handler 入口(供 Vercel / 本地共用) ├── cmd/ │ ├── ds2api/ # 本地 / 容器启动入口 │ └── ds2api-tests/ # 端到端测试集入口 diff --git a/README.en.md b/README.en.md index 34bc615..f5378d6 100644 --- a/README.en.md +++ b/README.en.md @@ -28,43 +28,58 @@ DS2API converts DeepSeek Web chat capability into OpenAI-compatible, Claude-comp ```mermaid flowchart LR - Client["🖥️ Clients\n(OpenAI / Claude / Gemini compat)"] + Client["🖥️ Clients / SDKs\n(OpenAI / Claude / Gemini)"] - subgraph DS2API["DS2API Service"] - direction TB - CORS["CORS Middleware"] - Auth["🔐 Auth Middleware"] + subgraph DS2API["DS2API 3.0 (Unified Go Routing Core)"] + Router["chi Router + Middleware\n(RequestID / Recoverer / CORS / Timeout)"] - subgraph Adapters["Adapter Layer"] - OA["OpenAI Adapter\n/v1/*"] - CA["Claude Adapter\n/anthropic/*"] - GA["Gemini Adapter\n/v1beta/models/*"] + subgraph Adapters["Protocol Adapters"] + OA["OpenAI\n/v1/*"] + CA["Claude\n/anthropic/* + /v1/messages"] + GA["Gemini\n/v1beta/models/* + /v1/models/*"] end - subgraph Support["Support Modules"] - Pool["📦 Account Pool / Queue"] - PoW["⚙️ PoW WASM\n(wazero)"] + subgraph Runtime["Runtime + Core Capabilities"] + Auth["Auth Resolver\n(API key / bearer / x-goog-api-key)"] + Pool["Account Pool + Queue\n(concurrency and rotation)"] + DS["DeepSeek Client\n(session / auth / HTTP)"] + Pow["PoW WASM (wazero)"] + Tool["Tool Sieve\n(Go/Node semantic parity)"] + Format["Response Render\n(OpenAI/Claude/Gemini)"] end - Admin["🛠️ Admin API\n/admin/*"] - WebUI["🌐 WebUI\n(/admin)"] + Admin["Admin API\n/admin/*"] + WebUI["WebUI Static\n/admin"] end - DS["☁️ DeepSeek API"] + Upstream["☁️ DeepSeek Upstream"] - Client -- "Request" --> CORS --> Auth - Auth --> OA & CA & GA - OA & CA & GA -- "Call" --> DS - Auth --> Admin - OA & CA & GA -. "Rotate accounts" .-> Pool - OA & CA & GA -. "Compute PoW" .-> PoW - DS -- "Response" --> Client + Client --> Router + Router --> OA & CA & GA + Router --> Admin + Router --> WebUI + OA --> Auth --> Pool --> DS + CA --> Auth + GA --> Auth + DS --> Pow + DS --> Tool --> Format + DS --> Upstream + Upstream --> DS ``` - **Backend**: Go (`cmd/ds2api/`, `api/`, `internal/`), no Python runtime - **Frontend**: React admin panel (`webui/`), served as static build at runtime - **Deployment**: local run, Docker, Vercel serverless, Linux systemd +### 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. +- **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. +- **Improved operability**: `/healthz`, `/readyz`, `/admin/version`, and `/admin/dev/captures` form a tighter post-deploy diagnostics loop. + ## Key Capabilities | Capability | Details | @@ -144,7 +159,7 @@ Recommended per deployment mode: ### Option 1: Local Run -**Prerequisites**: Go 1.24+, Node.js 20+ (only if building WebUI locally) +**Prerequisites**: Go 1.26+, Node.js 20+ (only if building WebUI locally) ```bash # 1. Clone @@ -406,6 +421,7 @@ Response fields include: ```text ds2api/ +├── app/ # Unified handler entry (shared by Vercel/local) ├── cmd/ │ ├── ds2api/ # Local / container entrypoint │ └── ds2api-tests/ # End-to-end testsuite entrypoint diff --git a/VERSION b/VERSION index 4fd0fe3..4a36342 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.5.1 \ No newline at end of file +3.0.0 diff --git a/docs/CONTRIBUTING.en.md b/docs/CONTRIBUTING.en.md index 6f8257b..a229969 100644 --- a/docs/CONTRIBUTING.en.md +++ b/docs/CONTRIBUTING.en.md @@ -8,7 +8,7 @@ Thanks for your interest in contributing to DS2API! ### Prerequisites -- Go 1.24+ +- Go 1.26+ - Node.js 20+ (for WebUI development) - npm (bundled with Node.js) @@ -94,6 +94,7 @@ Manually build WebUI to `static/admin/`: ```text ds2api/ +├── app/ # Unified handler entry (shared by Vercel/local) ├── cmd/ │ ├── ds2api/ # Local/container entrypoint │ └── ds2api-tests/ # End-to-end testsuite entrypoint diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index f956fad..d5b73c3 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -8,7 +8,7 @@ ### 前置要求 -- Go 1.24+ +- Go 1.26+ - Node.js 20+(WebUI 开发时) - npm(随 Node.js 提供) @@ -94,6 +94,7 @@ docker-compose -f docker-compose.dev.yml up ```text ds2api/ +├── app/ # 统一 Handler 入口(供 Vercel / 本地共用) ├── cmd/ │ ├── ds2api/ # 本地/容器启动入口 │ └── ds2api-tests/ # 端到端测试集入口 diff --git a/docs/DEPLOY.en.md b/docs/DEPLOY.en.md index 855af36..2ac7b7b 100644 --- a/docs/DEPLOY.en.md +++ b/docs/DEPLOY.en.md @@ -24,7 +24,7 @@ This guide covers all deployment methods for the current Go-based codebase. | Dependency | Minimum Version | Notes | | --- | --- | --- | -| Go | 1.24+ | Build backend | +| Go | 1.26+ | Build backend | | Node.js | 20+ | Only needed to build WebUI locally | | npm | Bundled with Node.js | Install WebUI dependencies | @@ -401,7 +401,7 @@ cp config.example.json config.json docker pull ghcr.io/cjackhwang/ds2api:latest # specific version (example) -docker pull ghcr.io/cjackhwang/ds2api:v2.1.2 +docker pull ghcr.io/cjackhwang/ds2api:v3.0.0 ``` --- diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 3082da7..4df1194 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -24,7 +24,7 @@ | 依赖 | 最低版本 | 说明 | | --- | --- | --- | -| Go | 1.24+ | 编译后端 | +| Go | 1.26+ | 编译后端 | | Node.js | 20+ | 仅在需要本地构建 WebUI 时 | | npm | 随 Node.js 提供 | 安装 WebUI 依赖 | @@ -401,7 +401,7 @@ cp config.example.json config.json docker pull ghcr.io/cjackhwang/ds2api:latest # 指定版本(示例) -docker pull ghcr.io/cjackhwang/ds2api:v2.1.2 +docker pull ghcr.io/cjackhwang/ds2api:v3.0.0 ``` --- From 47544fb385b49321b08a4afb42370d80bb6b87f6 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Fri, 3 Apr 2026 01:14:01 +0800 Subject: [PATCH 5/9] docs: align architecture diagram and structure with current code --- API.en.md | 19 +++++++++++++------ API.md | 19 +++++++++++++------ README.MD | 19 ++++++++++++++++++- README.en.md | 19 ++++++++++++++++++- VERSION | 2 +- docs/CONTRIBUTING.en.md | 8 +++++--- docs/CONTRIBUTING.md | 8 +++++--- docs/DEPLOY.en.md | 4 ++-- docs/DEPLOY.md | 4 ++-- 9 files changed, 77 insertions(+), 25 deletions(-) diff --git a/API.en.md b/API.en.md index 967c258..eccefc6 100644 --- a/API.en.md +++ b/API.en.md @@ -31,6 +31,13 @@ This document describes the actual behavior of the current Go codebase. | Health probes | `GET /healthz`, `GET /readyz` | | CORS | Enabled (`Access-Control-Allow-Origin: *`, allows `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Vercel-Protection-Bypass`) | +### 3.0 Adapter-Layer Notes + +- OpenAI / Claude / Gemini protocols are now mounted on one shared `chi` router tree assembled in `internal/server/router.go`. +- Adapter responsibilities are streamlined to: **request normalization → DeepSeek invocation → protocol-shaped rendering**, reducing legacy split-logic paths. +- Tool-calling semantics are aligned between Go and Node runtime: structured parsing first (JSON/XML/invoke/markup), plus stream-time anti-leak filtering. +- `Admin API` separates static config from runtime policy: `/admin/config*` for configuration state, `/admin/settings*` for runtime behavior. + --- ## Configuration Best Practice @@ -931,15 +938,15 @@ Checks the current build version and the latest GitHub Release: ```json { "success": true, - "current_version": "2.3.5", - "current_tag": "v2.3.5", + "current_version": "3.0.0", + "current_tag": "v3.0.0", "source": "file:VERSION", "checked_at": "2026-03-29T00:00:00Z", - "latest_tag": "v2.3.6", - "latest_version": "2.3.6", - "release_url": "https://github.com/CJackHwang/ds2api/releases/tag/v2.3.6", + "latest_tag": "v3.0.0", + "latest_version": "3.0.0", + "release_url": "https://github.com/CJackHwang/ds2api/releases/tag/v3.0.0", "published_at": "2026-03-28T12:00:00Z", - "has_update": true + "has_update": false } ``` diff --git a/API.md b/API.md index aac0e3b..a42a0fc 100644 --- a/API.md +++ b/API.md @@ -31,6 +31,13 @@ | 健康检查 | `GET /healthz`、`GET /readyz` | | CORS | 已启用(`Access-Control-Allow-Origin: *`,允许 `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Vercel-Protection-Bypass`) | +### 3.0 接口适配层说明 + +- OpenAI / Claude / Gemini 三套协议已统一挂在同一 `chi` 路由树上,由 `internal/server/router.go` 负责装配。 +- 适配器层职责收敛为:**请求归一化 → DeepSeek 调用 → 协议形态渲染**,减少历史版本中“同能力多处实现”的分叉。 +- Tool Calling 的解析策略在 Go 与 Node Runtime 间保持一致:优先结构化解析(JSON/XML/invoke/markup),并在流式场景执行防泄漏筛分。 +- `Admin API` 将配置与运行时策略分开:`/admin/config*` 管静态配置,`/admin/settings*` 管运行时行为。 + --- ## 配置最佳实践 @@ -937,15 +944,15 @@ data: {"type":"message_stop"} ```json { "success": true, - "current_version": "2.3.5", - "current_tag": "v2.3.5", + "current_version": "3.0.0", + "current_tag": "v3.0.0", "source": "file:VERSION", "checked_at": "2026-03-29T00:00:00Z", - "latest_tag": "v2.3.6", - "latest_version": "2.3.6", - "release_url": "https://github.com/CJackHwang/ds2api/releases/tag/v2.3.6", + "latest_tag": "v3.0.0", + "latest_version": "3.0.0", + "release_url": "https://github.com/CJackHwang/ds2api/releases/tag/v3.0.0", "published_at": "2026-03-28T12:00:00Z", - "has_update": true + "has_update": false } ``` diff --git a/README.MD b/README.MD index 1c20ee6..864336d 100644 --- a/README.MD +++ b/README.MD @@ -44,6 +44,8 @@ flowchart LR subgraph Support["支撑模块"] Pool["📦 账号池 / 并发队列"] PoW["⚙️ PoW WASM\n(wazero)"] + Stream["🌊 统一流式引擎\nstream + sse"] + Sieve["🧰 Tool Sieve\nGo + Node 对齐"] end Admin["🛠️ Admin API\n/admin/*"] @@ -58,6 +60,8 @@ flowchart LR Auth --> Admin OA & CA & GA -. "轮询选账号" .-> Pool OA & CA & GA -. "计算 PoW" .-> PoW + OA & CA & GA -. "流式解析" .-> Stream + OA & CA & GA -. "工具调用防泄漏" .-> Sieve DS -- "响应" --> Client ``` @@ -65,6 +69,15 @@ flowchart LR - **前端**:React 管理台(`webui/`),运行时托管静态构建产物 - **部署**:本地运行、Docker、Vercel Serverless、Linux systemd +### 3.0 底层架构调整(相较旧版本) + +- **统一路由内核**:所有协议入口统一汇聚到 `internal/server/router.go`,并在同一路由树中注册 OpenAI / Claude / Gemini / Admin / WebUI 路由,避免多入口行为漂移。 +- **适配器分层更清晰**:`internal/adapter/{openai,claude,gemini}` 只负责协议形态、错误格式和流式事件语义,DeepSeek 侧调用统一收敛到共享调用层。 +- **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 下的行为差异。 +- **可观测与可运维增强**:`/healthz`、`/readyz`、`/admin/version`、`/admin/dev/captures` 形成排障闭环,便于发布后验证。 + ## 核心能力 | 能力 | 说明 | @@ -144,7 +157,7 @@ cp config.example.json config.json ### 方式一:本地运行 -**前置要求**:Go 1.24+,Node.js 20+(仅在需要构建 WebUI 时) +**前置要求**:Go 1.26+,Node.js 20+(仅在需要构建 WebUI 时) ```bash # 1. 克隆仓库 @@ -415,6 +428,7 @@ ds2api/ ├── cmd/ │ ├── ds2api/ # 本地 / 容器启动入口 │ └── ds2api-tests/ # 端到端测试集入口 +├── app/ # 统一 HTTP Handler 组装层(供本地与 Serverless 复用) ├── api/ │ ├── index.go # Vercel Serverless Go 入口 │ ├── chat-stream.js # Vercel Node.js 流式转发 @@ -438,7 +452,10 @@ ds2api/ │ ├── server/ # HTTP 路由与中间件(chi router) │ ├── sse/ # SSE 解析工具 │ ├── stream/ # 统一流式消费引擎 +│ ├── testsuite/ # 端到端测试框架与用例编排 +│ ├── translatorcliproxy/ # CLIProxy 桥接与流写入组件 │ ├── util/ # 通用工具函数 +│ ├── version/ # 版本解析 / 比较与 tag 规范化 │ └── webui/ # WebUI 静态文件托管与自动构建 ├── webui/ # React WebUI 源码(Vite + Tailwind) │ └── src/ diff --git a/README.en.md b/README.en.md index 34bc615..3b2eeca 100644 --- a/README.en.md +++ b/README.en.md @@ -44,6 +44,8 @@ flowchart LR subgraph Support["Support Modules"] Pool["📦 Account Pool / Queue"] PoW["⚙️ PoW WASM\n(wazero)"] + Stream["🌊 Unified Streaming\nstream + sse"] + Sieve["🧰 Tool Sieve\nGo + Node parity"] end Admin["🛠️ Admin API\n/admin/*"] @@ -58,6 +60,8 @@ flowchart LR Auth --> Admin OA & CA & GA -. "Rotate accounts" .-> Pool OA & CA & GA -. "Compute PoW" .-> PoW + OA & CA & GA -. "Parse streams" .-> Stream + OA & CA & GA -. "Tool anti-leak" .-> Sieve DS -- "Response" --> Client ``` @@ -65,6 +69,15 @@ flowchart LR - **Frontend**: React admin panel (`webui/`), served as static build at runtime - **Deployment**: local run, Docker, Vercel serverless, Linux systemd +### 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. +- **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. +- **Improved operability**: `/healthz`, `/readyz`, `/admin/version`, and `/admin/dev/captures` form a tighter post-deploy diagnostics loop. + ## Key Capabilities | Capability | Details | @@ -144,7 +157,7 @@ Recommended per deployment mode: ### Option 1: Local Run -**Prerequisites**: Go 1.24+, Node.js 20+ (only if building WebUI locally) +**Prerequisites**: Go 1.26+, Node.js 20+ (only if building WebUI locally) ```bash # 1. Clone @@ -409,6 +422,7 @@ ds2api/ ├── cmd/ │ ├── ds2api/ # Local / container entrypoint │ └── ds2api-tests/ # End-to-end testsuite entrypoint +├── app/ # Unified HTTP handler assembly (shared by local + serverless) ├── api/ │ ├── index.go # Vercel Serverless Go entry │ ├── chat-stream.js # Vercel Node.js stream relay @@ -432,7 +446,10 @@ ds2api/ │ ├── server/ # HTTP routing and middleware (chi router) │ ├── sse/ # SSE parsing utilities │ ├── stream/ # Unified stream consumption engine +│ ├── testsuite/ # End-to-end testsuite framework and case orchestration +│ ├── translatorcliproxy/ # CLIProxy bridge and stream writer components │ ├── util/ # Common utilities +│ ├── version/ # Version parsing/comparison and tag normalization │ └── webui/ # WebUI static file serving and auto-build ├── webui/ # React WebUI source (Vite + Tailwind) │ └── src/ diff --git a/VERSION b/VERSION index 4fd0fe3..4a36342 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.5.1 \ No newline at end of file +3.0.0 diff --git a/docs/CONTRIBUTING.en.md b/docs/CONTRIBUTING.en.md index 6f8257b..e748d2b 100644 --- a/docs/CONTRIBUTING.en.md +++ b/docs/CONTRIBUTING.en.md @@ -8,7 +8,7 @@ Thanks for your interest in contributing to DS2API! ### Prerequisites -- Go 1.24+ +- Go 1.26+ - Node.js 20+ (for WebUI development) - npm (bundled with Node.js) @@ -97,6 +97,7 @@ ds2api/ ├── cmd/ │ ├── ds2api/ # Local/container entrypoint │ └── ds2api-tests/ # End-to-end testsuite entrypoint +├── app/ # Shared handler assembly (local + serverless) ├── api/ │ ├── index.go # Vercel Serverless Go entry │ ├── chat-stream.js # Vercel Node.js stream relay @@ -110,7 +111,6 @@ ds2api/ │ ├── admin/ # Admin API handlers │ ├── auth/ # Auth and JWT │ ├── claudeconv/ # Claude message conversion -│ ├── compat/ # Compatibility helpers │ ├── config/ # Config loading and hot-reload │ ├── deepseek/ # DeepSeek client, PoW WASM │ ├── js/ # Node runtime stream/compat logic @@ -120,8 +120,10 @@ ds2api/ │ ├── server/ # HTTP routing (chi router) │ ├── sse/ # SSE parsing utilities │ ├── stream/ # Unified stream consumption engine -│ ├── testsuite/ # Testsuite core logic +│ ├── testsuite/ # Testsuite framework and scenario orchestration +│ ├── translatorcliproxy/ # CLIProxy bridge and stream writer │ ├── util/ # Common utilities +│ ├── version/ # Version parsing and comparison │ └── webui/ # WebUI static hosting ├── webui/ # React WebUI source │ └── src/ diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index f956fad..a32b8b6 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -8,7 +8,7 @@ ### 前置要求 -- Go 1.24+ +- Go 1.26+ - Node.js 20+(WebUI 开发时) - npm(随 Node.js 提供) @@ -97,6 +97,7 @@ ds2api/ ├── cmd/ │ ├── ds2api/ # 本地/容器启动入口 │ └── ds2api-tests/ # 端到端测试集入口 +├── app/ # 统一 Handler 装配(本地 + Serverless) ├── api/ │ ├── index.go # Vercel Serverless Go 入口 │ ├── chat-stream.js # Vercel Node.js 流式转发 @@ -110,7 +111,6 @@ ds2api/ │ ├── admin/ # Admin API handlers │ ├── auth/ # 鉴权与 JWT │ ├── claudeconv/ # Claude 消息格式转换 -│ ├── compat/ # 兼容性辅助 │ ├── config/ # 配置加载与热更新 │ ├── deepseek/ # DeepSeek 客户端、PoW WASM │ ├── js/ # Node 运行时流式/兼容逻辑 @@ -120,8 +120,10 @@ ds2api/ │ ├── server/ # HTTP 路由(chi router) │ ├── sse/ # SSE 解析工具 │ ├── stream/ # 统一流式消费引擎 -│ ├── testsuite/ # 测试集核心逻辑 +│ ├── testsuite/ # 测试集框架与场景编排 +│ ├── translatorcliproxy/ # CLIProxy 桥接与流式写入 │ ├── util/ # 通用工具 +│ ├── version/ # 版本解析与比较 │ └── webui/ # WebUI 静态托管 ├── webui/ # React WebUI 源码 │ └── src/ diff --git a/docs/DEPLOY.en.md b/docs/DEPLOY.en.md index 855af36..2ac7b7b 100644 --- a/docs/DEPLOY.en.md +++ b/docs/DEPLOY.en.md @@ -24,7 +24,7 @@ This guide covers all deployment methods for the current Go-based codebase. | Dependency | Minimum Version | Notes | | --- | --- | --- | -| Go | 1.24+ | Build backend | +| Go | 1.26+ | Build backend | | Node.js | 20+ | Only needed to build WebUI locally | | npm | Bundled with Node.js | Install WebUI dependencies | @@ -401,7 +401,7 @@ cp config.example.json config.json docker pull ghcr.io/cjackhwang/ds2api:latest # specific version (example) -docker pull ghcr.io/cjackhwang/ds2api:v2.1.2 +docker pull ghcr.io/cjackhwang/ds2api:v3.0.0 ``` --- diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 3082da7..4df1194 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -24,7 +24,7 @@ | 依赖 | 最低版本 | 说明 | | --- | --- | --- | -| Go | 1.24+ | 编译后端 | +| Go | 1.26+ | 编译后端 | | Node.js | 20+ | 仅在需要本地构建 WebUI 时 | | npm | 随 Node.js 提供 | 安装 WebUI 依赖 | @@ -401,7 +401,7 @@ cp config.example.json config.json docker pull ghcr.io/cjackhwang/ds2api:latest # 指定版本(示例) -docker pull ghcr.io/cjackhwang/ds2api:v2.1.2 +docker pull ghcr.io/cjackhwang/ds2api:v3.0.0 ``` --- From 8b86f1c903da23b5a6cba16f9c5017739c4b9aee Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Fri, 3 Apr 2026 01:28:48 +0800 Subject: [PATCH 6/9] docs: refresh architecture and project structure docs --- README.MD | 54 +++++++++++++++++++++++------------------ README.en.md | 52 +++++++++++++++++++++------------------ docs/CONTRIBUTING.en.md | 11 ++++++--- docs/CONTRIBUTING.md | 11 ++++++--- 4 files changed, 73 insertions(+), 55 deletions(-) diff --git a/README.MD b/README.MD index e6c6229..fe97880 100644 --- a/README.MD +++ b/README.MD @@ -29,40 +29,46 @@ ```mermaid flowchart LR Client["🖥️ 客户端 / SDK\n(OpenAI / Claude / Gemini)"] + Upstream["☁️ DeepSeek API"] - subgraph DS2API["DS2API 3.0(统一 Go 路由内核)"] - Router["chi Router + 中间件\n(RequestID / Recoverer / CORS / Timeout)"] + subgraph DS2API["DS2API 3.x(统一 Go 路由内核)"] + Router["chi Router + 中间件\n(RequestID / RealIP / Logger / Recoverer / CORS)"] subgraph Adapters["协议适配层"] OA["OpenAI\n/v1/*"] CA["Claude\n/anthropic/* + /v1/messages"] GA["Gemini\n/v1beta/models/* + /v1/models/*"] + Admin["Admin API\n/admin/*"] + WebUI["WebUI\n/admin(静态托管)"] end - subgraph Runtime["运行时与核心能力"] + subgraph Runtime["运行时核心能力"] Auth["Auth Resolver\n(API key / bearer / x-goog-api-key)"] - Pool["Account Pool + Queue\n(并发与轮询)"] - DS["DeepSeek Client\n(Session / Auth / HTTP)"] - Pow["PoW WASM (wazero)"] + 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 语义对齐)"] - Format["Response Render\n(OpenAI/Claude/Gemini)"] + Render["Formatter\n(OpenAI/Claude/Gemini 输出)"] end - - Admin["Admin API\n/admin/*"] - WebUI["WebUI Static\n/admin"] end - DS["☁️ DeepSeek API"] + Client --> Router + Router --> OA & CA & GA + Router --> Admin + Router --> WebUI - Client -- "请求" --> CORS --> Auth - Auth --> OA & CA & GA - OA & CA & GA -- "调用" --> DS - Auth --> Admin - OA & CA & GA -. "轮询选账号" .-> Pool - OA & CA & GA -. "计算 PoW" .-> PoW - OA & CA & GA -. "流式解析" .-> Stream - OA & CA & GA -. "工具调用防泄漏" .-> Sieve - DS -- "响应" --> Client + OA & CA & GA --> Auth + OA & CA & GA -.账号轮询.-> Pool + OA & CA & GA -.工具调用解析.-> Tool + OA & CA & GA -.流式处理.-> SSE + OA & CA & GA -.PoW 计算.-> Pow + + Auth --> DSClient + DSClient --> Upstream + Upstream --> DSClient + DSClient --> Render + Render --> Client ``` - **后端**:Go(`cmd/ds2api/`、`api/`、`internal/`),不依赖 Python 运行时 @@ -425,11 +431,10 @@ go run ./cmd/ds2api ```text ds2api/ -├── app/ # 统一 Handler 入口(供 Vercel / 本地共用) +├── app/ # 统一 HTTP Handler 组装层(供本地与 Serverless 复用) ├── cmd/ │ ├── ds2api/ # 本地 / 容器启动入口 │ └── ds2api-tests/ # 端到端测试集入口 -├── app/ # 统一 HTTP Handler 组装层(供本地与 Serverless 复用) ├── api/ │ ├── index.go # Vercel Serverless Go 入口 │ ├── chat-stream.js # Vercel Node.js 流式转发 @@ -443,8 +448,8 @@ ds2api/ │ ├── admin/ # Admin API handlers(含 Settings 热更新) │ ├── auth/ # 鉴权与 JWT │ ├── claudeconv/ # Claude 消息格式转换 -│ ├── compat/ # 兼容性辅助 -│ ├── config/ # 配置加载与热更新 +│ ├── compat/ # Go 版本兼容与回归测试辅助 +│ ├── config/ # 配置加载、校验与热更新 │ ├── deepseek/ # DeepSeek API 客户端、PoW WASM │ ├── js/ # Node 运行时流式处理与兼容逻辑 │ ├── devcapture/ # 开发抓包模块 @@ -468,6 +473,7 @@ ds2api/ │ └── build-webui.sh # WebUI 手动构建脚本 ├── tests/ │ ├── compat/ # 兼容性测试夹具与期望输出 +│ ├── node/ # Node 侧单元测试(chat-stream / tool-sieve) │ └── scripts/ # 统一测试脚本入口(unit/e2e) ├── docs/ # 部署 / 贡献 / 测试等辅助文档 ├── static/admin/ # WebUI 构建产物(不提交到 Git) diff --git a/README.en.md b/README.en.md index 7ca9a60..347c426 100644 --- a/README.en.md +++ b/README.en.md @@ -29,40 +29,46 @@ DS2API converts DeepSeek Web chat capability into OpenAI-compatible, Claude-comp ```mermaid flowchart LR Client["🖥️ Clients / SDKs\n(OpenAI / Claude / Gemini)"] + Upstream["☁️ DeepSeek API"] - subgraph DS2API["DS2API 3.0 (Unified Go Routing Core)"] - Router["chi Router + Middleware\n(RequestID / Recoverer / CORS / Timeout)"] + subgraph DS2API["DS2API 3.x (Unified Go Routing Core)"] + Router["chi Router + Middleware\n(RequestID / RealIP / Logger / Recoverer / CORS)"] subgraph Adapters["Protocol Adapters"] OA["OpenAI\n/v1/*"] CA["Claude\n/anthropic/* + /v1/messages"] GA["Gemini\n/v1beta/models/* + /v1/models/*"] + Admin["Admin API\n/admin/*"] + WebUI["WebUI\n/admin (static hosting)"] end subgraph Runtime["Runtime + Core Capabilities"] Auth["Auth Resolver\n(API key / bearer / x-goog-api-key)"] - Pool["Account Pool + Queue\n(concurrency and rotation)"] - DS["DeepSeek Client\n(session / auth / HTTP)"] - Pow["PoW WASM (wazero)"] + 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)"] - Format["Response Render\n(OpenAI/Claude/Gemini)"] + Render["Formatter\n(OpenAI/Claude/Gemini output)"] end - - Admin["Admin API\n/admin/*"] - WebUI["WebUI Static\n/admin"] end - DS["☁️ DeepSeek API"] + Client --> Router + Router --> OA & CA & GA + Router --> Admin + Router --> WebUI - Client -- "Request" --> CORS --> Auth - Auth --> OA & CA & GA - OA & CA & GA -- "Call" --> DS - Auth --> Admin - OA & CA & GA -. "Rotate accounts" .-> Pool - OA & CA & GA -. "Compute PoW" .-> PoW - OA & CA & GA -. "Parse streams" .-> Stream - OA & CA & GA -. "Tool anti-leak" .-> Sieve - DS -- "Response" --> Client + 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 + + Auth --> DSClient + DSClient --> Upstream + Upstream --> DSClient + DSClient --> Render + Render --> Client ``` - **Backend**: Go (`cmd/ds2api/`, `api/`, `internal/`), no Python runtime @@ -419,11 +425,10 @@ Response fields include: ```text ds2api/ -├── app/ # Unified handler entry (shared by Vercel/local) +├── app/ # Unified HTTP handler assembly (shared by local + serverless) ├── cmd/ │ ├── ds2api/ # Local / container entrypoint │ └── ds2api-tests/ # End-to-end testsuite entrypoint -├── app/ # Unified HTTP handler assembly (shared by local + serverless) ├── api/ │ ├── index.go # Vercel Serverless Go entry │ ├── chat-stream.js # Vercel Node.js stream relay @@ -437,8 +442,8 @@ ds2api/ │ ├── admin/ # Admin API handlers (incl. Settings hot-reload) │ ├── auth/ # Auth and JWT │ ├── claudeconv/ # Claude message format conversion -│ ├── compat/ # Compatibility helpers -│ ├── config/ # Config loading and hot-reload +│ ├── compat/ # Go-version compatibility and regression helpers +│ ├── config/ # Config loading, validation, and hot-reload │ ├── deepseek/ # DeepSeek API client, PoW WASM │ ├── js/ # Node runtime stream/compat logic │ ├── devcapture/ # Dev packet capture module @@ -462,6 +467,7 @@ ds2api/ │ └── build-webui.sh # Manual WebUI build script ├── tests/ │ ├── compat/ # Compatibility fixtures and expected outputs +│ ├── node/ # Node-side unit tests (chat-stream / tool-sieve) │ └── scripts/ # Unified test script entrypoints (unit/e2e) ├── docs/ # Deployment / contributing / testing docs ├── static/admin/ # WebUI build output (not committed to Git) diff --git a/docs/CONTRIBUTING.en.md b/docs/CONTRIBUTING.en.md index dd8ee2c..ad04e0e 100644 --- a/docs/CONTRIBUTING.en.md +++ b/docs/CONTRIBUTING.en.md @@ -94,11 +94,10 @@ Manually build WebUI to `static/admin/`: ```text ds2api/ -├── app/ # Unified handler entry (shared by Vercel/local) +├── app/ # Shared HTTP handler assembly (local + serverless) ├── cmd/ │ ├── ds2api/ # Local/container entrypoint │ └── ds2api-tests/ # End-to-end testsuite entrypoint -├── app/ # Shared handler assembly (local + serverless) ├── api/ │ ├── index.go # Vercel Serverless Go entry │ ├── chat-stream.js # Vercel Node.js stream relay @@ -112,7 +111,8 @@ ds2api/ │ ├── admin/ # Admin API handlers │ ├── auth/ # Auth and JWT │ ├── claudeconv/ # Claude message conversion -│ ├── config/ # Config loading and hot-reload +│ ├── compat/ # Go-version compatibility and regression helpers +│ ├── config/ # Config loading, validation, and hot-reload │ ├── deepseek/ # DeepSeek client, PoW WASM │ ├── js/ # Node runtime stream/compat logic │ ├── devcapture/ # Dev packet capture @@ -133,7 +133,10 @@ ds2api/ │ ├── components/ # Shared components │ └── locales/ # Language packs ├── scripts/ # Build and test scripts -├── tests/ # Unit tests, Node tests, and end-to-end tests +├── tests/ +│ ├── compat/ # Compatibility fixtures and expected outputs +│ ├── node/ # Node-side unit tests +│ └── scripts/ # Test script entrypoints (unit/e2e) ├── plans/ # Plans, gates, and manual smoke-test records ├── static/admin/ # WebUI build output (not committed) ├── Dockerfile # Multi-stage build diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 5c55a99..0883df0 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -94,11 +94,10 @@ docker-compose -f docker-compose.dev.yml up ```text ds2api/ -├── app/ # 统一 Handler 入口(供 Vercel / 本地共用) +├── app/ # 统一 HTTP Handler 装配(本地 + Serverless) ├── cmd/ │ ├── ds2api/ # 本地/容器启动入口 │ └── ds2api-tests/ # 端到端测试集入口 -├── app/ # 统一 Handler 装配(本地 + Serverless) ├── api/ │ ├── index.go # Vercel Serverless Go 入口 │ ├── chat-stream.js # Vercel Node.js 流式转发 @@ -112,7 +111,8 @@ ds2api/ │ ├── admin/ # Admin API handlers │ ├── auth/ # 鉴权与 JWT │ ├── claudeconv/ # Claude 消息格式转换 -│ ├── config/ # 配置加载与热更新 +│ ├── compat/ # Go 版本兼容与回归测试辅助 +│ ├── config/ # 配置加载、校验与热更新 │ ├── deepseek/ # DeepSeek 客户端、PoW WASM │ ├── js/ # Node 运行时流式/兼容逻辑 │ ├── devcapture/ # 开发抓包 @@ -133,7 +133,10 @@ ds2api/ │ ├── components/ # 通用组件 │ └── locales/ # 语言包 ├── scripts/ # 构建与测试脚本 -├── tests/ # 单元测试、Node 测试与端到端测试 +├── tests/ +│ ├── compat/ # 兼容夹具与期望输出 +│ ├── node/ # Node 侧单元测试 +│ └── scripts/ # 测试脚本入口(unit/e2e) ├── plans/ # 计划、门禁和手工烟测记录 ├── static/admin/ # WebUI 构建产物(不提交) ├── Dockerfile # 多阶段构建 From 5722f21cddd0ce77c5bf861c887b9ce981b74d47 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Fri, 3 Apr 2026 01:49:33 +0800 Subject: [PATCH 7/9] Align docs and adapters with unified OpenAI proxy architecture --- README.MD | 26 +++-- README.en.md | 26 +++-- internal/adapter/claude/handler_messages.go | 88 +++------------ internal/adapter/claude/proxy_vercel_test.go | 42 +++++++ internal/adapter/claude/stream_status_test.go | 50 ++------- internal/adapter/gemini/handler_generate.go | 81 ++++---------- internal/adapter/gemini/handler_test.go | 104 ++++++++++++------ 7 files changed, 185 insertions(+), 232 deletions(-) diff --git a/README.MD b/README.MD index fe97880..7696bfc 100644 --- a/README.MD +++ b/README.MD @@ -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 下的行为差异。 diff --git a/README.en.md b/README.en.md index 347c426..65096ef 100644 --- a/README.en.md +++ b/README.en.md @@ -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. diff --git a/internal/adapter/claude/handler_messages.go b/internal/adapter/claude/handler_messages.go index ced0dc1..c066ac8 100644 --- a/internal/adapter/claude/handler_messages.go +++ b/internal/adapter/claude/handler_messages.go @@ -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" diff --git a/internal/adapter/claude/proxy_vercel_test.go b/internal/adapter/claude/proxy_vercel_test.go index 0439641..4a7736b 100644 --- a/internal/adapter/claude/proxy_vercel_test.go +++ b/internal/adapter/claude/proxy_vercel_test.go @@ -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) + } +} diff --git a/internal/adapter/claude/stream_status_test.go b/internal/adapter/claude/stream_status_test.go index c3936de..8743c44 100644 --- a/internal/adapter/claude/stream_status_test.go +++ b/internal/adapter/claude/stream_status_test.go @@ -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) diff --git a/internal/adapter/gemini/handler_generate.go b/internal/adapter/gemini/handler_generate.go index d2f33f1..5703d0b 100644 --- a/internal/adapter/gemini/handler_generate.go +++ b/internal/adapter/gemini/handler_generate.go @@ -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 { diff --git a/internal/adapter/gemini/handler_test.go b/internal/adapter/gemini/handler_test.go index 9316833..20cc0e6 100644 --- a/internal/adapter/gemini/handler_test.go +++ b/internal/adapter/gemini/handler_test.go @@ -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 } From f787e2564170b65d2149d393b835f1ee3ff90a6d Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Fri, 3 Apr 2026 01:58:16 +0800 Subject: [PATCH 8/9] chore(deploy): drop Vercel node runtime pin and bump Docker node --- Dockerfile | 4 ++-- zeabur.yaml | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8ee7888..544f29d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20 AS webui-builder +FROM node:24 AS webui-builder WORKDIR /app/webui COPY webui/package.json webui/package-lock.json ./ @@ -6,7 +6,7 @@ RUN npm ci COPY webui ./ RUN npm run build -FROM golang:1.24 AS go-builder +FROM golang:1.26 AS go-builder WORKDIR /app ARG TARGETOS ARG TARGETARCH diff --git a/zeabur.yaml b/zeabur.yaml index c1ba6bf..8ed1340 100644 --- a/zeabur.yaml +++ b/zeabur.yaml @@ -12,6 +12,9 @@ spec: readme: |- # DS2API (Zeabur) + ## Runtime baseline + - Go: 1.26 + ## After deployment - Admin panel: `/admin` - Health check: `/healthz` From a6a9863fc3392368257c60c36a57dc495e57a964 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Fri, 3 Apr 2026 01:59:35 +0800 Subject: [PATCH 9/9] Preserve upstream headers on Gemini proxy error responses --- internal/adapter/gemini/handler_generate.go | 5 +++++ internal/adapter/gemini/handler_test.go | 23 ++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/internal/adapter/gemini/handler_generate.go b/internal/adapter/gemini/handler_generate.go index 5703d0b..a7de92d 100644 --- a/internal/adapter/gemini/handler_generate.go +++ b/internal/adapter/gemini/handler_generate.go @@ -90,6 +90,11 @@ 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) + } + } writeGeminiErrorFromOpenAI(w, res.StatusCode, body) return true } diff --git a/internal/adapter/gemini/handler_test.go b/internal/adapter/gemini/handler_test.go index 20cc0e6..fdb4b79 100644 --- a/internal/adapter/gemini/handler_test.go +++ b/internal/adapter/gemini/handler_test.go @@ -64,9 +64,13 @@ func (m testGeminiDS) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ m type geminiOpenAIErrorStub struct { status int body string + headers map[string]string } func (s geminiOpenAIErrorStub) ChatCompletions(w http.ResponseWriter, _ *http.Request) { + for k, v := range s.headers { + w.Header().Set(k, v) + } w.Header().Set("Content-Type", "application/json") w.WriteHeader(s.status) _, _ = w.Write([]byte(s.body)) @@ -244,7 +248,15 @@ 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"}}`}, + OpenAI: geminiOpenAIErrorStub{ + status: http.StatusUnauthorized, + body: `{"error":{"message":"invalid api key"}}`, + headers: map[string]string{ + "WWW-Authenticate": `Bearer realm="example"`, + "Retry-After": "30", + "X-RateLimit-Remaining": "0", + }, + }, } r := chi.NewRouter() RegisterRoutes(r, h) @@ -267,6 +279,15 @@ func TestGenerateContentOpenAIProxyErrorUsesGeminiEnvelope(t *testing.T) { if errObj["message"] != "invalid api key" { t.Fatalf("expected parsed error message, got=%v", errObj["message"]) } + if got := rec.Header().Get("WWW-Authenticate"); got == "" { + t.Fatalf("expected WWW-Authenticate header to be preserved") + } + if got := rec.Header().Get("Retry-After"); got != "30" { + t.Fatalf("expected Retry-After header 30, got=%q", got) + } + if got := rec.Header().Get("X-RateLimit-Remaining"); got != "0" { + t.Fatalf("expected X-RateLimit-Remaining header 0, got=%q", got) + } } func extractGeminiSSEFrames(t *testing.T, body string) []map[string]any {