diff --git a/AGENTS.md b/AGENTS.md index 98a83a8c..85a8e09c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,8 +52,10 @@ sdk-typescript/ │ │ │ ├── models/ # Model provider implementations │ │ ├── __tests__/ # Unit tests for model providers -│ │ │ └── bedrock.test.ts # Tests for Bedrock model provider +│ │ │ ├── bedrock.test.ts # Tests for Bedrock model provider +│ │ │ └── gemini.test.ts # Tests for Gemini model provider │ │ ├── bedrock.ts # AWS Bedrock model provider +│ │ ├── gemini.ts # Google Gemini model provider │ │ ├── model.ts # Base model provider interface │ │ └── streaming.ts # Streaming event types │ │ @@ -136,7 +138,7 @@ sdk-typescript/ - **`src/agent/`**: Agent loop coordination, streaming event types, output printing, and conversation management - **`src/agent/conversation-manager/`**: Conversation history management strategies - **`src/hooks/`**: Hooks system for event-driven extensibility -- **`src/models/`**: Model provider implementations (Bedrock, OpenAI, future providers) +- **`src/models/`**: Model provider implementations (Bedrock, OpenAI, Gemini, future providers) - **`src/tools/`**: Tool definitions and types for agent tool use - **`src/types/`**: Core type definitions used across the SDK - **`vended_tools/`**: Optional vended tools (not part of core SDK, independently importable) diff --git a/README.md b/README.md index e16612c4..286c0520 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Strands Agents is a simple yet powerful SDK that takes a model-driven approach t - **🪶 Lightweight & Flexible**: Simple agent loop that works seamlessly in Node.js and browser environments - **🔒 Type-Safe Tools**: Define tools easily using Zod schemas for robust input validation and type inference -- **🔌 Model Agnostic**: First-class support for Amazon Bedrock and OpenAI, with extensible architecture for custom providers +- **🔌 Model Agnostic**: First-class support for Amazon Bedrock, OpenAI, and Google Gemini, with extensible architecture for custom providers - **🔗 Built-in MCP**: Native support for Model Context Protocol (MCP) clients, enabling access to external tools and servers - **⚡ Streaming Support**: Real-time response streaming for better user experience - **🎣 Extensible Hooks**: Lifecycle hooks for monitoring and customizing agent behavior @@ -120,6 +120,21 @@ const model = new OpenAIModel() const agent = new Agent({ model }) ``` +**Google Gemini** + +```typescript +import { Agent } from '@strands-agents/sdk' +import { GeminiModel } from '@strands-agents/sdk/gemini' + +const model = new GeminiModel({ + modelId: 'gemini-2.5-flash', + clientArgs: { apiKey: process.env.GOOGLE_API_KEY }, + params: { temperature: 0.7, maxTokens: 2048 } +}) + +const agent = new Agent({ model }) +``` + ### Streaming Responses Access responses as they are generated: diff --git a/package-lock.json b/package-lock.json index 737d7f59..1a078b4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "node": ">=20.0.0" }, "optionalDependencies": { + "@google/genai": "^1.31.0", "openai": "^6.7.0" } }, @@ -2707,6 +2708,28 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@google/genai": { + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.31.0.tgz", + "integrity": "sha512-rK0RKXxNkbK35eDl+G651SxtxwHNEOogjyeZJUJe+Ed4yxu3xy5ufCiU0+QLT7xo4M9Spey8OAYfD8LPRlYBKw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "google-auth-library": "^10.3.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.20.1" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2759,6 +2782,24 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -2899,6 +2940,16 @@ "node": ">= 8" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -4373,6 +4424,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -4406,11 +4467,24 @@ } } }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4455,9 +4529,40 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + } + }, "node_modules/body-parser": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", @@ -4492,7 +4597,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -4511,6 +4616,13 @@ "node": ">=8" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -4590,7 +4702,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4603,7 +4715,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/concat-map": { @@ -4680,6 +4792,16 @@ "node": ">= 8" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4727,12 +4849,36 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT", + "optional": true + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT", + "optional": true + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -5353,6 +5499,13 @@ "express": ">= 4.11" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT", + "optional": true + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5465,6 +5618,30 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5546,6 +5723,36 @@ "dev": true, "license": "ISC" }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "optional": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "optional": true, + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5588,6 +5795,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -5638,6 +5876,27 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "optional": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -5664,6 +5923,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5683,6 +5971,20 @@ "dev": true, "license": "MIT" }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "optional": true, + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5744,6 +6046,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -5854,6 +6170,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -5943,6 +6269,22 @@ "node": ">=8" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jju": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", @@ -5979,6 +6321,16 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -5999,6 +6351,29 @@ "dev": true, "license": "MIT" }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "optional": true, + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6046,6 +6421,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC", + "optional": true + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -6180,7 +6562,7 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -6192,6 +6574,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -6243,6 +6635,46 @@ "node": ">= 0.6" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "optional": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6368,6 +6800,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0", + "optional": true + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6416,6 +6855,23 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-to-regexp": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", @@ -6716,6 +7172,22 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", @@ -6798,6 +7270,27 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -6960,6 +7453,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -7008,6 +7514,110 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -7917,6 +8527,16 @@ } } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7959,6 +8579,101 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 1bd73211..fbd19176 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,10 @@ "types": "./dist/src/models/bedrock.d.ts", "default": "./dist/src/models/bedrock.js" }, + "./gemini": { + "types": "./dist/src/models/gemini.d.ts", + "default": "./dist/src/models/gemini.js" + }, "./vended_tools/notebook": { "types": "./dist/src/vended-tools/notebook/index.d.ts", "default": "./dist/src/vended-tools/notebook/index.js" @@ -108,6 +112,7 @@ "zod": "^4.1.12" }, "optionalDependencies": { + "@google/genai": "^1.31.0", "openai": "^6.7.0" }, "overrides": { diff --git a/src/index.ts b/src/index.ts index 740b1fea..6ed91300 100644 --- a/src/index.ts +++ b/src/index.ts @@ -133,6 +133,10 @@ export type { BaseModelConfig, StreamOptions, Model } from './models/model.js' export { BedrockModel as BedrockModel } from './models/bedrock.js' export type { BedrockModelConfig, BedrockModelOptions } from './models/bedrock.js' +// Gemini model provider +export { GeminiModel } from './models/gemini.js' +export type { GeminiModelConfig, GeminiModelOptions } from './models/gemini.js' + // Agent streaming event types export type { AgentStreamEvent } from './types/agent.js' diff --git a/src/models/__tests__/gemini.test.ts b/src/models/__tests__/gemini.test.ts new file mode 100644 index 00000000..0a9feb15 --- /dev/null +++ b/src/models/__tests__/gemini.test.ts @@ -0,0 +1,588 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { GeminiModel } from '../gemini.js' +import { ContextWindowOverflowError } from '../../errors.js' +import { Message, TextBlock } from '../../types/messages.js' +import { ImageBlock, DocumentBlock } from '../../types/media.js' +import { collectIterator } from '../../__fixtures__/model-test-helpers.js' + +/** + * Helper to create a mock Google GenAI client with streaming support + */ +function createMockClient(streamGenerator: () => AsyncGenerator): any { + return { + models: { + generateContentStream: vi.fn(async () => streamGenerator()), + }, + } +} + +// Mock the Google GenAI SDK +vi.mock('@google/genai', () => { + const mockConstructor = vi.fn(function (_this: unknown, _options?: unknown) { + return { + models: { + generateContentStream: vi.fn(), + }, + } + }) + return { + GoogleGenAI: mockConstructor, + } +}) + +describe('GeminiModel', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('constructor', () => { + it('creates an instance with required modelId and clientArgs', () => { + const provider = new GeminiModel({ + modelId: 'gemini-2.5-flash', + clientArgs: { apiKey: 'test-api-key' }, + }) + const config = provider.getConfig() + expect(config.modelId).toBe('gemini-2.5-flash') + }) + + it('uses default model ID when not specified', () => { + const provider = new GeminiModel({ + clientArgs: { apiKey: 'test-api-key' }, + }) + expect(provider.getConfig().modelId).toBe('gemini-2.5-flash') + }) + + it('uses custom model ID', () => { + const customModelId = 'gemini-1.5-pro' + const provider = new GeminiModel({ + modelId: customModelId, + clientArgs: { apiKey: 'test-api-key' }, + }) + expect(provider.getConfig()).toStrictEqual({ + modelId: customModelId, + }) + }) + + it('accepts params configuration', () => { + const provider = new GeminiModel({ + modelId: 'gemini-2.5-flash', + clientArgs: { apiKey: 'test-api-key' }, + params: { temperature: 0.7, maxTokens: 1024 }, + }) + expect(provider.getConfig().params).toStrictEqual({ + temperature: 0.7, + maxTokens: 1024, + }) + }) + }) + + describe('updateConfig', () => { + it('merges new config with existing config', () => { + const provider = new GeminiModel({ + modelId: 'gemini-2.5-flash', + clientArgs: { apiKey: 'test-api-key' }, + params: { temperature: 0.5 }, + }) + provider.updateConfig({ + modelId: 'gemini-2.5-flash', + params: { temperature: 0.8, maxTokens: 2048 }, + }) + expect(provider.getConfig()).toStrictEqual({ + modelId: 'gemini-2.5-flash', + params: { temperature: 0.8, maxTokens: 2048 }, + }) + }) + + it('preserves fields not included in the update', () => { + const provider = new GeminiModel({ + modelId: 'gemini-2.5-flash', + clientArgs: { apiKey: 'test-api-key' }, + params: { temperature: 0.5, maxTokens: 1024 }, + }) + provider.updateConfig({ + modelId: 'gemini-2.5-flash', + params: { temperature: 0.8 }, + }) + expect(provider.getConfig()).toStrictEqual({ + modelId: 'gemini-2.5-flash', + params: { temperature: 0.8, maxTokens: 1024 }, + }) + }) + }) + + describe('getConfig', () => { + it('returns the current configuration', () => { + const provider = new GeminiModel({ + modelId: 'gemini-2.5-flash', + clientArgs: { apiKey: 'test-api-key' }, + params: { temperature: 0.7, maxTokens: 1024 }, + }) + expect(provider.getConfig()).toStrictEqual({ + modelId: 'gemini-2.5-flash', + params: { temperature: 0.7, maxTokens: 1024 }, + }) + }) + }) + + describe('stream', () => { + describe('validation', () => { + it('throws error when messages array is empty', async () => { + const mockClient = createMockClient(async function* () {}) + const provider = new GeminiModel({ + modelId: 'gemini-2.5-flash', + clientArgs: { apiKey: 'test-api-key' }, + }) + // @ts-expect-error - Accessing private property for testing + provider._client = mockClient + + await expect(async () => { + await collectIterator(provider.stream([])) + }).rejects.toThrow('At least one message is required') + }) + }) + + describe('text generation', () => { + it('streams text content correctly', async () => { + const mockClient = createMockClient(async function* () { + yield { + candidates: [ + { + content: { + parts: [{ text: 'Hello' }], + }, + }, + ], + } + yield { + candidates: [ + { + content: { + parts: [{ text: ' world' }], + }, + }, + ], + } + yield { + candidates: [ + { + finishReason: 'STOP', + content: { + parts: [{ text: '!' }], + }, + }, + ], + usageMetadata: { + promptTokenCount: 10, + totalTokenCount: 15, + }, + } + }) + + const provider = new GeminiModel({ + modelId: 'gemini-2.5-flash', + clientArgs: { apiKey: 'test-api-key' }, + }) + // @ts-expect-error - Accessing private property for testing + provider._client = mockClient + + const messages: Message[] = [new Message({ role: 'user', content: [new TextBlock('Say hello')] })] + + const events = await collectIterator(provider.stream(messages)) + + expect(events).toEqual([ + { type: 'modelMessageStartEvent', role: 'assistant' }, + { type: 'modelContentBlockStartEvent' }, + { type: 'modelContentBlockDeltaEvent', delta: { type: 'textDelta', text: 'Hello' } }, + { type: 'modelContentBlockDeltaEvent', delta: { type: 'textDelta', text: ' world' } }, + { type: 'modelContentBlockDeltaEvent', delta: { type: 'textDelta', text: '!' } }, + { type: 'modelContentBlockStopEvent' }, + { type: 'modelMessageStopEvent', stopReason: 'endTurn' }, + { + type: 'modelMetadataEvent', + usage: { + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + }, + metrics: { + latencyMs: 0, + }, + }, + ]) + }) + }) + + describe('tool use', () => { + it('handles tool use blocks correctly', async () => { + const mockClient = createMockClient(async function* () { + yield { + candidates: [ + { + content: { + parts: [ + { + functionCall: { + name: 'calculator', + args: { operation: 'add', a: 2, b: 2 }, + }, + }, + ], + }, + }, + ], + } + yield { + candidates: [ + { + finishReason: 'STOP', + content: { + parts: [], + }, + }, + ], + usageMetadata: { + promptTokenCount: 20, + totalTokenCount: 25, + }, + } + }) + + const provider = new GeminiModel({ + modelId: 'gemini-2.5-flash', + clientArgs: { apiKey: 'test-api-key' }, + }) + // @ts-expect-error - Accessing private property for testing + provider._client = mockClient + + const messages: Message[] = [new Message({ role: 'user', content: [new TextBlock('Calculate 2+2')] })] + + const events = await collectIterator(provider.stream(messages)) + + expect(events).toEqual([ + { type: 'modelMessageStartEvent', role: 'assistant' }, + { + type: 'modelContentBlockStartEvent', + start: { + type: 'toolUseStart', + name: 'calculator', + toolUseId: 'calculator', + }, + }, + { + type: 'modelContentBlockDeltaEvent', + delta: { + type: 'toolUseInputDelta', + input: JSON.stringify({ operation: 'add', a: 2, b: 2 }), + }, + }, + { type: 'modelContentBlockStopEvent' }, + { type: 'modelMessageStopEvent', stopReason: 'toolUse' }, + { + type: 'modelMetadataEvent', + usage: { + inputTokens: 20, + outputTokens: 5, + totalTokens: 25, + }, + metrics: { + latencyMs: 0, + }, + }, + ]) + }) + }) + + describe('stop reasons', () => { + it('maps MAX_TOKENS finish reason correctly', async () => { + const mockClient = createMockClient(async function* () { + yield { + candidates: [ + { + finishReason: 'MAX_TOKENS', + content: { + parts: [{ text: 'Partial' }], + }, + }, + ], + usageMetadata: { + promptTokenCount: 10, + totalTokenCount: 1000, + }, + } + }) + + const provider = new GeminiModel({ + modelId: 'gemini-2.5-flash', + clientArgs: { apiKey: 'test-api-key' }, + }) + // @ts-expect-error - Accessing private property for testing + provider._client = mockClient + + const messages: Message[] = [new Message({ role: 'user', content: [new TextBlock('Test')] })] + + const events = await collectIterator(provider.stream(messages)) + + const stopEvent = events.find((e) => e.type === 'modelMessageStopEvent') + expect(stopEvent).toEqual({ + type: 'modelMessageStopEvent', + stopReason: 'maxTokens', + }) + }) + }) + + describe('error handling', () => { + it('throws ContextWindowOverflowError on token limit', async () => { + // eslint-disable-next-line require-yield + const mockClient = createMockClient(async function* () { + const error = new Error('Input exceeds the maximum number of tokens') as Error & { status?: string } + error.status = 'INVALID_ARGUMENT' + throw error + }) + + const provider = new GeminiModel({ + modelId: 'gemini-2.5-flash', + clientArgs: { apiKey: 'test-api-key' }, + }) + // @ts-expect-error - Accessing private property for testing + provider._client = mockClient + + const messages: Message[] = [new Message({ role: 'user', content: [new TextBlock('Test')] })] + + await expect(async () => { + await collectIterator(provider.stream(messages)) + }).rejects.toThrow(ContextWindowOverflowError) + }) + + it('re-throws throttling errors', async () => { + // eslint-disable-next-line require-yield + const mockClient = createMockClient(async function* () { + const error = new Error('Resource exhausted') as Error & { status?: string } + error.status = 'RESOURCE_EXHAUSTED' + throw error + }) + + const provider = new GeminiModel({ + modelId: 'gemini-2.5-flash', + clientArgs: { apiKey: 'test-api-key' }, + }) + // @ts-expect-error - Accessing private property for testing + provider._client = mockClient + + const messages: Message[] = [new Message({ role: 'user', content: [new TextBlock('Test')] })] + + await expect(async () => { + await collectIterator(provider.stream(messages)) + }).rejects.toThrow('Resource exhausted') + }) + }) + + describe('system prompt', () => { + it('handles string system prompt', async () => { + const mockClient = createMockClient(async function* () { + yield { + candidates: [ + { + finishReason: 'STOP', + content: { + parts: [{ text: 'Response' }], + }, + }, + ], + usageMetadata: { + promptTokenCount: 10, + totalTokenCount: 15, + }, + } + }) + + const provider = new GeminiModel({ + modelId: 'gemini-2.5-flash', + clientArgs: { apiKey: 'test-api-key' }, + }) + // @ts-expect-error - Accessing private property for testing + provider._client = mockClient + + const messages: Message[] = [new Message({ role: 'user', content: [new TextBlock('Test')] })] + + const events = await collectIterator(provider.stream(messages, { systemPrompt: 'You are helpful' })) + + expect(events.length).toBeGreaterThan(0) + expect(mockClient.models.generateContentStream).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + systemInstruction: 'You are helpful', + }), + }) + ) + }) + }) + + describe('tool specs', () => { + it('formats tool specs correctly', async () => { + const mockClient = createMockClient(async function* () { + yield { + candidates: [ + { + finishReason: 'STOP', + content: { + parts: [{ text: 'Response' }], + }, + }, + ], + usageMetadata: { + promptTokenCount: 10, + totalTokenCount: 15, + }, + } + }) + + const provider = new GeminiModel({ + modelId: 'gemini-2.5-flash', + clientArgs: { apiKey: 'test-api-key' }, + }) + // @ts-expect-error - Accessing private property for testing + provider._client = mockClient + + const messages: Message[] = [new Message({ role: 'user', content: [new TextBlock('Test')] })] + + const toolSpecs = [ + { + name: 'calculator', + description: 'Performs calculations', + inputSchema: { + type: 'object' as const, + properties: { + a: { type: 'number' as const }, + b: { type: 'number' as const }, + }, + }, + }, + ] + + await collectIterator(provider.stream(messages, { toolSpecs })) + + expect(mockClient.models.generateContentStream).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + tools: [ + { + functionDeclarations: [ + { + name: 'calculator', + description: 'Performs calculations', + parametersJsonSchema: { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'number' }, + }, + }, + }, + ], + }, + ], + }), + }) + ) + }) + }) + + describe('content block formatting', () => { + it('formats image blocks with bytes', async () => { + const mockClient = createMockClient(async function* () { + yield { + candidates: [ + { + finishReason: 'STOP', + content: { + parts: [{ text: 'Image received' }], + }, + }, + ], + usageMetadata: { + promptTokenCount: 10, + totalTokenCount: 15, + }, + } + }) + + const provider = new GeminiModel({ + modelId: 'gemini-2.5-flash', + clientArgs: { apiKey: 'test-api-key' }, + }) + // @ts-expect-error - Accessing private property for testing + provider._client = mockClient + + const imageBytes = new Uint8Array([1, 2, 3, 4]) + const messages: Message[] = [ + new Message({ + role: 'user', + content: [ + new ImageBlock({ + format: 'png', + source: { bytes: imageBytes }, + }), + ], + }), + ] + + await collectIterator(provider.stream(messages)) + + const callArgs = mockClient.models.generateContentStream.mock.calls[0][0] + expect(callArgs.contents[0].parts[0]).toHaveProperty('inlineData') + expect(callArgs.contents[0].parts[0].inlineData.mimeType).toBe('image/png') + }) + + it('formats document blocks with bytes', async () => { + const mockClient = createMockClient(async function* () { + yield { + candidates: [ + { + finishReason: 'STOP', + content: { + parts: [{ text: 'Document received' }], + }, + }, + ], + usageMetadata: { + promptTokenCount: 10, + totalTokenCount: 15, + }, + } + }) + + const provider = new GeminiModel({ + modelId: 'gemini-2.5-flash', + clientArgs: { apiKey: 'test-api-key' }, + }) + // @ts-expect-error - Accessing private property for testing + provider._client = mockClient + + const docBytes = new Uint8Array([1, 2, 3, 4]) + const messages: Message[] = [ + new Message({ + role: 'user', + content: [ + new DocumentBlock({ + name: 'test.pdf', + format: 'pdf', + source: { bytes: docBytes }, + }), + ], + }), + ] + + await collectIterator(provider.stream(messages)) + + const callArgs = mockClient.models.generateContentStream.mock.calls[0][0] + expect(callArgs.contents[0].parts[0]).toHaveProperty('inlineData') + expect(callArgs.contents[0].parts[0].inlineData.mimeType).toBe('application/pdf') + }) + }) + }) +}) diff --git a/src/models/gemini.ts b/src/models/gemini.ts new file mode 100644 index 00000000..e688c32a --- /dev/null +++ b/src/models/gemini.ts @@ -0,0 +1,704 @@ +/** + * Google Gemini model provider implementation. + * + * This module provides integration with Google's Gemini API, + * supporting streaming responses, tool use, and configurable model parameters. + * + * @see https://ai.google.dev/api + */ + +import { GoogleGenAI } from '@google/genai' +import { Model } from '../models/model.js' +import type { BaseModelConfig, StreamOptions } from '../models/model.js' +import type { Message, ContentBlock } from '../types/messages.js' +import type { ImageBlock, DocumentBlock } from '../types/media.js' +import { encodeBase64 } from '../types/media.js' +import type { ModelStreamEvent } from '../models/streaming.js' +import { ContextWindowOverflowError } from '../errors.js' +import { logger } from '../logging/logger.js' + +/** + * Browser-compatible MIME type lookup. + * Maps file extensions to MIME types without using Node.js path module. + */ +const mimeTypeLookup = (format: string): string => { + const mimeTypes: Record = { + // Images + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + // Documents + pdf: 'application/pdf', + csv: 'text/csv', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + xls: 'application/vnd.ms-excel', + txt: 'text/plain', + json: 'application/json', + xml: 'application/xml', + html: 'text/html', + md: 'text/markdown', + } + return mimeTypes[format.toLowerCase()] || 'application/octet-stream' +} + +const DEFAULT_GEMINI_MODEL_ID = 'gemini-2.5-flash' + +/** + * Error message patterns that indicate context window overflow. + * Used to detect when input exceeds the model's context window. + */ +const GEMINI_CONTEXT_WINDOW_OVERFLOW_PATTERNS = ['exceeds the maximum number of tokens'] + +/** + * Configuration interface for Gemini model provider. + * + * Extends BaseModelConfig with Gemini-specific configuration options + * for model parameters and request settings. + * + * @example + * ```typescript + * const config: GeminiModelConfig = { + * modelId: 'gemini-2.5-flash', + * params: { temperature: 0.7, maxTokens: 1024 } + * } + * ``` + */ +export interface GeminiModelConfig extends BaseModelConfig { + /** + * Gemini model identifier (e.g., gemini-2.5-flash, gemini-1.5-pro). + */ + modelId?: string + + /** + * Additional model parameters (e.g., temperature, maxTokens). + * For a complete list of supported parameters, see + * https://ai.google.dev/api/generate-content#generationconfig. + */ + params?: Record +} + +/** + * Options interface for creating a GeminiModel instance. + */ +export interface GeminiModelOptions extends GeminiModelConfig { + /** + * Arguments for the underlying Gemini client (e.g., apiKey). + * For a complete list of supported arguments, see + * https://googleapis.github.io/nodejs-genai/. + */ + clientArgs?: Record +} + +/** + * Gemini model provider implementation. + * + * Implements the Model interface for Google Gemini using the Generate Content API. + * Supports streaming responses, tool use, and comprehensive configuration. + * + * @example + * ```typescript + * const provider = new GeminiModel({ + * modelId: 'gemini-2.5-flash', + * clientArgs: { apiKey: 'your-api-key' }, + * params: { temperature: 0.7, maxTokens: 1024 } + * }) + * + * const messages: Message[] = [ + * { role: 'user', content: [{ type: 'textBlock', text: 'Hello!' }] } + * ] + * + * for await (const event of provider.stream(messages)) { + * if (event.type === 'modelContentBlockDeltaEvent' && event.delta.type === 'textDelta') { + * process.stdout.write(event.delta.text) + * } + * } + * ``` + */ +export class GeminiModel extends Model { + private _config: GeminiModelConfig + private _client: GoogleGenAI + + /** + * Creates a new GeminiModel instance. + * + * @param options - Configuration for model and client + * + * @example + * ```typescript + * // Minimal configuration with API key and model ID + * const provider = new GeminiModel({ + * modelId: 'gemini-2.5-flash', + * clientArgs: { apiKey: 'your-api-key' } + * }) + * + * // With additional model configuration + * const provider = new GeminiModel({ + * modelId: 'gemini-2.5-flash', + * clientArgs: { apiKey: 'your-api-key' }, + * params: { temperature: 0.8, maxTokens: 2048 } + * }) + * ``` + */ + constructor(options?: GeminiModelOptions) { + super() + const { clientArgs, ...modelConfig } = options || {} + + // Initialize model config with default model ID if not provided + this._config = { + modelId: DEFAULT_GEMINI_MODEL_ID, + ...modelConfig, + } + + // Initialize Google Gen AI client + // The constructor takes an options object with apiKey + const apiKey = + clientArgs && typeof clientArgs === 'object' && 'apiKey' in clientArgs ? (clientArgs.apiKey as string) : undefined + + if (apiKey) { + this._client = new GoogleGenAI({ apiKey }) + } else { + // Fallback: try to construct with empty apiKey (may fail, but allows for testing) + this._client = new GoogleGenAI({ apiKey: '' }) + } + } + + /** + * Updates the model configuration. + * Merges the provided configuration with existing settings. + * + * @param modelConfig - Configuration object with model-specific settings to update + * + * @example + * ```typescript + * // Update temperature and maxTokens + * provider.updateConfig({ + * params: { temperature: 0.9, maxTokens: 2048 } + * }) + * ``` + */ + updateConfig(modelConfig: GeminiModelConfig): void { + // Merge params object if both exist + if (this._config.params && modelConfig.params) { + this._config = { + ...this._config, + ...modelConfig, + params: { ...this._config.params, ...modelConfig.params }, + } + } else { + this._config = { ...this._config, ...modelConfig } + } + } + + /** + * Retrieves the current model configuration. + * + * @returns The current configuration object + * + * @example + * ```typescript + * const config = provider.getConfig() + * console.log(config.modelId) + * ``` + */ + getConfig(): GeminiModelConfig { + return this._config + } + + /** + * Streams a conversation with the Gemini model. + * Returns an async iterable that yields streaming events as they occur. + * + * @param messages - Array of conversation messages + * @param options - Optional streaming configuration + * @returns Async iterable of streaming events + * + * @throws \{ContextWindowOverflowError\} When input exceeds the model's context window + * + * @example + * ```typescript + * const provider = new GeminiModel({ + * modelId: 'gemini-2.5-flash', + * clientArgs: { apiKey: 'your-api-key' } + * }) + * const messages: Message[] = [ + * { role: 'user', content: [{ type: 'textBlock', text: 'What is 2+2?' }] } + * ] + * + * for await (const event of provider.stream(messages)) { + * if (event.type === 'modelContentBlockDeltaEvent' && event.delta.type === 'textDelta') { + * process.stdout.write(event.delta.text) + * } + * } + * ``` + * + * @example + * ```typescript + * // With tool use + * const options: StreamOptions = { + * systemPrompt: 'You are a helpful assistant', + * toolSpecs: [calculatorTool] + * } + * + * for await (const event of provider.stream(messages, options)) { + * if (event.type === 'modelMessageStopEvent' && event.stopReason === 'toolUse') { + * console.log('Model wants to use a tool') + * } + * } + * ``` + */ + async *stream(messages: Message[], options?: StreamOptions): AsyncIterable { + // Validate messages array is not empty + if (!messages || messages.length === 0) { + throw new Error('At least one message is required') + } + + try { + // Format the request + const request = this._formatRequest(messages, options) + + // Create streaming request using the models API + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const stream = await this._client.models.generateContentStream(request as any) + + // Track streaming state + const streamState = { + messageStarted: false, + textContentBlockStarted: false, + toolContentBlockStarted: false, + toolUsed: false, + } + + // Emit message start event + yield { + type: 'modelMessageStartEvent', + role: 'assistant', + } + streamState.messageStarted = true + + // Process streaming response + let lastEvent: { + candidates?: Array<{ + finishReason?: string + content?: { parts?: unknown[] } + }> + usageMetadata?: { + promptTokenCount?: number + totalTokenCount?: number + } + } | null = null + for await (const event of stream) { + lastEvent = event as { + candidates?: Array<{ + finishReason?: string + content?: { parts?: unknown[] } + }> + usageMetadata?: { + promptTokenCount?: number + totalTokenCount?: number + } + } + const candidates = event.candidates || [] + const candidate = candidates[0] + if (!candidate) continue + + const content = candidate.content + if (!content || !content.parts) continue + + const parts = content.parts + + for (const part of parts) { + // Handle function calls (tool use) + if (part.functionCall) { + const functionCall = part.functionCall + + // Emit tool use start event + if (!streamState.toolContentBlockStarted) { + // Stop text content block if it was started + if (streamState.textContentBlockStarted) { + yield { + type: 'modelContentBlockStopEvent', + } + streamState.textContentBlockStarted = false + } + + // Use function call id if available, otherwise use name + // Note: Gemini may not always populate id, so we fall back to name + const toolUseId = functionCall.id || functionCall.name || '' + + yield { + type: 'modelContentBlockStartEvent', + start: { + type: 'toolUseStart', + name: functionCall.name || '', + toolUseId, + }, + } + streamState.toolContentBlockStarted = true + streamState.toolUsed = true + } + + // Emit tool use input delta + if (functionCall.args) { + yield { + type: 'modelContentBlockDeltaEvent', + delta: { + type: 'toolUseInputDelta', + input: JSON.stringify(functionCall.args), + }, + } + } + } + + // Handle text content + if (part.text) { + // Start text content block if not already started + if (!streamState.textContentBlockStarted && !streamState.toolContentBlockStarted) { + yield { + type: 'modelContentBlockStartEvent', + } + streamState.textContentBlockStarted = true + } + // Handle reasoning content (thought) + if (part.thought) { + const delta: { + type: 'reasoningContentDelta' + text?: string + signature?: string + } = { + type: 'reasoningContentDelta' as const, + text: part.text, + } + + if (part.thoughtSignature) { + // Convert Uint8Array to string if needed + // Check if it's a Uint8Array by checking for byteLength property + const sig = part.thoughtSignature as unknown + if ( + sig && + typeof sig === 'object' && + sig !== null && + 'byteLength' in sig && + typeof (sig as { byteLength?: unknown }).byteLength === 'number' + ) { + delta.signature = new TextDecoder().decode(sig as Uint8Array) + } else { + delta.signature = String(part.thoughtSignature) + } + } + + yield { + type: 'modelContentBlockDeltaEvent', + delta, + } + } else { + // Regular text content + yield { + type: 'modelContentBlockDeltaEvent', + delta: { + type: 'textDelta', + text: part.text, + }, + } + } + } + } + } + + // Emit content block stop events + if (streamState.toolContentBlockStarted) { + yield { + type: 'modelContentBlockStopEvent', + } + streamState.toolContentBlockStarted = false + } + + if (streamState.textContentBlockStarted) { + yield { + type: 'modelContentBlockStopEvent', + } + streamState.textContentBlockStarted = false + } + + // Determine stop reason + let stopReason = 'endTurn' + if (streamState.toolUsed) { + stopReason = 'toolUse' + } else if (lastEvent?.candidates?.[0]?.finishReason) { + const finishReason = lastEvent.candidates[0].finishReason + if (finishReason === 'MAX_TOKENS') { + stopReason = 'maxTokens' + } else if (finishReason === 'STOP') { + stopReason = 'endTurn' + } + } + + // Emit message stop event + yield { + type: 'modelMessageStopEvent', + stopReason, + } + + // Emit metadata event if available + if (lastEvent?.usageMetadata) { + const usage = lastEvent.usageMetadata + yield { + type: 'modelMetadataEvent', + usage: { + inputTokens: usage.promptTokenCount || 0, + outputTokens: (usage.totalTokenCount || 0) - (usage.promptTokenCount || 0), + totalTokens: usage.totalTokenCount || 0, + }, + metrics: { + latencyMs: 0, // TODO: Gemini API doesn't provide latency in usage metadata + }, + } + } + } catch (error) { + const err = error as Error + + // Check for context window overflow + if (err.message && GEMINI_CONTEXT_WINDOW_OVERFLOW_PATTERNS.some((pattern) => err.message.includes(pattern))) { + throw new ContextWindowOverflowError(err.message) + } + + // Check for throttling errors (Google Gen AI SDK uses ApiError) + if ('status' in err && (err.status === 'RESOURCE_EXHAUSTED' || err.status === 'UNAVAILABLE')) { + // Re-throw as-is (no ModelThrottledError class exists in TypeScript SDK) + throw err + } + + // Re-throw other errors unchanged + throw err + } + } + + /** + * Formats a request for the Gemini Generate Content API. + * + * @param messages - Conversation messages + * @param options - Stream options + * @returns Formatted Gemini request + */ + private _formatRequest(messages: Message[], options?: StreamOptions): Record { + const request: Record = { + model: this._config.modelId || DEFAULT_GEMINI_MODEL_ID, + contents: this._formatMessages(messages), + } + + // Build config object + const config: Record = {} + + // Add system instruction if provided + if (options?.systemPrompt !== undefined) { + if (typeof options.systemPrompt === 'string') { + config.systemInstruction = options.systemPrompt + } else if (Array.isArray(options.systemPrompt) && options.systemPrompt.length > 0) { + // Extract text blocks from system prompt array + const textBlocks: string[] = [] + for (const block of options.systemPrompt) { + if (block.type === 'textBlock') { + textBlocks.push(block.text) + } else { + logger.warn(`block_type=<${block.type}> | unsupported system prompt block type, ignoring`) + } + } + if (textBlocks.length > 0) { + config.systemInstruction = textBlocks.join('') + } + } + } + + // Add tools if provided + if (options?.toolSpecs && options.toolSpecs.length > 0) { + config.tools = this._formatTools(options.toolSpecs) + } + + // Add generation config (model parameters) + if (this._config.params) { + Object.assign(config, this._config.params) + } + + if (Object.keys(config).length > 0) { + request.config = config + } + + return request + } + + /** + * Formats messages for Gemini API. + * Handles role mapping (user -\> user, assistant -\> model). + * + * @param messages - SDK messages + * @returns Gemini-formatted messages + */ + private _formatMessages(messages: Message[]): Array<{ role: string; parts: unknown[] }> { + return messages.map((message) => { + const parts = message.content.map((block) => this._formatContentBlock(block)).filter((part) => part !== null) + + return { + role: message.role === 'user' ? 'user' : 'model', + parts, + } + }) + } + + /** + * Formats a content block for Gemini API. + * + * @param block - SDK content block + * @returns Gemini-formatted part or null if unsupported + */ + private _formatContentBlock(block: ContentBlock): Record | null { + switch (block.type) { + case 'textBlock': + return { + text: block.text, + } + + case 'imageBlock': { + const imageBlock = block as ImageBlock + if (imageBlock.source.type === 'imageSourceBytes') { + const mimeType = mimeTypeLookup(imageBlock.format) + const base64 = encodeBase64(String.fromCharCode(...imageBlock.source.bytes)) + return { + inlineData: { + data: base64, + mimeType, + }, + } + } else { + logger.warn( + `image_source_type=<${imageBlock.source.type}> | unsupported image source type, only bytes are supported` + ) + return null + } + } + + case 'documentBlock': { + const docBlock = block as DocumentBlock + if (docBlock.source.type === 'documentSourceBytes') { + const mimeType = mimeTypeLookup(docBlock.format) + const base64 = encodeBase64(String.fromCharCode(...docBlock.source.bytes)) + return { + inlineData: { + data: base64, + mimeType, + }, + } + } else { + logger.warn( + `document_source_type=<${docBlock.source.type}> | unsupported document source type, only bytes are supported` + ) + return null + } + } + + case 'toolUseBlock': + return { + functionCall: { + name: block.name, + args: block.input, + }, + } + + case 'toolResultBlock': { + // Format tool result content + const output: Array<{ json?: unknown; text?: string }> = [] + for (const content of block.content) { + if (content.type === 'jsonBlock') { + output.push({ json: content.json }) + } else if (content.type === 'textBlock') { + // TextBlock formats to { text: string } + output.push({ text: content.text }) + } + } + + return { + functionResponse: { + name: block.toolUseId, // Note: Gemini requires name to be set, we use toolUseId + response: { + output, + }, + }, + } + } + + case 'reasoningBlock': { + const part: { + text: string + thought: boolean + thoughtSignature?: Uint8Array + } = { + text: block.text || '', + thought: true, + } + if (block.signature) { + // Convert string signature to Uint8Array + part.thoughtSignature = new TextEncoder().encode(block.signature) + } + return part + } + + default: + logger.warn(`block_type=<${block.type}> | unsupported content block type, skipping`) + return null + } + } + + /** + * Formats tool specifications for Gemini API. + * + * @param toolSpecs - Array of tool specifications + * @returns Gemini-formatted tools array + */ + private _formatTools(toolSpecs: Array<{ name: string; description: string; inputSchema?: unknown }>): Array<{ + functionDeclarations: Array<{ + name: string + description: string + parametersJsonSchema: Record + }> + }> { + return [ + { + functionDeclarations: toolSpecs.map((spec) => { + // Gemini requires parametersJsonSchema to be a JSON schema describing an object + // The schema must have type: 'object' at the root level + const inputSchema = (spec.inputSchema as Record) || {} + + // Ensure the schema describes an object type + let parametersSchema: Record + if (inputSchema.type === 'object') { + // Schema already has type: 'object', use it as-is + parametersSchema = inputSchema + } else if (inputSchema.properties || inputSchema.type) { + // Schema has properties or a type, but not type: 'object' + // Wrap it to ensure type: 'object' at root + parametersSchema = { + type: 'object', + properties: (inputSchema.properties as Record) || {}, + required: inputSchema.required, + additionalProperties: inputSchema.additionalProperties, + } + } else { + // No schema provided, use empty object schema + parametersSchema = { + type: 'object', + properties: {}, + } + } + + return { + name: spec.name, + description: spec.description, + parametersJsonSchema: parametersSchema, + } + }), + }, + ] + } +}