Merge remote-tracking branch 'origin/kimi-code-v2' into feat/de-barrel-agent-core-v2

This commit is contained in:
haozhe.yang 2026-07-07 16:41:46 +08:00
commit 957c493136
32 changed files with 1747 additions and 198 deletions

View file

@ -7,6 +7,7 @@ export interface IAgentContextProjectorService {
readonly _serviceBrand: undefined;
project(messages: readonly ContextMessage[]): readonly Message[];
projectStrict(messages: readonly ContextMessage[]): readonly Message[];
}
export const IAgentContextProjectorService = createDecorator<IAgentContextProjectorService>(

View file

@ -17,6 +17,10 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi
return project(this.microCompaction().compact(messages));
}
projectStrict(messages: readonly ContextMessage[]): readonly Message[] {
return projectStrict(this.microCompaction().compact(messages));
}
private microCompaction(): IAgentMicroCompactionService {
return this.instantiation.invokeFunction((accessor) =>
accessor.get(IAgentMicroCompactionService),
@ -24,6 +28,67 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi
}
}
function projectStrict(history: readonly ContextMessage[]): Message[] {
const projected = project(history);
return dropLeadingNonUserMessages(mergeConsecutiveAssistantMessages(dedupeDuplicateToolCalls(projected)));
}
function dedupeDuplicateToolCalls(messages: readonly Message[]): Message[] {
const seenToolCallIds = new Set<string>();
const keptToolResultIndexes = new Map<string, number>();
const out: Message[] = [];
for (const message of messages) {
if (message.role === 'assistant' && message.toolCalls.length > 0) {
const kept = message.toolCalls.filter((toolCall) => {
if (seenToolCallIds.has(toolCall.id)) return false;
seenToolCallIds.add(toolCall.id);
return true;
});
if (kept.length === message.toolCalls.length) {
out.push(message);
} else if (kept.length > 0 || message.content.length > 0) {
out.push({ ...message, toolCalls: kept });
}
continue;
}
if (message.role === 'tool' && message.toolCallId !== undefined) {
const previousIndex = keptToolResultIndexes.get(message.toolCallId);
if (previousIndex !== undefined) {
if (isInterruptedToolResult(out[previousIndex]) && !isInterruptedToolResult(message)) {
out[previousIndex] = message;
}
continue;
}
keptToolResultIndexes.set(message.toolCallId, out.length);
}
out.push(message);
}
return out;
}
function mergeConsecutiveAssistantMessages(messages: readonly Message[]): Message[] {
const out: Message[] = [];
for (const message of messages) {
const previous = out.at(-1);
if (previous !== undefined && previous.role === 'assistant' && message.role === 'assistant') {
out[out.length - 1] = {
...previous,
content: [...previous.content, ...message.content],
toolCalls: [...previous.toolCalls, ...message.toolCalls],
};
continue;
}
out.push(message);
}
return out;
}
function dropLeadingNonUserMessages(messages: readonly Message[]): Message[] {
let start = 0;
while (start < messages.length && messages[start]?.role !== 'user') start += 1;
return start === 0 ? [...messages] : messages.slice(start);
}
// Projects the stored context history into the wire messages sent to the
// model, in a single pass over the history.
//
@ -173,6 +238,12 @@ function createInterruptedToolResult(toolCallId: string): Message {
};
}
function isInterruptedToolResult(message: Message | undefined): boolean {
if (message?.role !== 'tool') return false;
const [part] = message.content;
return part?.type === 'text' && part.text === TOOL_INTERRUPTED_TEXT;
}
function isBlankText(part: ContentPart): boolean {
return part.type === 'text' && part.text.trim().length === 0;
}

View file

@ -23,7 +23,16 @@ import { IAgentProfileService } from '#/agent/profile/profile';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { IAgentUsageService } from '#/agent/usage/usage';
import { IConfigService } from '#/app/config/config';
import { APIConnectionError, APIContextOverflowError, APIEmptyResponseError, APIStatusError, APITimeoutError, isContextOverflowStatusError, isRetryableGenerateError } from '#/app/llmProtocol/errors';
import {
APIConnectionError,
APIContextOverflowError,
APIEmptyResponseError,
APIStatusError,
APITimeoutError,
isContextOverflowStatusError,
isRecoverableRequestStructureError,
isRetryableGenerateError,
} from '#/app/llmProtocol/errors';
import { type Message } from '#/app/llmProtocol/message';
import { type ThinkingEffort } from '#/app/llmProtocol/thinkingEffort';
import { type Tool } from '#/app/llmProtocol/tool';
@ -214,55 +223,71 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
fields: request.logFields,
});
const input = {
const requestInput = (strict: boolean) => ({
systemPrompt: request.systemPrompt,
tools: request.tools,
messages: this.projector.project(request.messages),
};
messages: strict
? this.projector.projectStrict(request.messages)
: this.projector.project(request.messages),
});
let message: Message | undefined;
let usage = emptyUsage();
let timing: LLMStreamTiming | undefined;
let finish: Extract<ModelRequestEvent, { type: 'finish' }> | undefined;
const run = async (strict: boolean): Promise<LLMRequestFinish> => {
let message: Message | undefined;
let usage = emptyUsage();
let timing: LLMStreamTiming | undefined;
let finish: Extract<ModelRequestEvent, { type: 'finish' }> | undefined;
for await (const event of request.model.request(input, signal)) {
switch (event.type) {
case 'part':
await onPart(event.part);
break;
case 'usage':
usage = event.usage;
break;
case 'finish':
finish = event;
message = event.message;
break;
case 'timing': {
const { type: _type, ...streamTiming } = event;
timing = streamTiming;
break;
for await (const event of request.model.request(requestInput(strict), signal)) {
switch (event.type) {
case 'part':
await onPart(event.part);
break;
case 'usage':
usage = event.usage;
break;
case 'finish':
finish = event;
message = event.message;
break;
case 'timing': {
const { type: _type, ...streamTiming } = event;
timing = streamTiming;
break;
}
}
}
}
if (message === undefined || finish === undefined) {
throw new Error('LLM request stream ended without a finish event.');
}
if (message === undefined || finish === undefined) {
throw new Error('LLM request stream ended without a finish event.');
}
const usageModel = request.modelAlias;
this.usage.record(usageModel, usage, request.source);
this.contextSize.measured(request.messages, [message], usage);
this.logResponse(request.logFields, usage, timing);
const usageModel = request.modelAlias;
this.usage.record(usageModel, usage, request.source);
this.contextSize.measured(request.messages, [message], usage);
this.logResponse(request.logFields, usage, timing);
return {
message,
usage,
model: usageModel,
providerFinishReason: finish.providerFinishReason,
rawFinishReason: finish.rawFinishReason,
providerMessageId: finish.id,
timing,
return {
message,
usage,
model: usageModel,
providerFinishReason: finish.providerFinishReason,
rawFinishReason: finish.rawFinishReason,
providerMessageId: finish.id,
timing,
};
};
try {
return await run(false);
} catch (error) {
if (signal?.aborted === true || !isRecoverableRequestStructureError(error)) throw error;
signal?.throwIfAborted();
this.log.warn('provider rejected request structure; resending with strict projection', {
model: request.model.name,
...request.logFields,
});
return await run(true);
}
}
private resolveRequest(

View file

@ -19,6 +19,7 @@ export const DEFAULT_THINKING_SECTION = 'defaultThinking';
export const ThinkingConfigSchema = z.object({
mode: z.enum(['auto', 'on', 'off']).optional(),
effort: z.string().optional(),
keep: z.string().optional(),
});
export type ThinkingConfig = z.infer<typeof ThinkingConfigSchema>;

View file

@ -34,7 +34,7 @@ export interface ProfileModelState {
readonly cwd?: string;
readonly modelAlias?: string;
readonly profileName?: string;
readonly thinkingLevel: ThinkingEffort;
readonly thinkingLevel: string;
readonly systemPrompt: string;
}

View file

@ -283,6 +283,11 @@ export class AgentProfileService implements IAgentProfileService {
if (this.modelAlias === undefined) return undefined;
let model: Model = this.modelFactory.resolve(this.modelAlias);
const thinkingLevel = this.thinkingLevel;
const thinkingConfig = this.config.get<ThinkingConfig>(THINKING_SECTION);
const forcedKimiThinkingEffort =
model.protocol === 'kimi' && thinkingLevel !== 'off'
? normalizeKimiThinkingEffort(thinkingConfig?.effort)
: undefined;
const kwargs: GenerationKwargs = {};
if (model.protocol === 'kimi') {
kwargs.prompt_cache_key = this.sessionContext.sessionId;
@ -295,17 +300,29 @@ export class AgentProfileService implements IAgentProfileService {
if (overrides !== undefined) {
if (overrides.temperature !== undefined) kwargs.temperature = overrides.temperature;
if (overrides.topP !== undefined) kwargs.top_p = overrides.topP;
if (
model.protocol === 'kimi' &&
thinkingLevel !== 'off' &&
overrides.thinkingKeep !== undefined &&
overrides.thinkingKeep.length > 0
) {
kwargs.extra_body = { thinking: { keep: overrides.thinkingKeep } };
}
const keep = resolveThinkingKeep(
overrides?.thinkingKeep,
thinkingConfig?.keep,
thinkingLevel,
);
if (keep !== undefined) {
if (model.protocol === 'kimi' && forcedKimiThinkingEffort === undefined) {
kwargs.extra_body = { thinking: { keep } };
} else if (model.protocol === 'anthropic') {
model = model.withThinkingKeep(keep);
}
}
if (Object.keys(kwargs).length > 0) model = model.withGenerationKwargs(kwargs);
model = model.withThinking(thinkingLevel);
model = model.withThinking(forcedKimiThinkingEffort ?? thinkingLevel);
if (forcedKimiThinkingEffort !== undefined) {
const thinking: { type: 'enabled'; effort: string; keep?: string } = {
type: 'enabled',
effort: forcedKimiThinkingEffort,
};
if (keep !== undefined) thinking.keep = keep;
model = model.withGenerationKwargs({ extra_body: { thinking } });
}
return model;
}
@ -500,6 +517,37 @@ export class AgentProfileService implements IAgentProfileService {
}
}
const KEEP_OFF_VALUES = new Set(['0', 'false', 'no', 'off', 'none', 'null']);
type KeepResolution =
| { readonly specified: false }
| { readonly specified: true; readonly value: string | undefined };
function parseKeepValue(raw: string | undefined): KeepResolution {
const trimmed = raw?.trim();
if (trimmed === undefined || trimmed.length === 0) return { specified: false };
if (KEEP_OFF_VALUES.has(trimmed.toLowerCase())) return { specified: true, value: undefined };
return { specified: true, value: trimmed };
}
function normalizeKimiThinkingEffort(raw: string | undefined): ThinkingEffort | undefined {
const trimmed = raw?.trim();
return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed;
}
function resolveThinkingKeep(
envKeep: string | undefined,
configKeep: string | undefined,
thinkingEffort: ThinkingEffort,
): string | undefined {
if (thinkingEffort === 'off') return undefined;
const fromEnv = parseKeepValue(envKeep);
if (fromEnv.specified) return fromEnv.value;
const fromConfig = parseKeepValue(configKeep);
if (fromConfig.specified) return fromConfig.value;
return 'all';
}
registerScopedService(
LifecycleScope.Agent,
IAgentProfileService,

View file

@ -15,6 +15,7 @@ export interface ModelCapability {
readonly thinking: boolean;
readonly tool_use: boolean;
readonly max_context_tokens: number;
readonly select_tools?: boolean;
}
const UNKNOWN_CAPABILITY_MARKER = Symbol.for('moonshot-ai.kosong.UNKNOWN_CAPABILITY');
@ -33,6 +34,7 @@ export const UNKNOWN_CAPABILITY: ModelCapability = Object.freeze(
thinking: false,
tool_use: false,
max_context_tokens: 0,
select_tools: false,
},
UNKNOWN_CAPABILITY_MARKER,
{ value: true },
@ -50,6 +52,7 @@ export function isUnknownCapability(capability: ModelCapability): boolean {
!capability.audio_in &&
!capability.thinking &&
!capability.tool_use &&
capability.select_tools !== true &&
capability.max_context_tokens === 0
);
}

View file

@ -13,6 +13,7 @@ export interface CatalogModelEntry {
readonly limit?: { readonly context?: number; readonly output?: number };
readonly tool_call?: boolean;
readonly reasoning?: boolean;
readonly select_tools?: boolean;
readonly interleaved?: boolean | { readonly field?: string };
readonly modalities?: {
readonly input?: readonly string[];
@ -136,6 +137,7 @@ export function catalogModelToCapability(model: CatalogModelEntry): CatalogModel
thinking: Boolean(model.reasoning),
tool_use: model.tool_call ?? true,
max_context_tokens: context,
select_tools: model.select_tools === true,
},
};
}

View file

@ -98,6 +98,19 @@ export function isRetryableGenerateError(error: unknown): boolean {
return error instanceof APIStatusError && [429, 500, 502, 503, 504].includes(error.statusCode);
}
const NETWORK_RE = /network|connection|connect|disconnect|terminated/i;
const TIMEOUT_RE = /timed?\s*out|timeout|deadline/i;
export function classifyBaseApiError(message: string): ChatProviderError {
if (TIMEOUT_RE.test(message)) {
return new APITimeoutError(message);
}
if (NETWORK_RE.test(message)) {
return new APIConnectionError(message);
}
return new ChatProviderError(`Error: ${message}`);
}
const CONTEXT_OVERFLOW_MESSAGE_PATTERNS = [
/context[ _-]?length/,
/(?:context[ _-]?window.*exceed|exceed.*context[ _-]?window)/,
@ -143,6 +156,43 @@ export function isContextOverflowStatusError(statusCode: number, message: string
return CONTEXT_OVERFLOW_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage));
}
const TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS = [
/tool_use[\s\S]*tool_result/,
/tool_result[\s\S]*tool_use/,
/unexpected\s+`?tool_result/,
/tool_call_id[\s\S]*not found/,
/role\s+['"`]?tool['"`]?\s+must be a response to a preceding message/,
/assistant message with\s+['"`]?tool_calls['"`]?\s+must be followed by tool messages/,
/tool_call_ids? did not have response messages/,
/insufficient tool messages following/,
] as const;
export function isToolExchangeAdjacencyError(error: unknown): boolean {
if (!(error instanceof APIStatusError)) return false;
if (error instanceof APIContextOverflowError) return false;
if (error.statusCode !== 400 && error.statusCode !== 422) return false;
const lowerMessage = error.message.toLowerCase();
return TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage));
}
const STRUCTURAL_REQUEST_MESSAGE_PATTERNS = [
/text content blocks must be non-empty/,
/text content blocks must contain non-whitespace/,
/first message must use the .*user.* role/,
/roles must alternate/,
/multiple .*(?:user|assistant).* roles in a row/,
/tool_use[\s\S]*ids must be unique/,
] as const;
export function isRecoverableRequestStructureError(error: unknown): boolean {
if (isToolExchangeAdjacencyError(error)) return true;
if (!(error instanceof APIStatusError)) return false;
if (error instanceof APIContextOverflowError) return false;
if (error.statusCode !== 400 && error.statusCode !== 422) return false;
const lowerMessage = error.message.toLowerCase();
return STRUCTURAL_REQUEST_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage));
}
export function isProviderRateLimitError(error: unknown): boolean {
if (error instanceof APIProviderRateLimitError) return true;

View file

@ -12,7 +12,6 @@ import type { ChatProvider, FinishReason, GenerateOptions, StreamedMessage } fro
import type { Tool } from './tool';
import type { TokenUsage } from './usage';
/** Snapshot of a ToolCall excluding the internal `_streamIndex` routing field. */
type StoredToolCall = Omit<ToolCall, '_streamIndex'>;
/**
@ -103,8 +102,12 @@ export async function generate(
throwAbortError();
}
const wireTools = tools.some((tool) => tool.deferred === true)
? tools.filter((tool) => tool.deferred !== true)
: tools;
options?.onRequestStart?.();
const stream = await provider.generate(systemPrompt, tools, history, options);
const stream = await provider.generate(systemPrompt, wireTools, history, options);
// Post-await abort check: `provider.generate()` may have resolved before
// noticing a mid-flight abort. Reject immediately rather than draining

View file

@ -1,3 +1,5 @@
import type { Tool } from './tool';
export type Role = 'system' | 'user' | 'assistant' | 'tool';
export interface TextPart {
@ -100,6 +102,7 @@ export interface Message {
readonly toolCallId?: string;
/** When `true`, indicates the message was not fully received (e.g. stream interrupted). */
readonly partial?: boolean;
readonly tools?: readonly Tool[];
}
/** Check if a streamed part is a ContentPart (text, think, image_url, audio_url, video_url). */
@ -110,6 +113,15 @@ export function isContentPart(part: StreamedMessagePart): part is ContentPart {
);
}
export function isToolDeclarationOnlyMessage(message: Message): boolean {
return (
message.tools !== undefined &&
message.tools.length > 0 &&
message.content.length === 0 &&
message.toolCalls.length === 0
);
}
/** Check if a streamed part is a ToolCall. */
export function isToolCall(part: StreamedMessagePart): part is ToolCall {
return part.type === 'function';

View file

@ -3,7 +3,6 @@
* wire messages / content parts / tool calls.
*
* Constructors: `createAssistantMessage | createToolMessage | createUserMessage`.
* Predicates: `isContentPart | isToolCall | isToolCallPart`.
* Utilities: `extractText | mergeInPlace` (in-place merge of streamed
* tool-call argument deltas).
*
@ -20,5 +19,6 @@ export {
isContentPart,
isToolCall,
isToolCallPart,
isToolDeclarationOnlyMessage,
mergeInPlace,
} from './message';

View file

@ -2,17 +2,7 @@ import type { Message, StreamedMessagePart, VideoURLPart } from './message';
import type { Tool } from './tool';
import type { TokenUsage } from './usage';
/**
* Normalized thinking effort level used across providers.
*
* `'on'` is the boolean-thinking signal for models that do not expose named
* efforts. Values above `high` are provider/model-specific and may be clamped
* by the adapter when the native API has no matching level. OpenAI maps `max`
* to its `xhigh` ceiling; Kimi and Gemini cap `xhigh`/`max` at `high`;
* Anthropic supports `xhigh`/`max` only on selected models and otherwise clamps
* to `high`.
*/
export type ThinkingEffort = 'off' | 'on' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
export type ThinkingEffort = 'off' | 'on' | (string & {});
/**
* Optional context passed to {@link ChatProvider.withMaxCompletionTokens} so a

View file

@ -2,9 +2,11 @@ import {
APIConnectionError,
APITimeoutError,
ChatProviderError,
classifyBaseApiError,
normalizeAPIStatusError,
} from '../errors';
import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '../message';
import { isToolDeclarationOnlyMessage } from '../message';
import type {
ChatProvider,
FinishReason,
@ -37,6 +39,7 @@ import type {
ToolUseBlockParam,
} from '@anthropic-ai/sdk/resources/messages/messages.js';
import { mergeConsecutiveUserMessages } from './merge-user-messages';
import { mergeRequestHeaders, resolveAuthBackedClient } from './request-auth';
import {
normalizeToolCallIdsForProvider,
@ -111,9 +114,18 @@ interface AnthropicGenerationKwargs {
thinking?: MessageCreateParams['thinking'] | undefined;
output_config?: MessageCreateParams['output_config'] | undefined;
betaFeatures?: string[] | undefined;
contextManagement?: AnthropicContextManagement;
}
interface AnthropicContextManagement {
edits: Array<{ type: string; keep?: unknown }>;
}
type AnthropicEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max';
const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14';
const CONTEXT_MANAGEMENT_BETA = 'context-management-2025-06-27';
const CLEAR_THINKING_EDIT = 'clear_thinking_20251015';
const OPUS_VERSION_RE = /opus[.-](\d+)[.-](\d{1,2})(?!\d)/;
const ADAPTIVE_MIN_VERSION = { major: 4, minor: 6 } as const;
const ANTHROPIC_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = {
@ -330,17 +342,7 @@ function supportsEffortParam(model: string, adaptive: boolean): boolean {
return normalized.includes('opus-4-5') || normalized.includes('opus-4.5');
}
type AnthropicThinkingEffort = Exclude<ThinkingEffort, 'on'>;
function normalizeAnthropicEffort(effort: ThinkingEffort): AnthropicThinkingEffort {
return effort === 'on' ? 'high' : effort;
}
function clampEffort(
effort: AnthropicThinkingEffort,
model: string,
adaptive: boolean,
): AnthropicThinkingEffort {
function clampEffort(effort: ThinkingEffort, model: string, adaptive: boolean): ThinkingEffort {
if (effort === 'off') {
return effort;
}
@ -350,10 +352,19 @@ function clampEffort(
if (effort === 'max' && !adaptive) {
return 'high';
}
if (
effort !== 'low' &&
effort !== 'medium' &&
effort !== 'high' &&
effort !== 'xhigh' &&
effort !== 'max'
) {
return 'high';
}
return effort;
}
function budgetTokensForEffort(effort: AnthropicThinkingEffort): number {
function budgetTokensForEffort(effort: ThinkingEffort): number {
switch (effort) {
case 'low':
return 1024;
@ -402,17 +413,6 @@ function injectCacheControlOnLastBlock(messages: MessageParam[]): void {
}
}
/**
* Check whether a MessageParam is a user message whose content consists
* entirely of `tool_result` blocks.
*
* Used to detect adjacent tool-result-only messages that must be merged
* before hitting the Anthropic wire. Per the Messages API parallel-tool-use
* spec, all `tool_result` blocks answering parallel `tool_use` calls must
* live in a single user message splitting them across consecutive user
* messages fails on strict Anthropic-compatible backends (HTTP 400) and
* silently degrades parallel tool use on api.anthropic.com.
*/
function isToolResultOnly(message: MessageParam): boolean {
if (message.role !== 'user') return false;
const content = message.content;
@ -650,7 +650,7 @@ export function convertAnthropicError(error: unknown): ChatProviderError {
return new ChatProviderError(`Anthropic error: ${error.message}`);
}
if (error instanceof Error) {
return new ChatProviderError(`Error: ${error.message}`);
return classifyBaseApiError(error.message);
}
return new ChatProviderError(`Error: ${String(error)}`);
}
@ -1010,25 +1010,25 @@ export class AnthropicChatProvider implements ChatProvider {
]
: undefined;
// Convert messages, merging consecutive tool-result-only user messages
// into a single user message (Anthropic parallel-tool-use spec).
const messages: MessageParam[] = [];
const normalizedHistory = normalizeToolCallIdsForProvider(
history,
ANTHROPIC_TOOL_CALL_ID_POLICY,
const messages = mergeConsecutiveUserMessages(
normalizeToolCallIdsForProvider(
history.filter((msg) => !isToolDeclarationOnlyMessage(msg)),
ANTHROPIC_TOOL_CALL_ID_POLICY,
).map((msg) =>
convertMessage(msg, this._model),
),
{
isUser: (message) => message.role === 'user',
isToolResultOnly,
merge: (last, next) => ({
...last,
content: [
...(last.content as ContentBlockParam[]),
...(next.content as ContentBlockParam[]),
],
}),
},
);
for (const msg of normalizedHistory) {
const converted = convertMessage(msg, this._model);
const last = messages.at(-1);
if (last !== undefined && isToolResultOnly(last) && isToolResultOnly(converted)) {
last.content = [
...(last.content as ContentBlockParam[]),
...(converted.content as ContentBlockParam[]),
];
} else {
messages.push(converted);
}
}
// Inject cache_control on last content block of last message (after merge,
// so it lands on the final tool_result block in the merged user message).
@ -1059,6 +1059,9 @@ export class AnthropicChatProvider implements ChatProvider {
if (this._generationKwargs.output_config !== undefined) {
kwargs['output_config'] = this._generationKwargs.output_config;
}
if (this._generationKwargs.contextManagement !== undefined) {
kwargs['context_management'] = this._generationKwargs.contextManagement;
}
// Build the beta feature list. On the standard Messages API these travel
// via the `anthropic-beta` header; on the beta Messages API (`betaApi`) the
@ -1232,10 +1235,11 @@ export class AnthropicChatProvider implements ChatProvider {
return clone;
}
const effectiveEffort = clampEffort(normalizeAnthropicEffort(effort), this._model, adaptive);
if (effectiveEffort === 'off') {
const clamped = clampEffort(effort, this._model, adaptive);
if (clamped === 'off') {
throw new Error('Non-off thinking effort unexpectedly clamped to off.');
}
const effectiveEffort = clamped as AnthropicEffort;
let newBetas = [...(this._generationKwargs.betaFeatures ?? [])];
@ -1264,6 +1268,24 @@ export class AnthropicChatProvider implements ChatProvider {
return clone;
}
withThinkingKeep(keep: string): AnthropicChatProvider {
const current = this._generationKwargs.betaFeatures ?? [];
const betaFeatures = current.includes(CONTEXT_MANAGEMENT_BETA)
? current
: [...current, CONTEXT_MANAGEMENT_BETA];
const existingEdits = this._generationKwargs.contextManagement?.edits ?? [];
const edits = [
{ type: CLEAR_THINKING_EDIT, keep },
...existingEdits.filter((edit) => edit.type !== CLEAR_THINKING_EDIT),
];
const clone = this._withGenerationKwargs({
contextManagement: { edits },
betaFeatures,
});
clone._betaApi = true;
return clone;
}
withGenerationKwargs(kwargs: Partial<AnthropicGenerationKwargs>): AnthropicChatProvider {
return this._withGenerationKwargs(kwargs);
}

View file

@ -5,6 +5,7 @@ import {
normalizeAPIStatusError,
} from '../errors';
import type { Message, StreamedMessagePart, ToolCall } from '../message';
import { isToolDeclarationOnlyMessage } from '../message';
import type {
ChatProvider,
FinishReason,
@ -16,6 +17,7 @@ import type {
import type { Tool } from '../tool';
import type { TokenUsage } from '../usage';
import { ApiError as GoogleApiError, GoogleGenAI as GenAIClient } from '@google/genai';
import { mergeConsecutiveUserMessages } from './merge-user-messages';
import { requireProviderApiKey, resolveAuthBackedClient } from './request-auth';
@ -74,6 +76,7 @@ function normalizeGoogleGenAIFinishReason(raw: unknown): {
export interface GoogleGenAIOptions {
apiKey?: string | undefined;
model: string;
baseUrl?: string;
vertexai?: boolean | undefined;
project?: string | undefined;
location?: string | undefined;
@ -83,36 +86,36 @@ export interface GoogleGenAIOptions {
}
export interface GoogleGenAIGenerationKwargs {
max_output_tokens?: number | undefined;
temperature?: number | undefined;
top_k?: number | undefined;
top_p?: number | undefined;
thinking_config?: ThinkingConfig | undefined;
maxOutputTokens?: number;
temperature?: number;
topK?: number;
topP?: number;
thinkingConfig?: ThinkingConfig;
[key: string]: unknown;
}
interface ThinkingConfig {
include_thoughts?: boolean;
thinking_budget?: number;
thinking_level?: string;
includeThoughts?: boolean;
thinkingBudget?: number;
thinkingLevel?: string;
}
interface GoogleFunctionDeclaration {
name: string;
description: string;
parameters_json_schema: Record<string, unknown>;
parametersJsonSchema: Record<string, unknown>;
}
interface GoogleTool {
function_declarations: GoogleFunctionDeclaration[];
functionDeclarations: GoogleFunctionDeclaration[];
}
function toolToGoogleGenAI(tool: Tool): GoogleTool {
return {
function_declarations: [
functionDeclarations: [
{
name: tool.name,
description: tool.description,
parameters_json_schema: tool.parameters,
parametersJsonSchema: tool.parameters,
},
],
};
@ -124,13 +127,13 @@ interface GoogleContent {
interface GooglePart {
text?: string;
function_call?: { name: string; args: Record<string, unknown> };
function_response?: {
functionCall?: { name: string; args: Record<string, unknown> };
functionResponse?: {
name: string;
response: Record<string, string>;
parts: unknown[];
};
thought_signature?: string;
thoughtSignature?: string;
[key: string]: unknown;
}
@ -265,15 +268,14 @@ function messageToGoogleGenAI(message: Message): GoogleContent {
}
const functionCallPart: GooglePart = {
function_call: {
functionCall: {
name: toolCall.name,
args,
},
};
// Restore thought_signature if available
if (toolCall.extras && 'thought_signature_b64' in toolCall.extras) {
functionCallPart['thought_signature'] = toolCall.extras['thought_signature_b64'] as string;
functionCallPart['thoughtSignature'] = toolCall.extras['thought_signature_b64'] as string;
}
parts.push(functionCallPart);
@ -326,7 +328,7 @@ function toolMessageToFunctionResponseParts(
}
const functionResponsePart: GooglePart = {
function_response: {
functionResponse: {
name: toolCallIdToName(message.toolCallId, toolNameById),
response: { output: textOutput },
parts: [],
@ -345,15 +347,12 @@ export function messagesToGoogleGenAIContents(messages: Message[]): GoogleConten
const message = messages[i];
if (message === undefined) break;
if (isToolDeclarationOnlyMessage(message)) {
i += 1;
continue;
}
if (message.role === 'system') {
// Google GenAI's `Content.role` only accepts "user" or "model", so a
// system message in the history (e.g. from session restore or
// cross-provider migration) would be rejected by the API. Preserve
// the content by wrapping it in a `<system>` tag and attaching it as
// a user turn — mirrors the Anthropic provider's behavior. The
// dedicated top-level `systemPrompt` still flows into
// `system_instruction` separately; only historical system messages
// come through here.
const text = message.content
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
.map((p) => p.text)
@ -447,7 +446,13 @@ export function messagesToGoogleGenAIContents(messages: Message[]): GoogleConten
i += 1;
}
return contents;
return mergeConsecutiveUserMessages(contents, {
isUser: (content) => content.role === 'user',
isToolResultOnly: (content) =>
content.parts.length > 0 &&
content.parts.every((part) => part.functionResponse !== undefined),
merge: (last, next) => ({ ...last, parts: [...last.parts, ...next.parts] }),
});
}
export class GoogleGenAIStreamedMessage implements StreamedMessage {
private _id: string | null = null;
@ -672,6 +677,7 @@ export class GoogleGenAIChatProvider implements ChatProvider {
private _vertexai: boolean;
private _stream: boolean;
private _apiKey: string | undefined;
private _baseUrl: string | undefined;
private _project: string | undefined;
private _location: string | undefined;
private _defaultHeaders: Record<string, string> | undefined;
@ -685,6 +691,8 @@ export class GoogleGenAIChatProvider implements ChatProvider {
const apiKey = options.apiKey ?? process.env['GOOGLE_API_KEY'];
this._apiKey = apiKey === undefined || apiKey.length === 0 ? undefined : apiKey;
this._baseUrl =
options.baseUrl === undefined || options.baseUrl.length === 0 ? undefined : options.baseUrl;
this._project = options.project;
this._location = options.location;
this._defaultHeaders = options.defaultHeaders;
@ -694,6 +702,13 @@ export class GoogleGenAIChatProvider implements ChatProvider {
}
private _buildClient(apiKey: string | undefined): GenAIClient {
const httpOptions: { headers?: Record<string, string>; baseUrl?: string } = {};
if (this._defaultHeaders !== undefined) {
httpOptions.headers = this._defaultHeaders;
}
if (this._baseUrl !== undefined) {
httpOptions.baseUrl = this._baseUrl;
}
return new GenAIClient({
apiKey,
...(this._vertexai
@ -703,13 +718,7 @@ export class GoogleGenAIChatProvider implements ChatProvider {
location: this._location,
}
: {}),
// The Google GenAI SDK deep-merges `httpOptions.headers` into its
// default request headers, so a `User-Agent` here overrides the SDK
// default (`google-genai-sdk/<ver> …`) while preserving the other
// defaults (`x-goog-api-client`, `Content-Type`).
...(this._defaultHeaders !== undefined
? { httpOptions: { headers: this._defaultHeaders } }
: {}),
httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined,
});
}
@ -718,16 +727,13 @@ export class GoogleGenAIChatProvider implements ChatProvider {
}
get thinkingEffort(): ThinkingEffort | null {
const thinkingConfig = this._generationKwargs.thinking_config;
const thinkingConfig = this._generationKwargs.thinkingConfig;
if (thinkingConfig === undefined) return null;
// For gemini-3 models that use thinking_level
if (thinkingConfig.thinking_level !== undefined) {
switch (thinkingConfig.thinking_level) {
if (thinkingConfig.thinkingLevel !== undefined) {
switch (thinkingConfig.thinkingLevel) {
case 'MINIMAL':
// MINIMAL + suppressed thoughts is how 'off' is encoded for Gemini 3,
// which has no true "disabled" level.
return thinkingConfig.include_thoughts === false ? 'off' : 'low';
return thinkingConfig.includeThoughts === false ? 'off' : 'low';
case 'LOW':
return 'low';
case 'MEDIUM':
@ -739,11 +745,10 @@ export class GoogleGenAIChatProvider implements ChatProvider {
}
}
// For other models that use thinking_budget
if (thinkingConfig.thinking_budget !== undefined) {
if (thinkingConfig.thinking_budget === 0) return 'off';
if (thinkingConfig.thinking_budget <= 1024) return 'low';
if (thinkingConfig.thinking_budget <= 4096) return 'medium';
if (thinkingConfig.thinkingBudget !== undefined) {
if (thinkingConfig.thinkingBudget === 0) return 'off';
if (thinkingConfig.thinkingBudget <= 1024) return 'low';
if (thinkingConfig.thinkingBudget <= 4096) return 'medium';
return 'high';
}
@ -773,7 +778,7 @@ export class GoogleGenAIChatProvider implements ChatProvider {
const config: Record<string, unknown> = {
...this._generationKwargs,
system_instruction: systemPrompt,
systemInstruction: systemPrompt,
...(tools.length > 0 ? { tools: tools.map((t) => toolToGoogleGenAI(t)) } : {}),
};
@ -838,53 +843,50 @@ export class GoogleGenAIChatProvider implements ChatProvider {
}
withThinking(effort: ThinkingEffort): GoogleGenAIChatProvider {
const thinkingConfig: ThinkingConfig = { include_thoughts: true };
const thinkingConfig: ThinkingConfig = { includeThoughts: true };
if (this._model.includes('gemini-3')) {
// Gemini 3 models use thinking_level (MINIMAL/LOW/MEDIUM/HIGH). The SDK
// does not expose a "disabled" level, so 'off' maps to MINIMAL with
// thought output suppressed — the lowest thinking intensity available.
switch (effort) {
case 'off':
thinkingConfig.thinking_level = 'MINIMAL';
thinkingConfig.include_thoughts = false;
thinkingConfig.thinkingLevel = 'MINIMAL';
thinkingConfig.includeThoughts = false;
break;
case 'low':
thinkingConfig.thinking_level = 'LOW';
thinkingConfig.thinkingLevel = 'LOW';
break;
case 'medium':
thinkingConfig.thinking_level = 'MEDIUM';
thinkingConfig.thinkingLevel = 'MEDIUM';
break;
case 'high':
case 'xhigh':
case 'max':
thinkingConfig.thinking_level = 'HIGH';
thinkingConfig.thinkingLevel = 'HIGH';
break;
}
} else {
switch (effort) {
case 'off':
thinkingConfig.thinking_budget = 0;
thinkingConfig.include_thoughts = false;
thinkingConfig.thinkingBudget = 0;
thinkingConfig.includeThoughts = false;
break;
case 'low':
thinkingConfig.thinking_budget = 1024;
thinkingConfig.include_thoughts = true;
thinkingConfig.thinkingBudget = 1024;
thinkingConfig.includeThoughts = true;
break;
case 'medium':
thinkingConfig.thinking_budget = 4096;
thinkingConfig.include_thoughts = true;
thinkingConfig.thinkingBudget = 4096;
thinkingConfig.includeThoughts = true;
break;
case 'high':
case 'xhigh':
case 'max':
thinkingConfig.thinking_budget = 32_000;
thinkingConfig.include_thoughts = true;
thinkingConfig.thinkingBudget = 32_000;
thinkingConfig.includeThoughts = true;
break;
}
}
return this.withGenerationKwargs({ thinking_config: thinkingConfig });
return this.withGenerationKwargs({ thinkingConfig });
}
withGenerationKwargs(kwargs: GoogleGenAIGenerationKwargs): GoogleGenAIChatProvider {
@ -894,7 +896,7 @@ export class GoogleGenAIChatProvider implements ChatProvider {
}
withMaxCompletionTokens(maxCompletionTokens: number): GoogleGenAIChatProvider {
return this.withGenerationKwargs({ max_output_tokens: maxCompletionTokens });
return this.withGenerationKwargs({ maxOutputTokens: maxCompletionTokens });
}
private _clone(): GoogleGenAIChatProvider {

View file

@ -67,13 +67,13 @@ export interface GenerationKwargs {
presence_penalty?: number | undefined;
frequency_penalty?: number | undefined;
stop?: string | string[] | undefined;
reasoning_effort?: string | undefined;
prompt_cache_key?: string | undefined;
extra_body?: ExtraBody;
}
export interface ThinkingConfig {
type?: 'enabled' | 'disabled';
effort?: string;
keep?: unknown;
[key: string]: unknown;
}
@ -93,6 +93,7 @@ interface OpenAIMessage {
tool_call_id?: string | undefined;
name?: string | undefined;
reasoning_content?: string | undefined;
tools?: OpenAIToolParam[];
}
interface OpenAIToolCallOut {
@ -166,6 +167,10 @@ function convertMessage(message: Message): OpenAIMessage {
result.reasoning_content = reasoningContent;
}
if (message.tools !== undefined && message.tools.length > 0) {
result.tools = message.tools.map((tool) => convertTool(tool));
}
return result;
}
function convertTool(tool: Tool): OpenAIToolParam {
@ -419,8 +424,7 @@ export class KimiChatProvider implements ChatProvider {
const thinking = this._generationKwargs.extra_body?.thinking;
if (thinking === undefined) return null;
if (thinking.type === 'disabled') return 'off';
const effort = thinking['effort'];
return typeof effort === 'string' ? (effort as ThinkingEffort) : 'on';
return thinking.effort ?? 'on';
}
get modelParameters(): Record<string, unknown> {
@ -492,8 +496,6 @@ export class KimiChatProvider implements ChatProvider {
try {
const client = this._createClient(options?.auth);
// Use type assertion via unknown because we pass Moonshot-proprietary fields
// (reasoning_effort, thinking) that don't exist in the OpenAI type definitions.
options?.onRequestSent?.();
const response = (await client.chat.completions.create(
createParams as unknown as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming,

View file

@ -0,0 +1,25 @@
export function mergeConsecutiveUserMessages<T>(
messages: readonly T[],
mergePolicy: {
readonly isUser: (message: T) => boolean;
readonly isToolResultOnly: (message: T) => boolean;
readonly merge: (last: T, next: T) => T;
},
): T[] {
const out: T[] = [];
for (const message of messages) {
const lastIndex = out.length - 1;
const last = lastIndex >= 0 ? out[lastIndex] : undefined;
if (
last !== undefined &&
mergePolicy.isUser(last) &&
mergePolicy.isUser(message) &&
(mergePolicy.isToolResultOnly(last) || !mergePolicy.isToolResultOnly(message))
) {
out[lastIndex] = mergePolicy.merge(last, message);
} else {
out.push(message);
}
}
return out;
}

View file

@ -2,6 +2,7 @@ import {
APIConnectionError,
APITimeoutError,
ChatProviderError,
classifyBaseApiError,
normalizeAPIStatusError,
} from '../errors';
import { extractText } from '../message';
@ -84,22 +85,6 @@ export function toolToOpenAI(tool: Tool): OpenAIToolParam {
},
};
}
// `terminated` is the undici signature for an SSE/HTTP body stream that is
// dropped mid-flight (common with Node's native fetch on long reasoning
// streams). It surfaces as a raw `TypeError: terminated`, so it must be
// recognized here as a transport-layer connection failure.
const NETWORK_RE = /network|connection|connect|disconnect|terminated/i;
const TIMEOUT_RE = /timed?\s*out|timeout|deadline/i;
function classifyBaseApiError(message: string): ChatProviderError {
if (TIMEOUT_RE.test(message)) {
return new APITimeoutError(message);
}
if (NETWORK_RE.test(message)) {
return new APIConnectionError(message);
}
return new ChatProviderError(`Error: ${message}`);
}
/**
* Convert an OpenAI SDK error (or raw Error) to a kosong `ChatProviderError`.
@ -177,7 +162,7 @@ export function thinkingEffortToReasoningEffort(effort: ThinkingEffort): string
case 'max':
return 'xhigh';
default:
throw new Error(`Unknown thinking effort: ${String(effort)}`);
return undefined;
}
}

View file

@ -1,4 +1,5 @@
import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '../message';
import { isToolDeclarationOnlyMessage } from '../message';
import type {
ChatProvider,
FinishReason,
@ -286,6 +287,7 @@ function convertHistoryMessages(
const pendingToolResultMedia: OpenAIContentPart[] = [];
for (const msg of history) {
if (isToolDeclarationOnlyMessage(msg)) continue;
if (msg.role !== 'tool') {
appendToolResultMediaMessage(messages, pendingToolResultMedia);
}

View file

@ -5,7 +5,7 @@ import {
isContextOverflowErrorCode,
} from '../errors';
import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '../message';
import { extractText } from '../message';
import { extractText, isToolDeclarationOnlyMessage } from '../message';
import type {
ChatProvider,
FinishReason,
@ -614,6 +614,7 @@ function convertHistoryMessages(
};
for (const msg of history) {
if (isToolDeclarationOnlyMessage(msg)) continue;
if (msg.role !== 'tool') {
flushPendingMedia();
}

View file

@ -12,4 +12,5 @@ export interface Tool {
description: string;
/** JSON Schema describing the tool's parameters. */
parameters: Record<string, unknown>;
deferred?: true;
}

View file

@ -169,6 +169,15 @@ export class ModelImpl implements Model {
});
}
withThinkingKeep(keep: string): Model {
return this.clone((p) => {
const applied = (p as ChatProvider & {
withThinkingKeep?: (k: string) => ChatProvider;
}).withThinkingKeep;
return applied !== undefined ? applied.call(p, keep) : p;
});
}
/** Materialize the transformed kosong ChatProvider. Cached per Model instance. */
private resolveChatProvider(): ChatProvider {
if (this.cachedChatProvider !== undefined) return this.cachedChatProvider;

View file

@ -127,6 +127,8 @@ export interface Model {
/** Return a new Model wrapper with additional protocol-constructor options applied. */
withProviderOptions(options: ProtocolProviderOptions): Model;
withThinkingKeep(keep: string): Model;
/**
* Drive one LLM request end-to-end. Streams `LLMEvent`s until the stream
* terminates (either normally with `usage`+`finish`, or with an error).

View file

@ -362,6 +362,7 @@ function resolveModelCapabilities(
thinking: declared.has('thinking') || declared.has('always_thinking') || detected.thinking,
tool_use: declared.has('tool_use') || detected.tool_use,
max_context_tokens: maxContextSize,
select_tools: declared.has('select_tools') || detected.select_tools === true,
};
}

View file

@ -66,6 +66,10 @@ describe('projector tool-exchange normalization', () => {
);
}
function projectStrict(history: readonly ContextMessage[]): readonly Message[] {
return projector.projectStrict(history);
}
it('leaves a fully resolved exchange untouched', () => {
const history = [user('go'), assistant('', ['c1']), toolResult('c1', 'one'), user('next')];
expect(shape(history)).toEqual(['user', 'assistant', 'tool:c1', 'user']);
@ -210,4 +214,60 @@ describe('projector tool-exchange normalization', () => {
expect(project([message])).toHaveLength(1);
});
it('strict mode dedupes duplicate assistant tool call ids', () => {
const history = [
user('go'),
assistant('first', ['dup']),
toolResult('dup', 'one'),
assistant('second', ['dup']),
toolResult('dup', 'two'),
];
const projected = projectStrict(history);
expect(projected.map((message) => (message.role === 'tool' ? `tool:${message.toolCallId}` : message.role))).toEqual([
'user',
'assistant',
'tool:dup',
'assistant',
]);
expect(projected[1]?.toolCalls.map((call) => call.id)).toEqual(['dup']);
expect(projected.filter((message) => message.role === 'tool')).toHaveLength(1);
});
it("strict mode reattaches a later duplicate's result when the first call has none", () => {
const projected = projectStrict([
user('go'),
assistant('first attempt', ['dup']),
assistant('second attempt', ['dup']),
toolResult('dup', 'late result'),
user('next'),
]);
expect(
projected.map((message) =>
message.role === 'tool' ? `tool:${message.toolCallId}` : message.role,
),
).toEqual(['user', 'assistant', 'tool:dup', 'assistant', 'user']);
expect(projected[1]?.toolCalls.map((call) => call.id)).toEqual(['dup']);
expect((projected[2]?.content[0] as { text: string }).text).toBe('late result');
});
it('strict mode drops leading non-user messages', () => {
const projected = projectStrict([assistant('stale'), toolResult('ghost', 'orphaned'), user('hi')]);
expect(projected.map((message) => message.role)).toEqual(['user']);
expect(projected[0]?.content).toEqual([{ type: 'text', text: 'hi' }]);
});
it('strict mode merges consecutive assistant messages', () => {
const projected = projectStrict([user('go'), assistant('one'), assistant('two')]);
expect(projected.map((message) => message.role)).toEqual(['user', 'assistant']);
expect(projected[1]?.content).toEqual([
{ type: 'text', text: 'one' },
{ type: 'text', text: 'two' },
]);
});
});

View file

@ -0,0 +1,90 @@
import type { Message } from '#/app/llmProtocol/message';
import { AnthropicChatProvider } from '#/app/llmProtocol/providers/anthropic';
import { describe, expect, it, vi } from 'vitest';
const HISTORY: Message[] = [{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }];
function createProvider(): AnthropicChatProvider {
return new AnthropicChatProvider({
model: 'kimi-for-coding',
apiKey: 'test-key',
defaultMaxTokens: 1024,
stream: false,
});
}
function makeAnthropicResponse() {
return {
id: 'msg_test_123',
type: 'message',
role: 'assistant',
model: 'kimi-for-coding',
content: [{ type: 'text', text: 'Hello' }],
stop_reason: 'end_turn',
usage: { input_tokens: 10, output_tokens: 5 },
};
}
async function captureBetaRequestBody(provider: AnthropicChatProvider): Promise<Record<string, unknown>> {
let capturedParams: Record<string, unknown> | undefined;
const standardCreate = vi.fn();
(provider as unknown as { _client: { beta: { messages: { create: unknown } }; messages: { create: unknown } } })._client.beta.messages.create =
vi.fn().mockImplementation((params: unknown) => {
capturedParams = params as Record<string, unknown>;
return Promise.resolve(makeAnthropicResponse());
});
(provider as unknown as { _client: { messages: { create: unknown } } })._client.messages.create = standardCreate;
const stream = await provider.generate('', [], HISTORY);
for await (const part of stream) void part;
if (capturedParams === undefined) {
throw new Error('Expected provider.generate() to call beta.messages.create');
}
expect(standardCreate).not.toHaveBeenCalled();
return capturedParams;
}
describe('Anthropic withThinkingKeep context_management parity', () => {
it('forces the beta endpoint and emits context_management clear_thinking keep', async () => {
const body = await captureBetaRequestBody(createProvider().withThinkingKeep('all'));
expect(body['context_management']).toEqual({
edits: [{ type: 'clear_thinking_20251015', keep: 'all' }],
});
expect(body['betas']).toContain('context-management-2025-06-27');
});
it('prepends clear_thinking before existing context-management edits', () => {
const provider = createProvider()
.withGenerationKwargs({
contextManagement: {
edits: [{ type: 'clear_tool_uses_20250919', keep: { type: 'tool_uses', value: 2 } }],
},
})
.withThinkingKeep('all');
expect(Reflect.get(provider, '_generationKwargs')).toMatchObject({
contextManagement: {
edits: [
{ type: 'clear_thinking_20251015', keep: 'all' },
{ type: 'clear_tool_uses_20250919', keep: { type: 'tool_uses', value: 2 } },
],
},
});
});
it('does not duplicate the context-management beta or clear_thinking edit', () => {
const provider = createProvider().withThinkingKeep('all').withThinkingKeep('all');
const generationKwargs = Reflect.get(provider, '_generationKwargs') as {
readonly betaFeatures?: readonly string[];
readonly contextManagement?: { readonly edits: readonly unknown[] };
};
expect(generationKwargs.betaFeatures?.filter((beta) => beta === 'context-management-2025-06-27')).toHaveLength(1);
expect(generationKwargs.contextManagement?.edits).toEqual([
{ type: 'clear_thinking_20251015', keep: 'all' },
]);
});
});

View file

@ -0,0 +1,457 @@
import {
APIConnectionError,
APIContextOverflowError,
APIEmptyResponseError,
APIProviderRateLimitError,
APIStatusError,
APITimeoutError,
ChatProviderError,
isProviderRateLimitError,
isRecoverableRequestStructureError,
isRetryableGenerateError,
isToolExchangeAdjacencyError,
normalizeAPIStatusError,
} from '#/app/llmProtocol/errors';
import { describe, expect, it } from 'vitest';
describe('ChatProviderError', () => {
it('is an instance of Error', () => {
const err = new ChatProviderError('base error');
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(ChatProviderError);
expect(err.message).toBe('base error');
expect(err.name).toBe('ChatProviderError');
});
});
describe('APIConnectionError', () => {
it('extends ChatProviderError', () => {
const err = new APIConnectionError('connection refused');
expect(err).toBeInstanceOf(ChatProviderError);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe('APIConnectionError');
expect(err.message).toBe('connection refused');
});
});
describe('APITimeoutError', () => {
it('extends ChatProviderError', () => {
const err = new APITimeoutError('request timed out after 30s');
expect(err).toBeInstanceOf(ChatProviderError);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe('APITimeoutError');
expect(err.message).toBe('request timed out after 30s');
});
});
describe('APIStatusError', () => {
it('extends ChatProviderError and stores status code', () => {
const err = new APIStatusError(429, 'rate limited', 'req-abc');
expect(err).toBeInstanceOf(ChatProviderError);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe('APIStatusError');
expect(err.message).toBe('rate limited');
expect(err.statusCode).toBe(429);
expect(err.requestId).toBe('req-abc');
});
it('accepts null requestId', () => {
const err = new APIStatusError(500, 'server error', null);
expect(err.statusCode).toBe(500);
expect(err.requestId).toBeNull();
});
it('defaults requestId to null when omitted', () => {
const err = new APIStatusError(502, 'bad gateway');
expect(err.statusCode).toBe(502);
expect(err.requestId).toBeNull();
});
});
describe('APIEmptyResponseError', () => {
it('extends ChatProviderError', () => {
const err = new APIEmptyResponseError('empty response');
expect(err).toBeInstanceOf(ChatProviderError);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe('APIEmptyResponseError');
expect(err.message).toBe('empty response');
expect(err.finishReason).toBeNull();
expect(err.rawFinishReason).toBeNull();
});
it('preserves provider finish reason details', () => {
const err = new APIEmptyResponseError('empty response', {
finishReason: 'filtered',
rawFinishReason: 'content_filter',
});
expect(err.finishReason).toBe('filtered');
expect(err.rawFinishReason).toBe('content_filter');
});
});
describe('APIContextOverflowError', () => {
it('extends APIStatusError and preserves HTTP details', () => {
const err = new APIContextOverflowError(400, 'Context length exceeded', 'req-context');
expect(err).toBeInstanceOf(APIStatusError);
expect(err).toBeInstanceOf(ChatProviderError);
expect(err.name).toBe('APIContextOverflowError');
expect(err.statusCode).toBe(400);
expect(err.requestId).toBe('req-context');
});
});
describe('APIProviderRateLimitError', () => {
it('extends APIStatusError and preserves HTTP details', () => {
const err = new APIProviderRateLimitError('Rate limited', 'req-rate');
expect(err).toBeInstanceOf(APIStatusError);
expect(err).toBeInstanceOf(ChatProviderError);
expect(err.name).toBe('APIProviderRateLimitError');
expect(err.statusCode).toBe(429);
expect(err.requestId).toBe('req-rate');
});
});
describe('isRetryableGenerateError', () => {
it('matches transient provider errors and empty generate responses', () => {
expect(isRetryableGenerateError(new APIConnectionError('conn'))).toBe(true);
expect(isRetryableGenerateError(new APITimeoutError('timeout'))).toBe(true);
expect(isRetryableGenerateError(new APIEmptyResponseError('empty'))).toBe(true);
});
it.each([429, 500, 502, 503, 504])('treats HTTP %i as retryable', (statusCode) => {
expect(isRetryableGenerateError(new APIStatusError(statusCode, 'retryable'))).toBe(true);
});
it.each([400, 401, 403, 404, 422])('treats HTTP %i as non-retryable', (statusCode) => {
expect(isRetryableGenerateError(new APIStatusError(statusCode, 'non-retryable'))).toBe(false);
});
it('does not retry context overflow or unknown errors', () => {
expect(
isRetryableGenerateError(new APIContextOverflowError(400, 'Context length exceeded')),
).toBe(false);
expect(isRetryableGenerateError(new Error('boom'))).toBe(false);
expect(isRetryableGenerateError('boom')).toBe(false);
});
});
describe('error hierarchy instanceof checks', () => {
it('all error types are instanceof ChatProviderError', () => {
const errors = [
new APIConnectionError('conn'),
new APITimeoutError('timeout'),
new APIStatusError(400, 'status', null),
new APIContextOverflowError(400, 'context length exceeded'),
new APIEmptyResponseError('empty'),
];
for (const err of errors) {
expect(err).toBeInstanceOf(ChatProviderError);
}
});
it('specific types are distinguishable', () => {
const connErr = new APIConnectionError('conn');
const statusErr = new APIStatusError(400, 'status', null);
expect(connErr).not.toBeInstanceOf(APIStatusError);
expect(statusErr).not.toBeInstanceOf(APIConnectionError);
});
it('can catch with ChatProviderError and inspect subtype', () => {
const err: ChatProviderError = new APIStatusError(404, 'not found', 'req-123');
if (err instanceof APIStatusError) {
expect(err.statusCode).toBe(404);
expect(err.requestId).toBe('req-123');
} else {
expect.unreachable('Expected APIStatusError');
}
});
});
describe('normalizeAPIStatusError', () => {
it('normalizes HTTP 429 to APIProviderRateLimitError', () => {
const error = normalizeAPIStatusError(429, 'Too many requests', 'req-rate');
expect(error).toBeInstanceOf(APIProviderRateLimitError);
expect(error.statusCode).toBe(429);
expect(error.requestId).toBe('req-rate');
});
it.each([
[400, 'Context length exceeded'],
[400, 'Exceeded max tokens'],
[413, 'Context length exceeded'],
[422, 'Maximum context window exceeded'],
[400, 'context_length_exceeded'],
[422, 'Too many tokens in prompt'],
[400, 'prompt is too long: 210000 tokens exceeds the maximum'],
[400, 'input token count 131072 exceeds the maximum number of tokens allowed'],
[400, 'Invalid request: Your request exceeded model token limit: 262144 (requested: 274613)'],
])('normalizes %i "%s" to APIContextOverflowError', (statusCode, message) => {
const error = normalizeAPIStatusError(statusCode, message, 'req-context');
expect(error).toBeInstanceOf(APIContextOverflowError);
expect(error.statusCode).toBe(statusCode);
expect(error.requestId).toBe('req-context');
});
it.each([
[401, 'Context length exceeded'],
[500, 'Context length exceeded'],
[400, 'Bad request'],
[422, 'Invalid tool schema'],
[400, 'max_tokens must be less than or equal to 4096'],
[422, 'max_output_tokens must not exceed 8192'],
[400, 'max tokens must not exceed the configured output limit'],
])('keeps %i "%s" as APIStatusError', (statusCode, message) => {
const error = normalizeAPIStatusError(statusCode, message);
expect(error).toBeInstanceOf(APIStatusError);
expect(error).not.toBeInstanceOf(APIContextOverflowError);
});
});
describe('isToolExchangeAdjacencyError', () => {
// The exact Anthropic message observed in the field when a tool_use was not
// immediately followed by its tool_result.
const ANTHROPIC_MISSING_RESULT =
'messages.142: `tool_use` ids were found without `tool_result` blocks immediately after: ' +
'toolu_01MWFhDRqdbB4nzCJNuWYiun. Each `tool_use` block must have a corresponding ' +
'`tool_result` block in the next message.';
it('matches the missing-tool_result 400', () => {
expect(isToolExchangeAdjacencyError(new APIStatusError(400, ANTHROPIC_MISSING_RESULT))).toBe(
true,
);
});
it('matches the reverse unexpected-tool_result 400', () => {
expect(
isToolExchangeAdjacencyError(
new APIStatusError(
400,
'messages.5: `tool_result` block(s) provided when previous message does not ' +
'contain any `tool_use` blocks',
),
),
).toBe(true);
expect(
isToolExchangeAdjacencyError(new APIStatusError(400, 'unexpected `tool_result` block')),
).toBe(true);
});
it('also matches a 422 with the same shape', () => {
expect(isToolExchangeAdjacencyError(new APIStatusError(422, ANTHROPIC_MISSING_RESULT))).toBe(
true,
);
});
// The exact OpenAI-compatible (Moonshot / Kimi) message observed in the field
// when a `tool` message's `tool_call_id` has no matching `tool_calls` entry in
// the preceding assistant message. The doubled space is verbatim from the
// provider.
const MOONSHOT_TOOL_CALL_ID_NOT_FOUND = '400 tool_call_id is not found';
it('matches the OpenAI/Moonshot tool_call_id-not-found 400', () => {
expect(
isToolExchangeAdjacencyError(new APIStatusError(400, MOONSHOT_TOOL_CALL_ID_NOT_FOUND)),
).toBe(true);
expect(
isToolExchangeAdjacencyError(new APIStatusError(400, "tool_call_id 'call_abc123' is not found")),
).toBe(true);
});
it('also matches a 422 tool_call_id-not-found', () => {
expect(
isToolExchangeAdjacencyError(new APIStatusError(422, MOONSHOT_TOOL_CALL_ID_NOT_FOUND)),
).toBe(true);
});
// OpenAI / DeepSeek / vLLM and other OpenAI-compatible providers phrase the
// orphan-`tool`-result case as a `role 'tool'` message that has no preceding
// assistant `tool_calls`. Observed verbatim in the field (see zed #41531,
// llama_index #13715). Quote style varies by provider (straight or backtick).
it('matches the OpenAI/DeepSeek role-tool-without-tool_calls 400', () => {
expect(
isToolExchangeAdjacencyError(
new APIStatusError(
400,
"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'",
),
),
).toBe(true);
expect(
isToolExchangeAdjacencyError(
new APIStatusError(
400,
'Role `tool` must be a response to a preceding message with `tool_calls`',
),
),
).toBe(true);
});
// The mirror-image OpenAI-compatible rejection: an assistant `tool_calls`
// message with no following `tool` results. OpenAI/Portkey (#6621, error
// 10067) spell it out; Qwen/DashScope (#454) uses double quotes; some
// providers emit the terse "(insufficient tool messages following ...)".
it('matches the assistant-tool_calls-without-response 400', () => {
expect(
isToolExchangeAdjacencyError(
new APIStatusError(
400,
"An assistant message with 'tool_calls' must be followed by tool messages responding to each " +
"'tool_call_id'. The following tool_call_ids did not have response messages: call_hSmZB4G8",
),
),
).toBe(true);
expect(
isToolExchangeAdjacencyError(
new APIStatusError(
400,
'An assistant message with "tool_calls" must be followed by tool messages responding to each ' +
'"tool_call_id". The following tool_call_ids did not have response messages: message[322].role',
),
),
).toBe(true);
expect(
isToolExchangeAdjacencyError(
new APIStatusError(400, '(insufficient tool messages following tool_calls message)'),
),
).toBe(true);
});
it('does not match a context-overflow 400 or unrelated errors', () => {
expect(
isToolExchangeAdjacencyError(new APIContextOverflowError(400, 'context length exceeded')),
).toBe(false);
expect(isToolExchangeAdjacencyError(new APIStatusError(400, 'Bad request'))).toBe(false);
// A bare "not found" without a tool_call_id anchor must not match, so an
// unrelated 404-style body cannot trip the tool-exchange recovery.
expect(isToolExchangeAdjacencyError(new APIStatusError(400, 'resource not found'))).toBe(false);
// A model-availability 400 (observed alongside this family in the field) is a
// config error, not a tool-exchange defect — strict resend must not fire.
expect(
isToolExchangeAdjacencyError(
new APIStatusError(400, '400 Not supported model mimo-v2.5-pro-ultraspeed'),
),
).toBe(false);
expect(isToolExchangeAdjacencyError(new APIStatusError(500, ANTHROPIC_MISSING_RESULT))).toBe(
false,
);
expect(isToolExchangeAdjacencyError(new Error(ANTHROPIC_MISSING_RESULT))).toBe(false);
expect(isToolExchangeAdjacencyError('boom')).toBe(false);
});
});
describe('isRecoverableRequestStructureError', () => {
it('matches the whole tool_use/tool_result adjacency family', () => {
expect(
isRecoverableRequestStructureError(
new APIStatusError(400, '`tool_use` ids were found without `tool_result` blocks'),
),
).toBe(true);
});
it('matches the OpenAI/Moonshot tool_call_id-not-found 400', () => {
expect(
isRecoverableRequestStructureError(new APIStatusError(400, '400 tool_call_id is not found')),
).toBe(true);
});
it('matches the OpenAI-compatible role-tool / assistant-tool_calls pairing 400s', () => {
expect(
isRecoverableRequestStructureError(
new APIStatusError(
400,
"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'",
),
),
).toBe(true);
expect(
isRecoverableRequestStructureError(
new APIStatusError(
400,
"An assistant message with 'tool_calls' must be followed by tool messages responding to each " +
"'tool_call_id'. The following tool_call_ids did not have response messages: call_hSmZB4G8",
),
),
).toBe(true);
});
it('matches the Anthropic duplicate tool_use id rejection', () => {
expect(
isRecoverableRequestStructureError(
new APIStatusError(400, 'messages: `tool_use` ids must be unique'),
),
).toBe(true);
});
it('matches empty / whitespace-only text content rejections', () => {
expect(
isRecoverableRequestStructureError(
new APIStatusError(400, 'messages: text content blocks must be non-empty'),
),
).toBe(true);
expect(
isRecoverableRequestStructureError(
new APIStatusError(400, 'text content blocks must contain non-whitespace text'),
),
).toBe(true);
});
it('matches first-message-must-be-user and role-alternation rejections', () => {
expect(
isRecoverableRequestStructureError(
new APIStatusError(400, 'messages: first message must use the "user" role'),
),
).toBe(true);
expect(
isRecoverableRequestStructureError(
new APIStatusError(
400,
'messages: roles must alternate between "user" and "assistant", but found multiple "user" roles in a row',
),
),
).toBe(true);
});
it('does not match context overflow, auth, or non-status errors', () => {
expect(
isRecoverableRequestStructureError(new APIContextOverflowError(400, 'context length exceeded')),
).toBe(false);
expect(isRecoverableRequestStructureError(new APIStatusError(401, 'unauthorized'))).toBe(false);
expect(isRecoverableRequestStructureError(new APIStatusError(400, 'Bad request'))).toBe(false);
expect(isRecoverableRequestStructureError(new Error('roles must alternate'))).toBe(false);
});
});
describe('isProviderRateLimitError', () => {
it('matches explicit HTTP 429 status errors', () => {
expect(isProviderRateLimitError(new APIProviderRateLimitError('rate limited'))).toBe(true);
expect(isProviderRateLimitError(new APIStatusError(429, 'rate limited'))).toBe(true);
expect(isProviderRateLimitError({ response: { status: 429 } })).toBe(true);
expect(isProviderRateLimitError({ statusCode: 503, message: 'rate limit' })).toBe(false);
});
it('matches wrapped provider rate-limit messages without status metadata', () => {
expect(
isProviderRateLimitError(
new Error(
'APIStatusError: 429 request id: req-429, request reached user+model max RPM: 50',
),
),
).toBe(true);
expect(
isProviderRateLimitError(
"[provider.api_error] We're receiving too many requests at the moment. Please wait.",
),
).toBe(true);
expect(isProviderRateLimitError(new Error('[provider.rate_limit] slow down'))).toBe(true);
});
it('does not match non-rate-limit provider errors', () => {
expect(isProviderRateLimitError(new APIStatusError(401, 'unauthorized'))).toBe(false);
expect(isProviderRateLimitError('APIStatusError: 401 unauthorized')).toBe(false);
expect(isProviderRateLimitError(new Error('context length exceeded'))).toBe(false);
});
});

View file

@ -0,0 +1,353 @@
/**
* select_tools progressive disclosure kosong-side contract tests.
*
* Covers the three primitives this package contributes:
* - `Message.tools` serialization on the Kimi wire (`messages[].tools`,
* `{type:'function', function:{...}}` wrapping, no `content`, schema
* normalization and the `$` builtin branch shared with top-level tools);
* - `Tool.deferred` stripping in `generate()` (single strip point for every
* provider call the marker itself must never reach the wire);
* - the `select_tools` capability bit (unknown/default-off semantics).
*/
import { UNKNOWN_CAPABILITY, isUnknownCapability } from '#/app/llmProtocol/capability';
import { catalogModelToCapability } from '#/app/llmProtocol/catalog';
import { generate } from '#/app/llmProtocol/generate';
import { isToolDeclarationOnlyMessage } from '#/app/llmProtocol/message';
import type { Message, StreamedMessagePart } from '#/app/llmProtocol/message';
import { AnthropicChatProvider } from '#/app/llmProtocol/providers/anthropic';
import { messagesToGoogleGenAIContents } from '#/app/llmProtocol/providers/google-genai';
import { KimiChatProvider } from '#/app/llmProtocol/providers/kimi';
import { OpenAILegacyChatProvider } from '#/app/llmProtocol/providers/openai-legacy';
import { OpenAIResponsesChatProvider } from '#/app/llmProtocol/providers/openai-responses';
import type { ChatProvider, StreamedMessage, ThinkingEffort } from '#/app/llmProtocol/provider';
import type { Tool } from '#/app/llmProtocol/tool';
import { describe, expect, it, vi } from 'vitest';
const ADD_TOOL: Tool = {
name: 'add',
description: 'Add two integers.',
parameters: {
type: 'object',
properties: {
a: { type: 'integer', description: 'First number' },
b: { type: 'integer', description: 'Second number' },
},
required: ['a', 'b'],
},
};
const BUILTIN_TOOL: Tool = {
name: '$web_search',
description: 'Search the web',
parameters: { type: 'object', properties: {} },
};
function makeChatCompletionResponse() {
return {
id: 'chatcmpl-test123',
object: 'chat.completion',
created: 1234567890,
model: 'kimi-test',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Hello' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
};
}
async function captureRequestBody(
tools: Tool[],
history: Message[],
): Promise<Record<string, unknown>> {
const provider = new KimiChatProvider({
model: 'kimi-test',
apiKey: 'test-key',
stream: false,
});
let capturedBody: Record<string, unknown> | undefined;
(provider as any)._client.chat.completions.create = vi
.fn()
.mockImplementation((params: unknown) => {
capturedBody = params as Record<string, unknown>;
return Promise.resolve(makeChatCompletionResponse());
});
const stream = await provider.generate('system prompt', tools, history);
for await (const part of stream) {
void part;
}
if (capturedBody === undefined) {
throw new Error('Expected provider.generate() to call chat.completions.create');
}
return capturedBody;
}
describe('Kimi messages[].tools serialization', () => {
it('serializes a system message carrying tools with function wrapping and no content', async () => {
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
{ role: 'system', content: [], toolCalls: [], tools: [ADD_TOOL] },
];
const body = await captureRequestBody([], history);
const messages = body['messages'] as Array<Record<string, unknown>>;
// [system prompt, user, system+tools]
expect(messages).toHaveLength(3);
const toolsMessage = messages[2]!;
expect(toolsMessage['role']).toBe('system');
expect('content' in toolsMessage).toBe(false);
expect(toolsMessage['tools']).toEqual([
{
type: 'function',
function: {
name: 'add',
description: 'Add two integers.',
parameters: ADD_TOOL.parameters,
},
},
]);
});
it('routes $-prefixed names through the builtin_function branch, same as top-level tools', async () => {
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
{ role: 'system', content: [], toolCalls: [], tools: [BUILTIN_TOOL] },
];
const body = await captureRequestBody([], history);
const messages = body['messages'] as Array<Record<string, unknown>>;
expect(messages[2]!['tools']).toEqual([
{ type: 'builtin_function', function: { name: '$web_search' } },
]);
});
it('leaves messages without tools untouched (no tools key)', async () => {
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
];
const body = await captureRequestBody([ADD_TOOL], history);
const messages = body['messages'] as Array<Record<string, unknown>>;
for (const message of messages) {
expect('tools' in message).toBe(false);
}
// Top-level tools[] unchanged by the feature.
expect(body['tools']).toEqual([
{
type: 'function',
function: {
name: 'add',
description: 'Add two integers.',
parameters: ADD_TOOL.parameters,
},
},
]);
});
it('does not serialize the deferred marker even if a marked tool reaches convertMessage', async () => {
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
{
role: 'system',
content: [],
toolCalls: [],
tools: [{ ...ADD_TOOL, deferred: true }],
},
];
const body = await captureRequestBody([], history);
const messages = body['messages'] as Array<Record<string, unknown>>;
const serialized = JSON.stringify(messages[2]!['tools']);
expect(serialized).not.toContain('deferred');
});
});
describe('generate() deferred tool stripping', () => {
function createCapturingProvider(): { provider: ChatProvider; seenTools: () => Tool[] } {
let captured: Tool[] = [];
const stream: StreamedMessage = {
id: null,
usage: null,
finishReason: 'completed',
rawFinishReason: 'stop',
async *[Symbol.asyncIterator](): AsyncIterator<StreamedMessagePart> {
yield { type: 'text', text: 'ok' };
},
};
const provider: ChatProvider = {
name: 'mock',
modelName: 'mock-model',
thinkingEffort: null as ThinkingEffort | null,
generate: async (_systemPrompt, tools, _history) => {
captured = tools;
return stream;
},
withThinking(_effort: ThinkingEffort): ChatProvider {
return this;
},
};
return { provider, seenTools: () => captured };
}
it('strips deferred tools before the provider builds the request', async () => {
const { provider, seenTools } = createCapturingProvider();
await generate(provider, 'sys', [ADD_TOOL, { ...BUILTIN_TOOL, deferred: true }], [
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
]);
expect(seenTools()).toEqual([ADD_TOOL]);
});
it('passes the identical array through when nothing is deferred', async () => {
const { provider, seenTools } = createCapturingProvider();
const tools = [ADD_TOOL, BUILTIN_TOOL];
await generate(provider, 'sys', tools, [
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
]);
expect(seenTools()).toBe(tools);
});
});
describe('providers without message-level tool declarations', () => {
const TOOLS_ONLY_MESSAGE: Message = {
role: 'system',
content: [],
toolCalls: [],
tools: [ADD_TOOL],
};
const HISTORY: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
TOOLS_ONLY_MESSAGE,
];
it('classifies tool-declaration-only messages', () => {
expect(isToolDeclarationOnlyMessage(TOOLS_ONLY_MESSAGE)).toBe(true);
expect(isToolDeclarationOnlyMessage(HISTORY[0]!)).toBe(false);
// A message that also carries content is NOT skipped wholesale (only the
// tools field stays off the wire via explicit field construction).
expect(
isToolDeclarationOnlyMessage({
...TOOLS_ONLY_MESSAGE,
content: [{ type: 'text', text: 'x' }],
}),
).toBe(false);
});
it('anthropic skips the message instead of emitting a <system></system> husk', async () => {
const provider = new AnthropicChatProvider({ model: 'k25', apiKey: 'test-key', stream: false });
let captured: Record<string, unknown> | undefined;
(provider as any)._client.messages.create = vi.fn().mockImplementation((params: unknown) => {
captured = params as Record<string, unknown>;
return Promise.resolve({
id: 'msg_test_123',
type: 'message',
role: 'assistant',
model: 'k25',
content: [{ type: 'text', text: 'Hello' }],
stop_reason: 'end_turn',
usage: { input_tokens: 10, output_tokens: 5 },
});
});
const stream = await provider.generate('sys', [], HISTORY);
for await (const part of stream) void part;
expect(JSON.stringify(captured!['messages'])).not.toContain('<system>');
expect(captured!['messages'] as unknown[]).toHaveLength(1);
});
it('openai chat completions skips the message instead of sending a content-free system entry', async () => {
const provider = new OpenAILegacyChatProvider({ model: 'gpt-4.1', apiKey: 'test-key', stream: false });
let captured: Record<string, unknown> | undefined;
(provider as any)._client.chat.completions.create = vi
.fn()
.mockImplementation((params: unknown) => {
captured = params as Record<string, unknown>;
return Promise.resolve({
id: 'chatcmpl-test123',
object: 'chat.completion',
created: 1234567890,
model: 'gpt-4.1',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Hello' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
});
});
const stream = await provider.generate('sys', [], HISTORY);
for await (const part of stream) void part;
const messages = captured!['messages'] as Array<Record<string, unknown>>;
// [system prompt, user] — no content-free leftover entry.
expect(messages).toHaveLength(2);
for (const message of messages) {
expect(message['content']).toBeDefined();
}
});
it('openai responses skips the message', async () => {
const provider = new OpenAIResponsesChatProvider({ model: 'gpt-4.1', apiKey: 'test-key' });
(provider as any)._stream = false;
let captured: Record<string, unknown> | undefined;
((provider as any)._client.responses as Record<string, unknown>)['create'] = vi
.fn()
.mockImplementation((params: unknown) => {
captured = params as Record<string, unknown>;
return Promise.resolve({
id: 'resp_test123',
object: 'response',
created_at: 1234567890,
status: 'completed',
model: 'gpt-4.1',
output: [
{
type: 'message',
id: 'msg_test',
role: 'assistant',
content: [{ type: 'output_text', text: 'Hello', annotations: [] }],
},
],
usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
});
});
const stream = await provider.generate('sys', [], HISTORY);
for await (const part of stream) void part;
// The tools-only message contributes no input item at all.
expect(captured!['input'] as unknown[]).toHaveLength(1);
expect(JSON.stringify(captured!['input'])).not.toContain('"tools"');
});
it('google genai skips the message explicitly (not just via the empty-text coincidence)', () => {
const contents = messagesToGoogleGenAIContents(HISTORY);
expect(contents).toHaveLength(1);
expect(JSON.stringify(contents)).not.toContain('<system>');
});
});
describe('select_tools capability bit', () => {
it('defaults to false on UNKNOWN_CAPABILITY', () => {
expect(UNKNOWN_CAPABILITY.select_tools).toBe(false);
});
it('a capability that only has select_tools is not "unknown"', () => {
expect(
isUnknownCapability({
image_in: false,
video_in: false,
audio_in: false,
thinking: false,
tool_use: false,
max_context_tokens: 0,
select_tools: true,
}),
).toBe(false);
});
it('catalog entries map select_tools and default it to false', () => {
const base = { id: 'm', limit: { context: 1000 } };
expect(catalogModelToCapability(base)?.capability.select_tools).toBe(false);
expect(
catalogModelToCapability({ ...base, select_tools: true })?.capability.select_tools,
).toBe(true);
});
});

View file

@ -0,0 +1,179 @@
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { IAgentContextProjectorService } from '#/agent/contextProjector';
import { AgentLLMRequesterService } from '#/agent/llmRequester/llmRequesterService';
import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester';
import { IAgentContextSizeService } from '#/agent/contextSize';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IAgentUsageService } from '#/agent/usage';
import { IConfigService } from '#/app/config';
import { APIStatusError, emptyUsage, type Message, type ModelCapability } from '#/app/llmProtocol';
import type { Model } from '#/app/model';
import { ITelemetryService } from '#/app/telemetry';
import { ILogService } from '#/_base/log';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const capabilities: ModelCapability = {
image_in: false,
video_in: false,
audio_in: false,
thinking: false,
tool_use: false,
max_context_tokens: 1000,
};
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'hello' }], toolCalls: [] },
];
function createModel(calls: { value: number }): Model {
const build = (): Model => ({
id: 'm',
name: 'wire-model',
aliases: [],
protocol: 'anthropic',
baseUrl: 'https://example.test',
headers: {},
capabilities,
maxContextSize: 1000,
thinkingEffort: null,
alwaysThinking: false,
providerName: 'p',
authProvider: { getAuth: async () => undefined },
withThinking: () => build(),
withMaxCompletionTokens: () => build(),
withGenerationKwargs: () => build(),
withProviderOptions: () => build(),
withThinkingKeep: () => build(),
request: async function* () {
calls.value += 1;
if (calls.value === 1) {
throw new APIStatusError(400, 'messages: `tool_use` ids must be unique');
}
yield {
type: 'finish',
message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }], toolCalls: [] },
providerFinishReason: 'completed',
rawFinishReason: 'stop',
id: 'resp-1',
};
},
});
return build();
}
let disposables: DisposableStore;
beforeEach(() => {
disposables = new DisposableStore();
});
afterEach(() => disposables.dispose());
function createService(
model: Model,
projector: Pick<IAgentContextProjectorService, 'project' | 'projectStrict'>,
) {
const ix = disposables.add(new TestInstantiationService());
const profile: Partial<IAgentProfileService> = {
resolveModelContext: () => ({
modelAlias: 'm',
modelCapabilities: capabilities,
maxOutputSize: undefined,
alwaysThinking: undefined,
thinkingLevel: 'off',
reservedContextSize: undefined,
compactionTriggerRatio: undefined,
}),
getProvider: () => model,
getSystemPrompt: () => 'system',
data: () => ({
cwd: '',
modelAlias: 'm',
modelCapabilities: capabilities,
thinkingLevel: 'off',
systemPrompt: 'system',
}),
isToolActive: () => true,
};
const contextSize = {
get: () => ({ size: 0, measured: 0, estimated: 0 }),
measured: () => undefined,
};
const usage = { record: () => undefined };
const context = { get: () => history };
const tools = { list: () => [] };
const config: Partial<IConfigService> = {
get: (() => undefined) as IConfigService['get'],
};
const log = { info: () => undefined, warn: () => undefined };
const telemetry = { track: () => undefined };
ix.stub(IAgentContextMemoryService, context);
ix.stub(IAgentContextProjectorService, projector);
ix.stub(IAgentContextSizeService, contextSize);
ix.stub(IAgentToolRegistryService, tools);
ix.stub(IAgentProfileService, profile);
ix.stub(IAgentUsageService, usage);
ix.stub(IConfigService, config);
ix.stub(ILogService, log);
ix.stub(ITelemetryService, telemetry);
ix.set(IAgentLLMRequesterService, new SyncDescriptor(AgentLLMRequesterService));
return ix.get(IAgentLLMRequesterService);
}
describe('AgentLLMRequesterService strict resend', () => {
it('resends once with strict projection after a recoverable structural 400', async () => {
const calls = { value: 0 };
let projectCalls = 0;
let strictCalls = 0;
const service = createService(createModel(calls), {
project: (messages: readonly ContextMessage[]) => {
projectCalls += 1;
return messages;
},
projectStrict: (messages: readonly ContextMessage[]) => {
strictCalls += 1;
return messages;
},
});
const result = await service.request({ retry: { maxAttempts: 1 } });
expect(result.message.content).toEqual([{ type: 'text', text: 'ok' }]);
expect(result.usage).toEqual(emptyUsage());
expect(calls.value).toBe(2);
expect(projectCalls).toBe(1);
expect(strictCalls).toBe(1);
});
it('does not resend for non-recoverable errors', async () => {
const model = createModel({ value: 0 });
Object.defineProperty(model, 'request', {
value: async function* () {
throw new APIStatusError(401, 'unauthorized');
},
});
Object.defineProperty(model, 'withMaxCompletionTokens', {
value: () => model,
});
let strictCalls = 0;
const service = createService(model, {
project: (messages: readonly ContextMessage[]) => messages,
projectStrict: (messages: readonly ContextMessage[]) => {
strictCalls += 1;
return messages;
},
});
await expect(service.request({ retry: { maxAttempts: 1 } })).rejects.toMatchObject({
statusCode: 401,
});
expect(strictCalls).toBe(0);
});
});

View file

@ -127,6 +127,18 @@ describe('ModelResolverService', () => {
expect(auth).toEqual({ apiKey: 'sk-model' });
});
it('forwards declared select_tools capability to the resolved model', () => {
providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk-test' };
models['m'] = {
provider: 'p',
model: 'wire-name',
maxContextSize: 1000,
capabilities: ['select_tools'],
};
expect(ix.get(IModelResolver).resolve('m').capabilities.select_tools).toBe(true);
});
it('returns an OAuth access token as ProviderRequestAuth.apiKey', async () => {
providers['p'] = {
type: 'kimi',
@ -600,6 +612,7 @@ describe('ModelResolverService', () => {
thinking: true,
tool_use: false,
max_context_tokens: 1000,
select_tools: false,
});
});
@ -614,6 +627,7 @@ describe('ModelResolverService', () => {
thinking: false,
tool_use: true,
max_context_tokens: 128000,
select_tools: false,
});
});
});

View file

@ -139,6 +139,7 @@ function createRecordingModel(
thinkingEfforts: ThinkingEffort[],
providerOptions: unknown[] = [],
protocol: Model['protocol'] = 'kimi',
thinkingKeeps: string[] = [],
): Model {
const build = (thinkingEffort: ThinkingEffort | null): Model => ({
id: 'kimi-code',
@ -173,6 +174,10 @@ function createRecordingModel(
providerOptions.push(options);
return build(thinkingEffort);
},
withThinkingKeep: (keep) => {
thinkingKeeps.push(keep);
return build(thinkingEffort);
},
request: async function* () {
return;
},
@ -294,6 +299,126 @@ describe('AgentProfileService (wire-backed config.update)', () => {
]);
});
it('forces configured Kimi thinking effort outside declared support_efforts', () => {
const generationKwargs: GenerationKwargs[] = [];
const thinkingEfforts: ThinkingEffort[] = [];
modelResolver = {
_serviceBrand: undefined,
resolve: () => createRecordingModel(generationKwargs, thinkingEfforts),
findByName: () => [],
};
const host = buildHost('profile-thinking-effort-force');
host.svc.configure({ emitStatusUpdated: () => undefined });
configValues['thinking'] = { effort: ' max ' };
host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'on' });
const model = host.svc.resolveModel();
expect(model?.thinkingEffort).toBe('max');
expect(thinkingEfforts).toEqual(['max']);
expect(generationKwargs).toEqual([
{ prompt_cache_key: 'session-test' },
{ extra_body: { thinking: { type: 'enabled', effort: 'max', keep: 'all' } } },
]);
});
it('applies thinking.keep model override on the Anthropic path', () => {
const generationKwargs: GenerationKwargs[] = [];
const thinkingEfforts: ThinkingEffort[] = [];
const providerOptions: unknown[] = [];
const thinkingKeeps: string[] = [];
modelResolver = {
_serviceBrand: undefined,
resolve: () =>
createRecordingModel(
generationKwargs,
thinkingEfforts,
providerOptions,
'anthropic',
thinkingKeeps,
),
findByName: () => [],
};
const host = buildHost('profile-thinking-keep-anthropic');
host.svc.configure({ emitStatusUpdated: () => undefined });
configValues['modelOverrides'] = { temperature: 0.3, thinkingKeep: 'all' };
host.svc.update({ modelAlias: 'claude-code', thinkingLevel: 'high' });
const model = host.svc.resolveModel();
expect(model?.thinkingEffort).toBe('high');
expect(thinkingEfforts).toEqual(['high']);
expect(thinkingKeeps).toEqual(['all']);
expect(providerOptions).toEqual([{ metadata: { user_id: 'session-test' } }]);
expect(generationKwargs).toEqual([{ temperature: 0.3 }]);
});
it('defaults thinking.keep to "all" when thinking is enabled on Kimi', () => {
const generationKwargs: GenerationKwargs[] = [];
const thinkingEfforts: ThinkingEffort[] = [];
modelResolver = {
_serviceBrand: undefined,
resolve: () => createRecordingModel(generationKwargs, thinkingEfforts),
findByName: () => [],
};
const host = buildHost('profile-thinking-keep-default');
host.svc.configure({ emitStatusUpdated: () => undefined });
host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' });
host.svc.resolveModel();
expect(generationKwargs).toEqual([
{ prompt_cache_key: 'session-test', extra_body: { thinking: { keep: 'all' } } },
]);
});
it('treats an off env thinking.keep override as disabled on Kimi', () => {
const generationKwargs: GenerationKwargs[] = [];
const thinkingEfforts: ThinkingEffort[] = [];
modelResolver = {
_serviceBrand: undefined,
resolve: () => createRecordingModel(generationKwargs, thinkingEfforts),
findByName: () => [],
};
const host = buildHost('profile-thinking-keep-env-off');
host.svc.configure({ emitStatusUpdated: () => undefined });
configValues['modelOverrides'] = { thinkingKeep: 'off' };
host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' });
host.svc.resolveModel();
expect(generationKwargs).toEqual([{ prompt_cache_key: 'session-test' }]);
});
it('applies config thinking.keep on the Anthropic path', () => {
const generationKwargs: GenerationKwargs[] = [];
const thinkingEfforts: ThinkingEffort[] = [];
const providerOptions: unknown[] = [];
const thinkingKeeps: string[] = [];
modelResolver = {
_serviceBrand: undefined,
resolve: () =>
createRecordingModel(
generationKwargs,
thinkingEfforts,
providerOptions,
'anthropic',
thinkingKeeps,
),
findByName: () => [],
};
const host = buildHost('profile-thinking-keep-anthropic-config');
host.svc.configure({ emitStatusUpdated: () => undefined });
configValues['thinking'] = { keep: 'config-keep' };
host.svc.update({ modelAlias: 'claude-code', thinkingLevel: 'high' });
const model = host.svc.resolveModel();
expect(model?.thinkingEffort).toBe('high');
expect(thinkingKeeps).toEqual(['config-keep']);
expect(generationKwargs).toEqual([]);
});
it('does not apply thinking.keep model override when thinking is off', () => {
const generationKwargs: GenerationKwargs[] = [];
const thinkingEfforts: ThinkingEffort[] = [];
@ -328,7 +453,9 @@ describe('AgentProfileService (wire-backed config.update)', () => {
host.svc.resolveModel();
expect(thinkingEfforts).toEqual(['high']);
expect(generationKwargs).toEqual([{ prompt_cache_key: 'session-test' }]);
expect(generationKwargs).toEqual([
{ prompt_cache_key: 'session-test', extra_body: { thinking: { keep: 'all' } } },
]);
});
it('does not apply the Kimi prompt cache hint to other protocols', () => {

View file

@ -94,4 +94,15 @@ describe('ProtocolAdapterRegistry', () => {
expect(Reflect.get(provider, '_project')).toBe('my-project');
expect(Reflect.get(provider, '_location')).toBe('us-central1');
});
it('maps baseUrl into Google GenAI provider config', () => {
const provider = new ProtocolAdapterRegistry().createChatProvider({
protocol: 'google-genai',
baseUrl: 'https://generativelanguage.example.com',
modelName: 'gemini-1.5-pro',
apiKey: 'test-key',
});
expect(Reflect.get(provider, '_baseUrl')).toBe('https://generativelanguage.example.com');
});
});