Skip to content
5 changes: 5 additions & 0 deletions .changeset/fix-text-model-content-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix conversations breaking after switching from a multimodal model to a text-only model. Media content blocks in history are now automatically stripped and retried when the text-only model rejects them.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
isImageFormatError,
isRecoverableRequestStructureError,
isRetryableGenerateError,
isUnsupportedContentTypeError,
} from '#/kosong/contract/errors';
import { isToolCall, type Message, type StreamedMessagePart } from '#/kosong/contract/message';
import { type ThinkingEffort } from '#/kosong/contract/provider';
Expand Down Expand Up @@ -487,6 +488,17 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
);
return { ...policy, media: captureMediaStripPolicy() };
}
if (typeof media !== 'object' && isUnsupportedContentTypeError(raw)) {
signal?.throwIfAborted();
this.log.warn(
'provider does not support non-text content types; resending with media stripped',
{
model: request.model.name,
...request.logFields,
},
);
return { ...policy, media: captureMediaStripPolicy() };
}
if (policy?.structure === undefined && isRecoverableRequestStructureError(raw)) {
signal?.throwIfAborted();
this.log.warn('provider rejected request structure; resending with strict projection', {
Expand Down
15 changes: 15 additions & 0 deletions packages/agent-core-v2/src/kosong/contract/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,21 @@ export function isRecoverableRequestStructureError(error: unknown): boolean {
return STRUCTURAL_REQUEST_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage));
}

const CONTENT_TYPE_INVALID_MESSAGE_PATTERNS = [
/messages\.content\.type is invalid/,
/content\[\d*\]\.type .*invalid/,
/allowed values.*\['?text'?\]/,
] as const;

export function isUnsupportedContentTypeError(error: unknown): boolean {
if (!(error instanceof APIStatusError)) return false;
if (error instanceof APIContextOverflowError) return false;
if (error instanceof APIRequestTooLargeError) return false;
if (error.statusCode !== 400) return false;
const lowerMessage = error.message.toLowerCase();
return CONTENT_TYPE_INVALID_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage));
}

export function isProviderRateLimitError(error: unknown): boolean {
if (error instanceof APIProviderQuotaExhaustedError) return false;
if (error instanceof APIProviderRateLimitError) return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,33 @@ describe('projector tool-exchange normalization', () => {
};
}

it('applies strict repairs alongside media stripping', () => {
const history = [
user('go'),
imageMessage('data:image/png;base64,AAAA'),
assistant('', ['c1']),
toolResult('c1', 'one'),
assistant('', ['c1']),
toolResult('c1', 'two'),
];
const snapshot = projector.captureMediaStripSnapshot(history);
const projected = projector.project(history, {
structure: 'strict',
media: { strip: snapshot },
});

const toolCallIds = projected.flatMap((message) => message.toolCalls.map((call) => call.id));
expect(toolCallIds).toEqual(['c1']);
const parts = projected.flatMap((message) => message.content);
expect(parts.some((part) => part.type === 'image_url')).toBe(false);
expect(
parts.some(
(part) =>
part.type === 'text' && part.text.includes('omitted for provider compatibility'),
),
).toBe(true);
});

function projectStripped(
history: readonly ContextMessage[],
snapshot = projector.captureMediaStripSnapshot(history),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,71 @@ describe('AgentLLMRequesterService media-stripped resend', () => {
expect(calls.value).toBe(1);
expect(projection.calls).toEqual(['normal']);
});

it('resends with media-stripped after a content-type-invalid 400 (non-text model)', async () => {
const CONTENT_TYPE_400 = new APIStatusError(
400,
"messages.content.type is invalid, allowed values: ['text']",
);
const calls = { value: 0 };
const projection = recordProjectionCalls();
const { service } = createService(createRequester(calls, CONTENT_TYPE_400), projection.projector);

const result = await service.request();

expect(result.message.content).toEqual([{ type: 'text', text: 'ok' }]);
expect(calls.value).toBe(2);
expect(projection.calls).toEqual(['normal', 'stripped']);
});

it('keeps strict repairs in the media-stripped resend after a structural then content-type rejection', async () => {
const STRUCTURAL_400 = new APIStatusError(400, 'messages: `tool_use` ids must be unique');
const CONTENT_TYPE_400 = new APIStatusError(
400,
"messages.content.type is invalid, allowed values: ['text']",
);
const historyWithDuplicateCallsAndMedia: Message[] = [
{
role: 'user',
content: [
{ type: 'text', text: 'look' },
{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } },
],
toolCalls: [],
},
{
role: 'assistant',
content: [],
toolCalls: [{ type: 'function', id: 'c1', name: 'Lookup', arguments: '{}' }],
},
{ role: 'tool', content: [{ type: 'text', text: 'one' }], toolCalls: [], toolCallId: 'c1' },
{
role: 'assistant',
content: [],
toolCalls: [{ type: 'function', id: 'c1', name: 'Lookup', arguments: '{}' }],
},
{ role: 'tool', content: [{ type: 'text', text: 'two' }], toolCalls: [], toolCallId: 'c1' },
];
const calls = { value: 0 };
const capturedInputs: ModelRequestInput[] = [];
const { service } = createService(
createRequester(calls, STRUCTURAL_400, [CONTENT_TYPE_400], capturedInputs),
undefined,
{ contextMessages: historyWithDuplicateCallsAndMedia },
);

const result = await service.request();

expect(result.message.content).toEqual([{ type: 'text', text: 'ok' }]);
expect(calls.value).toBe(3);
const finalMessages = capturedInputs[2]!.messages;
expect(
finalMessages.flatMap((message) => message.content).some((part) => part.type === 'image_url'),
).toBe(false);
expect(finalMessages.flatMap((message) => message.toolCalls.map((call) => call.id))).toEqual([
'c1',
]);
});
});

describe('AgentLLMRequesterService media-degraded resend', () => {
Expand Down