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
This commit is contained in:
Haozhe 2026-06-28 13:04:03 +08:00 committed by GitHub
parent f3b15322da
commit cf558cd742
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 891 additions and 47 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Recover from provider 413 context overflows by compacting before retrying.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Support the Anthropic-compatible protocol for managed Kimi Code, including video input.

View file

@ -401,9 +401,10 @@ export class SessionEventHandler {
private isAnthropicSessionActive(): boolean {
const { state } = this.host;
const providerKey = state.appState.availableModels[state.appState.model]?.provider;
if (providerKey === undefined) return false;
return state.appState.availableProviders[providerKey]?.type === 'anthropic';
const model = state.appState.availableModels[state.appState.model];
if (model === undefined) return false;
if (model.protocol === 'anthropic') return true;
return state.appState.availableProviders[model.provider]?.type === 'anthropic';
}
private handleStepInterrupted(event: TurnStepInterruptedEvent): void {

View file

@ -8,8 +8,10 @@ import {
APIEmptyResponseError,
isRetryableGenerateError,
type GenerateResult,
type Message,
type TokenUsage,
APIContextOverflowError,
APIStatusError,
createUserMessage,
} from '@moonshot-ai/kosong';
@ -23,6 +25,7 @@ import { renderPrompt } from '../../utils/render-prompt';
import {
estimateTokens,
estimateTokensForMessages,
estimateTokensForTools,
} from '../../utils/tokens';
import {
applyCompletionBudget,
@ -39,14 +42,9 @@ import {
export const MAX_COMPACTION_RETRY_ATTEMPTS = 5;
/**
* Default hard cap on compaction output tokens when `maxOutputSize` is not
* configured on the model alias. Without this, compaction falls back to the
* full context window size, which exceeds the `max_tokens` ceiling enforced
* by many OpenAI-compatible providers. 128k matches the chat-completions
* ceiling applied by the OpenAI Legacy provider.
*/
const DEFAULT_COMPACTION_MAX_COMPLETION_TOKENS = 128 * 1024;
const OVERFLOW_CONTEXT_SAFETY_RATIO = 0.85;
const OVERFLOW_STATUS_RECOVERY_RATIO = 0.5;
class CompactionTruncatedError extends Error {
constructor() {
@ -62,6 +60,7 @@ export class FullCompaction {
promise: Promise<void>;
blockedByTurn: boolean;
} | null = null;
private readonly observedMaxContextTokensByModel = new Map<string, number>();
protected readonly strategy: CompactionStrategy;
constructor(
@ -71,7 +70,7 @@ export class FullCompaction {
this.strategy =
strategy ??
new DefaultCompactionStrategy(
() => agent.config.modelCapabilities.max_context_tokens,
() => this.getEffectiveMaxContextTokens(),
{
...DEFAULT_COMPACTION_CONFIG,
reservedContextSize:
@ -85,6 +84,45 @@ export class FullCompaction {
return this.compacting !== null;
}
getEffectiveMaxContextTokens(): number {
const configured = this.agent.config.modelCapabilities.max_context_tokens;
const modelAlias = this.agent.config.modelAlias;
const observed =
modelAlias === undefined ? undefined : this.observedMaxContextTokensByModel.get(modelAlias);
if (observed === undefined) return configured;
if (configured <= 0) return observed;
return Math.min(configured, observed);
}
estimateCurrentRequestTokens(): number {
return this.estimateRequestTokens(this.agent.context.messages);
}
shouldRecoverFromContextOverflow(
error: unknown,
estimatedRequestTokens = this.estimateCurrentRequestTokens(),
): boolean {
if (error instanceof APIContextOverflowError) return true;
if (!(error instanceof APIStatusError) || error.statusCode !== 413) return false;
const effectiveMax = this.getEffectiveMaxContextTokens();
return (
effectiveMax > 0 && estimatedRequestTokens >= effectiveMax * OVERFLOW_STATUS_RECOVERY_RATIO
);
}
observeContextOverflow(estimatedRequestTokens: number): void {
if (!Number.isFinite(estimatedRequestTokens) || estimatedRequestTokens <= 0) return;
const modelAlias = this.agent.config.modelAlias;
if (modelAlias === undefined) return;
const observed = Math.max(
1,
Math.floor(estimatedRequestTokens * OVERFLOW_CONTEXT_SAFETY_RATIO),
);
const current = this.getEffectiveMaxContextTokens();
if (current > 0 && observed >= current) return;
this.observedMaxContextTokensByModel.set(modelAlias, observed);
}
begin(data: Readonly<CompactionBeginData>): void {
if (this.compacting) return;
if (data.source === 'manual') {
@ -145,6 +183,14 @@ export class FullCompaction {
return this.agent.context.tokenCountWithPending;
}
private estimateRequestTokens(messages: readonly Message[]): number {
return (
estimateTokens(this.agent.config.systemPrompt) +
estimateTokensForTools(this.agent.tools.loopTools) +
estimateTokensForMessages(messages)
);
}
resetForTurn(): void {
this.compactionCountInTurn = 0;
}
@ -300,6 +346,7 @@ export class FullCompaction {
...this.agent.context.project(messagesToCompact),
createUserMessage(renderPrompt(compactionInstructionTemplate, { customInstruction: data.instruction ?? '' })),
];
const estimatedCompactionRequestTokens = this.estimateRequestTokens(messages);
try {
const response = await this.agent.generate(
provider,
@ -316,8 +363,15 @@ export class FullCompaction {
summary = extractCompactionSummary(response);
break;
} catch (error) {
const isContextOverflow = this.shouldRecoverFromContextOverflow(
error,
estimatedCompactionRequestTokens,
);
if (isContextOverflow) {
this.observeContextOverflow(estimatedCompactionRequestTokens);
}
if (
error instanceof APIContextOverflowError ||
isContextOverflow ||
error instanceof CompactionTruncatedError ||
error instanceof APIEmptyResponseError // e.g. think-only
) {

View file

@ -537,8 +537,58 @@ export class ToolManager {
const withAuth = this.agent.modelProvider?.resolveAuth?.(modelAlias, {
log: this.agent.log,
});
if (withAuth === undefined) return (input) => uploadVideo(input);
return (input) => withAuth((auth) => uploadVideo(input, { auth }));
const baseProps = this.videoUploadTelemetryProps(modelAlias);
const upload =
withAuth === undefined
? (input: b.VideoUploadInput) => uploadVideo(input)
: (input: b.VideoUploadInput) => withAuth((auth) => uploadVideo(input, { auth }));
return async (input) => {
const startedAt = Date.now();
const base = {
...baseProps,
mime_type: input.mimeType,
size_bytes: input.data.length,
};
const track = (props: Record<string, string | number | boolean | undefined>): void => {
try {
this.agent.telemetry.track('video_upload', props);
} catch {
// Telemetry must never affect the upload outcome.
}
};
try {
const part = await upload(input);
track({ ...base, success: true, latency_ms: Date.now() - startedAt });
return part;
} catch (error) {
track({
...base,
success: false,
latency_ms: Date.now() - startedAt,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
};
}
private videoUploadTelemetryProps(modelAlias: string): {
type?: string;
protocol?: string;
model: string;
} {
try {
const resolved = this.agent.modelProvider?.resolveProviderConfig(modelAlias);
if (resolved === undefined) return { model: modelAlias };
return {
model: modelAlias,
type: resolved.type,
protocol: resolved.protocol ?? resolved.type,
};
} catch {
return { model: modelAlias };
}
}
get loopTools(): readonly ExecutableTool[] {

View file

@ -455,7 +455,7 @@ export class TurnFlow {
const telemetryMode = this.telemetryMode();
this.telemetryModeByTurn.set(turnId, telemetryMode);
this.currentStepByTurn.set(turnId, 0);
this.agent.telemetry.track('turn_started', { mode: telemetryMode });
this.agent.telemetry.track('turn_started', { mode: telemetryMode, ...this.requestProtocolProps() });
this.agent.fullCompaction.resetForTurn();
this.agent.usage.beginTurn();
this.agent.emitEvent({ type: 'turn.started', turnId, origin });
@ -760,10 +760,19 @@ export class TurnFlow {
return result.stopReason;
} catch (error) {
if (
const isContextOverflow =
error instanceof APIContextOverflowError ||
(isKimiError(error) && error.code === ErrorCodes.CONTEXT_OVERFLOW)
(isKimiError(error) && error.code === ErrorCodes.CONTEXT_OVERFLOW);
const estimatedRequestTokens = isContextOverflow
? this.agent.fullCompaction.estimateCurrentRequestTokens()
: undefined;
if (
isContextOverflow ||
this.agent.fullCompaction.shouldRecoverFromContextOverflow(error, estimatedRequestTokens)
) {
this.agent.fullCompaction.observeContextOverflow(
estimatedRequestTokens ?? this.agent.fullCompaction.estimateCurrentRequestTokens(),
);
await this.agent.fullCompaction.handleOverflowError(signal, error);
continue; // Retry with compacted context
}
@ -921,6 +930,27 @@ export class TurnFlow {
return this.agent.planMode.isActive ? 'plan' : 'agent';
}
/**
* Resolve the current model's provider wire type and any model-level protocol
* override for request telemetry. Never throws telemetry must not break a
* turn over an unresolvable provider config (the step loop will surface that
* error on its own).
*/
private requestProtocolProps(): { type?: string; protocol?: string } {
const model = this.agent.config.modelAlias;
if (model === undefined) return {};
try {
const resolved = this.agent.modelProvider?.resolveProviderConfig(model);
if (resolved === undefined) return {};
return {
type: resolved.type,
protocol: resolved.protocol ?? resolved.type,
};
} catch {
return {};
}
}
private shouldTrackApiError(turnId: number): boolean {
const failure = this.stepFailureByTurn.get(turnId);
return failure?.reason === 'error' && failure.activeStep !== undefined;

View file

@ -19,7 +19,9 @@ import {
emptyUsage,
generate as kosongGenerate,
isRetryableGenerateError,
isUnknownCapability,
type ChatProvider,
type ContentPart,
type GenerateCallbacks,
type Message,
type ModelCapability,
@ -119,7 +121,7 @@ export class KosongLLM implements LLM {
effectiveProvider,
this.systemPrompt,
[...params.tools],
params.messages,
downgradeUnsupportedMedia(params.messages, this.capability),
callbacks,
options,
);
@ -263,3 +265,50 @@ export function buildMessagesWithSystem(systemPrompt: string, history: Message[]
...history,
];
}
export function downgradeUnsupportedMedia(
messages: readonly Message[],
capability: ModelCapability | undefined,
): Message[] {
if (capability === undefined || isUnknownCapability(capability)) return [...messages];
const dropImage = !capability.image_in;
const dropVideo = !capability.video_in;
const dropAudio = !capability.audio_in;
if (!dropImage && !dropVideo && !dropAudio) return [...messages];
const drop = { dropImage, dropVideo, dropAudio };
let changed = false;
const out: Message[] = [];
for (const message of messages) {
let nextContent: ContentPart[] | undefined;
for (let i = 0; i < message.content.length; i++) {
const part = message.content[i]!;
const placeholder = mediaPlaceholder(part, drop);
if (placeholder === undefined) {
nextContent?.push(part);
continue;
}
nextContent ??= message.content.slice(0, i);
nextContent.push({ type: 'text', text: placeholder });
changed = true;
}
out.push(nextContent === undefined ? message : { ...message, content: nextContent });
}
return changed ? out : [...messages];
}
function mediaPlaceholder(
part: ContentPart,
drop: { readonly dropImage: boolean; readonly dropVideo: boolean; readonly dropAudio: boolean },
): string | undefined {
if (part.type === 'image_url' && drop.dropImage) {
return '[image omitted: current model has no image input]';
}
if (part.type === 'video_url' && drop.dropVideo) {
return '[video omitted: current model has no video input]';
}
if (part.type === 'audio_url' && drop.dropAudio) {
return '[audio omitted: current model has no audio input]';
}
return undefined;
}

View file

@ -45,6 +45,7 @@ export const ModelAliasSchema = z.object({
capabilities: z.array(z.string()).optional(),
displayName: z.string().optional(),
reasoningKey: z.string().optional(),
protocol: z.literal('anthropic').optional(),
// Explicitly declare adaptive-thinking support, overriding the kosong
// model-name version inference. Needed for custom-named Anthropic endpoints
// whose model name does not encode a parseable Claude version.

View file

@ -1,7 +1,7 @@
import type { Logger } from '#/logging/types';
import type { ProviderConfig as KosongProviderConfig, ModelCapability, ProviderRequestAuth } from '@moonshot-ai/kosong';
import { APIStatusError, getModelCapability, UNKNOWN_CAPABILITY } from '@moonshot-ai/kosong';
import type { KimiConfig, ModelAlias, OAuthRef, ProviderConfig } from '../config';
import type { KimiConfig, ModelAlias, OAuthRef, ProviderConfig, ProviderType } from '../config';
import { ErrorCodes, isKimiError, KimiError } from '../errors';
export interface BearerTokenProvider {
@ -20,6 +20,10 @@ export interface ResolvedRuntimeProvider {
/** Declared 'always_thinking' capability — the model cannot disable thinking. */
readonly alwaysThinking?: boolean;
readonly maxOutputSize?: number;
/** Configured provider wire type (`provider.type`), before any model-level protocol override. */
readonly type: ProviderType;
/** Model-level protocol override (`alias.protocol`); when set, takes precedence over `type` for transport selection. */
readonly protocol: ModelAlias['protocol'];
}
interface ProviderManagerOptions {
@ -60,6 +64,8 @@ export class SingleModelProvider implements ModelProvider {
modelCapabilities: this.modelCapabilities,
providerName: 'single-model-provider',
provider: this.providerConfig,
type: this.providerConfig.type,
protocol: undefined,
};
}
}
@ -107,6 +113,7 @@ export class ProviderManager implements ModelProvider {
const provider = toKosongProviderConfig(
providerConfig,
alias.model,
alias.protocol,
this.options.kimiRequestHeaders,
alias.maxOutputSize,
alias.reasoningKey,
@ -122,6 +129,8 @@ export class ProviderManager implements ModelProvider {
(c) => c.trim().toLowerCase() === 'always_thinking',
),
maxOutputSize: alias.maxOutputSize,
type: providerConfig.type,
protocol: alias.protocol,
};
}
@ -219,23 +228,33 @@ function resolveModelCapabilities(
function toKosongProviderConfig(
provider: ProviderConfig,
model: string,
modelProtocol: ModelAlias['protocol'],
kimiRequestHeaders: Record<string, string> | undefined,
maxOutputSize: number | undefined,
reasoningKey: string | undefined,
promptCacheKey: string | undefined,
adaptiveThinking: boolean | undefined,
): KosongProviderConfig {
switch (provider.type) {
case 'anthropic':
const effectiveType = modelProtocol === 'anthropic' ? 'anthropic' : provider.type;
switch (effectiveType) {
case 'anthropic': {
const baseUrl = providerValue(provider.baseUrl, provider.env, 'ANTHROPIC_BASE_URL');
return {
type: 'anthropic',
model,
baseUrl: providerValue(provider.baseUrl, provider.env, 'ANTHROPIC_BASE_URL'),
baseUrl:
modelProtocol === 'anthropic' && baseUrl !== undefined
? baseUrl.replace(/\/v1\/?$/, '')
: baseUrl,
apiKey: providerApiKey(provider),
...(maxOutputSize !== undefined ? { defaultMaxTokens: maxOutputSize } : {}),
...(adaptiveThinking !== undefined ? { adaptiveThinking } : {}),
// Session affinity: Anthropic's analog of OpenAI `prompt_cache_key` is
// `metadata.user_id` on the Messages API (cache-affinity / end-user id).
...(promptCacheKey !== undefined ? { metadata: { user_id: promptCacheKey } } : {}),
...defaultHeadersField(provider.customHeaders),
};
}
case 'openai':
return {
type: 'openai',
@ -280,7 +299,7 @@ function toKosongProviderConfig(
};
}
default: {
const exhaustive: never = provider.type;
const exhaustive: never = effectiveType;
throw new KimiError(
ErrorCodes.MODEL_CONFIG_INVALID,
`Unsupported provider type: ${String(exhaustive)}`,

View file

@ -1589,6 +1589,113 @@ describe('FullCompaction', () => {
await ctx.expectResumeMatches();
});
it('uses observed max from overflow to size compaction input', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: 1_000_000,
},
});
for (let i = 0; i < 20; i++) {
ctx.appendExchange(
i + 1,
`old user ${String(i)}`,
`old assistant ${String(i)} ${'x'.repeat(40_000)}`,
20_000,
);
}
ctx.agent.fullCompaction.observeContextOverflow(200_000);
const compacted = ctx.once('context.apply_compaction');
const completed = ctx.once('compaction.completed');
ctx.mockNextResponse({ type: 'text', text: 'Observed max summary.' });
await ctx.rpc.beginCompaction({});
await compacted;
await completed;
expect(ctx.agent.fullCompaction.getEffectiveMaxContextTokens()).toBe(170_000);
const compactionTokens = estimateTokensForMessages(ctx.llmCalls[0]?.history ?? []);
expect(compactionTokens).toBeLessThan(200_000);
expect(ctx.compactHistory()[0]).toEqual({ role: 'assistant', text: 'Observed max summary.' });
await ctx.expectResumeMatches();
});
it('recovers from plain 413 when estimated request is over effective max', async () => {
let callCount = 0;
const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => {
callCount += 1;
if (callCount === 1) {
throw new APIStatusError(413, 'Request Entity Too Large', 'req-plain-413');
}
if (callCount === 2) {
return textResult('Plain 413 compacted summary.');
}
await callbacks?.onMessagePart?.({
type: 'text',
text: 'Recovered after plain 413 compaction.',
});
return textResult('Recovered after plain 413 compaction.');
};
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: 200_000,
},
});
ctx.appendExchange(1, 'old user one', `old assistant one ${'x'.repeat(600_000)}`, 150_000);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry after plain 413' }] });
const events = await ctx.untilTurnEnd();
expect(callCount).toBe(3);
expect(ctx.agent.fullCompaction.getEffectiveMaxContextTokens()).toBeLessThan(200_000);
expect(events).toContainEqual(
expect.objectContaining({
event: 'compaction.started',
args: { trigger: 'auto' },
}),
);
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: { turnId: 0, reason: 'completed' },
}),
);
await ctx.expectResumeMatches();
});
it('does not compact plain 413 when estimated request is small', async () => {
const generate: GenerateFn = async () => {
throw new APIStatusError(413, 'Request Entity Too Large', 'req-small-413');
};
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: 200_000,
},
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'small prompt' }] });
const events = await ctx.untilTurnEnd();
expect(eventIndex(events, 'compaction.started')).toBe(-1);
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({ turnId: 0, reason: 'failed' }),
}),
);
});
it('preserves thinking effort when compacting after provider context overflow', async () => {
let callCount = 0;
const records: TelemetryRecord[] = [];

View file

@ -1,13 +1,19 @@
import {
emptyUsage,
UNKNOWN_CAPABILITY,
type ChatProvider,
type Message,
type ModelCapability,
type StreamedMessagePart,
type ToolCall,
} from '@moonshot-ai/kosong';
import { describe, expect, it } from 'vitest';
import { KosongLLM, type GenerateFn } from '../../src/agent/turn/kosong-llm';
import {
KosongLLM,
downgradeUnsupportedMedia,
type GenerateFn,
} from '../../src/agent/turn/kosong-llm';
import type { ToolCallDelta } from '../../src/loop';
const provider: ChatProvider = {
@ -227,3 +233,102 @@ function makeCapability(maxContextTokens: number): ModelCapability {
max_context_tokens: maxContextTokens,
};
}
function mediaMessage(content: Message['content']): Message {
return { role: 'tool', content, toolCalls: [], toolCallId: 'call_media' };
}
describe('downgradeUnsupportedMedia', () => {
const imagePart = { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAA' } } as const;
const videoPart = { type: 'video_url', videoUrl: { url: 'ms://file-1', id: 'file-1' } } as const;
const audioPart = { type: 'audio_url', audioUrl: { url: 'data:audio/mpeg;base64,AAA' } } as const;
it('replaces video parts when the model lacks video_in and keeps the rest', () => {
const capability: ModelCapability = { ...makeCapability(1000), image_in: true, audio_in: true };
const input = [mediaMessage([{ type: 'text', text: '<video path="a.mp4">' }, videoPart])];
const out = downgradeUnsupportedMedia(input, capability);
expect(out[0]?.content).toEqual([
{ type: 'text', text: '<video path="a.mp4">' },
{ type: 'text', text: '[video omitted: current model has no video input]' },
]);
});
it('replaces image and audio parts when those capabilities are missing', () => {
const capability: ModelCapability = { ...makeCapability(1000), video_in: true };
const input = [mediaMessage([imagePart, audioPart, videoPart])];
const out = downgradeUnsupportedMedia(input, capability);
expect(out[0]?.content).toEqual([
{ type: 'text', text: '[image omitted: current model has no image input]' },
{ type: 'text', text: '[audio omitted: current model has no audio input]' },
videoPart,
]);
});
it('keeps media untouched when the model is capable', () => {
const capability: ModelCapability = {
...makeCapability(1000),
image_in: true,
video_in: true,
audio_in: true,
};
const input = [mediaMessage([imagePart, videoPart])];
const out = downgradeUnsupportedMedia(input, capability);
expect(out[0]?.content).toEqual([imagePart, videoPart]);
});
it('does not downgrade for UNKNOWN_CAPABILITY or an undefined capability', () => {
const input = [mediaMessage([videoPart])];
expect(downgradeUnsupportedMedia(input, UNKNOWN_CAPABILITY)[0]?.content).toEqual([videoPart]);
expect(downgradeUnsupportedMedia(input, undefined)[0]?.content).toEqual([videoPart]);
});
it('returns a new array and never mutates the caller input', () => {
const capability = makeCapability(1000); // all media dropped
const message = mediaMessage([videoPart]);
const input = [message];
const originalContent = message.content;
const out = downgradeUnsupportedMedia(input, capability);
expect(out).not.toBe(input);
expect(out[0]).not.toBe(message);
expect(message.content).toBe(originalContent);
expect(message.content[0]).toEqual(videoPart);
});
it('KosongLLM strips unsupported video from messages sent to generate', async () => {
let captured: readonly Message[] | undefined;
const generate: GenerateFn = async (_p, _s, _t, messages) => {
captured = messages;
return {
id: 'response-1',
message: { role: 'assistant', content: [], toolCalls: [] },
usage: emptyUsage(),
finishReason: 'completed',
rawFinishReason: 'stop',
};
};
const llm = new KosongLLM({
provider,
systemPrompt: '',
capability: { ...makeCapability(1000), image_in: true },
generate,
});
await llm.chat({
messages: [mediaMessage([videoPart])],
tools: [],
signal: new AbortController().signal,
});
expect(captured?.[0]?.content).toEqual([
{ type: 'text', text: '[video omitted: current model has no video input]' },
]);
});
});

View file

@ -509,6 +509,49 @@ describe('resolveRuntimeProvider customHeaders propagation', () => {
});
});
it('passes the prompt cache key to Anthropic metadata.user_id', () => {
const resolved = resolveRuntimeProvider({
config: {
defaultModel: 'claude-alias',
providers: {
anthropic: {
type: 'anthropic',
apiKey: 'sk-anthropic',
},
},
models: {
'claude-alias': { provider: 'anthropic', model: 'claude-runtime', maxContextSize: 200000 },
},
},
promptCacheKey: 'session-test',
});
expect(resolved.provider).toMatchObject({
type: 'anthropic',
metadata: { user_id: 'session-test' },
});
});
it('omits Anthropic metadata when no prompt cache key is set', () => {
const resolved = resolveRuntimeProvider({
config: {
defaultModel: 'claude-alias',
providers: {
anthropic: {
type: 'anthropic',
apiKey: 'sk-anthropic',
},
},
models: {
'claude-alias': { provider: 'anthropic', model: 'claude-runtime', maxContextSize: 200000 },
},
},
});
expect(resolved.provider).toMatchObject({ type: 'anthropic' });
expect('metadata' in resolved.provider).toBe(false);
});
it('forwards customHeaders to an openai provider', () => {
const resolved = resolveRuntimeProvider({
config: {
@ -791,3 +834,64 @@ describe('resolveThinkingLevel', () => {
expect(resolveThinkingLevel(undefined, {})).toBe('high');
});
});
describe('per-model protocol routing', () => {
it('routes a protocol:anthropic model on a kimi provider through the anthropic transport with the REST base stripped of /v1', () => {
const resolved = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
models: {
'kimi-code/kimi-for-coding': {
...BASE_CONFIG.models!['kimi-code/kimi-for-coding']!,
protocol: 'anthropic',
},
},
},
});
expect(resolved.providerName).toBe('managed:kimi-code');
expect(resolved.provider).toMatchObject({
type: 'anthropic',
model: 'kimi-for-coding',
baseUrl: 'https://api.example',
});
});
it('keeps a model without protocol on the provider wire type and leaves the REST base intact', () => {
const resolved = resolveRuntimeProvider({ config: BASE_CONFIG });
expect(resolved.provider).toMatchObject({
type: 'kimi',
model: 'kimi-for-coding',
baseUrl: 'https://api.example/v1',
});
});
it('does not strip the baseUrl of a provider that is itself typed anthropic', () => {
const resolved = resolveRuntimeProvider({
config: {
defaultModel: 'claude',
providers: {
anthropic: {
type: 'anthropic',
apiKey: 'sk-anthropic',
baseUrl: 'https://api.anthropic.example/v1',
},
},
models: {
claude: {
provider: 'anthropic',
model: 'claude-sonnet-4-5',
maxContextSize: 200_000,
},
},
},
});
expect(resolved.provider).toMatchObject({
type: 'anthropic',
model: 'claude-sonnet-4-5',
baseUrl: 'https://api.anthropic.example/v1',
});
});
});

View file

@ -275,4 +275,57 @@ describe('ModelCatalogService', () => {
},
});
});
it('keeps the kimi-code provider on the REST base and records the model protocol when anthropic', async () => {
const configRef: { current: KimiConfig } = {
current: {
providers: {
[KIMI_CODE_PROVIDER_NAME]: {
type: 'kimi',
apiKey: '',
baseUrl: 'https://api.example.test/coding/v1',
oauth: { storage: 'file', key: 'oauth/kimi-code' },
},
},
defaultModel: 'kimi-code/kimi-for-coding',
models: {
'kimi-code/kimi-for-coding': {
provider: KIMI_CODE_PROVIDER_NAME,
model: 'kimi-for-coding',
maxContextSize: 200_000,
},
},
},
};
const { core, setCalls } = makeCore(configRef);
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(
JSON.stringify({
data: [
{
id: 'kimi-for-coding',
context_length: 200_000,
protocol: 'anthropic',
},
],
}),
),
),
);
const svc = ModelCatalogService._createForTest(makeEnv(), core, authFacade());
await svc.refreshOAuthProviderModels();
const last = setCalls.at(-1) as Record<string, unknown>;
const providers = last['providers'] as Record<string, { type: string; baseUrl: string }>;
const models = last['models'] as Record<string, { provider: string; protocol?: string }>;
// Provider type/baseUrl stay on the kimi wire + REST base; the anthropic
// transport is carried on the model alias for per-model resolution.
expect(providers[KIMI_CODE_PROVIDER_NAME]?.type).toBe('kimi');
expect(providers[KIMI_CODE_PROVIDER_NAME]?.baseUrl).toBe('https://api.example.test/coding/v1');
expect(models['kimi-code/kimi-for-coding']?.provider).toBe(KIMI_CODE_PROVIDER_NAME);
expect(models['kimi-code/kimi-for-coding']?.protocol).toBe('anthropic');
});
});

View file

@ -406,17 +406,34 @@ interface AnthropicImageBlock {
cache_control?: { type: 'ephemeral' };
}
// The Messages API has no representation for audio or video input. Instead of
interface AnthropicVideoBlock {
type: 'video';
source:
| { type: 'base64'; media_type: string; data: string }
| { type: 'url'; url: string };
}
// The Messages API has no representation for audio input. Instead of
// silently dropping such parts (the model would not even know an attachment
// existed), emit a placeholder text block so it can acknowledge the gap.
// Consecutive parts of the same kind collapse into a single placeholder.
const OMITTED_MEDIA_PLACEHOLDER = {
audio_url: '(audio omitted: not supported by this provider)',
video_url: '(video omitted: not supported by this provider)',
} as const;
const SUPPORTED_B64_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
const SUPPORTED_B64_VIDEO_TYPES = new Set([
'video/mp4',
'video/mpeg',
'video/quicktime',
'video/webm',
'video/x-matroska',
'video/x-msvideo',
'video/x-flv',
'video/3gpp',
]);
function imageUrlPartToAnthropic(url: string): AnthropicImageBlock {
if (url.startsWith('data:')) {
const withoutScheme = url.slice(5);
@ -441,6 +458,32 @@ function imageUrlPartToAnthropic(url: string): AnthropicImageBlock {
source: { type: 'url', url },
};
}
function videoUrlPartToAnthropic(url: string): AnthropicVideoBlock {
if (url.startsWith('data:')) {
const withoutScheme = url.slice(5);
const parts = withoutScheme.split(';base64,', 2);
if (parts.length !== 2 || parts[0] === undefined || parts[1] === undefined) {
throw new ChatProviderError(`Invalid data URL for video: ${url}`);
}
const mediaType = parts[0];
const data = parts[1];
if (!SUPPORTED_B64_VIDEO_TYPES.has(mediaType)) {
throw new ChatProviderError(
`Unsupported media type for base64 video: ${mediaType}, url: ${url}`,
);
}
return {
type: 'video',
source: { type: 'base64', media_type: mediaType, data },
};
}
return {
type: 'video',
source: { type: 'url', url },
};
}
interface AnthropicToolParam extends AnthropicTool {
cache_control?: { type: 'ephemeral' } | null;
}
@ -453,7 +496,7 @@ function convertTool(tool: Tool): AnthropicToolParam {
};
}
function toolResultToBlock(toolCallId: string, content: ContentPart[]): ToolResultBlockParam {
const blocks: Array<TextBlockParam | AnthropicImageBlock> = [];
const blocks: Array<TextBlockParam | AnthropicImageBlock | AnthropicVideoBlock> = [];
for (const part of content) {
if (part.type === 'text') {
if (part.text) {
@ -461,7 +504,9 @@ function toolResultToBlock(toolCallId: string, content: ContentPart[]): ToolResu
}
} else if (part.type === 'image_url') {
blocks.push(imageUrlPartToAnthropic(part.imageUrl.url));
} else if (part.type === 'audio_url' || part.type === 'video_url') {
} else if (part.type === 'video_url') {
blocks.push(videoUrlPartToAnthropic(part.videoUrl.url));
} else if (part.type === 'audio_url') {
const placeholder = OMITTED_MEDIA_PLACEHOLDER[part.type];
const last = blocks.at(-1);
if (!(last?.type === 'text' && last.text === placeholder)) {
@ -530,7 +575,9 @@ function convertMessage(message: Message, model: string): MessageParam {
} else if (part.think !== '' && shouldPreserveUnsignedThinking(model)) {
blocks.push({ type: 'thinking', thinking: part.think } as unknown as ThinkingBlockParam);
}
} else if (part.type === 'audio_url' || part.type === 'video_url') {
} else if (part.type === 'video_url') {
blocks.push(videoUrlPartToAnthropic(part.videoUrl.url) as unknown as ContentBlockParam);
} else if (part.type === 'audio_url') {
const placeholder = OMITTED_MEDIA_PLACEHOLDER[part.type];
const last = blocks.at(-1);
if (!(last?.type === 'text' && last.text === placeholder)) {

View file

@ -276,6 +276,114 @@ describe('AnthropicChatProvider', () => {
]);
});
it('video url content (base64 data URL)', async () => {
const provider = createProvider();
const history: Message[] = [
{
role: 'user',
content: [
{ type: 'text', text: "What's in this video?" },
{ type: 'video_url', videoUrl: { url: 'data:video/mp4;base64,AAAA' } },
] satisfies ContentPart[],
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['messages']).toEqual([
{
role: 'user',
content: [
{ type: 'text', text: "What's in this video?" },
{
type: 'video',
source: { type: 'base64', media_type: 'video/mp4', data: 'AAAA' },
},
],
},
]);
});
it('video url content passes a non-data URL through as a url source', async () => {
const provider = createProvider();
const history: Message[] = [
{
role: 'user',
content: [
{ type: 'text', text: 'describe' },
{ type: 'video_url', videoUrl: { url: 'ms://file-abc' } },
{ type: 'video_url', videoUrl: { url: 'https://example.com/video.mp4' } },
] satisfies ContentPart[],
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
// Non-data video references (moonshot `ms://` file ids carried over from a
// kimi turn, or http URLs) are emitted as url-source video blocks — the
// kimi anthropic endpoint resolves them server-side, exactly like image
// url sources.
expect(body['messages']).toEqual([
{
role: 'user',
content: [
{ type: 'text', text: 'describe' },
{ type: 'video', source: { type: 'url', url: 'ms://file-abc' } },
{ type: 'video', source: { type: 'url', url: 'https://example.com/video.mp4' } },
],
},
]);
});
it('video url content rejects unsupported media type', async () => {
const provider = createProvider();
const history: Message[] = [
{
role: 'user',
content: [
{ type: 'video_url', videoUrl: { url: 'data:image/png;base64,AAAA' } },
] satisfies ContentPart[],
toolCalls: [],
},
];
await expect(captureRequestBody(provider, '', [], history)).rejects.toThrow(ChatProviderError);
});
it('tool result with video content', async () => {
const provider = createProvider();
const history: Message[] = [
{
role: 'user',
content: [{ type: 'text', text: 'Run tool' }],
toolCalls: [],
},
{
role: 'assistant',
content: [],
toolCalls: [{ type: 'function', id: 'call_1', name: 'add', arguments: '{"a":1,"b":2}' }],
},
{
role: 'tool',
content: [
{ type: 'text', text: 'see video' },
{ type: 'video_url', videoUrl: { url: 'data:video/mp4;base64,AAAA' } },
] satisfies ContentPart[],
toolCalls: [],
toolCallId: 'call_1',
},
];
const body = await captureRequestBody(provider, '', [ADD_TOOL], history);
const messages = body['messages'] as Array<{ role: string; content: unknown[] }>;
const lastContent = messages.at(-1)!.content as Array<{ type: string; content: unknown[] }>;
const toolResult = lastContent[0]!;
expect(toolResult.type).toBe('tool_result');
expect(toolResult.content).toEqual([
{ type: 'text', text: 'see video' },
{ type: 'video', source: { type: 'base64', media_type: 'video/mp4', data: 'AAAA' } },
]);
});
it('tool definitions with cache_control on last tool', async () => {
const provider = createProvider();
const history: Message[] = [
@ -490,10 +598,10 @@ describe('AnthropicChatProvider', () => {
});
});
it('user audio/video parts degrade to placeholder text, consecutive same-kind collapse', async () => {
// The Messages API cannot carry audio or video. Dropping the parts
// silently would leave the model unaware an attachment ever existed,
// so each unsupported part degrades to a placeholder text block.
it('user audio parts degrade to placeholder text, video parts convert to video blocks', async () => {
// Audio still has no Messages-API representation and degrades to a
// placeholder text block (consecutive same-kind placeholders collapse).
// Video is now carried as a base64 `video` content block.
const provider = createProvider();
const history: Message[] = [
{
@ -502,7 +610,7 @@ describe('AnthropicChatProvider', () => {
{ type: 'text', text: 'Listen and watch:' },
{ type: 'audio_url', audioUrl: { url: 'https://example.com/a.mp3' } },
{ type: 'audio_url', audioUrl: { url: 'https://example.com/b.mp3' } },
{ type: 'video_url', videoUrl: { url: 'https://example.com/c.mp4' } },
{ type: 'video_url', videoUrl: { url: 'data:video/mp4;base64,AAAA' } },
] satisfies ContentPart[],
toolCalls: [],
},
@ -514,9 +622,8 @@ describe('AnthropicChatProvider', () => {
{ type: 'text', text: 'Listen and watch:' },
{ type: 'text', text: '(audio omitted: not supported by this provider)' },
{
type: 'text',
text: '(video omitted: not supported by this provider)',
cache_control: { type: 'ephemeral' },
type: 'video',
source: { type: 'base64', media_type: 'video/mp4', data: 'AAAA' },
},
]);
});

View file

@ -95,6 +95,8 @@ export class KimiForCodingProvider implements ModelProvider {
providerName: 'kimi-for-coding',
provider,
modelCapabilities: UNKNOWN_CAPABILITY,
type: 'kimi',
protocol: undefined,
};
}

View file

@ -60,6 +60,7 @@ export type {
FetchManagedKimiCodeModelsOptions,
ManagedKimiCodeApplyResult,
ManagedKimiCodeCleanupResult,
ManagedKimiCodeProtocol,
ManagedKimiEnv,
ManagedKimiLoginAuth,
ManagedKimiCodeModelInfo,

View file

@ -11,6 +11,12 @@ export const KIMI_CODE_PROVIDER_NAME = 'managed:kimi-code';
export const KIMI_CODE_OAUTH_KEY = 'oauth/kimi-code';
const KIMI_CODE_SCOPED_OAUTH_KEY_PREFIX = 'oauth/kimi-code-env-';
export type ManagedKimiCodeProtocol = 'kimi' | 'anthropic';
export function parseModelProtocol(value: unknown): ManagedKimiCodeProtocol | undefined {
return value === 'anthropic' ? 'anthropic' : undefined;
}
/**
* Server-declared thinking toggle support from `/models`:
* - 'only' thinking cannot be turned off (always-thinking)
@ -29,6 +35,7 @@ export interface ManagedKimiCodeModelInfo {
readonly supportsToolUse?: boolean;
readonly supportsThinkingType?: SupportsThinkingType;
readonly displayName?: string | undefined;
readonly protocol?: ManagedKimiCodeProtocol | undefined;
}
export interface ManagedKimiCodeProvisionResult {
@ -106,7 +113,7 @@ export class ManagedKimiCodeModelsAuthError extends OAuthUnauthorizedError {
}
export interface ManagedKimiProviderConfig {
type: 'kimi';
type: ManagedKimiCodeProtocol;
baseUrl?: string | undefined;
apiKey?: string | undefined;
oauth?: ManagedKimiOAuthRef | undefined;
@ -119,6 +126,7 @@ export interface ManagedKimiModelAlias {
maxContextSize: number;
capabilities?: string[] | undefined;
displayName?: string | undefined;
protocol?: ManagedKimiCodeProtocol;
readonly [key: string]: unknown;
}
@ -384,6 +392,7 @@ function toModelInfo(item: unknown): ManagedKimiCodeModelInfo | undefined {
supportsToolUse,
supportsThinkingType: parseSupportsThinkingType(item['supports_thinking_type']),
displayName: normalizedDisplayName,
protocol: parseModelProtocol(item['protocol']),
};
}
@ -474,6 +483,7 @@ export function applyManagedKimiCodeConfig(
maxContextSize: model.contextLength,
capabilities,
displayName: model.displayName,
protocol: model.protocol,
};
}

View file

@ -423,11 +423,6 @@ export function resolveKimiTokenStorageName(input: {
readonly providerName?: string | undefined;
readonly oauthKey?: string | undefined;
}): string {
const providerName = input.providerName ?? KIMI_CODE_PROVIDER_NAME;
if (providerName !== KIMI_CODE_PROVIDER_NAME) {
throw new Error(`No OAuth manager configured for provider "${providerName}".`);
}
const key = input.oauthKey ?? KIMI_CODE_OAUTH_KEY;
if (key === 'kimi-code' || key === KIMI_CODE_OAUTH_KEY) return 'kimi-code';

View file

@ -13,6 +13,7 @@ import {
resolveKimiCodeOAuthKey,
resolveKimiCodeOAuthRef,
resolveKimiCodeRuntimeAuth,
type ManagedKimiCodeModelInfo,
type ManagedKimiConfigShape,
} from '../src/managed-kimi-code';
import { OAuthUnauthorizedError } from '../src/errors';
@ -1068,3 +1069,95 @@ describe('supports_thinking_type', () => {
expect(config.defaultThinking).toBe(false);
});
});
function makeModelInfo(
id: string,
overrides: Partial<ManagedKimiCodeModelInfo> = {},
): ManagedKimiCodeModelInfo {
return {
id,
contextLength: 200000,
supportsReasoning: false,
supportsImageIn: false,
supportsVideoIn: false,
...overrides,
};
}
const KIMI_BASE_URL = 'https://api.kimi.com/coding/v1';
describe('managed protocol routing', () => {
it('reads protocol from the /models response', async () => {
const fetchImpl = vi.fn(
async () =>
new Response(
JSON.stringify({
data: [{ id: 'kimi-for-coding', context_length: 262144, protocol: 'anthropic' }],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
) as unknown as typeof fetch;
const models = await fetchManagedKimiCodeModels({ accessToken: 't', fetchImpl });
expect(models).toHaveLength(1);
expect(models[0]?.protocol).toBe('anthropic');
});
it('keeps the provider on the kimi REST base and records the model protocol when anthropic', () => {
const config: ManagedKimiConfigShape = { providers: {} };
applyManagedKimiCodeConfig(config, {
baseUrl: KIMI_BASE_URL,
models: [makeModelInfo('kimi-for-coding', { protocol: 'anthropic' })],
});
// The provider stays on the kimi wire + REST base; the anthropic transport
// is resolved per-model at runtime, not baked into the provider config, so
// the REST base keeps flowing to OAuth key derivation and plugin env.
expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({
type: 'kimi',
baseUrl: KIMI_BASE_URL,
apiKey: '',
});
expect(config.models?.['kimi-code/kimi-for-coding']).toMatchObject({
provider: KIMI_CODE_PROVIDER_NAME,
protocol: 'anthropic',
});
});
it('keeps the kimi protocol and baseUrl when the model has no anthropic protocol', () => {
const config: ManagedKimiConfigShape = { providers: {} };
applyManagedKimiCodeConfig(config, {
baseUrl: KIMI_BASE_URL,
models: [makeModelInfo('kimi-for-coding')],
});
expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({
type: 'kimi',
baseUrl: KIMI_BASE_URL,
apiKey: '',
});
expect(config.models?.['kimi-code/kimi-for-coding']?.provider).toBe(KIMI_CODE_PROVIDER_NAME);
expect(config.models?.['kimi-code/kimi-for-coding']?.protocol).toBeUndefined();
});
it('drops the model protocol on refresh when the server stops declaring anthropic', () => {
const config: ManagedKimiConfigShape = { providers: {} };
applyManagedKimiCodeConfig(config, {
baseUrl: KIMI_BASE_URL,
models: [makeModelInfo('kimi-for-coding', { protocol: 'anthropic' })],
});
expect(config.models?.['kimi-code/kimi-for-coding']?.protocol).toBe('anthropic');
applyManagedKimiCodeConfig(config, {
baseUrl: KIMI_BASE_URL,
models: [makeModelInfo('kimi-for-coding')],
});
// The provider never leaves the kimi wire / REST base across refreshes —
// only the per-model protocol annotation changes.
expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({
type: 'kimi',
baseUrl: KIMI_BASE_URL,
});
expect(config.models?.['kimi-code/kimi-for-coding']?.protocol).toBeUndefined();
});
});

View file

@ -87,13 +87,19 @@ describe('resolveKimiTokenStorageName', () => {
expect(resolveKimiTokenStorageName({ oauthKey: 'kimi-code' })).toBe('kimi-code');
});
it('rejects unsupported providers and unsafe token keys', () => {
expect(() =>
it('accepts non-managed providers with a valid key and rejects unsafe token keys', () => {
expect(
resolveKimiTokenStorageName({
providerName: 'custom',
oauthKey: 'kimi-code',
oauthKey: 'oauth/kimi-code',
}),
).toThrow(/No OAuth manager/);
).toBe('kimi-code');
expect(
resolveKimiTokenStorageName({
providerName: 'kimi-code-anthropic',
oauthKey: 'oauth/kimi-code',
}),
).toBe('kimi-code');
expect(() => resolveKimiTokenStorageName({ oauthKey: '../kimi-code' })).toThrow(/Invalid/);
});
});