From 443fa4ad8e48eeae12b155b54dc9a02adf2dacac Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Thu, 2 Apr 2026 22:28:36 +0800 Subject: [PATCH] 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) + } +}