fix(claude): prevent final events when no content streamed
Unified · Split
| @@ -35,6 +35,7 @@ type Params struct { · 34 unmodified lines | ||
| 35 | 35 | TotalTokenCount int64 // Cached total token count from usage metadata |
| 36 | 36 | HasSentFinalEvents bool // Indicates if final content/message events have been sent |
| 37 | 37 | HasToolUse bool // Indicates if tool use was observed in the stream |
| 38 | + HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output | |
| 38 | 39 | } |
| 39 | 40 | |
| 40 | 41 | // toolUseIDCounter provides a process-wide unique counter for tool use identifiers. |
| @@ -69,11 +70,14 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq · 28 unmodified lines | ||
| 69 | 70 | |
| 70 | 71 | if bytes.Equal(rawJSON, []byte("[DONE]")) { |
| 71 | 72 | output := "" |
| 72 | − appendFinalEvents(params, &output, true) | |
| 73 | − | |
| 74 | − return []string{ | |
| 75 | − output + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n\n", | |
| 73 | + // Only send final events if we have actually output content | |
| 74 | + if params.HasContent { | |
| 75 | + appendFinalEvents(params, &output, true) | |
| 76 | + return []string{ | |
| 77 | + output + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n\n", | |
| 78 | + } | |
| 76 | 79 | } |
| 80 | + return []string{} | |
| 77 | 81 | } |
| 78 | 82 | |
| 79 | 83 | output := "" |
| @@ -119,10 +123,12 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq · 39 unmodified lines | ||
| 119 | 123 | output = output + "event: content_block_delta\n" |
| 120 | 124 | data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, params.ResponseIndex), "delta.signature", thoughtSignature.String()) |
| 121 | 125 | output = output + fmt.Sprintf("data: %s\n\n\n", data) |
| 126 | + params.HasContent = true | |
| 122 | 127 | } else if params.ResponseType == 2 { // Continue existing thinking block if already in thinking state |
| 123 | 128 | output = output + "event: content_block_delta\n" |
| 124 | 129 | data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex), "delta.thinking", partTextResult.String()) |
| 125 | 130 | output = output + fmt.Sprintf("data: %s\n\n\n", data) |
| 131 | + params.HasContent = true | |
| 126 | 132 | } else { |
| 127 | 133 | // Transition from another state to thinking |
| 128 | 134 | // First, close any existing content block |
| @@ -146,6 +152,7 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq · 17 unmodified lines | ||
| 146 | 152 | data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex), "delta.thinking", partTextResult.String()) |
| 147 | 153 | output = output + fmt.Sprintf("data: %s\n\n\n", data) |
| 148 | 154 | params.ResponseType = 2 // Set state to thinking |
| 155 | + params.HasContent = true | |
| 149 | 156 | } |
| 150 | 157 | } else { |
| 151 | 158 | finishReasonResult := gjson.GetBytes(rawJSON, "response.candidates.0.finishReason") |
| @@ -156,6 +163,7 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq · 4 unmodified lines | ||
| 156 | 163 | output = output + "event: content_block_delta\n" |
| 157 | 164 | data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, params.ResponseIndex), "delta.text", partTextResult.String()) |
| 158 | 165 | output = output + fmt.Sprintf("data: %s\n\n\n", data) |
| 166 | + params.HasContent = true | |
| 159 | 167 | } else { |
| 160 | 168 | // Transition from another state to text content |
| 161 | 169 | // First, close any existing content block |
| @@ -179,6 +187,7 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq · 17 unmodified lines | ||
| 179 | 187 | data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, params.ResponseIndex), "delta.text", partTextResult.String()) |
| 180 | 188 | output = output + fmt.Sprintf("data: %s\n\n\n", data) |
| 181 | 189 | params.ResponseType = 1 // Set state to content |
| 190 | + params.HasContent = true | |
| 182 | 191 | } |
| 183 | 192 | } |
| 184 | 193 | } |
| @@ -230,6 +239,7 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq · 45 unmodified lines | ||
| 230 | 239 | output = output + fmt.Sprintf("data: %s\n\n\n", data) |
| 231 | 240 | } |
| 232 | 241 | params.ResponseType = 3 |
| 242 | + params.HasContent = true | |
| 233 | 243 | } |
| 234 | 244 | } |
| 235 | 245 | } |
| @@ -269,6 +279,11 @@ func appendFinalEvents(params *Params, output *string, force bool) { · 33 unmodified lines | ||
| 269 | 279 | return |
| 270 | 280 | } |
| 271 | 281 | |
| 282 | + // Only send final events if we have actually output content | |
| 283 | + if !params.HasContent { | |
| 284 | + return | |
| 285 | + } | |
| 286 | + | |
| 272 | 287 | if params.ResponseType != 0 { |
| 273 | 288 | *output = *output + "event: content_block_stop\n" |
| 274 | 289 | *output = *output + fmt.Sprintf(`data: {"type":"content_block_stop","index":%d}`, params.ResponseIndex) |
| @@ -26,6 +26,7 @@ type Params struct { · 25 unmodified lines | ||
| 26 | 26 | HasFirstResponse bool // Indicates if the initial message_start event has been sent |
| 27 | 27 | ResponseType int // Current response type: 0=none, 1=content, 2=thinking, 3=function |
| 28 | 28 | ResponseIndex int // Index counter for content blocks in the streaming response |
| 29 | + HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output | |
| 29 | 30 | } |
| 30 | 31 | |
| 31 | 32 | // toolUseIDCounter provides a process-wide unique counter for tool use identifiers. |
| @@ -57,9 +58,13 @@ func ConvertGeminiCLIResponseToClaude(_ context.Context, _ string, originalReque · 25 unmodified lines | ||
| 57 | 58 | } |
| 58 | 59 | |
| 59 | 60 | if bytes.Equal(rawJSON, []byte("[DONE]")) { |
| 60 | − return []string{ | |
| 61 | − "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n\n", | |
| 61 | + // Only send message_stop if we have actually output content | |
| 62 | + if (*param).(*Params).HasContent { | |
| 63 | + return []string{ | |
| 64 | + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n\n", | |
| 65 | + } | |
| 62 | 66 | } |
| 67 | + return []string{} | |
| 63 | 68 | } |
| 64 | 69 | |
| 65 | 70 | // Track whether tools are being used in this response chunk |
| @@ -108,6 +113,7 @@ func ConvertGeminiCLIResponseToClaude(_ context.Context, _ string, originalReque · 42 unmodified lines | ||
| 108 | 113 | output = output + "event: content_block_delta\n" |
| 109 | 114 | data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex), "delta.thinking", partTextResult.String()) |
| 110 | 115 | output = output + fmt.Sprintf("data: %s\n\n\n", data) |
| 116 | + (*param).(*Params).HasContent = true | |
| 111 | 117 | } else { |
| 112 | 118 | // Transition from another state to thinking |
| 113 | 119 | // First, close any existing content block |
| @@ -131,6 +137,7 @@ func ConvertGeminiCLIResponseToClaude(_ context.Context, _ string, originalReque · 17 unmodified lines | ||
| 131 | 137 | data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex), "delta.thinking", partTextResult.String()) |
| 132 | 138 | output = output + fmt.Sprintf("data: %s\n\n\n", data) |
| 133 | 139 | (*param).(*Params).ResponseType = 2 // Set state to thinking |
| 140 | + (*param).(*Params).HasContent = true | |
| 134 | 141 | } |
| 135 | 142 | } else { |
| 136 | 143 | // Process regular text content (user-visible output) |
| @@ -139,6 +146,7 @@ func ConvertGeminiCLIResponseToClaude(_ context.Context, _ string, originalReque · 2 unmodified lines | ||
| 139 | 146 | output = output + "event: content_block_delta\n" |
| 140 | 147 | data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex), "delta.text", partTextResult.String()) |
| 141 | 148 | output = output + fmt.Sprintf("data: %s\n\n\n", data) |
| 149 | + (*param).(*Params).HasContent = true | |
| 142 | 150 | } else { |
| 143 | 151 | // Transition from another state to text content |
| 144 | 152 | // First, close any existing content block |
| @@ -162,6 +170,7 @@ func ConvertGeminiCLIResponseToClaude(_ context.Context, _ string, originalReque · 17 unmodified lines | ||
| 162 | 170 | data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex), "delta.text", partTextResult.String()) |
| 163 | 171 | output = output + fmt.Sprintf("data: %s\n\n\n", data) |
| 164 | 172 | (*param).(*Params).ResponseType = 1 // Set state to content |
| 173 | + (*param).(*Params).HasContent = true | |
| 165 | 174 | } |
| 166 | 175 | } |
| 167 | 176 | } else if functionCallResult.Exists() { |
| @@ -211,6 +220,7 @@ func ConvertGeminiCLIResponseToClaude(_ context.Context, _ string, originalReque · 43 unmodified lines | ||
| 211 | 220 | output = output + fmt.Sprintf("data: %s\n\n\n", data) |
| 212 | 221 | } |
| 213 | 222 | (*param).(*Params).ResponseType = 3 |
| 223 | + (*param).(*Params).HasContent = true | |
| 214 | 224 | } |
| 215 | 225 | } |
| 216 | 226 | } |
| @@ -219,28 +229,31 @@ func ConvertGeminiCLIResponseToClaude(_ context.Context, _ string, originalReque · 2 unmodified lines | ||
| 219 | 229 | // Process usage metadata and finish reason when present in the response |
| 220 | 230 | if usageResult.Exists() && bytes.Contains(rawJSON, []byte(`"finishReason"`)) { |
| 221 | 231 | if candidatesTokenCountResult := usageResult.Get("candidatesTokenCount"); candidatesTokenCountResult.Exists() { |
| 222 | − // Close the final content block | |
| 223 | − output = output + "event: content_block_stop\n" | |
| 224 | − output = output + fmt.Sprintf(`data: {"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex) | |
| 225 | − output = output + "\n\n\n" | |
| 226 | − | |
| 227 | − // Send the final message delta with usage information and stop reason | |
| 228 | − output = output + "event: message_delta\n" | |
| 229 | − output = output + `data: ` | |
| 230 | − | |
| 231 | − // Create the message delta template with appropriate stop reason | |
| 232 | − template := `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}` | |
| 233 | − // Set tool_use stop reason if tools were used in this response | |
| 234 | − if usedTool { | |
| 235 | − template = `{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}` | |
| 236 | − } | |
| 232 | + // Only send final events if we have actually output content | |
| 233 | + if (*param).(*Params).HasContent { | |
| 234 | + // Close the final content block | |
| 235 | + output = output + "event: content_block_stop\n" | |
| 236 | + output = output + fmt.Sprintf(`data: {"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex) | |
| 237 | + output = output + "\n\n\n" | |
| 238 | + | |
| 239 | + // Send the final message delta with usage information and stop reason | |
| 240 | + output = output + "event: message_delta\n" | |
| 241 | + output = output + `data: ` | |
| 242 | + | |
| 243 | + // Create the message delta template with appropriate stop reason | |
| 244 | + template := `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}` | |
| 245 | + // Set tool_use stop reason if tools were used in this response | |
| 246 | + if usedTool { | |
| 247 | + template = `{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}` | |
| 248 | + } | |
| 237 | 249 | |
| 238 | − // Include thinking tokens in output token count if present | |
| 239 | − thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int() | |
| 240 | − template, _ = sjson.Set(template, "usage.output_tokens", candidatesTokenCountResult.Int()+thoughtsTokenCount) | |
| 241 | − template, _ = sjson.Set(template, "usage.input_tokens", usageResult.Get("promptTokenCount").Int()) | |
| 250 | + // Include thinking tokens in output token count if present | |
| 251 | + thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int() | |
| 252 | + template, _ = sjson.Set(template, "usage.output_tokens", candidatesTokenCountResult.Int()+thoughtsTokenCount) | |
| 253 | + template, _ = sjson.Set(template, "usage.input_tokens", usageResult.Get("promptTokenCount").Int()) | |
| 242 | 254 | |
| 243 | − output = output + template + "\n\n\n" | |
| 255 | + output = output + template + "\n\n\n" | |
| 256 | + } | |
| 244 | 257 | } |
| 245 | 258 | } |
| 246 | 259 | |
| @@ -25,6 +25,7 @@ type Params struct { · 24 unmodified lines | ||
| 25 | 25 | HasFirstResponse bool |
| 26 | 26 | ResponseType int |
| 27 | 27 | ResponseIndex int |
| 28 | + HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output | |
| 28 | 29 | } |
| 29 | 30 | |
| 30 | 31 | // toolUseIDCounter provides a process-wide unique counter for tool use identifiers. |
| @@ -57,9 +58,13 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR · 26 unmodified lines | ||
| 57 | 58 | } |
| 58 | 59 | |
| 59 | 60 | if bytes.Equal(rawJSON, []byte("[DONE]")) { |
| 60 | − return []string{ | |
| 61 | − "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n\n", | |
| 61 | + // Only send message_stop if we have actually output content | |
| 62 | + if (*param).(*Params).HasContent { | |
| 63 | + return []string{ | |
| 64 | + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n\n", | |
| 65 | + } | |
| 62 | 66 | } |
| 67 | + return []string{} | |
| 63 | 68 | } |
| 64 | 69 | |
| 65 | 70 | // Track whether tools are being used in this response chunk |
| @@ -108,6 +113,7 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR · 42 unmodified lines | ||
| 108 | 113 | output = output + "event: content_block_delta\n" |
| 109 | 114 | data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex), "delta.thinking", partTextResult.String()) |
| 110 | 115 | output = output + fmt.Sprintf("data: %s\n\n\n", data) |
| 116 | + (*param).(*Params).HasContent = true | |
| 111 | 117 | } else { |
| 112 | 118 | // Transition from another state to thinking |
| 113 | 119 | // First, close any existing content block |
| @@ -131,6 +137,7 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR · 17 unmodified lines | ||
| 131 | 137 | data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex), "delta.thinking", partTextResult.String()) |
| 132 | 138 | output = output + fmt.Sprintf("data: %s\n\n\n", data) |
| 133 | 139 | (*param).(*Params).ResponseType = 2 // Set state to thinking |
| 140 | + (*param).(*Params).HasContent = true | |
| 134 | 141 | } |
| 135 | 142 | } else { |
| 136 | 143 | // Process regular text content (user-visible output) |
| @@ -139,6 +146,7 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR · 2 unmodified lines | ||
| 139 | 146 | output = output + "event: content_block_delta\n" |
| 140 | 147 | data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex), "delta.text", partTextResult.String()) |
| 141 | 148 | output = output + fmt.Sprintf("data: %s\n\n\n", data) |
| 149 | + (*param).(*Params).HasContent = true | |
| 142 | 150 | } else { |
| 143 | 151 | // Transition from another state to text content |
| 144 | 152 | // First, close any existing content block |
| @@ -162,6 +170,7 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR · 17 unmodified lines | ||
| 162 | 170 | data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex), "delta.text", partTextResult.String()) |
| 163 | 171 | output = output + fmt.Sprintf("data: %s\n\n\n", data) |
| 164 | 172 | (*param).(*Params).ResponseType = 1 // Set state to content |
| 173 | + (*param).(*Params).HasContent = true | |
| 165 | 174 | } |
| 166 | 175 | } |
| 167 | 176 | } else if functionCallResult.Exists() { |
| @@ -211,6 +220,7 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR · 43 unmodified lines | ||
| 211 | 220 | output = output + fmt.Sprintf("data: %s\n\n\n", data) |
| 212 | 221 | } |
| 213 | 222 | (*param).(*Params).ResponseType = 3 |
| 223 | + (*param).(*Params).HasContent = true | |
| 214 | 224 | } |
| 215 | 225 | } |
| 216 | 226 | } |
| @@ -218,23 +228,26 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR · 1 unmodified line | ||
| 218 | 228 | usageResult := gjson.GetBytes(rawJSON, "usageMetadata") |
| 219 | 229 | if usageResult.Exists() && bytes.Contains(rawJSON, []byte(`"finishReason"`)) { |
| 220 | 230 | if candidatesTokenCountResult := usageResult.Get("candidatesTokenCount"); candidatesTokenCountResult.Exists() { |
| 221 | − output = output + "event: content_block_stop\n" | |
| 222 | − output = output + fmt.Sprintf(`data: {"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex) | |
| 223 | − output = output + "\n\n\n" | |
| 231 | + // Only send final events if we have actually output content | |
| 232 | + if (*param).(*Params).HasContent { | |
| 233 | + output = output + "event: content_block_stop\n" | |
| 234 | + output = output + fmt.Sprintf(`data: {"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex) | |
| 235 | + output = output + "\n\n\n" | |
| 236 | + | |
| 237 | + output = output + "event: message_delta\n" | |
| 238 | + output = output + `data: ` | |
| 239 | + | |
| 240 | + template := `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}` | |
| 241 | + if usedTool { | |
| 242 | + template = `{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}` | |
| 243 | + } | |
| 224 | 244 | |
| 225 | − output = output + "event: message_delta\n" | |
| 226 | − output = output + `data: ` | |
| 245 | + thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int() | |
| 246 | + template, _ = sjson.Set(template, "usage.output_tokens", candidatesTokenCountResult.Int()+thoughtsTokenCount) | |
| 247 | + template, _ = sjson.Set(template, "usage.input_tokens", usageResult.Get("promptTokenCount").Int()) | |
| 227 | 248 | |
| 228 | − template := `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}` | |
| 229 | − if usedTool { | |
| 230 | − template = `{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}` | |
| 249 | + output = output + template + "\n\n\n" | |
| 231 | 250 | } |
| 232 | − | |
| 233 | − thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int() | |
| 234 | − template, _ = sjson.Set(template, "usage.output_tokens", candidatesTokenCountResult.Int()+thoughtsTokenCount) | |
| 235 | − template, _ = sjson.Set(template, "usage.input_tokens", usageResult.Get("promptTokenCount").Int()) | |
| 236 | − | |
| 237 | − output = output + template + "\n\n\n" | |
| 238 | 251 | } |
| 239 | 252 | } |
| 240 | 253 | |