mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
fix(gemini): fix Gemini tool calling and thought-signature round-trip (#1389)
- send tool declarations, system prompt, and sampling/thinking settings in the camelCase shape the Google SDK forwards so tool calls reach the model - thread tool-call extras (thought signatures) through the loop tool-call event into context so Gemini 3 can resume a tool turn - update and add tests for the corrected request shape and signature round-trip
This commit is contained in:
parent
be7c9916b0
commit
ebdffc7df7
8 changed files with 201 additions and 113 deletions
5
.changeset/fix-gemini-tool-calling.md
Normal file
5
.changeset/fix-gemini-tool-calling.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Fix tool calling with Google Gemini models, including Gemini 3 thinking-signature round-trips across turns.
|
||||
|
|
@ -620,6 +620,7 @@ export class ContextMemory {
|
|||
id: event.toolCallId,
|
||||
name: event.name,
|
||||
arguments: event.args === undefined ? null : JSON.stringify(event.args),
|
||||
extras: event.extras,
|
||||
});
|
||||
this.pendingToolResultIds.add(event.toolCallId);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export interface LoopToolCallEvent {
|
|||
readonly args: unknown;
|
||||
readonly description?: string | undefined;
|
||||
readonly display?: ToolInputDisplay | undefined;
|
||||
readonly extras?: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
export interface LoopToolResultEvent {
|
||||
|
|
|
|||
|
|
@ -713,5 +713,6 @@ async function dispatchToolCall(
|
|||
args,
|
||||
description: displayFields?.description,
|
||||
display: displayFields?.display,
|
||||
extras: toolCall.extras,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,55 @@ describe('Agent context', () => {
|
|||
expect(ctx.agent.context.messages.some((message) => 'origin' in message)).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves tool call extras (Gemini thought_signature) through to projection', () => {
|
||||
// Regression: Gemini 3 requires the thought_signature returned on a
|
||||
// functionCall to be echoed back when the call is re-sent in the next turn.
|
||||
// The signature travels as ToolCall.extras.thought_signature_b64; it must
|
||||
// survive the loop tool.call event -> context recording -> projection so
|
||||
// the provider can put it back on the outbound functionCall part.
|
||||
const ctx = testAgent();
|
||||
ctx.configure();
|
||||
|
||||
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'run' }]);
|
||||
ctx.dispatch({
|
||||
type: 'context.append_loop_event',
|
||||
event: { type: 'step.begin', uuid: 'sig-step', turnId: '', step: 1 },
|
||||
});
|
||||
ctx.dispatch({
|
||||
type: 'context.append_loop_event',
|
||||
event: {
|
||||
type: 'tool.call',
|
||||
uuid: 'sig-tool',
|
||||
turnId: '',
|
||||
step: 1,
|
||||
stepUuid: 'sig-step',
|
||||
toolCallId: 'call_sig',
|
||||
name: 'Bash',
|
||||
args: { command: 'echo hi' },
|
||||
extras: { thought_signature_b64: 'c2lnbmF0dXJl' },
|
||||
},
|
||||
});
|
||||
ctx.dispatch({
|
||||
type: 'context.append_loop_event',
|
||||
event: { type: 'step.end', uuid: 'sig-step', turnId: '', step: 1 },
|
||||
});
|
||||
ctx.dispatch({
|
||||
type: 'context.append_loop_event',
|
||||
event: {
|
||||
type: 'tool.result',
|
||||
parentUuid: 'sig-tool',
|
||||
toolCallId: 'call_sig',
|
||||
result: { output: 'hi' },
|
||||
},
|
||||
});
|
||||
|
||||
const expectedExtras = { thought_signature_b64: 'c2lnbmF0dXJl' };
|
||||
const recorded = ctx.agent.context.history.find((m) => m.role === 'assistant');
|
||||
expect(recorded?.toolCalls[0]?.extras).toEqual(expectedExtras);
|
||||
const projected = ctx.agent.context.messages.find((m) => m.role === 'assistant');
|
||||
expect(projected?.toolCalls[0]?.extras).toEqual(expectedExtras);
|
||||
});
|
||||
|
||||
it('reroutes an inline image-compression caption into a hidden system reminder', () => {
|
||||
const ctx = testAgent();
|
||||
ctx.configure();
|
||||
|
|
|
|||
|
|
@ -92,36 +92,36 @@ export interface GoogleGenAIOptions {
|
|||
}
|
||||
|
||||
export interface GoogleGenAIGenerationKwargs {
|
||||
max_output_tokens?: number | undefined;
|
||||
maxOutputTokens?: number | undefined;
|
||||
temperature?: number | undefined;
|
||||
top_k?: number | undefined;
|
||||
top_p?: number | undefined;
|
||||
thinking_config?: ThinkingConfig | undefined;
|
||||
topK?: number | undefined;
|
||||
topP?: number | undefined;
|
||||
thinkingConfig?: ThinkingConfig | undefined;
|
||||
[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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -133,13 +133,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;
|
||||
}
|
||||
|
||||
|
|
@ -274,15 +274,15 @@ function messageToGoogleGenAI(message: Message): GoogleContent {
|
|||
}
|
||||
|
||||
const functionCallPart: GooglePart = {
|
||||
function_call: {
|
||||
functionCall: {
|
||||
name: toolCall.name,
|
||||
args,
|
||||
},
|
||||
};
|
||||
|
||||
// Restore thought_signature if available
|
||||
// Restore thoughtSignature 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);
|
||||
|
|
@ -335,7 +335,7 @@ function toolMessageToFunctionResponseParts(
|
|||
}
|
||||
|
||||
const functionResponsePart: GooglePart = {
|
||||
function_response: {
|
||||
functionResponse: {
|
||||
name: toolCallIdToName(message.toolCallId, toolNameById),
|
||||
response: { output: textOutput },
|
||||
parts: [],
|
||||
|
|
@ -361,7 +361,7 @@ export function messagesToGoogleGenAIContents(messages: Message[]): GoogleConten
|
|||
// 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
|
||||
// `systemInstruction` separately; only historical system messages
|
||||
// come through here.
|
||||
const text = message.content
|
||||
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
|
||||
|
|
@ -463,7 +463,7 @@ export function messagesToGoogleGenAIContents(messages: Message[]): GoogleConten
|
|||
isUser: (content) => content.role === 'user',
|
||||
isToolResultOnly: (content) =>
|
||||
content.parts.length > 0 &&
|
||||
content.parts.every((part) => part.function_response !== undefined),
|
||||
content.parts.every((part) => part.functionResponse !== undefined),
|
||||
merge: (last, next) => ({ ...last, parts: [...last.parts, ...next.parts] }),
|
||||
});
|
||||
}
|
||||
|
|
@ -746,16 +746,16 @@ 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) {
|
||||
// For gemini-3 models that use thinkingLevel
|
||||
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':
|
||||
|
|
@ -767,11 +767,11 @@ 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';
|
||||
// For other models that use thinkingBudget
|
||||
if (thinkingConfig.thinkingBudget !== undefined) {
|
||||
if (thinkingConfig.thinkingBudget === 0) return 'off';
|
||||
if (thinkingConfig.thinkingBudget <= 1024) return 'low';
|
||||
if (thinkingConfig.thinkingBudget <= 4096) return 'medium';
|
||||
return 'high';
|
||||
}
|
||||
|
||||
|
|
@ -801,7 +801,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)) } : {}),
|
||||
};
|
||||
|
||||
|
|
@ -866,53 +866,53 @@ 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
|
||||
// Gemini 3 models use thinkingLevel (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 {
|
||||
|
|
@ -922,7 +922,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 {
|
||||
|
|
|
|||
|
|
@ -62,11 +62,42 @@ describe('e2e: Google GenAI adapter bridge', () => {
|
|||
{ role: 'user', parts: [{ text: 'Add and multiply these numbers.' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'I will calculate both.' }, {}, {}],
|
||||
parts: [
|
||||
{ text: 'I will calculate both.' },
|
||||
{ functionCall: { name: 'add', args: { a: 2, b: 3 } } },
|
||||
{ functionCall: { name: 'multiply', args: { a: 4, b: 5 } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{}, {}],
|
||||
parts: [
|
||||
{ functionResponse: { name: 'add', response: { output: '5' }, parts: [] } },
|
||||
{
|
||||
functionResponse: { name: 'multiply', response: { output: '20' }, parts: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
// Regression: the snake_case `system_instruction` / tool declarations used
|
||||
// to be silently dropped by the @google/genai SDK, so the model saw neither
|
||||
// a system prompt nor any tools. Both must now reach the wire as camelCase.
|
||||
expect(body['systemInstruction']).toEqual({
|
||||
parts: [{ text: 'You are a calculator.' }],
|
||||
role: 'user',
|
||||
});
|
||||
expect(body['tools']).toEqual([
|
||||
{
|
||||
functionDeclarations: [
|
||||
expect.objectContaining({ name: 'add', parametersJsonSchema: expect.any(Object) }),
|
||||
],
|
||||
},
|
||||
{
|
||||
functionDeclarations: [
|
||||
expect.objectContaining({
|
||||
name: 'multiply',
|
||||
parametersJsonSchema: expect.any(Object),
|
||||
}),
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
|
||||
expect(body['contents']).toEqual([{ parts: [{ text: 'Hello!' }], role: 'user' }]);
|
||||
const config = body['config'] as Record<string, unknown>;
|
||||
expect(config['system_instruction']).toBe('You are helpful.');
|
||||
expect(config['systemInstruction']).toBe('You are helpful.');
|
||||
});
|
||||
|
||||
it('system messages in history are wrapped and emitted as user content', () => {
|
||||
|
|
@ -253,11 +253,11 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
|
||||
expect(contents.map((c) => c.role)).toEqual(['user', 'model', 'user']);
|
||||
const last = contents.at(-1)!;
|
||||
expect(last.parts.some((p) => p.function_response !== undefined)).toBe(true);
|
||||
expect(last.parts.some((p) => p.functionResponse !== undefined)).toBe(true);
|
||||
expect(last.parts.some((p) => p.text === 'Now multiply')).toBe(true);
|
||||
});
|
||||
|
||||
it('multi-turn conversation with system prompt sets system_instruction', async () => {
|
||||
it('multi-turn conversation with system prompt sets systemInstruction', async () => {
|
||||
const provider = createProvider();
|
||||
const history: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'What is 2+2?' }], toolCalls: [] },
|
||||
|
|
@ -273,10 +273,10 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
]);
|
||||
|
||||
const config = body['config'] as Record<string, unknown>;
|
||||
expect(config['system_instruction']).toBe('You are a math tutor.');
|
||||
expect(config['systemInstruction']).toBe('You are a math tutor.');
|
||||
});
|
||||
|
||||
it('tool definitions use parameters_json_schema', async () => {
|
||||
it('tool definitions use parametersJsonSchema', async () => {
|
||||
const provider = createProvider();
|
||||
const history: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Add 2 and 3' }], toolCalls: [] },
|
||||
|
|
@ -286,11 +286,11 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
const config = body['config'] as Record<string, unknown>;
|
||||
expect(config['tools']).toEqual([
|
||||
{
|
||||
function_declarations: [
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'add',
|
||||
description: 'Add two integers.',
|
||||
parameters_json_schema: {
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'integer', description: 'First number' },
|
||||
|
|
@ -302,11 +302,11 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
],
|
||||
},
|
||||
{
|
||||
function_declarations: [
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'multiply',
|
||||
description: 'Multiply two integers.',
|
||||
parameters_json_schema: {
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'integer', description: 'First number' },
|
||||
|
|
@ -348,14 +348,14 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
{
|
||||
parts: [
|
||||
{ text: "I'll add those numbers for you." },
|
||||
{ function_call: { name: 'add', args: { a: 2, b: 3 } } },
|
||||
{ functionCall: { name: 'add', args: { a: 2, b: 3 } } },
|
||||
],
|
||||
role: 'model',
|
||||
},
|
||||
{
|
||||
parts: [
|
||||
{
|
||||
function_response: {
|
||||
functionResponse: {
|
||||
name: 'add',
|
||||
response: { output: '5' },
|
||||
parts: [],
|
||||
|
|
@ -367,11 +367,11 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('tool call with thought_signature_b64 emits thoughtSignature on outbound function_call', async () => {
|
||||
it('tool call with thought_signature_b64 emits thoughtSignature on outbound functionCall', async () => {
|
||||
// Round-trip: a previous turn returned a tool call with thoughtSignature
|
||||
// (decoded into ToolCall.extras.thought_signature_b64). When we send
|
||||
// the assistant message back, the converter must put the original
|
||||
// signature back into the function_call part so Gemini can resume the
|
||||
// signature back into the functionCall part so Gemini can resume the
|
||||
// reasoning chain.
|
||||
const provider = createProvider();
|
||||
const history: Message[] = [
|
||||
|
|
@ -394,15 +394,15 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
const contents = body['contents'] as Array<{ parts: unknown[]; role: string }>;
|
||||
const assistantParts = contents.find((c) => c.role === 'model')!.parts;
|
||||
const fnCallPart = assistantParts.find(
|
||||
(p) => (p as Record<string, unknown>)['function_call'] !== undefined,
|
||||
) as { function_call: Record<string, unknown>; thought_signature?: unknown } | undefined;
|
||||
(p) => (p as Record<string, unknown>)['functionCall'] !== undefined,
|
||||
) as { functionCall: Record<string, unknown>; thoughtSignature?: unknown } | undefined;
|
||||
expect(fnCallPart).toMatchObject({
|
||||
function_call: { name: 'add', args: { a: 2, b: 3 } },
|
||||
thought_signature: 'dGhvdWdodF9zaWduYXR1cmVfZGF0YQ==',
|
||||
functionCall: { name: 'add', args: { a: 2, b: 3 } },
|
||||
thoughtSignature: 'dGhvdWdodF9zaWduYXR1cmVfZGF0YQ==',
|
||||
});
|
||||
});
|
||||
|
||||
it('tool message with image_url result yields function_response + inline data part', () => {
|
||||
it('tool message with image_url result yields functionResponse + inline data part', () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
|
|
@ -437,11 +437,11 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
expect(userContent.role).toBe('user');
|
||||
expect(userContent.parts.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const fnResp = userContent.parts.find((p) => 'function_response' in p) as
|
||||
| { function_response: { name: string; response: { output: string } } }
|
||||
const fnResp = userContent.parts.find((p) => 'functionResponse' in p) as
|
||||
| { functionResponse: { name: string; response: { output: string } } }
|
||||
| undefined;
|
||||
expect(fnResp).toMatchObject({
|
||||
function_response: {
|
||||
functionResponse: {
|
||||
name: 'fetch_image',
|
||||
response: { output: 'Found image:' },
|
||||
},
|
||||
|
|
@ -488,14 +488,14 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
parts: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(userContent.role).toBe('user');
|
||||
// function_response + audio + video
|
||||
// functionResponse + audio + video
|
||||
expect(userContent.parts).toHaveLength(3);
|
||||
|
||||
const fnResp = userContent.parts.find((p) => 'function_response' in p) as
|
||||
| { function_response: { response: { output: string } } }
|
||||
const fnResp = userContent.parts.find((p) => 'functionResponse' in p) as
|
||||
| { functionResponse: { response: { output: string } } }
|
||||
| undefined;
|
||||
expect(fnResp).toMatchObject({
|
||||
function_response: { response: { output: 'Got audio and video:' } },
|
||||
functionResponse: { response: { output: 'Got audio and video:' } },
|
||||
});
|
||||
|
||||
const fileDataParts = userContent.parts.filter((p) => 'fileData' in p) as Array<{
|
||||
|
|
@ -581,7 +581,7 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
const body = await captureRequestBody(provider, '', [ADD_TOOL, MUL_TOOL], history);
|
||||
|
||||
// Snapshot of the expected wire format:
|
||||
// - exactly 3 contents in order (user, model with 2 function_calls, user with 2 function_responses bundled)
|
||||
// - exactly 3 contents in order (user, model with 2 functionCalls, user with 2 functionResponses bundled)
|
||||
// - both tool results are N:1 packed into ONE user Content
|
||||
// - text parts are concatenated into `response.output` (system-reminder + result)
|
||||
// - functionCall / functionResponse never include an `id` field
|
||||
|
|
@ -590,15 +590,15 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
{
|
||||
parts: [
|
||||
{ text: "I'll calculate both." },
|
||||
{ function_call: { name: 'add', args: { a: 2, b: 3 } } },
|
||||
{ function_call: { name: 'multiply', args: { a: 4, b: 5 } } },
|
||||
{ functionCall: { name: 'add', args: { a: 2, b: 3 } } },
|
||||
{ functionCall: { name: 'multiply', args: { a: 4, b: 5 } } },
|
||||
],
|
||||
role: 'model',
|
||||
},
|
||||
{
|
||||
parts: [
|
||||
{
|
||||
function_response: {
|
||||
functionResponse: {
|
||||
name: 'add',
|
||||
response: {
|
||||
output: '<system-reminder>This is a system reminder</system-reminder>5',
|
||||
|
|
@ -607,7 +607,7 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
},
|
||||
},
|
||||
{
|
||||
function_response: {
|
||||
functionResponse: {
|
||||
name: 'multiply',
|
||||
response: {
|
||||
output: '<system-reminder>This is a system reminder</system-reminder>20',
|
||||
|
|
@ -632,15 +632,15 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
|
||||
expect(body['contents']).toEqual([{ parts: [{ text: 'Hello!' }], role: 'user' }]);
|
||||
const config = body['config'] as Record<string, unknown>;
|
||||
expect(config['system_instruction']).toBe('You are helpful.');
|
||||
expect(config['systemInstruction']).toBe('You are helpful.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generation kwargs', () => {
|
||||
it('applies temperature and max_output_tokens', async () => {
|
||||
it('applies temperature and maxOutputTokens', async () => {
|
||||
const provider = createProvider().withGenerationKwargs({
|
||||
temperature: 0.7,
|
||||
max_output_tokens: 2048,
|
||||
maxOutputTokens: 2048,
|
||||
});
|
||||
const history: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] },
|
||||
|
|
@ -649,10 +649,10 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
|
||||
const config = body['config'] as Record<string, unknown>;
|
||||
expect(config['temperature']).toBe(0.7);
|
||||
expect(config['max_output_tokens']).toBe(2048);
|
||||
expect(config['maxOutputTokens']).toBe(2048);
|
||||
});
|
||||
|
||||
it('withMaxCompletionTokens sets max_output_tokens on the cloned provider', async () => {
|
||||
it('withMaxCompletionTokens sets maxOutputTokens on the cloned provider', async () => {
|
||||
const original = createProvider();
|
||||
const provider = original.withMaxCompletionTokens(1024);
|
||||
const history: Message[] = [
|
||||
|
|
@ -662,7 +662,7 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
|
||||
const config = body['config'] as Record<string, unknown>;
|
||||
expect(provider).not.toBe(original);
|
||||
expect(config['max_output_tokens']).toBe(1024);
|
||||
expect(config['maxOutputTokens']).toBe(1024);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -678,7 +678,7 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
const contents = messagesToGoogleGenAIContents(history);
|
||||
for (const content of contents) {
|
||||
for (const part of content.parts) {
|
||||
if (part.function_response) return part.function_response.name;
|
||||
if (part.functionResponse) return part.functionResponse.name;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
|
|
@ -733,8 +733,8 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('no id in function_call or function_response', () => {
|
||||
it('does not include id in function_call or function_response parts', () => {
|
||||
describe('no id in functionCall or functionResponse', () => {
|
||||
it('does not include id in functionCall or functionResponse parts', () => {
|
||||
const history: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Add 2 and 3' }], toolCalls: [] },
|
||||
{
|
||||
|
|
@ -760,11 +760,11 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
|
||||
for (const content of contents) {
|
||||
for (const part of content.parts) {
|
||||
if (part.function_call) {
|
||||
expect(part.function_call).not.toHaveProperty('id');
|
||||
if (part.functionCall) {
|
||||
expect(part.functionCall).not.toHaveProperty('id');
|
||||
}
|
||||
if (part.function_response) {
|
||||
expect(part.function_response).not.toHaveProperty('id');
|
||||
if (part.functionResponse) {
|
||||
expect(part.functionResponse).not.toHaveProperty('id');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -772,7 +772,7 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
});
|
||||
|
||||
describe('with thinking', () => {
|
||||
it('non-gemini-3 model uses thinking_budget', async () => {
|
||||
it('non-gemini-3 model uses thinkingBudget', async () => {
|
||||
const provider = createProvider({ model: 'gemini-2.5-flash' }).withThinking('high');
|
||||
const history: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] },
|
||||
|
|
@ -780,13 +780,13 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
const body = await captureRequestBody(provider, '', [], history);
|
||||
|
||||
const config = body['config'] as Record<string, unknown>;
|
||||
expect(config['thinking_config']).toEqual({
|
||||
include_thoughts: true,
|
||||
thinking_budget: 32_000,
|
||||
expect(config['thinkingConfig']).toEqual({
|
||||
includeThoughts: true,
|
||||
thinkingBudget: 32_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('gemini-3 model uses thinking_level', async () => {
|
||||
it('gemini-3 model uses thinkingLevel', async () => {
|
||||
const provider = createProvider({ model: 'gemini-3-pro-preview' }).withThinking('high');
|
||||
const history: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] },
|
||||
|
|
@ -794,9 +794,9 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
const body = await captureRequestBody(provider, '', [], history);
|
||||
|
||||
const config = body['config'] as Record<string, unknown>;
|
||||
expect(config['thinking_config']).toEqual({
|
||||
include_thoughts: true,
|
||||
thinking_level: 'HIGH',
|
||||
expect(config['thinkingConfig']).toEqual({
|
||||
includeThoughts: true,
|
||||
thinkingLevel: 'HIGH',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -808,9 +808,9 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
const body = await captureRequestBody(provider, '', [], history);
|
||||
|
||||
const config = body['config'] as Record<string, unknown>;
|
||||
expect(config['thinking_config']).toEqual({
|
||||
include_thoughts: false,
|
||||
thinking_budget: 0,
|
||||
expect(config['thinkingConfig']).toEqual({
|
||||
includeThoughts: false,
|
||||
thinkingBudget: 0,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -824,7 +824,7 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
];
|
||||
const body = await captureRequestBody(provider, '', [], history);
|
||||
const config = body['config'] as Record<string, unknown>;
|
||||
return config['thinking_config'] as Record<string, unknown> | undefined;
|
||||
return config['thinkingConfig'] as Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
it('off minimizes thinking and hides thoughts (not just default config)', async () => {
|
||||
|
|
@ -832,32 +832,32 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
// Gemini 3 cannot be fully disabled, but we should request the lowest
|
||||
// available level (MINIMAL) and suppress thought output.
|
||||
expect(thinkingConfig).toEqual({
|
||||
include_thoughts: false,
|
||||
thinking_level: 'MINIMAL',
|
||||
includeThoughts: false,
|
||||
thinkingLevel: 'MINIMAL',
|
||||
});
|
||||
});
|
||||
|
||||
it('low maps to LOW', async () => {
|
||||
const thinkingConfig = await captureThinkingConfig('low');
|
||||
expect(thinkingConfig).toEqual({
|
||||
include_thoughts: true,
|
||||
thinking_level: 'LOW',
|
||||
includeThoughts: true,
|
||||
thinkingLevel: 'LOW',
|
||||
});
|
||||
});
|
||||
|
||||
it('medium maps to MEDIUM (not HIGH)', async () => {
|
||||
const thinkingConfig = await captureThinkingConfig('medium');
|
||||
expect(thinkingConfig).toEqual({
|
||||
include_thoughts: true,
|
||||
thinking_level: 'MEDIUM',
|
||||
includeThoughts: true,
|
||||
thinkingLevel: 'MEDIUM',
|
||||
});
|
||||
});
|
||||
|
||||
it('high maps to HIGH', async () => {
|
||||
const thinkingConfig = await captureThinkingConfig('high');
|
||||
expect(thinkingConfig).toEqual({
|
||||
include_thoughts: true,
|
||||
thinking_level: 'HIGH',
|
||||
includeThoughts: true,
|
||||
thinkingLevel: 'HIGH',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue