diff --git a/docs/core/model.mdx b/docs/core/model.mdx index 4a264d1..8a595d3 100644 --- a/docs/core/model.mdx +++ b/docs/core/model.mdx @@ -17,6 +17,9 @@ AgentScope provides a unified interface for multiple language model providers: Qwen models from Alibaba Cloud + + Native Gemini Developer API with function calling + Local models via Ollama @@ -116,6 +119,63 @@ const model = new DashScopeChatModel({ }); ``` +### Gemini + +```typescript +import { GeminiChatModel } from '@agentscope-ai/agentscope/model'; +import { createMsg, TextBlock } from '@agentscope-ai/agentscope/message'; + +const model = new GeminiChatModel({ + modelName: 'gemini-3.8-flash', + apiKey: process.env.GEMINI_API_KEY!, + stream: false, + presetGenParams: { + temperature: 0.7, + maxOutputTokens: 1024, + }, +}); + +const response = await model.call({ + messages: [ + createMsg({ + name: 'user', + role: 'user', + content: [TextBlock({ text: 'Explain how a rainbow forms.' })], + }), + ], +}); +``` + +This adapter uses the Gemini Developer API's native `generateContent` and +`streamGenerateContent` endpoints. `baseURL` can override the API root (including +its version); Vertex AI authentication is not supported. No Google SDK dependency +is required. A `GeminiChatFormatter` is provided automatically and can also be +imported from `@agentscope-ai/agentscope/formatter`. + +Pass native camelCase generation settings in `presetGenParams`, or override them +per call using `generationConfig`. Per-call `safetySettings` and `signal` +(`AbortSignal`) are also supported. The adapter uses the first response candidate. + +Function calling supports `auto`, `none`, `required`, and a specific function name. +Tool schemas use Gemini's `parametersJsonSchema` field. Parallel function calls are +returned as separate pending tool-call blocks; their results are grouped into one +user turn on the following request. `callStructured` uses the base class's named +function-calling mechanism. Function arguments are received as complete objects; +experimental partial function-argument streaming is not supported. + +With `stream: true` (the default), `call()` returns an async generator. Each yield +contains incremental content; the generator's return value contains the complete +response and the final token usage, including thought tokens. The `Agent` consumes +both automatically. When using the generator directly, read it with `.next()` if +you need the return value; a `for await` loop only exposes the deltas. + +The formatter accepts inline base64 data and Gemini-compatible file URIs. +HTTP image URLs are not downloaded automatically: use the Gemini Files API or +supply base64 data. Signed response parts retain an optional `thought_signature` +field through message schema parsing. Keep these blocks intact in saved history; +dropping their signatures can cause Gemini to reject subsequent tool turns. +Unsigned thought summaries are omitted from replay. + ### Ollama ```typescript diff --git a/packages/agentscope/src/formatter/gemini-chat-formatter.test.ts b/packages/agentscope/src/formatter/gemini-chat-formatter.test.ts new file mode 100644 index 0000000..ed1b337 --- /dev/null +++ b/packages/agentscope/src/formatter/gemini-chat-formatter.test.ts @@ -0,0 +1,125 @@ +import { GeminiChatFormatter } from './gemini-chat-formatter'; +import { + createMsg, + TextBlock, + DataBlock, + ThinkingBlock, + HintBlock, + ToolCallBlock, + ToolResultBlock, + parseContentBlock, +} from '../message'; + +const formatter = new GeminiChatFormatter(); + +describe('GeminiChatFormatter', () => { + test('formats multimodal input and hints without changing the conversation', async () => { + const msgs = [ + createMsg({ + name: 'user', + role: 'user', + content: [ + TextBlock({ text: 'Describe' }), + DataBlock({ + source: { type: 'base64', data: 'aGVsbG8=', media_type: 'image/png' }, + }), + DataBlock({ + source: { + type: 'url', + url: 'https://generativelanguage.googleapis.com/v1beta/files/sample', + media_type: 'video/mp4', + }, + }), + ], + }), + createMsg({ + name: 'agent', + role: 'assistant', + content: [HintBlock({ hint: 'A hint' })], + }), + ]; + const before = JSON.stringify(msgs); + expect(await formatter.format({ msgs })).toEqual([ + { + role: 'user', + parts: [ + { text: 'Describe' }, + { inlineData: { mimeType: 'image/png', data: 'aGVsbG8=' } }, + { + fileData: { + mimeType: 'video/mp4', + fileUri: + 'https://generativelanguage.googleapis.com/v1beta/files/sample', + }, + }, + { text: 'A hint' }, + ], + }, + ]); + expect(JSON.stringify(msgs)).toBe(before); + }); + + test('retains signed thought parts and omits unsigned summaries', async () => { + const msg = createMsg({ + name: 'agent', + role: 'assistant', + content: [ + ThinkingBlock({ thinking: 'Unsigned summary' }), + ThinkingBlock({ thinking: 'Signed', thought_signature: 'sig' }), + ], + }); + expect(await formatter.format({ msgs: [msg] })).toEqual([ + { role: 'model', parts: [{ text: 'Signed', thought: true, thoughtSignature: 'sig' }] }, + ]); + }); + + test('rejects non-object function arguments instead of sending an invalid request', async () => { + const msg = createMsg({ + name: 'agent', + role: 'assistant', + content: [ToolCallBlock({ id: 'call', name: 'weather', input: '[]' })], + }); + await expect(formatter.format({ msgs: [msg] })).rejects.toThrow('JSON object'); + }); + + test('converts tool failures into native error responses', async () => { + const msg = createMsg({ + name: 'tools', + role: 'assistant', + content: [ + ToolResultBlock({ id: 'call', name: 'weather', output: 'timeout', state: 'error' }), + ], + }); + expect(await formatter.format({ msgs: [msg] })).toEqual([ + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'weather', + id: 'call', + response: { error: 'timeout' }, + }, + }, + ], + }, + ]); + }); + + test('preserves optional signatures on text, tool-call and data blocks during schema parsing', () => { + for (const block of [ + TextBlock({ text: 'hello', thought_signature: 'text-sig' }), + ToolCallBlock({ + id: 'call', + name: 'weather', + input: '{}', + thought_signature: 'call-sig', + }), + DataBlock({ + source: { type: 'base64', data: 'aGVsbG8=', media_type: 'image/png' }, + thought_signature: 'data-sig', + }), + ]) + expect(parseContentBlock(JSON.parse(JSON.stringify(block)))).toEqual(block); + }); +}); diff --git a/packages/agentscope/src/formatter/gemini-chat-formatter.ts b/packages/agentscope/src/formatter/gemini-chat-formatter.ts new file mode 100644 index 0000000..5354b1c --- /dev/null +++ b/packages/agentscope/src/formatter/gemini-chat-formatter.ts @@ -0,0 +1,119 @@ +import { FormatterBase } from './base'; +import type { ContentBlock, DataBlock } from '../message/block'; +import { getContentBlocks } from '../message/message'; +import type { Msg } from '../message/message'; + +/** Gemini REST content part, including the signature needed for history replay. */ +export interface GeminiPart { + text?: string; + thought?: boolean; + thoughtSignature?: string; + functionCall?: { name: string; args?: Record; id?: string }; + functionResponse?: { name: string; response: Record; id?: string }; + inlineData?: { mimeType: string; data: string }; + fileData?: { mimeType: string; fileUri: string }; +} + +/** Converts AgentScope messages to Gemini's native contents format. */ +export class GeminiChatFormatter extends FormatterBase { + /** + * Format messages without mutating the caller's conversation. + * @param root0 + * @param root0.msgs + * @returns The formatted or accumulated result. + */ + async format({ msgs }: { msgs: Msg[] }): Promise[]> { + const result: { role: string; parts: GeminiPart[] }[] = []; + const append = (role: string, parts: GeminiPart[]) => { + if (!parts.length) return; + const last = result.at(-1); + // Parallel function responses must stay in one user turn. + if (last?.role === role) last.parts.push(...parts); + else result.push({ role, parts }); + }; + for (const msg of msgs) { + const role = msg.role === 'assistant' ? 'model' : msg.role; + for (const block of getContentBlocks(msg)) { + if (block.type === 'tool_result') { + const { text } = this.convertToolOutputToString(block.output, false); + append('user', [ + { + functionResponse: { + name: block.name, + id: block.id, + response: + block.state === 'error' ? { error: text } : { output: text }, + }, + }, + ]); + } else if (block.type === 'hint') { + append( + 'user', + typeof block.hint === 'string' + ? [{ text: block.hint }] + : block.hint.map(b => this.formatPart(b)).filter(p => p !== null) + ); + } else { + const part = this.formatPart(block); + if (part) append(role, [part]); + } + } + } + return result; + } + + /** + * Format one content block and preserve its opaque provider signature. + * @param block + * @returns The formatted or accumulated result. + */ + private formatPart(block: ContentBlock): GeminiPart | null { + let part: GeminiPart; + switch (block.type) { + case 'text': + part = { text: block.text }; + break; + case 'thinking': + // Unsigned thought summaries are not user-facing conversation text. + if (!block.thought_signature) return null; + part = { text: block.thinking, thought: true }; + break; + case 'tool_call': { + const args: unknown = JSON.parse(block.input || '{}'); + if (!args || typeof args !== 'object' || Array.isArray(args)) { + throw new Error( + `Gemini tool arguments for ${block.name} must be a JSON object` + ); + } + part = { + functionCall: { + name: block.name, + args: args as Record, + id: block.id, + }, + }; + break; + } + case 'data': + part = this.formatData(block); + break; + default: + return null; + } + if ('thought_signature' in block && typeof block.thought_signature === 'string') { + part.thoughtSignature = block.thought_signature; + } + return part; + } + + /** + * Convert inline data or a Gemini Files API URI to a native data part. + * @param block + * @returns The formatted or accumulated result. + */ + private formatData(block: DataBlock): GeminiPart { + return block.source.type === 'base64' + ? { inlineData: { mimeType: block.source.media_type, data: block.source.data } } + : { fileData: { mimeType: block.source.media_type, fileUri: block.source.url } }; + } +} diff --git a/packages/agentscope/src/formatter/index.ts b/packages/agentscope/src/formatter/index.ts index b25de07..d9ed01c 100644 --- a/packages/agentscope/src/formatter/index.ts +++ b/packages/agentscope/src/formatter/index.ts @@ -3,3 +3,4 @@ export { DashScopeChatFormatter } from './dashscope-chat-formatter'; export { DeepSeekChatFormatter } from './deepseek-chat-formatter'; export { OllamaChatFormatter } from './ollama-chat-formatter'; export { OpenAIChatFormatter } from './openai-chat-formatter'; +export { GeminiChatFormatter } from './gemini-chat-formatter'; diff --git a/packages/agentscope/src/message/block.ts b/packages/agentscope/src/message/block.ts index 902d7e2..3d5c355 100644 --- a/packages/agentscope/src/message/block.ts +++ b/packages/agentscope/src/message/block.ts @@ -2,6 +2,8 @@ import { _generateId, _generateTimestamp } from '../_utils/common'; import type { PermissionRule } from '../permission'; export interface TextBlock { + /** Opaque Gemini part signature, retained for conversation replay. */ + thought_signature?: string; type: 'text'; text: string; id: string; @@ -51,6 +53,8 @@ export interface HintBlock { export type ToolCallState = 'pending' | 'asking' | 'allowed' | 'submitted' | 'finished'; export interface ToolCallBlock { + /** Opaque Gemini part signature, retained for conversation replay. */ + thought_signature?: string; type: 'tool_call'; name: string; id: string; @@ -91,6 +95,8 @@ export interface URLSource { } export interface DataBlock { + /** Opaque Gemini part signature, retained for conversation replay. */ + thought_signature?: string; type: 'data'; source: Base64Source | URLSource; id: string; @@ -113,6 +119,9 @@ export function TextBlock( Partial> ): TextBlock { return { + ...(input.thought_signature !== undefined + ? { thought_signature: input.thought_signature } + : {}), type: 'text', text: input.text, id: input.id ?? _generateId(), @@ -176,6 +185,9 @@ export function DataBlock( Partial> ): DataBlock { return { + ...(input.thought_signature !== undefined + ? { thought_signature: input.thought_signature } + : {}), type: 'data', id: input.id ?? _generateId(), source: input.source, @@ -218,6 +230,9 @@ export function ToolCallBlock( Partial> ): ToolCallBlock { return { + ...(input.thought_signature !== undefined + ? { thought_signature: input.thought_signature } + : {}), type: 'tool_call', id: input.id, name: input.name, diff --git a/packages/agentscope/src/message/schema.ts b/packages/agentscope/src/message/schema.ts index 32a4d90..4acac24 100644 --- a/packages/agentscope/src/message/schema.ts +++ b/packages/agentscope/src/message/schema.ts @@ -27,6 +27,7 @@ const jsonRecordSchema = z.record(z.string(), z.json()); export const TextBlockSchema = z .object({ type: z.literal('text'), + thought_signature: z.string().optional(), text: z.string(), ...entityFields, }) @@ -66,6 +67,7 @@ export const DataSourceSchema = z.union([Base64SourceSchema, URLSourceSchema]); export const DataBlockSchema = z .object({ type: z.literal('data'), + thought_signature: z.string().optional(), source: DataSourceSchema, name: z.string().nullable().optional(), ...entityFields, @@ -91,6 +93,7 @@ export const HintBlockSchema = z export const ToolCallBlockSchema = z .object({ type: z.literal('tool_call'), + thought_signature: z.string().optional(), id: z.string(), name: z.string(), input: z.string(), diff --git a/packages/agentscope/src/model/gemini-model.test.ts b/packages/agentscope/src/model/gemini-model.test.ts new file mode 100644 index 0000000..4d65be8 --- /dev/null +++ b/packages/agentscope/src/model/gemini-model.test.ts @@ -0,0 +1,498 @@ +import { z } from 'zod'; + +import { GeminiChatModel } from './gemini-model'; +import { ChatResponse } from './response'; +import { Agent } from '../agent/agent'; +import { GeminiChatFormatter } from '../formatter/gemini-chat-formatter'; +import { createMsg, parseMsg, TextBlock, ToolResultBlock } from '../message'; +import { Toolkit } from '../tool'; +import { createToolResponse } from '../tool/response'; +import { ToolSchema } from '../type'; + +const tool: ToolSchema = { + type: 'function', + function: { + name: 'weather', + description: 'Look up weather', + parameters: { + type: 'object', + properties: { city: { type: 'string' } }, + required: ['city'], + additionalProperties: false, + }, + }, +}; +const messages = [ + createMsg({ name: 'user', role: 'user', content: [TextBlock({ text: '天气?' })] }), +]; +const originalFetch = global.fetch; +const fetchMock = jest.fn(); + +/** + * Read all deltas and the generator's final accumulated response. + * @param generator + * @returns The formatted or accumulated result. + */ +async function collect(generator: AsyncGenerator) { + const deltas: ChatResponse[] = []; + while (true) { + const next = await generator.next(); + if (next.done) return { deltas, final: next.value }; + deltas.push(next.value); + } +} + +/** + * Create a byte-fragmented SSE response, including CRLF and an unterminated final frame. + * @param chunks + * @returns The formatted or accumulated result. + */ +function streamResponse(chunks: unknown[]) { + const bytes = new TextEncoder().encode( + chunks + .map((c, i) => `data: ${JSON.stringify(c)}${i === chunks.length - 1 ? '' : '\r\n\r\n'}`) + .join('') + ); + return new Response( + new ReadableStream({ + start(controller) { + for (let i = 0; i < bytes.length; i += 7) controller.enqueue(bytes.slice(i, i + 7)); + controller.close(); + }, + }) + ); +} + +/** + * Native response fixture. + * @param parts + * @param extra + * @returns The formatted or accumulated result. + */ +function response(parts: unknown[], extra: Record = {}) { + return { candidates: [{ content: { role: 'model', parts } }], ...extra }; +} + +describe('GeminiChatModel', () => { + beforeEach(() => { + fetchMock.mockReset(); + global.fetch = fetchMock; + }); + afterAll(() => { + global.fetch = originalFetch; + }); + + test('uses native endpoint, headers, system instruction, generation config and tool schemas', async () => { + fetchMock.mockResolvedValue(Response.json(response([{ text: 'OK' }]))); + const model = new GeminiChatModel({ + apiKey: 'test-key', + modelName: 'models/gemini-3.8-flash', + stream: false, + baseURL: 'https://gemini.example/v1beta/', + presetGenParams: { temperature: 0.5, maxOutputTokens: 100 }, + presetHeaders: { 'X-Client': 'test' }, + }); + const result = (await model.call({ + messages: [ + createMsg({ + name: 'system', + role: 'system', + content: [TextBlock({ text: 'Be concise' })], + }), + ...messages, + ], + tools: [tool], + toolChoice: 'weather', + generationConfig: { temperature: 0.2 }, + })) as ChatResponse; + expect(result.content).toEqual([expect.objectContaining({ type: 'text', text: 'OK' })]); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://gemini.example/v1beta/models/gemini-3.8-flash:generateContent'); + expect(url).not.toContain('test-key'); + expect(init.headers).toMatchObject({ 'x-goog-api-key': 'test-key', 'X-Client': 'test' }); + expect(JSON.parse(init.body)).toEqual({ + contents: [{ role: 'user', parts: [{ text: '天气?' }] }], + systemInstruction: { parts: [{ text: 'Be concise' }] }, + tools: [ + { + functionDeclarations: [ + { + name: 'weather', + description: 'Look up weather', + parametersJsonSchema: tool.function.parameters, + }, + ], + }, + ], + toolConfig: { + functionCallingConfig: { mode: 'ANY', allowedFunctionNames: ['weather'] }, + }, + generationConfig: { temperature: 0.2, maxOutputTokens: 100 }, + }); + }); + + test.each([ + ['auto', 'AUTO'], + ['none', 'NONE'], + ['required', 'ANY'], + ])('maps tool choice %s', (choice, mode) => { + const model = new GeminiChatModel({ apiKey: 'key', modelName: 'gemini-test' }); + expect(model._formatToolChoice(choice)).toEqual({ functionCallingConfig: { mode } }); + expect(model._formatToolSchemas()).toEqual([]); + }); + + test('rejects missing tools and unknown tool names before making a request', async () => { + const model = new GeminiChatModel({ apiKey: 'key', modelName: 'gemini-test' }); + await expect(model.call({ messages, toolChoice: 'required' })).rejects.toThrow( + 'requires tools' + ); + await expect( + model.call({ messages, tools: [tool], toolChoice: 'missing' }) + ).rejects.toThrow('Unknown'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('returns parallel function calls, preserves signatures through serialization, and groups responses in one user turn', async () => { + fetchMock.mockResolvedValue( + Response.json( + response( + [ + { + functionCall: { name: 'weather', args: { city: '北京' }, id: 'call-a' }, + thoughtSignature: 'opaque-signature', + }, + { + functionCall: { + name: 'weather', + args: { city: 'Paris' }, + id: 'call-b', + }, + }, + ], + { + usageMetadata: { + promptTokenCount: 20, + candidatesTokenCount: 5, + thoughtsTokenCount: 7, + }, + } + ) + ) + ); + const model = new GeminiChatModel({ + apiKey: 'key', + modelName: 'gemini-test', + stream: false, + }); + const res = (await model.call({ messages, tools: [tool] })) as ChatResponse; + expect(res.content.map(b => b.id)).toEqual(['call-a', 'call-b']); + expect(res.usage).toMatchObject({ inputTokens: 20, outputTokens: 12 }); + const assistant = parseMsg( + JSON.parse( + JSON.stringify( + createMsg({ name: 'agent', role: 'assistant', content: res.content }) + ) + ) + ); + const history = await new GeminiChatFormatter().format({ + msgs: [ + assistant, + createMsg({ + name: 'tools', + role: 'assistant', + content: [ + ToolResultBlock({ + id: 'call-a', + name: 'weather', + output: 'Sunny', + state: 'success', + }), + ], + }), + createMsg({ + name: 'tools', + role: 'assistant', + content: [ + ToolResultBlock({ + id: 'call-b', + name: 'weather', + output: 'Rainy', + state: 'success', + }), + ], + }), + ], + }); + expect(history).toEqual([ + { + role: 'model', + parts: [ + { + functionCall: { name: 'weather', args: { city: '北京' }, id: 'call-a' }, + thoughtSignature: 'opaque-signature', + }, + { functionCall: { name: 'weather', args: { city: 'Paris' }, id: 'call-b' } }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'weather', + id: 'call-a', + response: { output: 'Sunny' }, + }, + }, + { + functionResponse: { + name: 'weather', + id: 'call-b', + response: { output: 'Rainy' }, + }, + }, + ], + }, + ]); + }); + + test('streams deltas, final complete function arguments and terminal usage across byte boundaries', async () => { + fetchMock.mockResolvedValue( + streamResponse([ + response([{ text: '分析', thought: true }]), + response([{ text: '天气', thought: true }, { text: '结果:' }]), + response([{ text: '晴天' }]), + response([ + { + functionCall: { name: 'weather', args: { city: '北京' } }, + thoughtSignature: 'sig', + }, + ]), + { + usageMetadata: { + promptTokenCount: 8, + candidatesTokenCount: 4, + thoughtsTokenCount: 3, + }, + }, + { candidates: [{ finishReason: 'STOP' }] }, + ]) + ); + const model = new GeminiChatModel({ apiKey: 'key', modelName: 'gemini-test' }); + const { deltas, final } = await collect( + (await model.call({ messages, tools: [tool] })) as AsyncGenerator< + ChatResponse, + ChatResponse + > + ); + expect(fetchMock.mock.calls[0][0]).toContain(':streamGenerateContent?alt=sse'); + expect(final.content).toEqual([ + expect.objectContaining({ type: 'thinking', thinking: '分析天气' }), + expect.objectContaining({ type: 'text', text: '结果:晴天' }), + expect.objectContaining({ + type: 'tool_call', + name: 'weather', + input: '{"city":"北京"}', + thought_signature: 'sig', + }), + ]); + expect(deltas.every(d => d.id === final.id)).toBe(true); + expect(deltas[2].content[0]).toMatchObject({ type: 'text', text: '晴天' }); + expect(final.usage).toMatchObject({ inputTokens: 8, outputTokens: 7 }); + expect(deltas[0].content[0]).toMatchObject({ thinking: '分析' }); // no mutation after yield + }); + + test('attaches a late signature to the accumulated text part and retains signed part boundaries', async () => { + fetchMock.mockResolvedValue( + streamResponse([ + response([{ text: 'First ' }]), + response([{ text: 'part', thoughtSignature: 'sig-a' }]), + response([{ text: 'Second' }]), + response([{ thoughtSignature: 'sig-b' }]), + ]) + ); + const model = new GeminiChatModel({ apiKey: 'key', modelName: 'gemini-test' }); + const { final } = await collect( + (await model.call({ messages })) as AsyncGenerator + ); + const msg = parseMsg( + JSON.parse( + JSON.stringify( + createMsg({ name: 'agent', role: 'assistant', content: final.content }) + ) + ) + ); + expect(await new GeminiChatFormatter().format({ msgs: [msg] })).toEqual([ + { + role: 'model', + parts: [ + { text: 'First part', thoughtSignature: 'sig-a' }, + { text: 'Second', thoughtSignature: 'sig-b' }, + ], + }, + ]); + }); + + test('callStructured uses named function calling in streaming mode', async () => { + fetchMock.mockResolvedValue( + streamResponse([ + response([ + { + functionCall: { + name: 'GenerateStructuredResponse', + args: { city: 'Paris' }, + }, + }, + ]), + ]) + ); + const model = new GeminiChatModel({ apiKey: 'key', modelName: 'gemini-test' }); + const res = await model.callStructured({ + messages, + schema: z.object({ city: z.string() }), + }); + expect(res.content).toEqual({ city: 'Paris' }); + expect( + JSON.parse(fetchMock.mock.calls[0][1].body).toolConfig.functionCallingConfig + ).toEqual({ mode: 'ANY', allowedFunctionNames: ['GenerateStructuredResponse'] }); + }); + + test('Agent executes a Gemini tool call and replays its signature in the next model request', async () => { + fetchMock + .mockResolvedValueOnce( + streamResponse([ + response([ + { + functionCall: { + name: 'weather', + args: { city: 'Paris' }, + id: 'provider-call', + }, + thoughtSignature: 'round-trip-sig', + }, + ]), + ]) + ) + .mockResolvedValueOnce(streamResponse([response([{ text: 'Sunny in Paris.' }])])); + const call = jest + .fn() + .mockResolvedValue( + createToolResponse({ content: [TextBlock({ text: 'Sunny' })], state: 'success' }) + ); + const toolkit = new Toolkit({ + builtInSkillTool: false, + tools: [ + { + name: 'weather', + description: 'Look up weather', + inputSchema: z.object({ city: z.string() }), + call, + requireUserConfirm: false, + }, + ], + }); + const agent = new Agent({ + name: 'agent', + sysPrompt: 'Help the user', + toolkit, + model: new GeminiChatModel({ apiKey: 'key', modelName: 'gemini-test' }), + }); + for await (const _event of agent.replyStream({ msgs: messages })) { + /* consume the real agent/tool lifecycle */ + } + expect(call).toHaveBeenCalledWith({ city: 'Paris' }); + expect(fetchMock).toHaveBeenCalledTimes(2); + const next = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(next.contents).toEqual([ + { role: 'user', parts: [{ text: '天气?' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + name: 'weather', + args: { city: 'Paris' }, + id: 'provider-call', + }, + thoughtSignature: 'round-trip-sig', + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'weather', + id: 'provider-call', + response: { output: 'Sunny' }, + }, + }, + ], + }, + ]); + expect(agent.context.at(-1)?.content.at(-1)).toMatchObject({ + type: 'text', + text: 'Sunny in Paris.', + }); + }); + + test('cancels the response body if the consumer stops streaming', async () => { + const cancel = jest.fn(); + fetchMock.mockResolvedValue( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + `data: ${JSON.stringify(response([{ text: 'hello' }]))}\n\n` + ) + ); + }, + cancel, + }) + ) + ); + const model = new GeminiChatModel({ apiKey: 'key', modelName: 'gemini-test' }); + const generator = (await model.call({ messages })) as AsyncGenerator< + ChatResponse, + ChatResponse + >; + await generator.next(); + await generator.return({ type: 'chat', id: '', createdAt: '', content: [] }); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + test('surfaces HTTP errors, blocked prompts, empty and malformed stream responses', async () => { + const model = new GeminiChatModel({ + apiKey: 'key', + modelName: 'gemini-test', + stream: false, + }); + fetchMock.mockResolvedValueOnce(new Response('rate limited', { status: 429 })); + await expect(model.call({ messages })).rejects.toThrow('429'); + fetchMock.mockResolvedValueOnce( + Response.json({ promptFeedback: { blockReason: 'SAFETY' } }) + ); + await expect(model.call({ messages })).rejects.toThrow('SAFETY'); + fetchMock.mockResolvedValueOnce( + Response.json({ candidates: [{ finishReason: 'SAFETY' }] }) + ); + await expect(model.call({ messages })).rejects.toThrow('no content'); + const streaming = new GeminiChatModel({ apiKey: 'key', modelName: 'gemini-test' }); + fetchMock.mockResolvedValueOnce(new Response('data: {broken}\n\n')); + const generator = (await streaming.call({ messages })) as AsyncGenerator< + ChatResponse, + ChatResponse + >; + await expect(generator.next()).rejects.toThrow(); + fetchMock.mockResolvedValueOnce( + streamResponse([{ error: { message: 'invalid request' } }]) + ); + const errorStream = (await streaming.call({ messages })) as AsyncGenerator< + ChatResponse, + ChatResponse + >; + await expect(errorStream.next()).rejects.toThrow('invalid request'); + }); +}); diff --git a/packages/agentscope/src/model/gemini-model.ts b/packages/agentscope/src/model/gemini-model.ts new file mode 100644 index 0000000..bf33395 --- /dev/null +++ b/packages/agentscope/src/model/gemini-model.ts @@ -0,0 +1,310 @@ +import { ChatModelBase, ChatModelOptions, ChatModelRequestOptions } from './base'; +import { ChatResponse } from './response'; +import { GeminiChatFormatter, GeminiPart } from '../formatter/gemini-chat-formatter'; +import { DataBlock, TextBlock, ThinkingBlock, ToolCallBlock } from '../message'; +import { ToolChoice, ToolSchema } from '../type'; + +/** Options for the Gemini Developer API (not Vertex AI). */ +export interface GeminiChatModelOptions extends ChatModelOptions { + apiKey: string; + /** REST API root, including the version, e.g. https://generativelanguage.googleapis.com/v1beta. */ + baseURL?: string; + /** Native generationConfig defaults; per-call generationConfig takes precedence. */ + presetGenParams?: Record; + presetHeaders?: Record; +} + +interface GeminiResponse { + responseId?: string; + candidates?: { content?: { parts?: GeminiPart[] }; finishReason?: string }[]; + usageMetadata?: { + promptTokenCount?: number; + candidatesTokenCount?: number; + thoughtsTokenCount?: number; + }; + promptFeedback?: { blockReason?: string }; + error?: { message?: string }; +} + +type GeminiBlock = TextBlock | ThinkingBlock | ToolCallBlock | DataBlock; + +/** Gemini native REST adapter with text, thinking, data and function-call responses. */ +export class GeminiChatModel extends ChatModelBase { + private apiKey: string; + private baseURL: string; + private presetGenParams: Record; + private presetHeaders: Record; + + /** + * Initialize the model with a native Gemini formatter by default. + * @param options + */ + constructor(options: GeminiChatModelOptions) { + super({ ...options, formatter: options.formatter ?? new GeminiChatFormatter() }); + this.apiKey = options.apiKey; + this.baseURL = ( + options.baseURL ?? 'https://generativelanguage.googleapis.com/v1beta' + ).replace(/\/$/, ''); + this.presetGenParams = options.presetGenParams ?? {}; + this.presetHeaders = options.presetHeaders ?? {}; + } + + /** + * Send a generateContent or streamGenerateContent request. + * @param modelName + * @param options + * @returns The formatted or accumulated result. + */ + async _callAPI( + modelName: string, + options: ChatModelRequestOptions> + ): Promise> { + const system = options.messages.filter(m => m.role === 'system'); + const contents = options.messages.filter(m => m.role !== 'system'); + if (!contents.length) throw new Error('Gemini requires at least one non-system message'); + const names = (options.tools ?? []).map(t => t.function.name); + if (options.toolChoice && !['auto', 'none'].includes(options.toolChoice)) { + if (!names.length) throw new Error('Gemini tool choice requires tools'); + if (options.toolChoice !== 'required' && !names.includes(options.toolChoice)) { + throw new Error(`Unknown Gemini tool choice: ${options.toolChoice}`); + } + } + const body = { + contents, + ...(system.length + ? { systemInstruction: { parts: system.flatMap(m => m.parts as GeminiPart[]) } } + : {}), + ...(names.length + ? { + tools: this._formatToolSchemas(options.tools), + toolConfig: this._formatToolChoice(options.toolChoice), + } + : {}), + generationConfig: { + ...this.presetGenParams, + ...((options.generationConfig as Record) ?? {}), + }, + ...(options.safetySettings ? { safetySettings: options.safetySettings } : {}), + }; + const method = this.stream ? 'streamGenerateContent?alt=sse' : 'generateContent'; + const model = encodeURIComponent(modelName.replace(/^models\//, '')); + const startTime = Date.now(); + const response = await fetch(`${this.baseURL}/models/${model}:${method}`, { + method: 'POST', + headers: { + ...this.presetHeaders, + 'Content-Type': 'application/json', + 'x-goog-api-key': this.apiKey, + }, + body: JSON.stringify(body), + signal: options.signal as AbortSignal | undefined, + }); + if (!response.ok) + throw new Error( + `Gemini API request failed with status ${response.status}: ${await response.text()}` + ); + if (this.stream) return this.parseStream(response, startTime); + const raw: GeminiResponse = await response.json(); + const result = this.emptyResponse(); + this.appendResponse(result, raw, startTime); + if (!result.content.length) + throw new Error( + `Gemini returned no content (${raw.promptFeedback?.blockReason ?? raw.candidates?.[0]?.finishReason ?? 'empty response'})` + ); + return result; + } + + /** + * Map unified tool choice modes to Gemini functionCallingConfig. + * @param toolChoice + * @returns The formatted or accumulated result. + */ + _formatToolChoice(toolChoice: ToolChoice = 'auto') { + const mode = toolChoice === 'auto' ? 'AUTO' : toolChoice === 'none' ? 'NONE' : 'ANY'; + return { + functionCallingConfig: { + mode, + ...(!['auto', 'none', 'required'].includes(toolChoice) + ? { allowedFunctionNames: [toolChoice] } + : {}), + }, + }; + } + + /** + * Use the REST API's JSON Schema field without dropping schema constraints. + * @param tools + * @returns The formatted or accumulated result. + */ + _formatToolSchemas(tools: ToolSchema[] = []): Record[] { + if (!tools.length) return []; + return [ + { + functionDeclarations: tools.map(t => ({ + name: t.function.name, + description: t.function.description, + parametersJsonSchema: t.function.parameters, + })), + }, + ]; + } + + /** Create a response whose identity remains stable across streaming deltas. + * @returns The formatted or accumulated result. + */ + private emptyResponse(): ChatResponse { + return { + type: 'chat', + id: crypto.randomUUID(), + createdAt: new Date().toISOString(), + content: [], + }; + } + + /** + * Append incremental native parts and retain usage-only terminal chunks. + * @param result + * @param raw + * @param startTime + * @param streaming + * @returns The formatted or accumulated result. + */ + private appendResponse( + result: ChatResponse, + raw: GeminiResponse, + startTime: number, + streaming = false + ): GeminiBlock[] { + if (raw.error) throw new Error(`Gemini API error: ${raw.error.message ?? 'unknown error'}`); + if (raw.promptFeedback?.blockReason) + throw new Error(`Gemini prompt blocked: ${raw.promptFeedback.blockReason}`); + const deltas: GeminiBlock[] = []; + for (const [index, part] of (raw.candidates?.[0]?.content?.parts ?? []).entries()) { + const common = { + id: crypto.randomUUID(), + created_at: result.createdAt, + ...(part.thoughtSignature ? { thought_signature: part.thoughtSignature } : {}), + }; + let block: GeminiBlock; + if (part.functionCall) { + block = { + ...common, + type: 'tool_call', + id: part.functionCall.id ?? common.id, + name: part.functionCall.name, + input: JSON.stringify(part.functionCall.args ?? {}), + state: 'pending', + }; + } else if (part.inlineData) { + block = { + ...common, + type: 'data', + source: { + type: 'base64', + data: part.inlineData.data, + media_type: part.inlineData.mimeType, + }, + }; + } else if (part.text !== undefined) { + block = part.thought + ? { ...common, type: 'thinking', thinking: part.text } + : { ...common, type: 'text', text: part.text }; + const last = result.content.at(-1); + // Merge deltas of an unfinished text part; a signature closes that part. + if ( + streaming && + index === 0 && + last?.type === block.type && + !('thought_signature' in last) + ) { + block.id = last.id; + if (part.thoughtSignature) + Object.assign(last, { thought_signature: part.thoughtSignature }); + if (last.type === 'text' && block.type === 'text') last.text += block.text; + if (last.type === 'thinking' && block.type === 'thinking') + last.thinking += block.thinking; + deltas.push(block); + continue; + } + } else if (part.thoughtSignature) { + // Providers can finish a text part with a signature-only chunk. + const last = result.content.at(-1); + if (last) { + Object.assign(last, { thought_signature: part.thoughtSignature }); + continue; + } + block = { ...common, type: 'text', text: '' }; + } else continue; + result.content.push({ ...block }); + deltas.push(block); + } + if (raw.usageMetadata) { + result.usage = { + type: 'chat_usage', + inputTokens: raw.usageMetadata.promptTokenCount ?? 0, + outputTokens: + (raw.usageMetadata.candidatesTokenCount ?? 0) + + (raw.usageMetadata.thoughtsTokenCount ?? 0), + time: (Date.now() - startTime) / 1000, + }; + } + return deltas; + } + + /** + * Parse SSE frames across arbitrary network boundaries and cancel on early return. + * @param response + * @param startTime + * @returns The formatted or accumulated result. + */ + private async *parseStream( + response: Response, + startTime: number + ): AsyncGenerator { + const reader = response.body?.getReader(); + if (!reader) throw new Error('Gemini streaming response has no body'); + const decoder = new TextDecoder(); + const result = this.emptyResponse(); + let buffer = ''; + let ended = false; + const parseFrame = (frame: string) => { + const data = frame + .split(/\r?\n/) + .filter(line => line.startsWith('data:')) + .map(line => line.slice(5).trimStart()) + .join('\n'); + if (!data || data === '[DONE]') return null; + const raw: GeminiResponse = JSON.parse(data); + const content = this.appendResponse(result, raw, startTime, true); + return { ...result, content, ...(result.usage ? { usage: { ...result.usage } } : {}) }; + }; + try { + while (true) { + const { value, done } = await reader.read(); + buffer += done ? decoder.decode() : decoder.decode(value, { stream: true }); + let boundary: RegExpExecArray | null; + while ((boundary = /\r?\n\r?\n/.exec(buffer))) { + const chunk = parseFrame(buffer.slice(0, boundary.index)); + buffer = buffer.slice(boundary.index + boundary[0].length); + if (chunk) yield chunk; + } + if (done) { + ended = true; + break; + } + } + if (buffer.trim()) { + const chunk = parseFrame(buffer); + if (chunk) yield chunk; + } + if (!result.content.length) throw new Error('Gemini returned no content in stream'); + return result; + } finally { + try { + if (!ended) await reader.cancel(); + } finally { + reader.releaseLock(); + } + } + } +} diff --git a/packages/agentscope/src/model/index.ts b/packages/agentscope/src/model/index.ts index b49cb7c..6e9a245 100644 --- a/packages/agentscope/src/model/index.ts +++ b/packages/agentscope/src/model/index.ts @@ -5,3 +5,5 @@ export { DashScopeChatModel } from './dashscope-model'; export { DeepSeekChatModel } from './deepseek-model'; export { OllamaChatModel } from './ollama-model'; export { OpenAIChatModel } from './openai-model'; +export { GeminiChatModel } from './gemini-model'; +export type { GeminiChatModelOptions } from './gemini-model';