From 480b4a0bc18f7130f9727c2454bdf91fac4cfefc Mon Sep 17 00:00:00 2001 From: Marvin Date: Sat, 17 Jan 2026 07:46:28 +0100 Subject: [PATCH 1/2] fix: preserve whitespace-only chunks in SSE stream (#139) * fix: preserve whitespace-only chunks in SSE stream Fixes issue #138 where Claude models would swallow spaces between words because whitespace-only chunks (e.g., " ") were being filtered out as empty. Changes: - Modified sse-streamer.js to only skip truly empty strings (""), preserving strings that contain only whitespace. - Added regression test case in tests/test-streaming-whitespace.cjs to verify whitespace preservation. * test: add streaming whitespace regression test to main suite --------- Co-authored-by: walczak --- src/cloudcode/sse-streamer.js | 4 +- tests/run-all.cjs | 3 +- tests/test-streaming-whitespace.cjs | 119 ++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 tests/test-streaming-whitespace.cjs diff --git a/src/cloudcode/sse-streamer.js b/src/cloudcode/sse-streamer.js index 9d72bc0..7fb640a 100644 --- a/src/cloudcode/sse-streamer.js +++ b/src/cloudcode/sse-streamer.js @@ -123,8 +123,8 @@ export async function* streamSSEResponse(response, originalModel) { }; } else if (part.text !== undefined) { - // Skip empty text parts - if (!part.text || part.text.trim().length === 0) { + // Skip empty text parts (but preserve whitespace-only chunks for proper spacing) + if (part.text === '') { continue; } diff --git a/tests/run-all.cjs b/tests/run-all.cjs index 95015d3..01256cd 100644 --- a/tests/run-all.cjs +++ b/tests/run-all.cjs @@ -18,7 +18,8 @@ const tests = [ { name: 'Cross-Model Thinking', file: 'test-cross-model-thinking.cjs' }, { name: 'OAuth No-Browser Mode', file: 'test-oauth-no-browser.cjs' }, { name: 'Empty Response Retry', file: 'test-empty-response-retry.cjs' }, - { name: 'Schema Sanitizer', file: 'test-schema-sanitizer.cjs' } + { name: 'Schema Sanitizer', file: 'test-schema-sanitizer.cjs' }, + { name: 'Streaming Whitespace', file: 'test-streaming-whitespace.cjs' } ]; async function runTest(test) { diff --git a/tests/test-streaming-whitespace.cjs b/tests/test-streaming-whitespace.cjs new file mode 100644 index 0000000..ad05fb2 --- /dev/null +++ b/tests/test-streaming-whitespace.cjs @@ -0,0 +1,119 @@ +/** + * Test Streaming Whitespace - Verifies that whitespace-only chunks are not dropped + * + * Reproduction for Issue #138: "Claude models swallow spaces between words" + */ + +const { TextEncoder } = require('util'); + +// Mock Response class +class MockResponse { + constructor(chunks) { + this.body = { + getReader: () => { + let index = 0; + return { + read: async () => { + if (index >= chunks.length) { + return { done: true, value: undefined }; + } + const chunk = chunks[index++]; + const encoder = new TextEncoder(); + return { done: false, value: encoder.encode(chunk) }; + } + }; + } + }; + } +} + +async function runTests() { + console.log('╔══════════════════════════════════════════════════════════════╗'); + console.log('║ STREAMING WHITESPACE TEST SUITE ║'); + console.log('╚══════════════════════════════════════════════════════════════╝\n'); + + // Dynamic import for ESM module + const { streamSSEResponse } = await import('../src/cloudcode/sse-streamer.js'); + + let passed = 0; + let failed = 0; + + async function test(name, fn) { + try { + await fn(); + console.log(`✓ ${name}`); + passed++; + } catch (e) { + console.log(`✗ ${name}`); + console.log(` Error: ${e.message}`); + failed++; + } + } + + function assertEqual(actual, expected, message = '') { + if (actual !== expected) { + throw new Error(`${message}\nExpected: "${expected}"\nActual: "${actual}"`); + } + } + + // Test Case: Whitespace preservation + await test('Preserves whitespace-only chunks', async () => { + // Construct chunks that simulate the Google SSE format + // We split "Hello World" into "Hello", " ", "World" + const chunks = [ + 'data: ' + JSON.stringify({ candidates: [{ content: { parts: [{ text: "Hello" }] } }] }) + '\n\n', + 'data: ' + JSON.stringify({ candidates: [{ content: { parts: [{ text: " " }] } }] }) + '\n\n', + 'data: ' + JSON.stringify({ candidates: [{ content: { parts: [{ text: "World" }] } }] }) + '\n\n' + ]; + + const response = new MockResponse(chunks); + const originalModel = 'claude-sonnet-4-5'; + + let fullText = ''; + const generator = streamSSEResponse(response, originalModel); + + for await (const event of generator) { + if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') { + fullText += event.delta.text; + } + } + + assertEqual(fullText, 'Hello World', 'Should preserve space between words'); + }); + + // Test Case: Empty string (should be skipped) + await test('Skips truly empty strings but keeps newlines', async () => { + const chunks = [ + 'data: ' + JSON.stringify({ candidates: [{ content: { parts: [{ text: "Line1" }] } }] }) + '\n\n', + 'data: ' + JSON.stringify({ candidates: [{ content: { parts: [{ text: "" }] } }] }) + '\n\n', // Empty + 'data: ' + JSON.stringify({ candidates: [{ content: { parts: [{ text: "\n" }] } }] }) + '\n\n', // Newline + 'data: ' + JSON.stringify({ candidates: [{ content: { parts: [{ text: "Line2" }] } }] }) + '\n\n' + ]; + + const response = new MockResponse(chunks); + const originalModel = 'claude-sonnet-4-5'; + + let fullText = ''; + const generator = streamSSEResponse(response, originalModel); + + for await (const event of generator) { + if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') { + fullText += event.delta.text; + } + } + + assertEqual(fullText, 'Line1\nLine2', 'Should preserve newline but ignore empty string'); + }); + + console.log('\n' + '═'.repeat(60)); + console.log(`Tests completed: ${passed} passed, ${failed} failed`); + + if (failed > 0) { + process.exit(1); + } +} + +runTests().catch(err => { + console.error('Test suite failed:', err); + process.exit(1); +}); From ed68f4b21ebe996ac5582320d019ffc04a76d02a Mon Sep 17 00:00:00 2001 From: Badri Narayanan S Date: Sat, 17 Jan 2026 14:47:48 +0530 Subject: [PATCH 2/2] fix: enable strict tool parameter validation for Claude models Set functionCallingConfig.mode = 'VALIDATED' when using Claude models to ensure strict parameter validation, matching opencode-antigravity-auth. Co-Authored-By: Claude --- src/format/request-converter.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/format/request-converter.js b/src/format/request-converter.js index da2d9da..9ec1416 100644 --- a/src/format/request-converter.js +++ b/src/format/request-converter.js @@ -226,6 +226,16 @@ export function convertAnthropicToGoogle(anthropicRequest) { googleRequest.tools = [{ functionDeclarations }]; logger.debug(`[RequestConverter] Tools: ${JSON.stringify(googleRequest.tools).substring(0, 300)}`); + + // For Claude models, set functionCallingConfig.mode = "VALIDATED" + // This ensures strict parameter validation (matches opencode-antigravity-auth) + if (isClaudeModel) { + googleRequest.toolConfig = { + functionCallingConfig: { + mode: 'VALIDATED' + } + }; + } } // Cap max tokens for Gemini models