From 37d2bae001cc52753696c575d86a4889591d8789 Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Mon, 29 Jun 2026 13:26:16 +0200 Subject: [PATCH 1/3] test(glmt): assert thinking signature is a non-empty opaque string Add two contract tests on the public proxy path createAnthropicProxyResponse: JSON and SSE. Anthropic requires a thinking block's signature to be a non-empty opaque string, but ccs currently fabricates the signature as an object via generateThinkingSignature(), which breaks the stream against reasoning backends. Built [OnSteroids](https://onsteroids.ai) --- .../sse-stream-transformer.test.ts | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/tests/unit/proxy/transformers/sse-stream-transformer.test.ts b/tests/unit/proxy/transformers/sse-stream-transformer.test.ts index 9faebabc..61d99223 100644 --- a/tests/unit/proxy/transformers/sse-stream-transformer.test.ts +++ b/tests/unit/proxy/transformers/sse-stream-transformer.test.ts @@ -67,4 +67,108 @@ describe('proxy SSE stream transformer', () => { expect(body).toContain('"type":"text_delta"'); expect(body).toContain('event: message_stop'); }); + + // Helper: extract all JSON `data:` payloads from an Anthropic SSE response body, + // skipping the `[DONE]` sentinel and any non-JSON lines. + function parseSseDataEvents(text: string): Record[] { + const events: Record[] = []; + for (const line of text.split('\n')) { + if (!line.startsWith('data: ')) continue; + const raw = line.slice('data: '.length).trim(); + if (raw.length === 0 || raw === '[DONE]') continue; + try { + events.push(JSON.parse(raw) as Record); + } catch { + // skip non-JSON data lines + } + } + return events; + } + + it('emits thinking.signature as a non-empty string — JSON (Anthropic contract)', async () => { + // Anthropic requires a thinking block's `signature` to be a non-empty opaque + // STRING. This test asserts that contract; it is intentionally RED on the + // current code, which fabricates the signature as an object via + // generateThinkingSignature(). After the fix it becomes a regression guard. + const response = new Response( + JSON.stringify({ + id: 'chatcmpl_sig_json', + model: 'gpt-5.x', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Final answer.', + reasoning_content: 'Step-by-step reasoning before answering.', + }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + + const transformed = await createAnthropicProxyResponse(response); + const body = (await transformed.json()) as { + content: Array<{ type: string; signature?: unknown }>; + }; + + const thinkingBlock = body.content.find((block) => block.type === 'thinking'); + expect( + thinkingBlock, + `no thinking block found; content = ${JSON.stringify(body.content)}` + ).toBeDefined(); + + const signature = thinkingBlock?.signature; + expect( + typeof signature, + `expected non-empty string signature, received ${JSON.stringify(signature)}` + ).toBe('string'); + expect((signature as string).length).toBeGreaterThan(0); + }); + + it('emits thinking signature as a non-empty string — SSE (Anthropic contract)', async () => { + // Streaming counterpart of the contract test above: mirrors the failed + // prod path where reasoning_content streams in and ccs closes the thinking + // block with a fabricated signature via createSignatureDeltaEvent(). + const openAISse = [ + 'data: {"id":"chatcmpl_sig_stream","object":"chat.completion.chunk","created":1,"model":"gpt-5.x","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"Planning the verification step by step."},"finish_reason":null}]}\n\n', + 'data: {"id":"chatcmpl_sig_stream","object":"chat.completion.chunk","created":1,"model":"gpt-5.x","choices":[{"index":0,"delta":{"content":"Final answer."},"finish_reason":null}]}\n\n', + 'data: {"id":"chatcmpl_sig_stream","object":"chat.completion.chunk","created":1,"model":"gpt-5.x","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}\n\n', + 'data: [DONE]\n\n', + ].join(''); + + const transformed = await createAnthropicProxyResponse( + new Response(openAISse, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) + ); + + const body = await transformed.text(); + const payloads = parseSseDataEvents(body); + + // ccs emits the thinking signature through createSignatureDeltaEvent, + // which produces a content_block_delta with delta.type === 'thinking_signature_delta'. + const signatureDelta = payloads.find((payload) => { + const delta = (payload as { delta?: { type?: string } }).delta; + return delta?.type === 'thinking_signature_delta'; + }); + expect( + signatureDelta, + `no thinking_signature_delta event in stream; payloads = ${JSON.stringify(payloads)}` + ).toBeDefined(); + + const signature = (signatureDelta as { delta?: { signature?: unknown } }).delta?.signature; + // Anthropic contract: streaming thinking signature MUST be a non-empty opaque string. + expect( + typeof signature, + `expected non-empty string signature, received ${JSON.stringify(signature)}` + ).toBe('string'); + }); }); From cbcdb6ba626ee5438502ea32eb9505735fa0963a Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Mon, 29 Jun 2026 13:27:09 +0200 Subject: [PATCH 2/3] fix(glmt): emit thinking signature as opaque string generateThinkingSignature() returned an object ({type, hash, length, timestamp}) but Anthropic requires a thinking block's `signature` to be a non-empty opaque STRING. The object made the thinking block invalid, breaking the stream against reasoning backends and surfacing as a false 502 "did not respond within 600s". Return a deterministic base64 token instead (no Date.now()), and narrow ThinkingSignature to a string alias so existing imports stay intact. The signature is only ever assigned, never read as an object, so consumers are unaffected. Update the existing unit test to the corrected string contract. Built [OnSteroids](https://onsteroids.ai) --- src/glmt/pipeline/response-builder.ts | 13 ++++++------- src/glmt/pipeline/types.ts | 11 ++++------- tests/unit/glmt/glmt-transformer.test.js | 13 +++++++------ 3 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/glmt/pipeline/response-builder.ts b/src/glmt/pipeline/response-builder.ts index 50862703..6426e288 100644 --- a/src/glmt/pipeline/response-builder.ts +++ b/src/glmt/pipeline/response-builder.ts @@ -177,17 +177,16 @@ export class ResponseBuilder { /** * Generate thinking signature for Claude Code UI + * + * Anthropic requires a thinking block's `signature` to be a non-empty + * opaque STRING. Emit a deterministic base64 token (no Date.now()) so the + * thinking block stays valid against reasoning backends; returning an object + * here produces an invalid thinking block and breaks the stream. */ generateThinkingSignature(thinking: string): ThinkingSignature { - // Generate signature hash const hash = crypto.createHash('sha256').update(thinking).digest('hex').substring(0, 16); - return { - type: 'thinking_signature', - hash: hash, - length: thinking.length, - timestamp: Date.now(), - }; + return Buffer.from(`ccs-thinking:${hash}:${thinking.length}`).toString('base64'); } /** diff --git a/src/glmt/pipeline/types.ts b/src/glmt/pipeline/types.ts index 27f68749..e18629ca 100644 --- a/src/glmt/pipeline/types.ts +++ b/src/glmt/pipeline/types.ts @@ -7,7 +7,7 @@ export interface ContentBlock { type: string; text?: string; thinking?: string; - signature?: ThinkingSignature; + signature?: string; id?: string; name?: string; input?: Record; @@ -95,12 +95,9 @@ export interface TransformResult { error?: string; } -export interface ThinkingSignature { - type: string; - hash: string; - length: number; - timestamp: number; -} +// Anthropic contract: a thinking block's signature is a non-empty opaque +// string. Kept as a named alias so existing imports stay intact. +export type ThinkingSignature = string; // OpenAI response types export interface OpenAIChoice { diff --git a/tests/unit/glmt/glmt-transformer.test.js b/tests/unit/glmt/glmt-transformer.test.js index 05950ba7..930fbb7e 100644 --- a/tests/unit/glmt/glmt-transformer.test.js +++ b/tests/unit/glmt/glmt-transformer.test.js @@ -161,16 +161,17 @@ describe('GlmtTransformer', () => { }); describe('Thinking signature', () => { - it('generates thinking signature', () => { + it('generates thinking signature as a non-empty opaque string', () => { const transformer = new GlmtTransformer(); const thinking = 'This is my reasoning process'; const signature = transformer.generateThinkingSignature(thinking); - assert.strictEqual(signature.type, 'thinking_signature'); - assert.ok(signature.hash); - assert.strictEqual(signature.hash.length, 16); - assert.strictEqual(signature.length, thinking.length); - assert.ok(signature.timestamp); + // Anthropic contract: a thinking block's signature is a non-empty + // opaque string, not an object. + assert.strictEqual(typeof signature, 'string'); + assert.ok(signature.length > 0); + // Deterministic: same reasoning yields the same signature (no Date.now()). + assert.strictEqual(signature, transformer.generateThinkingSignature(thinking)); }); }); From 045fc932857bdc9c52fa7573ec1bbd81a951b6a0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 29 Jun 2026 12:58:38 -0400 Subject: [PATCH 3/3] test(glmt): align thinking signature coverage --- .../unit/glmt/test-thinking-signature-race.js | 18 +++++++++--------- .../sse-stream-transformer.test.ts | 1 + 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/unit/glmt/test-thinking-signature-race.js b/tests/unit/glmt/test-thinking-signature-race.js index c9a7b40f..1537ceb5 100755 --- a/tests/unit/glmt/test-thinking-signature-race.js +++ b/tests/unit/glmt/test-thinking-signature-race.js @@ -12,6 +12,9 @@ const GlmtTransformer = require('../../../dist/glmt/glmt-transformer').default; const { DeltaAccumulator } = require('../../../dist/glmt/delta-accumulator'); +const { ResponseBuilder } = require('../../../dist/glmt/pipeline'); + +const responseBuilder = new ResponseBuilder(false); // Test runner class TestRunner { @@ -59,7 +62,6 @@ function assert(condition, message) { // Test 1: Signature not generated for empty thinking block runner.test('Signature not generated for empty thinking block', () => { - const transformer = new GlmtTransformer({ verbose: false }); const accumulator = new DeltaAccumulator({ thinking: true }); // Start thinking block but add no content @@ -67,7 +69,7 @@ runner.test('Signature not generated for empty thinking block', () => { const block = accumulator.startBlock('thinking'); // Try to generate signature for empty block - const signatureEvent = transformer._createSignatureDeltaEvent(block); + const signatureEvent = responseBuilder.createSignatureDeltaEvent(block); // Should return null for empty block (fix for race condition) assert(signatureEvent === null, 'Expected null for empty thinking block'); @@ -75,7 +77,6 @@ runner.test('Signature not generated for empty thinking block', () => { // Test 2: Signature generated correctly after content accumulated runner.test('Signature generated correctly after content accumulated', () => { - const transformer = new GlmtTransformer({ verbose: false }); const accumulator = new DeltaAccumulator({ thinking: true }); // Start thinking block and add content @@ -85,15 +86,14 @@ runner.test('Signature generated correctly after content accumulated', () => { accumulator.addDelta('Second thinking delta.'); // Generate signature - const signatureEvent = transformer._createSignatureDeltaEvent(block); + const signatureEvent = responseBuilder.createSignatureDeltaEvent(block); // Should return valid signature event assert(signatureEvent !== null, 'Expected signature event for non-empty block'); assert(signatureEvent.event === 'content_block_delta', 'Expected content_block_delta event'); assert(signatureEvent.data.delta.type === 'thinking_signature_delta', 'Expected thinking_signature_delta type'); + assert(typeof signatureEvent.data.delta.signature === 'string', 'Expected signature string'); assert(signatureEvent.data.delta.signature.length > 0, 'Expected signature length > 0'); - assert(signatureEvent.data.delta.signature.hash, 'Expected signature hash'); - assert(signatureEvent.data.delta.signature.hash.length === 16, 'Expected 16-char hash'); }); // Test 3: transformDelta skips signature for empty thinking blocks @@ -179,8 +179,8 @@ runner.test('transformDelta generates signature for non-empty thinking blocks', // Verify signature structure const sig = signatureEvents[0].data.delta.signature; - assert(sig.hash && sig.hash.length === 16, 'Expected valid 16-char hash'); - assert(sig.length > 0, 'Expected content length > 0'); + assert(typeof sig === 'string', 'Expected signature string'); + assert(sig.length > 0, 'Expected signature length > 0'); }); // Test 5: Loop detection handles empty thinking blocks @@ -217,7 +217,7 @@ runner.test('Loop detection handles empty thinking blocks without signature', () const currentBlock = accumulator.getCurrentBlock(); if (currentBlock && currentBlock.type === 'thinking') { - const signatureEvent = transformer._createSignatureDeltaEvent(currentBlock); + const signatureEvent = responseBuilder.createSignatureDeltaEvent(currentBlock); // Should return null for empty block assert(signatureEvent === null, 'Expected null signature for empty thinking block during loop detection'); } diff --git a/tests/unit/proxy/transformers/sse-stream-transformer.test.ts b/tests/unit/proxy/transformers/sse-stream-transformer.test.ts index 61d99223..6a946d78 100644 --- a/tests/unit/proxy/transformers/sse-stream-transformer.test.ts +++ b/tests/unit/proxy/transformers/sse-stream-transformer.test.ts @@ -170,5 +170,6 @@ describe('proxy SSE stream transformer', () => { typeof signature, `expected non-empty string signature, received ${JSON.stringify(signature)}` ).toBe('string'); + expect((signature as string).length).toBeGreaterThan(0); }); });