diff --git a/.changeset/kosong-provider-conversion-fixes.md b/.changeset/kosong-provider-conversion-fixes.md new file mode 100644 index 000000000..0fdb9d2fe --- /dev/null +++ b/.changeset/kosong-provider-conversion-fixes.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kimi-code-sdk": patch +--- + +Fix two provider message-conversion bugs: the OpenAI Responses provider no longer drops encrypted reasoning on non-streaming responses whose reasoning item has an empty summary (it now round-trips `encrypted_content` like the streaming path), and the Google GenAI provider now keeps the explicit MIME type of a data URL that has no `;base64` parameter instead of falling back to a generic type. diff --git a/packages/kosong/src/providers/google-genai.ts b/packages/kosong/src/providers/google-genai.ts index 1feadb639..eb63bd32c 100644 --- a/packages/kosong/src/providers/google-genai.ts +++ b/packages/kosong/src/providers/google-genai.ts @@ -169,9 +169,14 @@ function convertMediaUrl( const data = url.slice(commaIndex + 1); const colonIndex = meta.indexOf(':'); const semiIndex = meta.indexOf(';'); + // A data URL may carry an explicit MIME type without a `;base64` + // parameter (e.g. `data:image/png,`), so end the MIME slice at the + // first `;` when present, otherwise at the end of the meta segment — + // requiring only the leading `:` and a non-empty type. + const mimeEnd = semiIndex !== -1 ? semiIndex : meta.length; const mimeType = - colonIndex !== -1 && semiIndex !== -1 - ? meta.slice(colonIndex + 1, semiIndex) + colonIndex !== -1 && mimeEnd > colonIndex + 1 + ? meta.slice(colonIndex + 1, mimeEnd) : fallbackMimeType; return { inlineData: { mimeType, data } }; } diff --git a/packages/kosong/src/providers/openai-responses.ts b/packages/kosong/src/providers/openai-responses.ts index 3595e2105..e1054f392 100644 --- a/packages/kosong/src/providers/openai-responses.ts +++ b/packages/kosong/src/providers/openai-responses.ts @@ -734,6 +734,19 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { } yield thinkPart; } + // Mirror the streaming `output_item.done` path: when there is no + // summary text but the item still carries encrypted reasoning, emit a + // single empty think part so the encrypted content the provider asked + // for (`include: reasoning.encrypted_content`) survives round-tripping + // into the next turn instead of being silently dropped. + if (outputItem.summary.length === 0 && outputItem.encryptedContent !== undefined) { + const thinkPart: StreamedMessagePart & { encrypted: string } = { + type: 'think', + think: '', + encrypted: outputItem.encryptedContent, + }; + yield thinkPart; + } } } } diff --git a/packages/kosong/test/google-genai.test.ts b/packages/kosong/test/google-genai.test.ts index 61c51f42c..439758668 100644 --- a/packages/kosong/test/google-genai.test.ts +++ b/packages/kosong/test/google-genai.test.ts @@ -410,6 +410,27 @@ describe('GoogleGenAIChatProvider', () => { }); }); + it('keeps the explicit MIME type of a data URL that has no ;base64 parameter', () => { + const messages: Message[] = [ + { + role: 'user', + content: [{ type: 'image_url', imageUrl: { url: 'data:image/png,RAWDATA' } }], + toolCalls: [], + }, + ]; + + const contents = messagesToGoogleGenAIContents(messages); + const userContent = contents[0] as unknown as { parts: Array> }; + const inlineData = userContent.parts.find((p) => 'inlineData' in p) as + | { inlineData: { mimeType: string; data: string } } + | undefined; + // Without the fix the MIME slice required a ';', so this fell back to the + // generic fallback type instead of the URL's own `image/png`. + expect(inlineData).toMatchObject({ + inlineData: { mimeType: 'image/png', data: 'RAWDATA' }, + }); + }); + it('tool message with audio_url and video_url results yields independent parts', () => { const messages: Message[] = [ { diff --git a/packages/kosong/test/openai-responses.test.ts b/packages/kosong/test/openai-responses.test.ts index 5946b6309..ac6bedd18 100644 --- a/packages/kosong/test/openai-responses.test.ts +++ b/packages/kosong/test/openai-responses.test.ts @@ -1192,6 +1192,31 @@ describe('OpenAIResponsesChatProvider', () => { expect(parts).toEqual([{ type: 'think', think: 'Thinking...' }]); }); + + it('non-stream reasoning with empty summary still preserves encrypted_content', async () => { + // Matches the streaming output_item.done path: an encrypted reasoning + // item with no summary text must still emit a think part carrying the + // encrypted content, so it survives round-tripping into the next turn. + const provider = createProvider(); + (provider as any)._stream = false; + ((provider as any)._client.responses as unknown as Record)['create'] = vi + .fn() + .mockResolvedValue({ + id: 'resp_reason3', + output: [{ type: 'reasoning', encrypted_content: 'enc_empty_summary', summary: [] }], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + + const stream = await provider.generate( + '', + [], + [{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }], + ); + const parts: StreamedMessagePart[] = []; + for await (const p of stream) parts.push(p); + + expect(parts).toEqual([{ type: 'think', think: '', encrypted: 'enc_empty_summary' }]); + }); }); describe('provider property accessors', () => {