From e34b2b4f1d07623cf5a5ed2e2f9b6287d5d43920 Mon Sep 17 00:00:00 2001 From: Aikins Laryea Date: Wed, 1 Apr 2026 19:49:38 +0000 Subject: [PATCH 1/4] fix(gemini): clean tool schemas and eager_input_streaming delegate schema sanitization to util.CleanJSONSchemaForGemini and drop the top-level eager_input_streaming key to prevent validation errors when sending claude tools to the gemini api --- internal/api/modules/amp/fallback_handlers.go | 2 + internal/api/modules/amp/response_rewriter.go | 51 +++---------------- .../gemini/claude/gemini_claude_request.go | 6 +-- 3 files changed, 11 insertions(+), 48 deletions(-) diff --git a/internal/api/modules/amp/fallback_handlers.go b/internal/api/modules/amp/fallback_handlers.go index 97dd0c9d..e4e0f8a6 100644 --- a/internal/api/modules/amp/fallback_handlers.go +++ b/internal/api/modules/amp/fallback_handlers.go @@ -253,6 +253,7 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc log.Debugf("amp model mapping: request %s -> %s", normalizedModel, resolvedModel) logAmpRouting(RouteTypeModelMapping, modelName, resolvedModel, providerName, requestPath) rewriter := NewResponseRewriter(c.Writer, modelName) + rewriter.suppressThinking = true c.Writer = rewriter // Filter Anthropic-Beta header only for local handling paths filterAntropicBetaHeader(c) @@ -267,6 +268,7 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc // proxies (e.g. NewAPI) may return a different model name and lack // Amp-required fields like thinking.signature. rewriter := NewResponseRewriter(c.Writer, modelName) + rewriter.suppressThinking = providerName != "claude" c.Writer = rewriter // Filter Anthropic-Beta header only for local handling paths filterAntropicBetaHeader(c) diff --git a/internal/api/modules/amp/response_rewriter.go b/internal/api/modules/amp/response_rewriter.go index 64757963..0de95cf0 100644 --- a/internal/api/modules/amp/response_rewriter.go +++ b/internal/api/modules/amp/response_rewriter.go @@ -20,7 +20,7 @@ type ResponseRewriter struct { body *bytes.Buffer originalModel string isStreaming bool - suppressedContentBlock map[int]struct{} + suppressThinking bool } // NewResponseRewriter creates a new response rewriter for model name substitution. @@ -28,8 +28,7 @@ func NewResponseRewriter(w gin.ResponseWriter, originalModel string) *ResponseRe return &ResponseRewriter{ ResponseWriter: w, body: &bytes.Buffer{}, - originalModel: originalModel, - suppressedContentBlock: make(map[int]struct{}), + originalModel: originalModel, } } @@ -91,7 +90,8 @@ func (rw *ResponseRewriter) Write(data []byte) (int, error) { } if rw.isStreaming { - n, err := rw.ResponseWriter.Write(rw.rewriteStreamChunk(data)) + rewritten := rw.rewriteStreamChunk(data) + n, err := rw.ResponseWriter.Write(rewritten) if err == nil { if flusher, ok := rw.ResponseWriter.(http.Flusher); ok { flusher.Flush() @@ -154,19 +154,11 @@ func ensureAmpSignature(data []byte) []byte { return data } -func (rw *ResponseRewriter) markSuppressedContentBlock(index int) { - if rw.suppressedContentBlock == nil { - rw.suppressedContentBlock = make(map[int]struct{}) - } - rw.suppressedContentBlock[index] = struct{}{} -} - -func (rw *ResponseRewriter) isSuppressedContentBlock(index int) bool { - _, ok := rw.suppressedContentBlock[index] - return ok -} func (rw *ResponseRewriter) suppressAmpThinking(data []byte) []byte { + if !rw.suppressThinking { + return data + } if gjson.GetBytes(data, `content.#(type=="tool_use")`).Exists() { filtered := gjson.GetBytes(data, `content.#(type!="thinking")#`) if filtered.Exists() { @@ -177,33 +169,11 @@ func (rw *ResponseRewriter) suppressAmpThinking(data []byte) []byte { data, err = sjson.SetBytes(data, "content", filtered.Value()) if err != nil { log.Warnf("Amp ResponseRewriter: failed to suppress thinking blocks: %v", err) - } else { - log.Debugf("Amp ResponseRewriter: Suppressed %d thinking blocks due to tool usage", originalCount-filteredCount) } } } } - eventType := gjson.GetBytes(data, "type").String() - indexResult := gjson.GetBytes(data, "index") - if eventType == "content_block_start" && gjson.GetBytes(data, "content_block.type").String() == "thinking" && indexResult.Exists() { - rw.markSuppressedContentBlock(int(indexResult.Int())) - return nil - } - if gjson.GetBytes(data, "delta.type").String() == "thinking_delta" { - if indexResult.Exists() { - rw.markSuppressedContentBlock(int(indexResult.Int())) - } - return nil - } - if eventType == "content_block_stop" && indexResult.Exists() { - index := int(indexResult.Int()) - if rw.isSuppressedContentBlock(index) { - delete(rw.suppressedContentBlock, index) - return nil - } - } - return data } @@ -255,7 +225,6 @@ func (rw *ResponseRewriter) rewriteStreamChunk(chunk []byte) []byte { if len(jsonData) > 0 && jsonData[0] == '{' { rewritten := rw.rewriteStreamEvent(jsonData) if rewritten == nil { - // Event suppressed (e.g. thinking block), skip event+data pair i = dataIdx + 1 continue } @@ -303,12 +272,6 @@ func (rw *ResponseRewriter) rewriteStreamChunk(chunk []byte) []byte { // rewriteStreamEvent processes a single JSON event in the SSE stream. // It rewrites model names and ensures signature fields exist. func (rw *ResponseRewriter) rewriteStreamEvent(data []byte) []byte { - // Suppress thinking blocks before any other processing. - data = rw.suppressAmpThinking(data) - if len(data) == 0 { - return nil - } - // Inject empty signature where needed data = ensureAmpSignature(data) diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index 4a52d4a8..b12042dd 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -6,7 +6,6 @@ package claude import ( - "bytes" "strings" "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" @@ -31,8 +30,6 @@ const geminiClaudeThoughtSignature = "skip_thought_signature_validator" // - []byte: The transformed request in Gemini CLI format. func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) []byte { rawJSON := inputRawJSON - rawJSON = bytes.Replace(rawJSON, []byte(`"url":{"type":"string","format":"uri",`), []byte(`"url":{"type":"string",`), -1) - // Build output Gemini CLI request JSON out := []byte(`{"contents":[]}`) out, _ = sjson.SetBytes(out, "model", modelName) @@ -152,7 +149,7 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) toolsResult.ForEach(func(_, toolResult gjson.Result) bool { inputSchemaResult := toolResult.Get("input_schema") if inputSchemaResult.Exists() && inputSchemaResult.IsObject() { - inputSchema := inputSchemaResult.Raw + inputSchema := util.CleanJSONSchemaForGemini(inputSchemaResult.Raw) tool := []byte(toolResult.Raw) var err error tool, err = sjson.DeleteBytes(tool, "input_schema") @@ -168,6 +165,7 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) tool, _ = sjson.DeleteBytes(tool, "type") tool, _ = sjson.DeleteBytes(tool, "cache_control") tool, _ = sjson.DeleteBytes(tool, "defer_loading") + tool, _ = sjson.DeleteBytes(tool, "eager_input_streaming") tool, _ = sjson.SetBytes(tool, "name", util.SanitizeFunctionName(gjson.GetBytes(tool, "name").String())) if gjson.ValidBytes(tool) && gjson.ParseBytes(tool).IsObject() { if !hasTools { From ff7dbb58678abd3fe09cfc67efef2fe266193764 Mon Sep 17 00:00:00 2001 From: Aikins Laryea Date: Wed, 1 Apr 2026 20:04:00 +0000 Subject: [PATCH 2/4] test(amp): update tests to expect thinking blocks to pass through during streaming --- internal/api/modules/amp/response_rewriter_test.go | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/internal/api/modules/amp/response_rewriter_test.go b/internal/api/modules/amp/response_rewriter_test.go index 2f23d74d..4ff597d7 100644 --- a/internal/api/modules/amp/response_rewriter_test.go +++ b/internal/api/modules/amp/response_rewriter_test.go @@ -100,17 +100,14 @@ func TestRewriteStreamChunk_MessageModel(t *testing.T) { } } -func TestRewriteStreamChunk_SuppressesThinkingContentBlockFrames(t *testing.T) { - rw := &ResponseRewriter{suppressedContentBlock: make(map[int]struct{})} +func TestRewriteStreamChunk_PassesThroughThinkingBlocks(t *testing.T) { + rw := &ResponseRewriter{} chunk := []byte("event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"abc\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"name\":\"bash\",\"input\":{}}}\n\n") result := rw.rewriteStreamChunk(chunk) - if contains(result, []byte("\"thinking\"")) || contains(result, []byte("\"thinking_delta\"")) { - t.Fatalf("expected thinking content_block frames to be suppressed, got %s", string(result)) - } - if contains(result, []byte("content_block_stop")) { - t.Fatalf("expected suppressed thinking content_block_stop to be removed, got %s", string(result)) + if !contains(result, []byte("\"thinking_delta\"")) { + t.Fatalf("expected thinking blocks to pass through in streaming, got %s", string(result)) } if !contains(result, []byte("\"tool_use\"")) { t.Fatalf("expected tool_use content_block frame to remain, got %s", string(result)) From f5e9f01811a79700aec2410f6a4487e01a0d9514 Mon Sep 17 00:00:00 2001 From: Aikins Laryea Date: Wed, 1 Apr 2026 20:35:23 +0000 Subject: [PATCH 3/4] test(amp): update tests to expect thinking blocks to pass through during streaming --- .../gemini/claude/gemini_claude_request.go | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index b12042dd..e230f5fd 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -6,6 +6,7 @@ package claude import ( + "fmt" "strings" "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" @@ -143,6 +144,30 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) }) } + // strip trailing model turn with unanswered function calls — + // Gemini returns empty responses when the last turn is a model + // functionCall with no corresponding user functionResponse. + contents := gjson.GetBytes(out, "contents") + if contents.Exists() && contents.IsArray() { + arr := contents.Array() + if len(arr) > 0 { + last := arr[len(arr)-1] + if last.Get("role").String() == "model" { + hasFC := false + last.Get("parts").ForEach(func(_, part gjson.Result) bool { + if part.Get("functionCall").Exists() { + hasFC = true + return false + } + return true + }) + if hasFC { + out, _ = sjson.DeleteBytes(out, fmt.Sprintf("contents.%d", len(arr)-1)) + } + } + } + } + // tools if toolsResult := gjson.GetBytes(rawJSON, "tools"); toolsResult.IsArray() { hasTools := false From 7ee37ee4b97c44287f423a1133e6dffa94266d62 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 2 Apr 2026 21:56:27 +0800 Subject: [PATCH 4/4] feat: add /healthz endpoint and test coverage for health check Closes: #2493 --- internal/api/server.go | 4 ++++ internal/api/server_test.go | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/internal/api/server.go b/internal/api/server.go index 0325ca30..2bdc4ab0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -317,6 +317,10 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk // setupRoutes configures the API routes for the server. // It defines the endpoints and associates them with their respective handlers. func (s *Server) setupRoutes() { + s.engine.GET("/healthz", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + s.engine.GET("/management.html", s.serveManagementControlPanel) openaiHandlers := openai.NewOpenAIAPIHandler(s.handlers) geminiHandlers := gemini.NewGeminiAPIHandler(s.handlers) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 7ce38b8f..dbc2cd5a 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -1,6 +1,7 @@ package api import ( + "encoding/json" "net/http" "net/http/httptest" "os" @@ -46,6 +47,28 @@ func newTestServer(t *testing.T) *Server { return NewServer(cfg, authManager, accessManager, configPath) } +func TestHealthz(t *testing.T) { + server := newTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rr := httptest.NewRecorder() + server.engine.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("unexpected status code: got %d want %d; body=%s", rr.Code, http.StatusOK, rr.Body.String()) + } + + var resp struct { + Status string `json:"status"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response JSON: %v; body=%s", err, rr.Body.String()) + } + if resp.Status != "ok" { + t.Fatalf("unexpected response status: got %q want %q", resp.Status, "ok") + } +} + func TestAmpProviderModelRoutes(t *testing.T) { testCases := []struct { name string