mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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:
parent
f3b15322da
commit
cf558cd742
21 changed files with 891 additions and 47 deletions
5
.changeset/recover-from-context-overflow-413.md
Normal file
5
.changeset/recover-from-context-overflow-413.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Recover from provider 413 context overflows by compacting before retrying.
|
||||
5
.changeset/support-managed-anthropic-protocol.md
Normal file
5
.changeset/support-managed-anthropic-protocol.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Support the Anthropic-compatible protocol for managed Kimi Code, including video input.
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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]' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -95,6 +95,8 @@ export class KimiForCodingProvider implements ModelProvider {
|
|||
providerName: 'kimi-for-coding',
|
||||
provider,
|
||||
modelCapabilities: UNKNOWN_CAPABILITY,
|
||||
type: 'kimi',
|
||||
protocol: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export type {
|
|||
FetchManagedKimiCodeModelsOptions,
|
||||
ManagedKimiCodeApplyResult,
|
||||
ManagedKimiCodeCleanupResult,
|
||||
ManagedKimiCodeProtocol,
|
||||
ManagedKimiEnv,
|
||||
ManagedKimiLoginAuth,
|
||||
ManagedKimiCodeModelInfo,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue