Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/kosong-provider-conversion-fixes.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 7 additions & 2 deletions packages/kosong/src/providers/google-genai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,<raw>`), 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 } };
}
Expand Down
13 changes: 13 additions & 0 deletions packages/kosong/src/providers/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
}
Expand Down
21 changes: 21 additions & 0 deletions packages/kosong/test/google-genai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> };
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[] = [
{
Expand Down
25 changes: 25 additions & 0 deletions packages/kosong/test/openai-responses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)['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', () => {
Expand Down