Skip to content

Commit cf558cd

Browse files
authored
feat(managed-kimi-code): support Anthropic-compatible protocol (#1170)
* fix(agent-core): recover from context overflow 413 - track provider-observed effective context limit after overflow - compact with the reduced limit before retrying the turn - treat large plain 413 responses as recoverable context overflow - add CLI patch changeset * feat(managed-kimi-code): support Anthropic-compatible protocol - switch managed provider to anthropic when models declare anthropic protocol - add base64 video content blocks to the kosong anthropic provider - downgrade unsupported media parts to text placeholders by capability - pass prompt cache key as Anthropic metadata.user_id for session affinity * feat(agent-core): add protocol/type to request and video upload telemetry - turn_started now carries `type` (configured provider wire type) and `protocol` (effective transport, i.e. alias.protocol ?? provider.type) - new video_upload event reports mime type, size, latency and success/failure, plus type/protocol/model context - ResolvedRuntimeProvider gains `type` and `protocol` fields
1 parent f3b1532 commit cf558cd

21 files changed

Lines changed: 891 additions & 47 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
Recover from provider 413 context overflows by compacting before retrying.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
Support the Anthropic-compatible protocol for managed Kimi Code, including video input.

apps/kimi-code/src/tui/controllers/session-event-handler.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -401,9 +401,10 @@ export class SessionEventHandler {
401401

402402
private isAnthropicSessionActive(): boolean {
403403
const { state } = this.host;
404-
const providerKey = state.appState.availableModels[state.appState.model]?.provider;
405-
if (providerKey === undefined) return false;
406-
return state.appState.availableProviders[providerKey]?.type === 'anthropic';
404+
const model = state.appState.availableModels[state.appState.model];
405+
if (model === undefined) return false;
406+
if (model.protocol === 'anthropic') return true;
407+
return state.appState.availableProviders[model.provider]?.type === 'anthropic';
407408
}
408409

409410
private handleStepInterrupted(event: TurnStepInterruptedEvent): void {

packages/agent-core/src/agent/compaction/full.ts

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import {
88
APIEmptyResponseError,
99
isRetryableGenerateError,
1010
type GenerateResult,
11+
type Message,
1112
type TokenUsage,
1213
APIContextOverflowError,
14+
APIStatusError,
1315
createUserMessage,
1416
} from '@moonshot-ai/kosong';
1517

@@ -23,6 +25,7 @@ import { renderPrompt } from '../../utils/render-prompt';
2325
import {
2426
estimateTokens,
2527
estimateTokensForMessages,
28+
estimateTokensForTools,
2629
} from '../../utils/tokens';
2730
import {
2831
applyCompletionBudget,
@@ -39,14 +42,9 @@ import {
3942

4043
export const MAX_COMPACTION_RETRY_ATTEMPTS = 5;
4144

42-
/**
43-
* Default hard cap on compaction output tokens when `maxOutputSize` is not
44-
* configured on the model alias. Without this, compaction falls back to the
45-
* full context window size, which exceeds the `max_tokens` ceiling enforced
46-
* by many OpenAI-compatible providers. 128k matches the chat-completions
47-
* ceiling applied by the OpenAI Legacy provider.
48-
*/
4945
const DEFAULT_COMPACTION_MAX_COMPLETION_TOKENS = 128 * 1024;
46+
const OVERFLOW_CONTEXT_SAFETY_RATIO = 0.85;
47+
const OVERFLOW_STATUS_RECOVERY_RATIO = 0.5;
5048

5149
class CompactionTruncatedError extends Error {
5250
constructor() {
@@ -62,6 +60,7 @@ export class FullCompaction {
6260
promise: Promise<void>;
6361
blockedByTurn: boolean;
6462
} | null = null;
63+
private readonly observedMaxContextTokensByModel = new Map<string, number>();
6564
protected readonly strategy: CompactionStrategy;
6665

6766
constructor(
@@ -71,7 +70,7 @@ export class FullCompaction {
7170
this.strategy =
7271
strategy ??
7372
new DefaultCompactionStrategy(
74-
() => agent.config.modelCapabilities.max_context_tokens,
73+
() => this.getEffectiveMaxContextTokens(),
7574
{
7675
...DEFAULT_COMPACTION_CONFIG,
7776
reservedContextSize:
@@ -85,6 +84,45 @@ export class FullCompaction {
8584
return this.compacting !== null;
8685
}
8786

87+
getEffectiveMaxContextTokens(): number {
88+
const configured = this.agent.config.modelCapabilities.max_context_tokens;
89+
const modelAlias = this.agent.config.modelAlias;
90+
const observed =
91+
modelAlias === undefined ? undefined : this.observedMaxContextTokensByModel.get(modelAlias);
92+
if (observed === undefined) return configured;
93+
if (configured <= 0) return observed;
94+
return Math.min(configured, observed);
95+
}
96+
97+
estimateCurrentRequestTokens(): number {
98+
return this.estimateRequestTokens(this.agent.context.messages);
99+
}
100+
101+
shouldRecoverFromContextOverflow(
102+
error: unknown,
103+
estimatedRequestTokens = this.estimateCurrentRequestTokens(),
104+
): boolean {
105+
if (error instanceof APIContextOverflowError) return true;
106+
if (!(error instanceof APIStatusError) || error.statusCode !== 413) return false;
107+
const effectiveMax = this.getEffectiveMaxContextTokens();
108+
return (
109+
effectiveMax > 0 && estimatedRequestTokens >= effectiveMax * OVERFLOW_STATUS_RECOVERY_RATIO
110+
);
111+
}
112+
113+
observeContextOverflow(estimatedRequestTokens: number): void {
114+
if (!Number.isFinite(estimatedRequestTokens) || estimatedRequestTokens <= 0) return;
115+
const modelAlias = this.agent.config.modelAlias;
116+
if (modelAlias === undefined) return;
117+
const observed = Math.max(
118+
1,
119+
Math.floor(estimatedRequestTokens * OVERFLOW_CONTEXT_SAFETY_RATIO),
120+
);
121+
const current = this.getEffectiveMaxContextTokens();
122+
if (current > 0 && observed >= current) return;
123+
this.observedMaxContextTokensByModel.set(modelAlias, observed);
124+
}
125+
88126
begin(data: Readonly<CompactionBeginData>): void {
89127
if (this.compacting) return;
90128
if (data.source === 'manual') {
@@ -145,6 +183,14 @@ export class FullCompaction {
145183
return this.agent.context.tokenCountWithPending;
146184
}
147185

186+
private estimateRequestTokens(messages: readonly Message[]): number {
187+
return (
188+
estimateTokens(this.agent.config.systemPrompt) +
189+
estimateTokensForTools(this.agent.tools.loopTools) +
190+
estimateTokensForMessages(messages)
191+
);
192+
}
193+
148194
resetForTurn(): void {
149195
this.compactionCountInTurn = 0;
150196
}
@@ -300,6 +346,7 @@ export class FullCompaction {
300346
...this.agent.context.project(messagesToCompact),
301347
createUserMessage(renderPrompt(compactionInstructionTemplate, { customInstruction: data.instruction ?? '' })),
302348
];
349+
const estimatedCompactionRequestTokens = this.estimateRequestTokens(messages);
303350
try {
304351
const response = await this.agent.generate(
305352
provider,
@@ -316,8 +363,15 @@ export class FullCompaction {
316363
summary = extractCompactionSummary(response);
317364
break;
318365
} catch (error) {
366+
const isContextOverflow = this.shouldRecoverFromContextOverflow(
367+
error,
368+
estimatedCompactionRequestTokens,
369+
);
370+
if (isContextOverflow) {
371+
this.observeContextOverflow(estimatedCompactionRequestTokens);
372+
}
319373
if (
320-
error instanceof APIContextOverflowError ||
374+
isContextOverflow ||
321375
error instanceof CompactionTruncatedError ||
322376
error instanceof APIEmptyResponseError // e.g. think-only
323377
) {

packages/agent-core/src/agent/tool/index.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -537,8 +537,58 @@ export class ToolManager {
537537
const withAuth = this.agent.modelProvider?.resolveAuth?.(modelAlias, {
538538
log: this.agent.log,
539539
});
540-
if (withAuth === undefined) return (input) => uploadVideo(input);
541-
return (input) => withAuth((auth) => uploadVideo(input, { auth }));
540+
const baseProps = this.videoUploadTelemetryProps(modelAlias);
541+
const upload =
542+
withAuth === undefined
543+
? (input: b.VideoUploadInput) => uploadVideo(input)
544+
: (input: b.VideoUploadInput) => withAuth((auth) => uploadVideo(input, { auth }));
545+
546+
return async (input) => {
547+
const startedAt = Date.now();
548+
const base = {
549+
...baseProps,
550+
mime_type: input.mimeType,
551+
size_bytes: input.data.length,
552+
};
553+
const track = (props: Record<string, string | number | boolean | undefined>): void => {
554+
try {
555+
this.agent.telemetry.track('video_upload', props);
556+
} catch {
557+
// Telemetry must never affect the upload outcome.
558+
}
559+
};
560+
try {
561+
const part = await upload(input);
562+
track({ ...base, success: true, latency_ms: Date.now() - startedAt });
563+
return part;
564+
} catch (error) {
565+
track({
566+
...base,
567+
success: false,
568+
latency_ms: Date.now() - startedAt,
569+
error: error instanceof Error ? error.message : String(error),
570+
});
571+
throw error;
572+
}
573+
};
574+
}
575+
576+
private videoUploadTelemetryProps(modelAlias: string): {
577+
type?: string;
578+
protocol?: string;
579+
model: string;
580+
} {
581+
try {
582+
const resolved = this.agent.modelProvider?.resolveProviderConfig(modelAlias);
583+
if (resolved === undefined) return { model: modelAlias };
584+
return {
585+
model: modelAlias,
586+
type: resolved.type,
587+
protocol: resolved.protocol ?? resolved.type,
588+
};
589+
} catch {
590+
return { model: modelAlias };
591+
}
542592
}
543593

544594
get loopTools(): readonly ExecutableTool[] {

packages/agent-core/src/agent/turn/index.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,7 @@ export class TurnFlow {
455455
const telemetryMode = this.telemetryMode();
456456
this.telemetryModeByTurn.set(turnId, telemetryMode);
457457
this.currentStepByTurn.set(turnId, 0);
458-
this.agent.telemetry.track('turn_started', { mode: telemetryMode });
458+
this.agent.telemetry.track('turn_started', { mode: telemetryMode, ...this.requestProtocolProps() });
459459
this.agent.fullCompaction.resetForTurn();
460460
this.agent.usage.beginTurn();
461461
this.agent.emitEvent({ type: 'turn.started', turnId, origin });
@@ -760,10 +760,19 @@ export class TurnFlow {
760760

761761
return result.stopReason;
762762
} catch (error) {
763-
if (
763+
const isContextOverflow =
764764
error instanceof APIContextOverflowError ||
765-
(isKimiError(error) && error.code === ErrorCodes.CONTEXT_OVERFLOW)
765+
(isKimiError(error) && error.code === ErrorCodes.CONTEXT_OVERFLOW);
766+
const estimatedRequestTokens = isContextOverflow
767+
? this.agent.fullCompaction.estimateCurrentRequestTokens()
768+
: undefined;
769+
if (
770+
isContextOverflow ||
771+
this.agent.fullCompaction.shouldRecoverFromContextOverflow(error, estimatedRequestTokens)
766772
) {
773+
this.agent.fullCompaction.observeContextOverflow(
774+
estimatedRequestTokens ?? this.agent.fullCompaction.estimateCurrentRequestTokens(),
775+
);
767776
await this.agent.fullCompaction.handleOverflowError(signal, error);
768777
continue; // Retry with compacted context
769778
}
@@ -921,6 +930,27 @@ export class TurnFlow {
921930
return this.agent.planMode.isActive ? 'plan' : 'agent';
922931
}
923932

933+
/**
934+
* Resolve the current model's provider wire type and any model-level protocol
935+
* override for request telemetry. Never throws — telemetry must not break a
936+
* turn over an unresolvable provider config (the step loop will surface that
937+
* error on its own).
938+
*/
939+
private requestProtocolProps(): { type?: string; protocol?: string } {
940+
const model = this.agent.config.modelAlias;
941+
if (model === undefined) return {};
942+
try {
943+
const resolved = this.agent.modelProvider?.resolveProviderConfig(model);
944+
if (resolved === undefined) return {};
945+
return {
946+
type: resolved.type,
947+
protocol: resolved.protocol ?? resolved.type,
948+
};
949+
} catch {
950+
return {};
951+
}
952+
}
953+
924954
private shouldTrackApiError(turnId: number): boolean {
925955
const failure = this.stepFailureByTurn.get(turnId);
926956
return failure?.reason === 'error' && failure.activeStep !== undefined;

packages/agent-core/src/agent/turn/kosong-llm.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ import {
1919
emptyUsage,
2020
generate as kosongGenerate,
2121
isRetryableGenerateError,
22+
isUnknownCapability,
2223
type ChatProvider,
24+
type ContentPart,
2325
type GenerateCallbacks,
2426
type Message,
2527
type ModelCapability,
@@ -119,7 +121,7 @@ export class KosongLLM implements LLM {
119121
effectiveProvider,
120122
this.systemPrompt,
121123
[...params.tools],
122-
params.messages,
124+
downgradeUnsupportedMedia(params.messages, this.capability),
123125
callbacks,
124126
options,
125127
);
@@ -263,3 +265,50 @@ export function buildMessagesWithSystem(systemPrompt: string, history: Message[]
263265
...history,
264266
];
265267
}
268+
269+
export function downgradeUnsupportedMedia(
270+
messages: readonly Message[],
271+
capability: ModelCapability | undefined,
272+
): Message[] {
273+
if (capability === undefined || isUnknownCapability(capability)) return [...messages];
274+
const dropImage = !capability.image_in;
275+
const dropVideo = !capability.video_in;
276+
const dropAudio = !capability.audio_in;
277+
if (!dropImage && !dropVideo && !dropAudio) return [...messages];
278+
279+
const drop = { dropImage, dropVideo, dropAudio };
280+
let changed = false;
281+
const out: Message[] = [];
282+
for (const message of messages) {
283+
let nextContent: ContentPart[] | undefined;
284+
for (let i = 0; i < message.content.length; i++) {
285+
const part = message.content[i]!;
286+
const placeholder = mediaPlaceholder(part, drop);
287+
if (placeholder === undefined) {
288+
nextContent?.push(part);
289+
continue;
290+
}
291+
nextContent ??= message.content.slice(0, i);
292+
nextContent.push({ type: 'text', text: placeholder });
293+
changed = true;
294+
}
295+
out.push(nextContent === undefined ? message : { ...message, content: nextContent });
296+
}
297+
return changed ? out : [...messages];
298+
}
299+
300+
function mediaPlaceholder(
301+
part: ContentPart,
302+
drop: { readonly dropImage: boolean; readonly dropVideo: boolean; readonly dropAudio: boolean },
303+
): string | undefined {
304+
if (part.type === 'image_url' && drop.dropImage) {
305+
return '[image omitted: current model has no image input]';
306+
}
307+
if (part.type === 'video_url' && drop.dropVideo) {
308+
return '[video omitted: current model has no video input]';
309+
}
310+
if (part.type === 'audio_url' && drop.dropAudio) {
311+
return '[audio omitted: current model has no audio input]';
312+
}
313+
return undefined;
314+
}

packages/agent-core/src/config/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export const ModelAliasSchema = z.object({
4545
capabilities: z.array(z.string()).optional(),
4646
displayName: z.string().optional(),
4747
reasoningKey: z.string().optional(),
48+
protocol: z.literal('anthropic').optional(),
4849
// Explicitly declare adaptive-thinking support, overriding the kosong
4950
// model-name version inference. Needed for custom-named Anthropic endpoints
5051
// whose model name does not encode a parseable Claude version.

0 commit comments

Comments
 (0)