diff --git a/internal/translator/antigravity/claude/antigravity_claude_response.go b/internal/translator/antigravity/claude/antigravity_claude_response.go index 6dd061f5..ad6b5fbb 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response.go @@ -212,83 +212,51 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR // Handle text content (both regular content and thinking) if partTextResult.Exists() { - // Process thinking content (internal reasoning) - if partResult.Get("thought").Bool() || hasThoughtSignature { - if hasThoughtSignature { - // log.Debug("Branch: signature_delta") - - // Flush co-located text before emitting the signature - if partText := partTextResult.String(); partText != "" { - if params.ResponseType != 2 { - if params.ResponseType != 0 { - appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, params.ResponseIndex)) - params.ResponseIndex++ - } - appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, params.ResponseIndex)) - params.ResponseType = 2 - params.CurrentThinkingText.Reset() - } + partText := partTextResult.String() + if partResult.Get("thought").Bool() { + if partText != "" { + if params.ResponseType == 2 { params.CurrentThinkingText.WriteString(partText) data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex)), "delta.thinking", partText) appendEvent("content_block_delta", string(data)) - } - - appendThinkingSignature(thoughtSignatureResult.String()) - } else if params.ResponseType == 2 { // Continue existing thinking block if already in thinking state - params.CurrentThinkingText.WriteString(partTextResult.String()) - data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex)), "delta.thinking", partTextResult.String()) - appendEvent("content_block_delta", string(data)) - params.HasContent = true - } else { - // Transition from another state to thinking - // First, close any existing content block - if params.ResponseType != 0 { - if params.ResponseType == 2 { - // output = output + "event: content_block_delta\n" - // output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, params.ResponseIndex) - // output = output + "\n\n\n" - } - appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, params.ResponseIndex)) - params.ResponseIndex++ - } - - // Start a new thinking content block - appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, params.ResponseIndex)) - data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex)), "delta.thinking", partTextResult.String()) - appendEvent("content_block_delta", string(data)) - params.ResponseType = 2 // Set state to thinking - params.HasContent = true - // Start accumulating thinking text for signature caching - params.CurrentThinkingText.Reset() - params.CurrentThinkingText.WriteString(partTextResult.String()) - } - } else { - finishReasonResult := gjson.GetBytes(rawJSON, "response.candidates.0.finishReason") - if partTextResult.String() != "" || !finishReasonResult.Exists() { - // Process regular text content (user-visible output) - // Continue existing text block if already in content state - if params.ResponseType == 1 { - data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, params.ResponseIndex)), "delta.text", partTextResult.String()) - appendEvent("content_block_delta", string(data)) params.HasContent = true } else { - // Transition from another state to text content - // First, close any existing content block if params.ResponseType != 0 { - if params.ResponseType == 2 { - // output = output + "event: content_block_delta\n" - // output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, params.ResponseIndex) - // output = output + "\n\n\n" - } appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, params.ResponseIndex)) params.ResponseIndex++ } - if partTextResult.String() != "" { - // Start a new text content block + appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, params.ResponseIndex)) + data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex)), "delta.thinking", partText) + appendEvent("content_block_delta", string(data)) + params.ResponseType = 2 + params.HasContent = true + params.CurrentThinkingText.Reset() + params.CurrentThinkingText.WriteString(partText) + } + } + if hasThoughtSignature { + appendThinkingSignature(thoughtSignatureResult.String()) + } + } else { + if hasThoughtSignature { + appendThinkingSignature(thoughtSignatureResult.String()) + } + finishReasonResult := gjson.GetBytes(rawJSON, "response.candidates.0.finishReason") + if partText != "" || !finishReasonResult.Exists() { + if params.ResponseType == 1 { + data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, params.ResponseIndex)), "delta.text", partText) + appendEvent("content_block_delta", string(data)) + params.HasContent = true + } else { + if params.ResponseType != 0 { + appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, params.ResponseIndex)) + params.ResponseIndex++ + } + if partText != "" { appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, params.ResponseIndex)) - data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, params.ResponseIndex)), "delta.text", partTextResult.String()) + data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, params.ResponseIndex)), "delta.text", partText) appendEvent("content_block_delta", string(data)) - params.ResponseType = 1 // Set state to content + params.ResponseType = 1 params.HasContent = true } } @@ -556,8 +524,8 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or sig = part.Get("thought_signature") } hasThoughtSignature := sig.Exists() && sig.String() != "" && !part.Get("functionCall").Exists() - isThought := part.Get("thought").Bool() || hasThoughtSignature - if hasThoughtSignature { + isThought := part.Get("thought").Bool() + if hasThoughtSignature && (isThought || thinkingBuilder.Len() > 0) { thinkingSignature = sig.String() } diff --git a/internal/translator/antigravity/claude/antigravity_claude_response_test.go b/internal/translator/antigravity/claude/antigravity_claude_response_test.go index 7999e64d..c039062c 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response_test.go @@ -755,3 +755,105 @@ func TestConvertAntigravityResponseToClaudeNonStream_SignatureOnlyPartWithoutTho t.Fatalf("expected signature %q, got %q: %s", validSignature, got, output) } } + +func TestConvertAntigravityResponseToClaudeNonStream_TextWithThoughtSignatureStaysText(t *testing.T) { + previousCache := cache.SignatureCacheEnabled() + cache.SetSignatureCacheEnabled(false) + defer cache.SetSignatureCacheEnabled(previousCache) + + requestJSON := []byte(`{"model":"gemini-3.1-pro-low"}`) + translatedRequestJSON := []byte(`{"model":"gemini-3.1-pro-low"}`) + responseJSON := []byte(`{ + "response": { + "candidates": [{ + "content": { + "parts": [ + {"text": "I need to multiply 17 by 24.", "thought": true}, + {"text": "408", "thoughtSignature": "sig-final-answer"} + ] + }, + "finishReason": "STOP" + }], + "usageMetadata": { + "promptTokenCount": 16, + "candidatesTokenCount": 3, + "thoughtsTokenCount": 42, + "totalTokenCount": 61 + }, + "modelVersion": "gemini-3.1-pro-low", + "responseId": "resp-text-sig" + } + }`) + + output := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.1-pro-low", requestJSON, translatedRequestJSON, responseJSON, nil) + if got := gjson.GetBytes(output, "content.#").Int(); got != 2 { + t.Fatalf("content block count = %d, want 2. Output: %s", got, output) + } + if got := gjson.GetBytes(output, "content.0.type").String(); got != "thinking" { + t.Fatalf("content.0.type = %q, want thinking. Output: %s", got, output) + } + if got := gjson.GetBytes(output, "content.0.thinking").String(); got != "I need to multiply 17 by 24." { + t.Fatalf("thinking = %q, want thought text. Output: %s", got, output) + } + if got := gjson.GetBytes(output, "content.0.signature").String(); got != "sig-final-answer" { + t.Fatalf("signature = %q, want sig-final-answer. Output: %s", got, output) + } + if got := gjson.GetBytes(output, "content.1.type").String(); got != "text" { + t.Fatalf("content.1.type = %q, want text. Output: %s", got, output) + } + if got := gjson.GetBytes(output, "content.1.text").String(); got != "408" { + t.Fatalf("text = %q, want final answer. Output: %s", got, output) + } +} + +func TestConvertAntigravityResponseToClaudeStream_TextWithThoughtSignatureStaysText(t *testing.T) { + previousCache := cache.SignatureCacheEnabled() + cache.SetSignatureCacheEnabled(false) + defer cache.SetSignatureCacheEnabled(previousCache) + + requestJSON := []byte(`{"model":"gemini-3.1-pro-low"}`) + translatedRequestJSON := []byte(`{"model":"gemini-3.1-pro-low"}`) + thoughtChunk := []byte(`{ + "response": { + "candidates": [{"content": {"parts": [{"text": "I need to multiply 17 by 24.", "thought": true}]}}], + "modelVersion": "gemini-3.1-pro-low", + "responseId": "resp-text-sig" + } + }`) + textChunk := []byte(`{ + "response": { + "candidates": [{"content": {"parts": [{"text": "408", "thoughtSignature": "sig-final-answer"}]}}], + "modelVersion": "gemini-3.1-pro-low", + "responseId": "resp-text-sig" + } + }`) + finishChunk := []byte(`{ + "response": { + "candidates": [{"finishReason": "STOP"}], + "usageMetadata": {"promptTokenCount": 16, "candidatesTokenCount": 3, "thoughtsTokenCount": 42, "totalTokenCount": 61}, + "modelVersion": "gemini-3.1-pro-low", + "responseId": "resp-text-sig" + } + }`) + + var param any + ctx := context.Background() + output := bytes.Join(ConvertAntigravityResponseToClaude(ctx, "gemini-3.1-pro-low", requestJSON, translatedRequestJSON, thoughtChunk, ¶m), nil) + output = append(output, bytes.Join(ConvertAntigravityResponseToClaude(ctx, "gemini-3.1-pro-low", requestJSON, translatedRequestJSON, textChunk, ¶m), nil)...) + output = append(output, bytes.Join(ConvertAntigravityResponseToClaude(ctx, "gemini-3.1-pro-low", requestJSON, translatedRequestJSON, finishChunk, ¶m), nil)...) + output = append(output, bytes.Join(ConvertAntigravityResponseToClaude(ctx, "gemini-3.1-pro-low", requestJSON, translatedRequestJSON, []byte("[DONE]"), ¶m), nil)...) + outputText := string(output) + + if !strings.Contains(outputText, `"delta":{"type":"signature_delta","signature":"sig-final-answer"}`) { + t.Fatalf("expected signature delta for thinking block: %s", outputText) + } + if !strings.Contains(outputText, `"content_block":{"type":"text","text":""}`) { + t.Fatalf("expected text content block after thinking: %s", outputText) + } + if !strings.Contains(outputText, `"delta":{"type":"text_delta","text":"408"}`) { + t.Fatalf("expected final answer as text delta: %s", outputText) + } + if strings.Contains(outputText, `"delta":{"type":"thinking_delta","thinking":"408"}`) { + t.Fatalf("final answer must not be emitted as thinking delta: %s", outputText) + } +} diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go index 00b5f8c0..1c95b731 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go @@ -37,6 +37,8 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ // Let user-provided generationConfig pass through if genConfig := gjson.GetBytes(rawJSON, "generationConfig"); genConfig.Exists() { out, _ = sjson.SetRawBytes(out, "request.generationConfig", []byte(genConfig.Raw)) + } else if genConfig := gjson.GetBytes(rawJSON, "generation_config"); genConfig.Exists() { + out, _ = sjson.SetRawBytes(out, "request.generationConfig", []byte(genConfig.Raw)) } // Apply thinking configuration: convert OpenAI reasoning_effort to Antigravity thinkingConfig. @@ -55,6 +57,7 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ } } } + out = applyOpenAIThinkingCompatibilityToAntigravity(out, rawJSON, modelName) // Temperature/top_p/top_k/max_tokens if tr := gjson.GetBytes(rawJSON, "temperature"); tr.Exists() && tr.Type == gjson.Number { @@ -451,5 +454,83 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ return common.AttachDefaultSafetySettings(out, "request.safetySettings") } +func applyOpenAIThinkingCompatibilityToAntigravity(out []byte, rawJSON []byte, modelName string) []byte { + out = normalizeAntigravityOpenAIThinkingConfig(out) + + for _, path := range []string{ + "thinking.includeThoughts", + "thinking.include_thoughts", + "reasoning.includeThoughts", + "reasoning.include_thoughts", + } { + if value := gjson.GetBytes(rawJSON, path); value.Exists() { + out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", value.Bool()) + } + } + + if exclude := gjson.GetBytes(rawJSON, "reasoning.exclude"); exclude.Exists() { + out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", !exclude.Bool()) + } + + if !gjson.GetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts").Exists() && antigravityOpenAIDefaultIncludeThoughts(modelName) { + out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", true) + } + + return normalizeAntigravityOpenAIThinkingConfig(out) +} + +func normalizeAntigravityOpenAIThinkingConfig(out []byte) []byte { + for _, prefix := range []string{ + "request.generationConfig.thinking_config", + "request.generationConfig.thinkingConfig", + } { + if includeThoughts := gjson.GetBytes(out, prefix+".includeThoughts"); includeThoughts.Exists() { + out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts.Bool()) + } + if includeThoughts := gjson.GetBytes(out, prefix+".include_thoughts"); includeThoughts.Exists() { + out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts.Bool()) + } + if thinkingLevel := gjson.GetBytes(out, prefix+".thinkingLevel"); thinkingLevel.Exists() { + out, _ = sjson.SetRawBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel", []byte(thinkingLevel.Raw)) + } + if thinkingLevel := gjson.GetBytes(out, prefix+".thinking_level"); thinkingLevel.Exists() { + out, _ = sjson.SetRawBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel", []byte(thinkingLevel.Raw)) + } + if thinkingBudget := gjson.GetBytes(out, prefix+".thinkingBudget"); thinkingBudget.Exists() { + out, _ = sjson.SetRawBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", []byte(thinkingBudget.Raw)) + } + if thinkingBudget := gjson.GetBytes(out, prefix+".thinking_budget"); thinkingBudget.Exists() { + out, _ = sjson.SetRawBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", []byte(thinkingBudget.Raw)) + } + } + + for _, path := range []string{ + "request.generationConfig.includeThoughts", + "request.generationConfig.include_thoughts", + } { + if includeThoughts := gjson.GetBytes(out, path); includeThoughts.Exists() { + out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts.Bool()) + } + } + + for _, path := range []string{ + "request.generationConfig.thinking_config", + "request.generationConfig.thinkingConfig.include_thoughts", + "request.generationConfig.thinkingConfig.thinking_level", + "request.generationConfig.thinkingConfig.thinking_budget", + "request.generationConfig.includeThoughts", + "request.generationConfig.include_thoughts", + } { + out, _ = sjson.DeleteBytes(out, path) + } + + return out +} + +func antigravityOpenAIDefaultIncludeThoughts(modelName string) bool { + modelName = strings.ToLower(modelName) + return strings.Contains(modelName, "gemini-3") +} + // itoa converts int to string without strconv import for few usages. func itoa(i int) string { return fmt.Sprintf("%d", i) } diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go index 9a06cbc6..a4bacce9 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go @@ -54,3 +54,72 @@ func TestConvertOpenAIRequestToAntigravitySkipsEmptyTextPartsWithoutNulls(t *tes t.Fatalf("functionCall missing. Output: %s", result) } } + +func TestConvertOpenAIRequestToAntigravityThinkingAliases(t *testing.T) { + tests := []struct { + name string + body string + want bool + }{ + { + name: "Default Gemini include thoughts", + body: `{ + "model":"gemini-3.1-pro-low", + "messages":[{"role":"user","content":"hi"}] + }`, + want: true, + }, + { + name: "GenerationConfig snake include thoughts", + body: `{ + "model":"gemini-3.1-pro-low", + "messages":[{"role":"user","content":"hi"}], + "generationConfig":{"thinkingConfig":{"include_thoughts":true}} + }`, + want: true, + }, + { + name: "Top-level thinking include thoughts", + body: `{ + "model":"gemini-3.1-pro-low", + "messages":[{"role":"user","content":"hi"}], + "thinking":{"include_thoughts":true} + }`, + want: true, + }, + { + name: "Reasoning exclude false includes thoughts", + body: `{ + "model":"gemini-3.1-pro-low", + "messages":[{"role":"user","content":"hi"}], + "reasoning":{"exclude":false} + }`, + want: true, + }, + { + name: "Reasoning exclude true hides thoughts", + body: `{ + "model":"gemini-3.1-pro-low", + "messages":[{"role":"user","content":"hi"}], + "reasoning":{"exclude":true} + }`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ConvertOpenAIRequestToAntigravity("gemini-3.1-pro-low", []byte(tt.body), false) + includeThoughts := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts") + if !includeThoughts.Exists() { + t.Fatalf("includeThoughts missing. Output: %s", result) + } + if got := includeThoughts.Bool(); got != tt.want { + t.Fatalf("includeThoughts = %v, want %v. Output: %s", got, tt.want, result) + } + if snake := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.include_thoughts"); snake.Exists() { + t.Fatalf("include_thoughts should be normalized away. Output: %s", result) + } + }) + } +} diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response_test.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response_test.go index bd2eb891..fe0ab86c 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response_test.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response_test.go @@ -126,3 +126,40 @@ func TestNoFinishReasonOnIntermediateChunks(t *testing.T) { t.Errorf("Expected no finish_reason on intermediate chunk, got: %v", fr2) } } + +func TestConvertAntigravityResponseToOpenAINonStreamIncludesReasoningContent(t *testing.T) { + ctx := context.Background() + responseJSON := []byte(`{ + "response": { + "candidates": [{ + "index": 0, + "content": { + "parts": [ + {"text": "I need to multiply 17 by 24.", "thought": true}, + {"text": "408", "thoughtSignature": "sig-final-answer"} + ] + }, + "finishReason": "STOP" + }], + "usageMetadata": { + "promptTokenCount": 16, + "candidatesTokenCount": 3, + "thoughtsTokenCount": 42, + "totalTokenCount": 61 + }, + "modelVersion": "gemini-3.1-pro-low", + "responseId": "resp-reasoning" + } + }`) + + output := ConvertAntigravityResponseToOpenAINonStream(ctx, "gemini-3.1-pro-low", nil, nil, responseJSON, nil) + if got := gjson.GetBytes(output, "choices.0.message.reasoning_content").String(); got != "I need to multiply 17 by 24." { + t.Fatalf("reasoning_content = %q, want thought text. Output: %s", got, output) + } + if got := gjson.GetBytes(output, "choices.0.message.content").String(); got != "408" { + t.Fatalf("content = %q, want final answer. Output: %s", got, output) + } + if got := gjson.GetBytes(output, "usage.completion_tokens_details.reasoning_tokens").Int(); got != 42 { + t.Fatalf("reasoning_tokens = %d, want 42. Output: %s", got, output) + } +}