mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
Merge remote-tracking branch 'origin/kimi-code-v2' into feat/de-barrel-agent-core-v2
This commit is contained in:
commit
957c493136
32 changed files with 1747 additions and 198 deletions
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,4 +12,5 @@ export interface Tool {
|
|||
description: string;
|
||||
/** JSON Schema describing the tool's parameters. */
|
||||
parameters: Record<string, unknown>;
|
||||
deferred?: true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
457
packages/agent-core-v2/test/llmProtocol/errors.test.ts
Normal file
457
packages/agent-core-v2/test/llmProtocol/errors.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
353
packages/agent-core-v2/test/llmProtocol/select-tools.test.ts
Normal file
353
packages/agent-core-v2/test/llmProtocol/select-tools.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
179
packages/agent-core-v2/test/llmRequester/strict-resend.test.ts
Normal file
179
packages/agent-core-v2/test/llmRequester/strict-resend.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue