From cbcdb6ba626ee5438502ea32eb9505735fa0963a Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Mon, 29 Jun 2026 13:27:09 +0200 Subject: [PATCH] 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)); }); });