mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-11 10:30:46 -07:00
Add Claude Vertex content generator
This commit is contained in:
@@ -1043,6 +1043,72 @@ describe('convertSessionToHistoryFormats', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render inline thought parts as message text', () => {
|
||||
const messages: MessageRecord[] = [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'gemini',
|
||||
content: [
|
||||
{ text: '**Planning** I should inspect the files.', thought: true },
|
||||
{ text: 'I found the issue.' },
|
||||
],
|
||||
thoughts: [
|
||||
{
|
||||
subject: 'Planning',
|
||||
description: 'I should inspect the files.',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertSessionToHistoryFormats(messages);
|
||||
|
||||
expect(result.uiHistory).toHaveLength(2);
|
||||
expect(result.uiHistory[0]).toEqual({
|
||||
type: 'thinking',
|
||||
thought: {
|
||||
subject: 'Planning',
|
||||
description: 'I should inspect the files.',
|
||||
},
|
||||
});
|
||||
expect(result.uiHistory[1]).toEqual({
|
||||
type: 'gemini',
|
||||
text: 'I found the issue.',
|
||||
});
|
||||
expect(JSON.stringify(result.uiHistory)).not.toContain('[Thought: true]');
|
||||
});
|
||||
|
||||
it('should convert inline thought parts to thinking items without metadata', () => {
|
||||
const messages: MessageRecord[] = [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'gemini',
|
||||
content: [
|
||||
{ text: '**Planning** I should inspect the files.', thought: true },
|
||||
{ text: 'I found the issue.' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertSessionToHistoryFormats(messages);
|
||||
|
||||
expect(result.uiHistory).toHaveLength(2);
|
||||
expect(result.uiHistory[0]).toEqual({
|
||||
type: 'thinking',
|
||||
thought: {
|
||||
subject: 'Planning',
|
||||
description: 'I should inspect the files.',
|
||||
},
|
||||
});
|
||||
expect(result.uiHistory[1]).toEqual({
|
||||
type: 'gemini',
|
||||
text: 'I found the issue.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out <session_context> from UI history', () => {
|
||||
const messages: MessageRecord[] = [
|
||||
{
|
||||
|
||||
@@ -7,13 +7,16 @@
|
||||
import {
|
||||
checkExhaustive,
|
||||
partListUnionToString,
|
||||
parseThought,
|
||||
SESSION_FILE_PREFIX,
|
||||
CoreToolCallStatus,
|
||||
type Storage,
|
||||
type ConversationRecord,
|
||||
type MessageRecord,
|
||||
type ThoughtSummary,
|
||||
loadConversationRecord,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type Part, type PartListUnion } from '@google/genai';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { stripUnsafeCharacters } from '../ui/utils/textUtils.js';
|
||||
@@ -139,6 +142,58 @@ export interface SessionSelectionResult {
|
||||
displayInfo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a session has at least one user or assistant (gemini) message.
|
||||
* Sessions with only system messages (info, error, warning) are considered empty.
|
||||
* @param messages - The array of message records to check
|
||||
* @returns true if the session has meaningful content
|
||||
*/
|
||||
export const hasUserOrAssistantMessage = (messages: MessageRecord[]): boolean =>
|
||||
messages.some((msg) => msg.type === 'user' || msg.type === 'gemini');
|
||||
|
||||
function ensurePartArray(content: PartListUnion): Part[] {
|
||||
if (Array.isArray(content)) {
|
||||
return content.map((part) =>
|
||||
typeof part === 'string' ? { text: part } : part,
|
||||
);
|
||||
}
|
||||
if (typeof content === 'string') {
|
||||
return [{ text: content }];
|
||||
}
|
||||
return [content];
|
||||
}
|
||||
|
||||
function inlineThoughtText(part: Part): string | undefined {
|
||||
const thoughtValue = (part as { thought?: unknown }).thought;
|
||||
if (!thoughtValue) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof part.text === 'string' && part.text.trim()) {
|
||||
return part.text;
|
||||
}
|
||||
if (typeof thoughtValue === 'string' && thoughtValue.trim()) {
|
||||
return thoughtValue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function inlineThoughtSummaries(content: PartListUnion): ThoughtSummary[] {
|
||||
return ensurePartArray(content)
|
||||
.map(inlineThoughtText)
|
||||
.filter((text): text is string => text !== undefined)
|
||||
.map(parseThought);
|
||||
}
|
||||
|
||||
function visibleContentString(content: PartListUnion): string {
|
||||
const visibleParts = ensurePartArray(content).filter(
|
||||
(part) => !(part as { thought?: unknown }).thought,
|
||||
);
|
||||
if (visibleParts.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return partListUnionToString(visibleParts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans and sanitizes message content for display by:
|
||||
* - Converting newlines to spaces
|
||||
@@ -579,9 +634,13 @@ export function convertSessionToHistoryFormats(
|
||||
const uiHistory: HistoryItemWithoutId[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
// Add thoughts if present
|
||||
if (msg.type === 'gemini' && msg.thoughts && msg.thoughts.length > 0) {
|
||||
for (const thought of msg.thoughts) {
|
||||
if (msg.type === 'gemini') {
|
||||
const thoughts =
|
||||
msg.thoughts && msg.thoughts.length > 0
|
||||
? msg.thoughts
|
||||
: inlineThoughtSummaries(msg.content);
|
||||
|
||||
for (const thought of thoughts) {
|
||||
uiHistory.push({
|
||||
type: 'thinking',
|
||||
thought: {
|
||||
@@ -594,9 +653,9 @@ export function convertSessionToHistoryFormats(
|
||||
|
||||
// Add the message only if it has content
|
||||
const displayContentString = msg.displayContent
|
||||
? partListUnionToString(msg.displayContent)
|
||||
? visibleContentString(msg.displayContent)
|
||||
: undefined;
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
const contentString = visibleContentString(msg.content);
|
||||
const uiText = displayContentString || contentString;
|
||||
|
||||
// Skip internal context messages in the UI history
|
||||
|
||||
@@ -116,6 +116,17 @@ describe('modelStringToModelConfigAlias', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use Claude Vertex models directly', () => {
|
||||
expect(modelStringToModelConfigAlias('claude-opus-4-8')).toBe(
|
||||
'claude-opus-4-8',
|
||||
);
|
||||
expect(
|
||||
modelStringToModelConfigAlias(
|
||||
'publishers/anthropic/models/claude-opus-4-8',
|
||||
),
|
||||
).toBe('publishers/anthropic/models/claude-opus-4-8');
|
||||
});
|
||||
|
||||
it('should handle valid names', () => {
|
||||
expect(modelStringToModelConfigAlias('gemini-3-pro-preview')).toBe(
|
||||
'chat-compression-3-pro',
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
PREVIEW_GEMINI_FLASH_LITE_MODEL,
|
||||
} from '../config/models.js';
|
||||
import { PreCompressTrigger } from '../hooks/types.js';
|
||||
import { isClaudeVertexModel } from '../core/vertexAnthropicContentGenerator.js';
|
||||
|
||||
/**
|
||||
* Default threshold for compression token count as a fraction of the model's
|
||||
@@ -100,6 +101,10 @@ export function findCompressSplitPoint(
|
||||
}
|
||||
|
||||
export function modelStringToModelConfigAlias(model: string): string {
|
||||
if (isClaudeVertexModel(model)) {
|
||||
return model;
|
||||
}
|
||||
|
||||
switch (model) {
|
||||
case PREVIEW_GEMINI_MODEL:
|
||||
case PREVIEW_GEMINI_3_1_MODEL:
|
||||
|
||||
@@ -32,6 +32,10 @@ import { getVersion, resolveModel } from '../../index.js';
|
||||
import type { LlmRole } from '../telemetry/llmRole.js';
|
||||
import { ModelMappingContentGenerator } from './modelMappingContentGenerator.js';
|
||||
import { CCPA_AI_MODEL_MAPPINGS } from '../config/models.js';
|
||||
import {
|
||||
VertexAiContentGeneratorRouter,
|
||||
VertexAnthropicContentGenerator,
|
||||
} from './vertexAnthropicContentGenerator.js';
|
||||
|
||||
/**
|
||||
* Interface abstracting the core functionalities for generating content and counting tokens.
|
||||
@@ -380,7 +384,23 @@ export async function createContentGenerator(
|
||||
},
|
||||
}),
|
||||
});
|
||||
return new LoggingContentGenerator(googleGenAI.models, gcConfig);
|
||||
const contentGenerator =
|
||||
config.authType === AuthType.USE_VERTEX_AI
|
||||
? new VertexAiContentGeneratorRouter(
|
||||
googleGenAI.models,
|
||||
new VertexAnthropicContentGenerator({
|
||||
projectId:
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] ||
|
||||
process.env['GOOGLE_CLOUD_PROJECT_ID'] ||
|
||||
undefined,
|
||||
location: process.env['GOOGLE_CLOUD_LOCATION'] || undefined,
|
||||
baseUrl,
|
||||
headers,
|
||||
proxy: proxyUrl,
|
||||
}),
|
||||
)
|
||||
: googleGenAI.models;
|
||||
return new LoggingContentGenerator(contentGenerator, gcConfig);
|
||||
}
|
||||
throw new Error(
|
||||
`Error creating contentGenerator: Unsupported authType: ${config.authType}`,
|
||||
|
||||
@@ -0,0 +1,916 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
FunctionCallingConfigMode,
|
||||
GenerateContentResponse,
|
||||
ThinkingLevel,
|
||||
} from '@google/genai';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { LlmRole } from '../telemetry/llmRole.js';
|
||||
import type { ContentGenerator } from './contentGenerator.js';
|
||||
import {
|
||||
isClaudeVertexModel,
|
||||
VertexAiContentGeneratorRouter,
|
||||
VertexAnthropicContentGenerator,
|
||||
} from './vertexAnthropicContentGenerator.js';
|
||||
|
||||
const mockAuth = {
|
||||
getClient: vi.fn(async () => ({
|
||||
getRequestHeaders: vi.fn(async () => ({
|
||||
Authorization: 'Bearer test-token',
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
function sseResponse(chunks: unknown[]): Response {
|
||||
const body = chunks
|
||||
.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`)
|
||||
.join('');
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(body));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe('isClaudeVertexModel', () => {
|
||||
it('detects Claude Vertex model IDs', () => {
|
||||
expect(isClaudeVertexModel('claude-opus-4-8')).toBe(true);
|
||||
expect(
|
||||
isClaudeVertexModel('publishers/anthropic/models/claude-sonnet-4-6'),
|
||||
).toBe(true);
|
||||
expect(isClaudeVertexModel('gemini-2.5-pro')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VertexAiContentGeneratorRouter', () => {
|
||||
it('routes Claude models to the Claude generator and other models to Gemini', async () => {
|
||||
const geminiResponse = new GenerateContentResponse();
|
||||
const claudeResponse = new GenerateContentResponse();
|
||||
const geminiGenerator = {
|
||||
generateContent: vi.fn(async () => geminiResponse),
|
||||
} as unknown as ContentGenerator;
|
||||
const claudeGenerator = {
|
||||
generateContent: vi.fn(async () => claudeResponse),
|
||||
} as unknown as ContentGenerator;
|
||||
|
||||
const router = new VertexAiContentGeneratorRouter(
|
||||
geminiGenerator,
|
||||
claudeGenerator,
|
||||
);
|
||||
|
||||
await expect(
|
||||
router.generateContent(
|
||||
{ model: 'claude-opus-4-8', contents: 'hello' },
|
||||
'prompt-id',
|
||||
LlmRole.MAIN,
|
||||
),
|
||||
).resolves.toBe(claudeResponse);
|
||||
await expect(
|
||||
router.generateContent(
|
||||
{ model: 'gemini-2.5-pro', contents: 'hello' },
|
||||
'prompt-id',
|
||||
LlmRole.MAIN,
|
||||
),
|
||||
).resolves.toBe(geminiResponse);
|
||||
|
||||
expect(claudeGenerator.generateContent).toHaveBeenCalledOnce();
|
||||
expect(geminiGenerator.generateContent).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('VertexAnthropicContentGenerator', () => {
|
||||
it('converts Gemini requests and Anthropic SSE chunks', async () => {
|
||||
const fetchFn = vi.fn(async (_input: string | URL, _init?: RequestInit) =>
|
||||
sseResponse([
|
||||
{
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: 'msg_1',
|
||||
model: 'claude-opus-4-8',
|
||||
usage: { input_tokens: 7 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'text_delta', text: 'hello' },
|
||||
},
|
||||
{
|
||||
type: 'content_block_start',
|
||||
index: 1,
|
||||
content_block: {
|
||||
type: 'tool_use',
|
||||
id: 'toolu_1',
|
||||
name: 'read_file',
|
||||
input: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'content_block_delta',
|
||||
index: 1,
|
||||
delta: {
|
||||
type: 'input_json_delta',
|
||||
partial_json: '{"path":"a.txt"}',
|
||||
},
|
||||
},
|
||||
{ type: 'content_block_stop', index: 1 },
|
||||
{
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: 'tool_use' },
|
||||
usage: { output_tokens: 3 },
|
||||
},
|
||||
{ type: 'message_stop' },
|
||||
]),
|
||||
);
|
||||
const generator = new VertexAnthropicContentGenerator({
|
||||
projectId: 'my-project',
|
||||
location: 'global',
|
||||
auth: mockAuth,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
const stream = await generator.generateContentStream(
|
||||
{
|
||||
model: 'claude-opus-4-8',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'hi' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'toolu_prev',
|
||||
name: 'read_file',
|
||||
args: { path: 'old.txt' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'toolu_prev',
|
||||
name: 'read_file',
|
||||
response: { output: 'old contents' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
config: {
|
||||
systemInstruction: 'system prompt',
|
||||
maxOutputTokens: 123,
|
||||
topP: 0.95,
|
||||
topK: 40,
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'read_file',
|
||||
description: 'Read a file',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: { path: { type: 'string' } },
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
toolConfig: {
|
||||
functionCallingConfig: {
|
||||
mode: FunctionCallingConfigMode.ANY,
|
||||
allowedFunctionNames: ['read_file'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'prompt-id',
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
|
||||
const chunks: GenerateContentResponse[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledWith(
|
||||
'https://aiplatform.googleapis.com/v1/projects/my-project/locations/global/publishers/anthropic/models/claude-opus-4-8:streamRawPredict',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer test-token',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const body = JSON.parse(
|
||||
(fetchFn.mock.calls[0]?.[1] as RequestInit).body as string,
|
||||
) as Record<string, unknown>;
|
||||
expect(body).toMatchObject({
|
||||
anthropic_version: 'vertex-2023-10-16',
|
||||
system: 'system prompt',
|
||||
max_tokens: 123,
|
||||
stream: true,
|
||||
tool_choice: { type: 'tool', name: 'read_file' },
|
||||
});
|
||||
expect(body).not.toHaveProperty('model');
|
||||
expect(body).not.toHaveProperty('top_p');
|
||||
expect(body).not.toHaveProperty('top_k');
|
||||
expect(body['tools']).toEqual([
|
||||
{
|
||||
name: 'read_file',
|
||||
description: 'Read a file',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: { path: { type: 'string' } },
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(body['messages']).toEqual([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_prev',
|
||||
name: 'read_file',
|
||||
input: { path: 'old.txt' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'toolu_prev',
|
||||
content: 'old contents',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(chunks[0].candidates?.[0]?.content?.parts?.[0]?.text).toBe('hello');
|
||||
expect(chunks[1].functionCalls).toEqual([
|
||||
{ id: 'toolu_1', name: 'read_file', args: { path: 'a.txt' } },
|
||||
]);
|
||||
expect(chunks[2].candidates?.[0]?.finishReason).toBe('STOP');
|
||||
expect(chunks[2].usageMetadata).toMatchObject({
|
||||
promptTokenCount: 7,
|
||||
candidatesTokenCount: 3,
|
||||
totalTokenCount: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses adaptive thinking for Claude Opus 4.8 and omits unsupported sampling parameters', async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async (_input: string | URL, _init?: RequestInit) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
id: 'msg_1',
|
||||
model: 'claude-opus-4-8',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
stop_reason: 'end_turn',
|
||||
}),
|
||||
),
|
||||
);
|
||||
const generator = new VertexAnthropicContentGenerator({
|
||||
projectId: 'my-project',
|
||||
location: 'global',
|
||||
auth: mockAuth,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
await generator.generateContent(
|
||||
{
|
||||
model: 'claude-opus-4-8',
|
||||
contents: 'hi',
|
||||
config: {
|
||||
temperature: 1,
|
||||
thinkingConfig: {
|
||||
includeThoughts: true,
|
||||
thinkingBudget: 8192,
|
||||
},
|
||||
},
|
||||
},
|
||||
'prompt-id',
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
|
||||
const body = JSON.parse(
|
||||
(fetchFn.mock.calls[0]?.[1] as RequestInit).body as string,
|
||||
) as Record<string, unknown>;
|
||||
expect(body['thinking']).toEqual({
|
||||
type: 'adaptive',
|
||||
display: 'summarized',
|
||||
});
|
||||
expect(body['max_tokens']).toBe(128_000);
|
||||
expect(body['thinking']).not.toHaveProperty('budget_tokens');
|
||||
expect(body).not.toHaveProperty('temperature');
|
||||
});
|
||||
|
||||
it('uses max output defaults only for Claude Opus 4 models', async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async (_input: string | URL, _init?: RequestInit) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
id: 'msg_1',
|
||||
model: 'claude-opus-4-8',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
stop_reason: 'end_turn',
|
||||
}),
|
||||
),
|
||||
);
|
||||
const generator = new VertexAnthropicContentGenerator({
|
||||
projectId: 'my-project',
|
||||
location: 'global',
|
||||
auth: mockAuth,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
for (const model of [
|
||||
'claude-opus-4-8',
|
||||
'claude-opus-4-5@20251101',
|
||||
'claude-opus-4-1@20250805',
|
||||
'claude-sonnet-4-6',
|
||||
]) {
|
||||
await generator.generateContent(
|
||||
{ model, contents: 'hi' },
|
||||
'prompt-id',
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
}
|
||||
|
||||
const bodies = fetchFn.mock.calls.map(
|
||||
(call) =>
|
||||
JSON.parse((call[1] as RequestInit).body as string) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
);
|
||||
expect(bodies.map((body) => body['max_tokens'])).toEqual([
|
||||
128_000, 64_000, 32_000, 8192,
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps Gemini thinking levels to Claude effort and keeps tool choice compatible with thinking', async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async (_input: string | URL, _init?: RequestInit) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
id: 'msg_1',
|
||||
model: 'claude-opus-4-8',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
stop_reason: 'end_turn',
|
||||
}),
|
||||
),
|
||||
);
|
||||
const generator = new VertexAnthropicContentGenerator({
|
||||
projectId: 'my-project',
|
||||
location: 'global',
|
||||
auth: mockAuth,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
await generator.generateContent(
|
||||
{
|
||||
model: 'claude-opus-4-8',
|
||||
contents: 'hi',
|
||||
config: {
|
||||
thinkingConfig: {
|
||||
thinkingLevel: ThinkingLevel.LOW,
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'read_file',
|
||||
parametersJsonSchema: { type: 'object' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
toolConfig: {
|
||||
functionCallingConfig: {
|
||||
mode: FunctionCallingConfigMode.ANY,
|
||||
allowedFunctionNames: ['read_file'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'prompt-id',
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
|
||||
const body = JSON.parse(
|
||||
(fetchFn.mock.calls[0]?.[1] as RequestInit).body as string,
|
||||
) as Record<string, unknown>;
|
||||
expect(body['thinking']).toEqual({ type: 'adaptive' });
|
||||
expect(body['output_config']).toEqual({ effort: 'low' });
|
||||
expect(body['tool_choice']).toEqual({ type: 'auto' });
|
||||
});
|
||||
|
||||
it('keeps manual thinking budgets for older Claude models', async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async (_input: string | URL, _init?: RequestInit) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
id: 'msg_1',
|
||||
model: 'claude-sonnet-4-5',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
stop_reason: 'end_turn',
|
||||
}),
|
||||
),
|
||||
);
|
||||
const generator = new VertexAnthropicContentGenerator({
|
||||
projectId: 'my-project',
|
||||
location: 'global',
|
||||
auth: mockAuth,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
await generator.generateContent(
|
||||
{
|
||||
model: 'claude-sonnet-4-5',
|
||||
contents: 'hi',
|
||||
config: {
|
||||
maxOutputTokens: 5000,
|
||||
thinkingConfig: {
|
||||
includeThoughts: true,
|
||||
thinkingBudget: 4096,
|
||||
},
|
||||
},
|
||||
},
|
||||
'prompt-id',
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
|
||||
const body = JSON.parse(
|
||||
(fetchFn.mock.calls[0]?.[1] as RequestInit).body as string,
|
||||
) as Record<string, unknown>;
|
||||
expect(body['thinking']).toEqual({
|
||||
type: 'enabled',
|
||||
budget_tokens: 4096,
|
||||
display: 'summarized',
|
||||
});
|
||||
expect(body['max_tokens']).toBe(5120);
|
||||
});
|
||||
|
||||
it('round-trips Claude thinking signatures on tool-use turns', async () => {
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
sseResponse([
|
||||
{
|
||||
type: 'message_start',
|
||||
message: { id: 'msg_1', model: 'claude-opus-4-8' },
|
||||
},
|
||||
{
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: { type: 'thinking', thinking: '', signature: '' },
|
||||
},
|
||||
{
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: {
|
||||
type: 'thinking_delta',
|
||||
thinking: 'I should call the tool.',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'signature_delta', signature: 'sig_old' },
|
||||
},
|
||||
{
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'signature_delta', signature: 'sig_abc' },
|
||||
},
|
||||
{ type: 'content_block_stop', index: 0 },
|
||||
{
|
||||
type: 'content_block_start',
|
||||
index: 1,
|
||||
content_block: {
|
||||
type: 'redacted_thinking',
|
||||
data: 'opaque_redacted_data',
|
||||
},
|
||||
},
|
||||
{ type: 'content_block_stop', index: 1 },
|
||||
{
|
||||
type: 'content_block_start',
|
||||
index: 2,
|
||||
content_block: {
|
||||
type: 'tool_use',
|
||||
id: 'toolu_1',
|
||||
name: 'read_file',
|
||||
input: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'content_block_delta',
|
||||
index: 2,
|
||||
delta: {
|
||||
type: 'input_json_delta',
|
||||
partial_json: '{"path":"a.txt"}',
|
||||
},
|
||||
},
|
||||
{ type: 'content_block_stop', index: 2 },
|
||||
{ type: 'message_delta', delta: { stop_reason: 'tool_use' } },
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
id: 'msg_2',
|
||||
model: 'claude-opus-4-8',
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
stop_reason: 'end_turn',
|
||||
}),
|
||||
),
|
||||
);
|
||||
const generator = new VertexAnthropicContentGenerator({
|
||||
projectId: 'my-project',
|
||||
location: 'global',
|
||||
auth: mockAuth,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
const stream = await generator.generateContentStream(
|
||||
{
|
||||
model: 'claude-opus-4-8',
|
||||
contents: 'hi',
|
||||
},
|
||||
'prompt-id',
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
const chunks: GenerateContentResponse[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const toolPart = chunks
|
||||
.flatMap((chunk) => chunk.candidates?.[0]?.content?.parts ?? [])
|
||||
.find((part) => part.functionCall);
|
||||
const thoughtSignature = (toolPart as { thoughtSignature?: string })
|
||||
.thoughtSignature;
|
||||
expect(thoughtSignature).toMatch(/^claude_thinking:/);
|
||||
|
||||
await generator.generateContent(
|
||||
{
|
||||
model: 'claude-opus-4-8',
|
||||
contents: [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [toolPart!],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'toolu_1',
|
||||
name: 'read_file',
|
||||
response: { output: 'contents' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'prompt-id',
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
|
||||
const body = JSON.parse(
|
||||
(fetchFn.mock.calls[1]?.[1] as RequestInit).body as string,
|
||||
) as Record<string, Array<{ content: unknown[] }>>;
|
||||
expect(body['messages'][0]?.content).toEqual([
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'I should call the tool.',
|
||||
signature: 'sig_abc',
|
||||
},
|
||||
{
|
||||
type: 'redacted_thinking',
|
||||
data: 'opaque_redacted_data',
|
||||
},
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_1',
|
||||
name: 'read_file',
|
||||
input: { path: 'a.txt' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles a full JSON message on the streaming endpoint', async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async (_input: string | URL, _init?: RequestInit) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
id: 'msg_json',
|
||||
type: 'message',
|
||||
model: 'claude-opus-4-8',
|
||||
content: [{ type: 'text', text: 'complete response' }],
|
||||
stop_reason: 'end_turn',
|
||||
usage: { input_tokens: 5, output_tokens: 2 },
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
const generator = new VertexAnthropicContentGenerator({
|
||||
projectId: 'my-project',
|
||||
location: 'global',
|
||||
auth: mockAuth,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
const stream = await generator.generateContentStream(
|
||||
{
|
||||
model: 'claude-opus-4-8',
|
||||
contents: [{ role: 'user', parts: [{ text: 'hi' }] }],
|
||||
},
|
||||
'prompt-id',
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
|
||||
const chunks: GenerateContentResponse[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const body = JSON.parse(
|
||||
(fetchFn.mock.calls[0]?.[1] as RequestInit).body as string,
|
||||
) as Record<string, unknown>;
|
||||
expect(body['stream']).toBe(true);
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(chunks[0].candidates?.[0]?.content?.parts?.[0]?.text).toBe(
|
||||
'complete response',
|
||||
);
|
||||
expect(chunks[0].candidates?.[0]?.finishReason).toBe('STOP');
|
||||
expect(chunks[0].usageMetadata).toMatchObject({
|
||||
promptTokenCount: 5,
|
||||
candidatesTokenCount: 2,
|
||||
totalTokenCount: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it('sanitizes Claude tool names and maps tool calls back to Gemini names', async () => {
|
||||
const geminiToolName = 'mcp.read/file:custom';
|
||||
const claudeToolName = 'mcp_read_file_custom';
|
||||
const fetchFn = vi.fn(async (_input: string | URL, _init?: RequestInit) =>
|
||||
sseResponse([
|
||||
{
|
||||
type: 'message_start',
|
||||
message: { id: 'msg_2', model: 'claude-opus-4-8' },
|
||||
},
|
||||
{
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: 'tool_use',
|
||||
id: 'toolu_2',
|
||||
name: claudeToolName,
|
||||
input: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: {
|
||||
type: 'input_json_delta',
|
||||
partial_json: '{"path":"b.txt"}',
|
||||
},
|
||||
},
|
||||
{ type: 'content_block_stop', index: 0 },
|
||||
{ type: 'message_delta', delta: { stop_reason: 'tool_use' } },
|
||||
]),
|
||||
);
|
||||
const generator = new VertexAnthropicContentGenerator({
|
||||
projectId: 'my-project',
|
||||
location: 'global',
|
||||
auth: mockAuth,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
const stream = await generator.generateContentStream(
|
||||
{
|
||||
model: 'claude-opus-4-8',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'call the tool' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'toolu_prev',
|
||||
name: geminiToolName,
|
||||
args: { path: 'old.txt' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
config: {
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: geminiToolName,
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: { path: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
toolConfig: {
|
||||
functionCallingConfig: {
|
||||
mode: FunctionCallingConfigMode.ANY,
|
||||
allowedFunctionNames: [geminiToolName],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'prompt-id',
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
|
||||
const chunks: GenerateContentResponse[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const body = JSON.parse(
|
||||
(fetchFn.mock.calls[0]?.[1] as RequestInit).body as string,
|
||||
) as Record<string, unknown>;
|
||||
expect(body['tools']).toEqual([
|
||||
{
|
||||
name: claudeToolName,
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: { path: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(body['tool_choice']).toEqual({
|
||||
type: 'tool',
|
||||
name: claudeToolName,
|
||||
});
|
||||
expect(body['messages']).toEqual([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'call the tool' }] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_prev',
|
||||
name: claudeToolName,
|
||||
input: { path: 'old.txt' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(chunks[0].functionCalls).toEqual([
|
||||
{ id: 'toolu_2', name: geminiToolName, args: { path: 'b.txt' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes tool input schemas for Anthropic JSON Schema validation', async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async (_input: string | URL, _init?: RequestInit) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
id: 'msg_3',
|
||||
content: [],
|
||||
stop_reason: 'end_turn',
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
const generator = new VertexAnthropicContentGenerator({
|
||||
projectId: 'my-project',
|
||||
location: 'global',
|
||||
auth: mockAuth,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
await generator.generateContent(
|
||||
{
|
||||
model: 'claude-opus-4-8',
|
||||
contents: [{ role: 'user', parts: [{ text: 'hi' }] }],
|
||||
config: {
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'schema_tool',
|
||||
parametersJsonSchema: {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
$ref: '#/definitions/Root',
|
||||
definitions: {
|
||||
Root: {
|
||||
type: 'OBJECT',
|
||||
propertyOrdering: ['meta', 'maybeTags'],
|
||||
properties: {
|
||||
meta: {
|
||||
type: 'OBJECT',
|
||||
additionalProperties: { type: 'STRING' },
|
||||
},
|
||||
maybeTags: {
|
||||
type: 'ARRAY',
|
||||
items: { type: 'STRING' },
|
||||
nullable: true,
|
||||
},
|
||||
union: {
|
||||
oneOf: [{ type: 'STRING' }, { type: 'INTEGER' }],
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'prompt-id',
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
|
||||
const body = JSON.parse(
|
||||
(fetchFn.mock.calls[0]?.[1] as RequestInit).body as string,
|
||||
) as Record<string, unknown>;
|
||||
expect(body['tools']).toEqual([
|
||||
{
|
||||
name: 'schema_tool',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
meta: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
},
|
||||
maybeTags: {
|
||||
type: ['array', 'null'],
|
||||
items: { type: 'string' },
|
||||
},
|
||||
union: {
|
||||
oneOf: [{ type: 'string' }, { type: 'integer' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the count-tokens rawPredict endpoint', async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async (_input: string | URL, _init?: RequestInit) =>
|
||||
new Response(JSON.stringify({ input_tokens: 42 }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const generator = new VertexAnthropicContentGenerator({
|
||||
projectId: 'my-project',
|
||||
location: 'us-east5',
|
||||
auth: mockAuth,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
await expect(
|
||||
generator.countTokens({
|
||||
model: 'claude-opus-4-8',
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
}),
|
||||
).resolves.toEqual({ totalTokens: 42 });
|
||||
|
||||
expect(fetchFn.mock.calls[0]?.[0]).toBe(
|
||||
'https://us-east5-aiplatform.googleapis.com/v1/projects/my-project/locations/us-east5/publishers/anthropic/models/count-tokens:rawPredict',
|
||||
);
|
||||
const body = JSON.parse(
|
||||
(fetchFn.mock.calls[0]?.[1] as RequestInit).body as string,
|
||||
) as Record<string, unknown>;
|
||||
expect(body['model']).toBe('claude-opus-4-8');
|
||||
expect(body).not.toHaveProperty('max_tokens');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -90,6 +90,13 @@ describe('partUtils', () => {
|
||||
expect(partToString(part, verboseOptions)).toBe('[Thought: thinking]');
|
||||
});
|
||||
|
||||
it('should use text for boolean thought parts', () => {
|
||||
const part = { text: 'thinking text', thought: true } as Part;
|
||||
expect(partToString(part, verboseOptions)).toBe(
|
||||
'[Thought: thinking text]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return descriptive string for codeExecutionResult part', () => {
|
||||
const part = { codeExecutionResult: {} } as Part;
|
||||
expect(partToString(part, verboseOptions)).toBe(
|
||||
|
||||
@@ -30,10 +30,9 @@ export function partToString(
|
||||
}
|
||||
|
||||
// Cast to Part, assuming it might contain project-specific fields
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const part = value as Part & {
|
||||
videoMetadata?: unknown;
|
||||
thought?: string;
|
||||
thought?: unknown;
|
||||
codeExecutionResult?: unknown;
|
||||
executableCode?: unknown;
|
||||
};
|
||||
@@ -43,7 +42,11 @@ export function partToString(
|
||||
return `[Video Metadata]`;
|
||||
}
|
||||
if (part.thought !== undefined) {
|
||||
return `[Thought: ${part.thought}]`;
|
||||
const thoughtText =
|
||||
typeof part.text === 'string' && part.text.length > 0
|
||||
? part.text
|
||||
: String(part.thought);
|
||||
return `[Thought: ${thoughtText}]`;
|
||||
}
|
||||
if (part.codeExecutionResult !== undefined) {
|
||||
return `[Code Execution Result]`;
|
||||
|
||||
@@ -186,6 +186,153 @@ describe('convertSessionToClientHistory', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not duplicate explicit tool response turns on resume', () => {
|
||||
const toolResponse = {
|
||||
functionResponse: {
|
||||
id: 'toolu_1',
|
||||
name: 'web_fetch',
|
||||
response: { output: 'page content' },
|
||||
},
|
||||
};
|
||||
const messages: ConversationRecord['messages'] = [
|
||||
{
|
||||
id: 'msg1',
|
||||
type: 'user',
|
||||
timestamp: '2024-01-01T10:00:00Z',
|
||||
content: 'Fetch this page',
|
||||
},
|
||||
{
|
||||
id: 'msg2',
|
||||
type: 'gemini',
|
||||
timestamp: '2024-01-01T10:01:00Z',
|
||||
content: [
|
||||
{ text: 'Let me fetch it.' },
|
||||
{
|
||||
functionCall: {
|
||||
id: 'toolu_1',
|
||||
name: 'web_fetch',
|
||||
args: { url: 'https://example.com' },
|
||||
},
|
||||
},
|
||||
],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'toolu_1',
|
||||
name: 'web_fetch',
|
||||
args: { url: 'https://example.com' },
|
||||
status: CoreToolCallStatus.Success,
|
||||
timestamp: '2024-01-01T10:01:05Z',
|
||||
result: [toolResponse],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'msg3',
|
||||
type: 'user',
|
||||
timestamp: '2024-01-01T10:01:06Z',
|
||||
content: [toolResponse],
|
||||
},
|
||||
];
|
||||
|
||||
const history = convertSessionToClientHistory(messages);
|
||||
|
||||
expect(history.map((h) => h.content)).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'Fetch this page' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'Let me fetch it.' },
|
||||
{
|
||||
functionCall: {
|
||||
id: 'toolu_1',
|
||||
name: 'web_fetch',
|
||||
args: { url: 'https://example.com' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'user', parts: [toolResponse] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should deduplicate grouped tool results stored on multiple tool calls', () => {
|
||||
const groupedResults = [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call1',
|
||||
name: 'read_file',
|
||||
response: { output: 'file contents' },
|
||||
},
|
||||
},
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call2',
|
||||
name: 'list_files',
|
||||
response: { output: 'file.txt' },
|
||||
},
|
||||
},
|
||||
];
|
||||
const messages: ConversationRecord['messages'] = [
|
||||
{
|
||||
id: 'msg1',
|
||||
type: 'user',
|
||||
timestamp: '2024-01-01T10:00:00Z',
|
||||
content: 'Inspect the project',
|
||||
},
|
||||
{
|
||||
id: 'msg2',
|
||||
type: 'gemini',
|
||||
timestamp: '2024-01-01T10:01:00Z',
|
||||
content: 'I will inspect it.',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call1',
|
||||
name: 'read_file',
|
||||
args: { path: 'README.md' },
|
||||
status: CoreToolCallStatus.Success,
|
||||
timestamp: '2024-01-01T10:01:05Z',
|
||||
result: groupedResults,
|
||||
},
|
||||
{
|
||||
id: 'call2',
|
||||
name: 'list_files',
|
||||
args: { dir: '.' },
|
||||
status: CoreToolCallStatus.Success,
|
||||
timestamp: '2024-01-01T10:01:06Z',
|
||||
result: groupedResults,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const history = convertSessionToClientHistory(messages);
|
||||
|
||||
expect(history.map((h) => h.content)).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'Inspect the project' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'I will inspect it.' },
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call1',
|
||||
name: 'read_file',
|
||||
args: { path: 'README.md' },
|
||||
},
|
||||
},
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call2',
|
||||
name: 'list_files',
|
||||
args: { dir: '.' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'user', parts: groupedResults },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should preserve multi-modal parts (inlineData)', () => {
|
||||
const messages: ConversationRecord['messages'] = [
|
||||
{
|
||||
|
||||
@@ -104,6 +104,65 @@ export function isIgnoredUserContent(trimmedContent: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function collectExplicitFunctionResponseIds(
|
||||
messages: ConversationRecord['messages'],
|
||||
): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const msg of messages) {
|
||||
if (msg.type !== 'user') {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const part of ensurePartArray(msg.content)) {
|
||||
const id = part.functionResponse?.id;
|
||||
if (id) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function appendFunctionResponseParts(
|
||||
target: Part[],
|
||||
parts: Part[],
|
||||
explicitResponseIds: ReadonlySet<string>,
|
||||
generatedResponseIds: Set<string>,
|
||||
): void {
|
||||
const partsToAppend: Part[] = [];
|
||||
const idsToMark: string[] = [];
|
||||
let hasFunctionResponse = false;
|
||||
let hasNewFunctionResponse = false;
|
||||
|
||||
for (const part of parts) {
|
||||
const id = part.functionResponse?.id;
|
||||
if (!part.functionResponse) {
|
||||
partsToAppend.push(part);
|
||||
continue;
|
||||
}
|
||||
|
||||
hasFunctionResponse = true;
|
||||
if (id && (explicitResponseIds.has(id) || generatedResponseIds.has(id))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partsToAppend.push(part);
|
||||
hasNewFunctionResponse = true;
|
||||
if (id) {
|
||||
idsToMark.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasFunctionResponse && !hasNewFunctionResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.push(...partsToAppend);
|
||||
for (const id of idsToMark) {
|
||||
generatedResponseIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts session/conversation data into Gemini client history formats.
|
||||
*/
|
||||
@@ -111,6 +170,7 @@ export function convertSessionToClientHistory(
|
||||
messages: ConversationRecord['messages'],
|
||||
): HistoryTurn[] {
|
||||
const clientHistory: HistoryTurn[] = [];
|
||||
const explicitResponseIds = collectExplicitFunctionResponseIds(messages);
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') {
|
||||
@@ -185,12 +245,18 @@ export function convertSessionToClientHistory(
|
||||
// 4. Generate tool response turns
|
||||
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
||||
const functionResponseParts: Part[] = [];
|
||||
const generatedResponseIds = new Set<string>();
|
||||
for (const toolCall of msg.toolCalls) {
|
||||
if (toolCall.result) {
|
||||
let responseData: Part;
|
||||
|
||||
if (typeof toolCall.result === 'string') {
|
||||
responseData = {
|
||||
if (
|
||||
explicitResponseIds.has(toolCall.id) ||
|
||||
generatedResponseIds.has(toolCall.id)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
functionResponseParts.push({
|
||||
functionResponse: {
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
@@ -198,15 +264,16 @@ export function convertSessionToClientHistory(
|
||||
output: toolCall.result,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (Array.isArray(toolCall.result)) {
|
||||
functionResponseParts.push(...ensurePartArray(toolCall.result));
|
||||
continue;
|
||||
});
|
||||
generatedResponseIds.add(toolCall.id);
|
||||
} else {
|
||||
responseData = toolCall.result;
|
||||
appendFunctionResponseParts(
|
||||
functionResponseParts,
|
||||
ensurePartArray(toolCall.result),
|
||||
explicitResponseIds,
|
||||
generatedResponseIds,
|
||||
);
|
||||
}
|
||||
|
||||
functionResponseParts.push(responseData);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user