Add Claude Vertex content generator

This commit is contained in:
Sandy Tao
2026-05-29 11:19:06 -07:00
parent 3a13b8eeb6
commit 5b07a97630
11 changed files with 3095 additions and 19 deletions
@@ -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[] = [
{
+64 -5
View File
@@ -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:
+21 -1
View File
@@ -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(
+6 -3
View File
@@ -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'] = [
{
+77 -10
View File
@@ -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);
}
}