From 876901c46085752cc6270a5dd36358e421211e3c Mon Sep 17 00:00:00 2001 From: Felix Wu Date: Thu, 23 Jul 2026 15:42:08 +0200 Subject: [PATCH 01/10] feat(ai-anthropic): accept preconfigured messages clients --- .changeset/anthropic-client-injection.md | 6 +++ README.md | 2 +- docs/adapters/anthropic.md | 52 +++++++++++++++++- docs/config.json | 2 +- packages/ai-anthropic/package.json | 2 + packages/ai-anthropic/src/adapters/text.ts | 53 +++++++++++++++++-- packages/ai-anthropic/src/index.ts | 3 ++ packages/ai-anthropic/src/utils/client.ts | 21 ++++++++ .../tests/anthropic-adapter.test.ts | 30 +++++++++++ .../client-injection-type-safety.test.ts | 18 +++++++ pnpm-lock.yaml | 43 +++++++++++++++ testing/e2e/package.json | 1 + testing/e2e/src/lib/providers.ts | 15 ++++-- 13 files changed, 235 insertions(+), 13 deletions(-) create mode 100644 .changeset/anthropic-client-injection.md create mode 100644 packages/ai-anthropic/tests/client-injection-type-safety.test.ts diff --git a/.changeset/anthropic-client-injection.md b/.changeset/anthropic-client-injection.md new file mode 100644 index 0000000000..2f32f02d6c --- /dev/null +++ b/.changeset/anthropic-client-injection.md @@ -0,0 +1,6 @@ +--- +'@tanstack/ai-anthropic': minor +--- + +Add a client-injection factory for Anthropic-compatible clients with custom +authentication and transport. diff --git a/README.md b/README.md index 82723d1ab0..9eede6306a 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ Official adapters include: | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | | [`@tanstack/ai-openrouter`](https://tanstack.com/ai/latest/docs/adapters/openrouter) | 300+ models through one OpenRouter API, with per-request cost tracking | | [`@tanstack/ai-openai`](https://tanstack.com/ai/latest/docs/adapters/openai) | OpenAI chat, image, video, speech, transcription, realtime, and provider tools | -| [`@tanstack/ai-anthropic`](https://tanstack.com/ai/latest/docs/adapters/anthropic) | Anthropic Claude chat, thinking, tools, and structured outputs | +| [`@tanstack/ai-anthropic`](https://tanstack.com/ai/latest/docs/adapters/anthropic) | Anthropic Claude chat, thinking, tools, structured outputs, and custom clients | | [`@tanstack/ai-gemini`](https://tanstack.com/ai/latest/docs/adapters/gemini) | Google Gemini chat, image, speech, and audio generation | | [`@tanstack/ai-ollama`](https://tanstack.com/ai/latest/docs/adapters/ollama) | Local Ollama models | | [`@tanstack/ai-grok`](https://tanstack.com/ai/latest/docs/adapters/grok) | xAI Grok chat, images, and realtime | diff --git a/docs/adapters/anthropic.md b/docs/adapters/anthropic.md index 17f1544478..f1ec1ed142 100644 --- a/docs/adapters/anthropic.md +++ b/docs/adapters/anthropic.md @@ -61,7 +61,46 @@ const config: Omit = { const adapter = createAnthropicChat("claude-sonnet-4-6", process.env.ANTHROPIC_API_KEY!, config); ``` - + +## Custom Anthropic Client + +Use `createAnthropicChatWithClient` when authentication or transport is +provided by an Anthropic-compatible client. The adapter only requires the +client's `beta.messages.create` capability; message mapping, streaming, tools, +media, usage, and structured output still follow the same TanStack adapter +path. + +For example, Anthropic's Vertex client can discover Google Cloud credentials +through Application Default Credentials: + +```bash +npm install @tanstack/ai-anthropic @anthropic-ai/vertex-sdk +``` + +```typescript +import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"; +import { + createAnthropicChatWithClient, + type AnthropicMessagesClient, +} from "@tanstack/ai-anthropic"; + +const projectId = process.env.GOOGLE_CLOUD_PROJECT; + +if (!projectId) { + throw new Error("GOOGLE_CLOUD_PROJECT is required"); +} + +const client = new AnthropicVertex({ + projectId, + region: "eu", +}) satisfies AnthropicMessagesClient; + +const adapter = createAnthropicChatWithClient("claude-sonnet-5", client); +``` + +The injected client must implement the Anthropic Beta Messages protocol. +Endpoint-specific model and feature support remains the caller's +responsibility. ## Example: Chat Completion @@ -257,7 +296,7 @@ ANTHROPIC_API_KEY=sk-ant-... ## API Reference -Every factory pair follows the same shape: the short factory (`anthropicText`, `anthropicSummarize`) reads `ANTHROPIC_API_KEY` from the environment, while `createAnthropicChat` / `createAnthropicSummarize` take an explicit API key. Both take `model` as the first argument. +Every factory pair follows the same shape: the short factory (`anthropicText`, `anthropicSummarize`) reads `ANTHROPIC_API_KEY` from the environment, while `createAnthropicChat` / `createAnthropicSummarize` take an explicit API key. Both take `model` as the first argument. For custom authentication or transport, `createAnthropicChatWithClient` accepts an Anthropic-compatible Messages client instead. ### `anthropicText(model, config?)` / `createAnthropicChat(model, apiKey, config?)` @@ -268,6 +307,15 @@ Creates an Anthropic chat adapter. - `model` - Claude model id (e.g. `"claude-sonnet-5"`, `"claude-fable-5"`, `"claude-opus-4-8"`) - `config?.baseURL` - Custom base URL (optional) +### `createAnthropicChatWithClient(model, client)` + +Creates an Anthropic chat adapter using an injected client. + +**Parameters:** + +- `model` - Claude model id +- `client` - Client exposing `beta.messages.create` + ### `anthropicSummarize(model, config?)` / `createAnthropicSummarize(model, apiKey, config?)` Creates an Anthropic summarization adapter. diff --git a/docs/config.json b/docs/config.json index 3dd794fdfc..77e575ef17 100644 --- a/docs/config.json +++ b/docs/config.json @@ -888,7 +888,7 @@ "label": "Anthropic", "to": "adapters/anthropic", "addedAt": "2026-04-15", - "updatedAt": "2026-07-04" + "updatedAt": "2026-07-23" }, { "label": "Google Gemini", diff --git a/packages/ai-anthropic/package.json b/packages/ai-anthropic/package.json index 05a2fca785..248313cac9 100644 --- a/packages/ai-anthropic/package.json +++ b/packages/ai-anthropic/package.json @@ -66,6 +66,8 @@ "zod": "^4.0.0" }, "devDependencies": { + "@anthropic-ai/sdk-v112": "npm:@anthropic-ai/sdk@0.112.4", + "@anthropic-ai/vertex-sdk": "^0.19.0", "@tanstack/ai": "workspace:*", "@vitest/coverage-v8": "4.1.10", "zod": "^4.2.0" diff --git a/packages/ai-anthropic/src/adapters/text.ts b/packages/ai-anthropic/src/adapters/text.ts index ec3ba259e1..93b809c98f 100644 --- a/packages/ai-anthropic/src/adapters/text.ts +++ b/packages/ai-anthropic/src/adapters/text.ts @@ -65,7 +65,10 @@ import type { AnthropicMessageMetadataByModality, AnthropicTextMetadata, } from '../message-types' -import type { AnthropicClientConfig } from '../utils/client' +import type { + AnthropicClientConfig, + AnthropicMessagesClient, +} from '../utils/client' /** * The block type carried by an Anthropic provider-executed (server) tool's @@ -213,6 +216,10 @@ export function computeAnthropicBetas( */ export interface AnthropicTextConfig extends AnthropicClientConfig {} +export type AnthropicTextAdapterConfig = + | AnthropicTextConfig + | { client: AnthropicMessagesClient } + /** * Anthropic-specific provider options for text/chat */ @@ -245,6 +252,24 @@ type ResolveToolCapabilities = ? NonNullable : readonly [] +type SdkAnthropicMessagesClient = { + beta: { + messages: Pick + } +} + +/** + * Restore the package SDK's precise overloads at the adapter boundary. + * Alternative clients may use a separate Anthropic 0.x SDK whose declarations + * drift while implementing the same Messages protocol at runtime. + */ +function asSdkAnthropicMessagesClient( + client: AnthropicMessagesClient, +): SdkAnthropicMessagesClient { + // oxlint-disable-next-line eslint-js/no-restricted-syntax -- The public callable deliberately erases version-specific SDK overloads; restore this package's SDK type at the internal boundary. + return client as unknown as SdkAnthropicMessagesClient +} + // =========================== // Adapter Implementation // =========================== @@ -277,11 +302,14 @@ export class AnthropicTextAdapter< override readonly kind = 'text' as const readonly name = 'anthropic' as const - private readonly client: Anthropic_SDK + private readonly client: SdkAnthropicMessagesClient - constructor(config: AnthropicTextConfig, model: TModel) { + constructor(config: AnthropicTextAdapterConfig, model: TModel) { super({}, model) - this.client = createAnthropicClient(config) + this.client = + 'client' in config + ? asSdkAnthropicMessagesClient(config.client) + : createAnthropicClient(config) } async *chatStream( @@ -1492,6 +1520,23 @@ export function createAnthropicChat< return new AnthropicTextAdapter({ apiKey, ...config }, model) } +/** + * Creates an Anthropic chat adapter with an injected Messages client. + * Type resolution happens here at the call site. + */ +export function createAnthropicChatWithClient< + TModel extends (typeof ANTHROPIC_MODELS)[number], +>( + model: TModel, + client: AnthropicMessagesClient, +): AnthropicTextAdapter< + TModel, + ResolveProviderOptions, + ResolveInputModalities +> { + return new AnthropicTextAdapter({ client }, model) +} + /** * Creates an Anthropic text adapter with automatic API key detection. * Type resolution happens here at the call site. diff --git a/packages/ai-anthropic/src/index.ts b/packages/ai-anthropic/src/index.ts index 468edaf5ff..5a68819584 100644 --- a/packages/ai-anthropic/src/index.ts +++ b/packages/ai-anthropic/src/index.ts @@ -7,9 +7,12 @@ export { AnthropicTextAdapter, anthropicText, createAnthropicChat, + createAnthropicChatWithClient, + type AnthropicTextAdapterConfig, type AnthropicTextConfig, type AnthropicTextProviderOptions, } from './adapters/text' +export type { AnthropicMessagesClient } from './utils/client' export type { AnthropicSystemPromptMetadata } from './text/text-provider-options' // Summarize - thin factory functions over @tanstack/ai's ChatStreamSummarizeAdapter diff --git a/packages/ai-anthropic/src/utils/client.ts b/packages/ai-anthropic/src/utils/client.ts index d07d2b2af0..ee1013ce88 100644 --- a/packages/ai-anthropic/src/utils/client.ts +++ b/packages/ai-anthropic/src/utils/client.ts @@ -6,6 +6,27 @@ export interface AnthropicClientConfig extends ClientOptions { apiKey: string } +type AnyAnthropicMessagesCreate = ( + params: never, + ...args: Array +) => unknown + +/** + * The minimal Anthropic client surface used by the text adapter. + * + * The callable is intentionally type-erased because alternative Anthropic + * clients can depend on a different 0.x release of the Anthropic SDK. Their + * request and response declarations may drift even when the runtime Messages + * protocol remains compatible. + */ +export interface AnthropicMessagesClient { + readonly beta: { + readonly messages: { + readonly create: AnyAnthropicMessagesCreate + } + } +} + /** * Creates an Anthropic SDK client instance */ diff --git a/packages/ai-anthropic/tests/anthropic-adapter.test.ts b/packages/ai-anthropic/tests/anthropic-adapter.test.ts index 75be66c072..bff15ecf8a 100644 --- a/packages/ai-anthropic/tests/anthropic-adapter.test.ts +++ b/packages/ai-anthropic/tests/anthropic-adapter.test.ts @@ -6,6 +6,7 @@ import { type StreamChunk, type UIMessage, } from '@tanstack/ai' +import { createAnthropicChatWithClient } from '../src' import { AnthropicTextAdapter } from '../src/adapters/text' import type { AnthropicTextProviderOptions } from '../src/adapters/text' import type { AnthropicDocumentMetadata } from '../src/message-types' @@ -78,6 +79,35 @@ function createTextStream(text: string) { })() } +describe('Anthropic client injection', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('uses the injected messages client instead of constructing one', async () => { + const create = vi.fn().mockResolvedValueOnce(createTextStream('Hello')) + const client = { + beta: { + messages: { + create, + }, + }, + } + + const adapter = createAnthropicChatWithClient('claude-opus-4-1', client) + + for await (const _ of chat({ + adapter, + messages: [{ role: 'user', content: 'Hi' }], + })) { + // consume stream + } + + expect(create).toHaveBeenCalledOnce() + expect(mocks.betaMessagesCreate).not.toHaveBeenCalled() + }) +}) + describe('Anthropic adapter option mapping', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/packages/ai-anthropic/tests/client-injection-type-safety.test.ts b/packages/ai-anthropic/tests/client-injection-type-safety.test.ts new file mode 100644 index 0000000000..57392a90c4 --- /dev/null +++ b/packages/ai-anthropic/tests/client-injection-type-safety.test.ts @@ -0,0 +1,18 @@ +import { expectTypeOf, it } from 'vitest' +import type AnthropicSdkV112 from '@anthropic-ai/sdk-v112' +import type { AnthropicVertex } from '@anthropic-ai/vertex-sdk' +import type { AnthropicMessagesClient } from '../src' + +type V112MessagesClient = { + readonly beta: { + readonly messages: Pick + } +} + +it('accepts the official Vertex client', () => { + expectTypeOf().toExtend() +}) + +it('accepts clients backed by a newer Anthropic SDK version', () => { + expectTypeOf().toExtend() +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 976ceb5479..8cdba65fb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1553,6 +1553,12 @@ importers: specifier: workspace:^ version: link:../ai-utils devDependencies: + '@anthropic-ai/sdk-v112': + specifier: npm:@anthropic-ai/sdk@0.112.4 + version: '@anthropic-ai/sdk@0.112.4(zod@4.2.1)' + '@anthropic-ai/vertex-sdk': + specifier: ^0.19.0 + version: 0.19.0(zod@4.2.1) '@tanstack/ai': specifier: workspace:* version: link:../ai @@ -2797,6 +2803,9 @@ importers: testing/e2e: dependencies: + '@anthropic-ai/sdk': + specifier: ^0.97.1 + version: 0.97.1(zod@4.3.6) '@copilotkit/aimock': specifier: ^1.34.0 version: 1.34.0(vitest@4.1.10) @@ -3271,6 +3280,15 @@ packages: '@angular/animations': optional: true + '@anthropic-ai/sdk@0.112.4': + resolution: {integrity: sha512-7eXJJnrmBI5GMC6drrCiSkycVsT7crRZX3qv5HusLSm+qiILjmtqP7gf+UiT7ASu/7Gdj+Zfl4f2haV8wATKUg==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@anthropic-ai/sdk@0.97.1': resolution: {integrity: sha512-wOf7AUeJPitcVpvKO4UMu63mWH5SaVipkGd7OOQJt/G6VYGlV8D2Gp9dLxOrttDJh/9gqPqdaBwDGcBevumeAg==} hasBin: true @@ -3280,6 +3298,9 @@ packages: zod: optional: true + '@anthropic-ai/vertex-sdk@0.19.0': + resolution: {integrity: sha512-Ja5NkDAmdCcvCJCvkY/6uJ+9krOiXFN56dzb+8n5apElHrYpUMlOCHCVRuLLsJCVWTsN8w60sgN9RSXZ+LPqNA==} + '@apidevtools/json-schema-ref-parser@11.9.3': resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} engines: {node: '>= 16'} @@ -17114,6 +17135,13 @@ snapshots: '@angular/core': 21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2)(zone.js@0.15.1) tslib: 2.8.1 + '@anthropic-ai/sdk@0.112.4(zod@4.2.1)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 + optionalDependencies: + zod: 4.2.1 + '@anthropic-ai/sdk@0.97.1(zod@4.2.1)': dependencies: json-schema-to-ts: 3.1.1 @@ -17121,6 +17149,21 @@ snapshots: optionalDependencies: zod: 4.2.1 + '@anthropic-ai/sdk@0.97.1(zod@4.3.6)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 + optionalDependencies: + zod: 4.3.6 + + '@anthropic-ai/vertex-sdk@0.19.0(zod@4.2.1)': + dependencies: + '@anthropic-ai/sdk': 0.112.4(zod@4.2.1) + google-auth-library: 10.5.0 + transitivePeerDependencies: + - supports-color + - zod + '@apidevtools/json-schema-ref-parser@11.9.3': dependencies: '@jsdevtools/ono': 7.1.3 diff --git a/testing/e2e/package.json b/testing/e2e/package.json index 90a09fa03c..469453c11d 100644 --- a/testing/e2e/package.json +++ b/testing/e2e/package.json @@ -12,6 +12,7 @@ "postinstall": "playwright install chromium" }, "dependencies": { + "@anthropic-ai/sdk": "^0.97.1", "@copilotkit/aimock": "^1.34.0", "@modelcontextprotocol/sdk": "^1.29.0", "@openrouter/sdk": "0.13.20", diff --git a/testing/e2e/src/lib/providers.ts b/testing/e2e/src/lib/providers.ts index 6f03ca0428..2dd7fae869 100644 --- a/testing/e2e/src/lib/providers.ts +++ b/testing/e2e/src/lib/providers.ts @@ -1,6 +1,7 @@ import { createChatOptions } from '@tanstack/ai' import { createOpenaiChat } from '@tanstack/ai-openai' -import { createAnthropicChat } from '@tanstack/ai-anthropic' +import Anthropic from '@anthropic-ai/sdk' +import { createAnthropicChatWithClient } from '@tanstack/ai-anthropic' import { createGeminiChat } from '@tanstack/ai-gemini' import { createGeminiTextInteractions } from '@tanstack/ai-gemini/experimental' import { createOllamaChat } from '@tanstack/ai-ollama' @@ -100,10 +101,14 @@ export function createTextAdapter( }), anthropic: () => createChatOptions({ - adapter: createAnthropicChat(model as 'claude-sonnet-4-5', DUMMY_KEY, { - baseURL: base, - defaultHeaders: testHeaders, - }), + adapter: createAnthropicChatWithClient( + model as 'claude-sonnet-4-5', + new Anthropic({ + apiKey: DUMMY_KEY, + baseURL: base, + defaultHeaders: testHeaders, + }), + ), }), gemini: () => createChatOptions({ From b735ba60f75716a10195e4afac9a94e256772df5 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 19 Aug 2026 15:52:11 +0200 Subject: [PATCH 02/10] feat: add Vertex AI support for Gemini and Claude Unlock Vertex auth on the Gemini client, add @tanstack/ai-vertex factories for Gemini activities, and add anthropicVertexText on @tanstack/ai-anthropic/vertex. --- .changeset/anthropic-client-injection.md | 5 +- .changeset/vertex-gemini.md | 7 + README.md | 3 +- docs/adapters/anthropic.md | 76 +++++-- docs/adapters/vertex.md | 208 ++++++++++++++++++ docs/config.json | 7 +- packages/ai-anthropic/package.json | 10 + packages/ai-anthropic/src/vertex/auth.ts | 63 ++++++ packages/ai-anthropic/src/vertex/index.ts | 25 +++ .../ai-anthropic/tests/vertex-auth.test.ts | 51 +++++ .../ai-anthropic/tests/vertex-factory.test.ts | 47 ++++ packages/ai-anthropic/vite.config.ts | 2 +- packages/ai-gemini/src/index.ts | 1 + packages/ai-gemini/src/utils/client.ts | 23 +- packages/ai-gemini/tests/client.test.ts | 71 ++++++ packages/ai-vertex/LICENSE | 21 ++ packages/ai-vertex/README.md | 32 +++ packages/ai-vertex/package.json | 66 ++++++ packages/ai-vertex/src/auth.ts | 59 +++++ packages/ai-vertex/src/errors.ts | 6 + packages/ai-vertex/src/index.ts | 115 ++++++++++ packages/ai-vertex/tests/auth.test.ts | 95 ++++++++ packages/ai-vertex/tests/factories.test.ts | 78 +++++++ packages/ai-vertex/tsconfig.json | 8 + packages/ai-vertex/vite.config.ts | 36 +++ pnpm-lock.yaml | 19 ++ 26 files changed, 1098 insertions(+), 36 deletions(-) create mode 100644 .changeset/vertex-gemini.md create mode 100644 docs/adapters/vertex.md create mode 100644 packages/ai-anthropic/src/vertex/auth.ts create mode 100644 packages/ai-anthropic/src/vertex/index.ts create mode 100644 packages/ai-anthropic/tests/vertex-auth.test.ts create mode 100644 packages/ai-anthropic/tests/vertex-factory.test.ts create mode 100644 packages/ai-gemini/tests/client.test.ts create mode 100644 packages/ai-vertex/LICENSE create mode 100644 packages/ai-vertex/README.md create mode 100644 packages/ai-vertex/package.json create mode 100644 packages/ai-vertex/src/auth.ts create mode 100644 packages/ai-vertex/src/errors.ts create mode 100644 packages/ai-vertex/src/index.ts create mode 100644 packages/ai-vertex/tests/auth.test.ts create mode 100644 packages/ai-vertex/tests/factories.test.ts create mode 100644 packages/ai-vertex/tsconfig.json create mode 100644 packages/ai-vertex/vite.config.ts diff --git a/.changeset/anthropic-client-injection.md b/.changeset/anthropic-client-injection.md index 2f32f02d6c..f17c509df7 100644 --- a/.changeset/anthropic-client-injection.md +++ b/.changeset/anthropic-client-injection.md @@ -2,5 +2,6 @@ '@tanstack/ai-anthropic': minor --- -Add a client-injection factory for Anthropic-compatible clients with custom -authentication and transport. +Add `createAnthropicChatWithClient` and `anthropicVertexText` (from +`@tanstack/ai-anthropic/vertex`) so Claude can run on Vertex AI and other +Anthropic-compatible transports. diff --git a/.changeset/vertex-gemini.md b/.changeset/vertex-gemini.md new file mode 100644 index 0000000000..e2ebb043af --- /dev/null +++ b/.changeset/vertex-gemini.md @@ -0,0 +1,7 @@ +--- +'@tanstack/ai-gemini': minor +'@tanstack/ai-vertex': minor +--- + +Add `@tanstack/ai-vertex` for Gemini on Vertex AI, and allow the Gemini +client to start without an API key when Vertex or Enterprise mode is on. \ No newline at end of file diff --git a/README.md b/README.md index 9eede6306a..c9bbb8c10d 100644 --- a/README.md +++ b/README.md @@ -210,8 +210,9 @@ Official adapters include: | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | | [`@tanstack/ai-openrouter`](https://tanstack.com/ai/latest/docs/adapters/openrouter) | 300+ models through one OpenRouter API, with per-request cost tracking | | [`@tanstack/ai-openai`](https://tanstack.com/ai/latest/docs/adapters/openai) | OpenAI chat, image, video, speech, transcription, realtime, and provider tools | -| [`@tanstack/ai-anthropic`](https://tanstack.com/ai/latest/docs/adapters/anthropic) | Anthropic Claude chat, thinking, tools, structured outputs, and custom clients | +| [`@tanstack/ai-anthropic`](https://tanstack.com/ai/latest/docs/adapters/anthropic) | Anthropic Claude chat, thinking, tools, structured outputs, and Vertex Claude | | [`@tanstack/ai-gemini`](https://tanstack.com/ai/latest/docs/adapters/gemini) | Google Gemini chat, image, speech, and audio generation | +| [`@tanstack/ai-vertex`](https://tanstack.com/ai/latest/docs/adapters/vertex) | Gemini on Vertex AI with regional endpoints and Google Cloud credentials | | [`@tanstack/ai-ollama`](https://tanstack.com/ai/latest/docs/adapters/ollama) | Local Ollama models | | [`@tanstack/ai-grok`](https://tanstack.com/ai/latest/docs/adapters/grok) | xAI Grok chat, images, and realtime | | [`@tanstack/ai-groq`](https://tanstack.com/ai/latest/docs/adapters/groq) | Groq low-latency inference | diff --git a/docs/adapters/anthropic.md b/docs/adapters/anthropic.md index f1ec1ed142..c3a5f1a983 100644 --- a/docs/adapters/anthropic.md +++ b/docs/adapters/anthropic.md @@ -62,45 +62,64 @@ const config: Omit = { const adapter = createAnthropicChat("claude-sonnet-4-6", process.env.ANTHROPIC_API_KEY!, config); ``` -## Custom Anthropic Client +## Claude on Vertex -Use `createAnthropicChatWithClient` when authentication or transport is -provided by an Anthropic-compatible client. The adapter only requires the -client's `beta.messages.create` capability; message mapping, streaming, tools, -media, usage, and structured output still follow the same TanStack adapter -path. +Use `@tanstack/ai-anthropic/vertex` when Claude must run on Vertex AI. That +is the path for regional endpoints and Google Cloud credentials. -For example, Anthropic's Vertex client can discover Google Cloud credentials -through Application Default Credentials: +Install the Vertex SDK next to the Anthropic adapter: ```bash npm install @tanstack/ai-anthropic @anthropic-ai/vertex-sdk ``` ```typescript -import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"; -import { - createAnthropicChatWithClient, - type AnthropicMessagesClient, -} from "@tanstack/ai-anthropic"; +import { chat } from "@tanstack/ai"; +import { anthropicVertexText } from "@tanstack/ai-anthropic/vertex"; -const projectId = process.env.GOOGLE_CLOUD_PROJECT; +const stream = chat({ + adapter: anthropicVertexText("claude-sonnet-5", { + project: "my-project", + location: "europe-west1", + }), + messages: [{ role: "user", content: "Hello!" }], +}); +``` -if (!projectId) { - throw new Error("GOOGLE_CLOUD_PROJECT is required"); -} +`project` and `location` use the same names as `@tanstack/ai-vertex`, so one +auth object works for Gemini and Claude. + +If you omit `project`, Application Default Credentials can still fill it. +`location` is required. You can pass it on the factory or set +`GOOGLE_CLOUD_LOCATION`, `GOOGLE_VERTEX_LOCATION`, or `CLOUD_ML_REGION`. + +Gemini on Vertex lives in [`@tanstack/ai-vertex`](./vertex). + +## Custom Anthropic client + +Use `createAnthropicChatWithClient` when you already have an +Anthropic-compatible client. The adapter only needs `beta.messages.create`. +Message mapping, streaming, tools, media, usage, and structured output stay +on the same TanStack path. + +```bash +npm install @tanstack/ai-anthropic @anthropic-ai/vertex-sdk +``` + +```typescript +import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"; +import { createAnthropicChatWithClient } from "@tanstack/ai-anthropic"; const client = new AnthropicVertex({ - projectId, - region: "eu", -}) satisfies AnthropicMessagesClient; + projectId: "my-project", + region: "europe-west1", +}); const adapter = createAnthropicChatWithClient("claude-sonnet-5", client); ``` The injected client must implement the Anthropic Beta Messages protocol. -Endpoint-specific model and feature support remains the caller's -responsibility. +Endpoint-specific model and feature support stays the caller's job. ## Example: Chat Completion @@ -296,7 +315,7 @@ ANTHROPIC_API_KEY=sk-ant-... ## API Reference -Every factory pair follows the same shape: the short factory (`anthropicText`, `anthropicSummarize`) reads `ANTHROPIC_API_KEY` from the environment, while `createAnthropicChat` / `createAnthropicSummarize` take an explicit API key. Both take `model` as the first argument. For custom authentication or transport, `createAnthropicChatWithClient` accepts an Anthropic-compatible Messages client instead. +Every factory pair follows the same shape: the short factory (`anthropicText`, `anthropicSummarize`) reads `ANTHROPIC_API_KEY` from the environment, while `createAnthropicChat` / `createAnthropicSummarize` take an explicit API key. Both take `model` as the first argument. For Claude on Vertex, use `anthropicVertexText` from `@tanstack/ai-anthropic/vertex`. For any other custom transport, `createAnthropicChatWithClient` accepts an Anthropic-compatible Messages client. ### `anthropicText(model, config?)` / `createAnthropicChat(model, apiKey, config?)` @@ -307,6 +326,17 @@ Creates an Anthropic chat adapter. - `model` - Claude model id (e.g. `"claude-sonnet-5"`, `"claude-fable-5"`, `"claude-opus-4-8"`) - `config?.baseURL` - Custom base URL (optional) +### `anthropicVertexText(model, config?)` + +Creates an Anthropic chat adapter on Vertex. Import it from +`@tanstack/ai-anthropic/vertex`. + +**Parameters:** + +- `model` - Claude model id +- `config.project` - GCP project id (optional if ADC can resolve it) +- `config.location` - Vertex region (or set `GOOGLE_CLOUD_LOCATION`) + ### `createAnthropicChatWithClient(model, client)` Creates an Anthropic chat adapter using an injected client. diff --git a/docs/adapters/vertex.md b/docs/adapters/vertex.md new file mode 100644 index 0000000000..8703490fd2 --- /dev/null +++ b/docs/adapters/vertex.md @@ -0,0 +1,208 @@ +--- +title: Google Vertex AI +id: vertex-adapter +order: 4 +description: "Run Gemini on Google Vertex AI with TanStack AI. Use regional endpoints and Google Cloud credentials via @tanstack/ai-vertex." +keywords: + - tanstack ai + - vertex + - vertex ai + - gemini + - google cloud + - regional + - adapter +--- + +The Gemini Developer API has no regional endpoint. If you need EU data residency, CMEK, or VPC-SC, you have to run Gemini on Vertex AI. + +`@tanstack/ai-vertex` is that path. It builds the existing Gemini adapters with Vertex auth. Request mapping, tools, and streaming stay the same. + +Claude on Vertex is a different package. See [Anthropic Vertex](./anthropic#claude-on-vertex). + +## Installation + +```bash +npm install @tanstack/ai-vertex +``` + +## Basic usage + +```typescript +import { chat } from "@tanstack/ai"; +import { vertexText } from "@tanstack/ai-vertex"; + +const stream = chat({ + adapter: vertexText("gemini-3.7-flash", { + project: "my-project", + location: "europe-west1", + }), + messages: [{ role: "user", content: "Hello!" }], +}); +``` + +Reuse one auth object for every factory: + +```typescript +import { vertexImage, vertexText } from "@tanstack/ai-vertex"; + +const auth = { + project: "my-project", + location: "europe-west1", +}; + +const text = vertexText("gemini-3.7-flash", auth); +const image = vertexImage("gemini-3.1-flash-image", auth); +``` + +## Authentication + +Vertex factories accept every auth option `@google/genai` accepts. They do **not** read `GEMINI_API_KEY` or `GOOGLE_API_KEY`. Those keys are AI Studio, not Vertex. + +### Application Default Credentials + +This is the usual Google Cloud path. Sign in with `gcloud auth application-default login`, or set `GOOGLE_APPLICATION_CREDENTIALS` to a service account JSON file. + +Pass `project` and `location` on the factory, or set: + +```bash +GOOGLE_CLOUD_PROJECT=my-project +GOOGLE_CLOUD_LOCATION=europe-west1 +``` + +`GOOGLE_VERTEX_PROJECT` and `GOOGLE_VERTEX_LOCATION` are also accepted. + +```typescript +import { vertexText } from "@tanstack/ai-vertex"; + +const adapter = vertexText("gemini-3.7-flash", { + project: "my-project", + location: "europe-west1", +}); +``` + +### Service account fields + +```typescript +import { vertexText } from "@tanstack/ai-vertex"; + +const adapter = vertexText("gemini-3.7-flash", { + project: "my-project", + location: "europe-west1", + googleAuthOptions: { + credentials: { + client_email: "sa@my-project.iam.gserviceaccount.com", + private_key: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", + }, + }, +}); +``` + +### Express API key + +Vertex express mode uses an API key and does not need project or location. + +```typescript +import { vertexText } from "@tanstack/ai-vertex"; + +const adapter = vertexText("gemini-3.7-flash", { + apiKey: "vertex-express-key", +}); +``` + +Or set `GOOGLE_VERTEX_API_KEY`. + +## Example: chat on the server + +```typescript +import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { vertexText } from "@tanstack/ai-vertex"; + +export async function POST(request: Request) { + const { messages } = await request.json(); + + const stream = chat({ + adapter: vertexText("gemini-3.7-flash", { + project: "my-project", + location: "europe-west1", + }), + messages, + }); + + return toServerSentEventsResponse(stream); +} +``` + +## Other Gemini activities + +Every factory uses the same auth object. + +```typescript +import { + vertexAudio, + vertexEmbedding, + vertexImage, + vertexSpeech, + vertexSummarize, + vertexText, + vertexVideo, +} from "@tanstack/ai-vertex"; + +const auth = { project: "my-project", location: "europe-west1" }; + +vertexText("gemini-3.7-flash", auth); +vertexSummarize("gemini-3.7-flash", auth); +vertexImage("gemini-3.1-flash-image", auth); +vertexEmbedding("gemini-embedding-001", auth); +vertexSpeech("gemini-3.1-flash-tts-preview", auth); +vertexAudio("lyria-3-pro-preview", auth); +vertexVideo("veo-3.1-generate-preview", auth); +``` + +Model ids and provider options are the same as `@tanstack/ai-gemini`. Vertex-only image options (for example `9:21`) are not unlocked in this release. + +## Environment variables + +| Variable | Purpose | +| --- | --- | +| `GOOGLE_CLOUD_PROJECT` | GCP project id | +| `GOOGLE_VERTEX_PROJECT` | Alias for the project id | +| `GOOGLE_CLOUD_LOCATION` | Region, for example `europe-west1` | +| `GOOGLE_VERTEX_LOCATION` | Alias for the region | +| `GOOGLE_VERTEX_API_KEY` | Vertex express API key | +| `GOOGLE_APPLICATION_CREDENTIALS` | Path to a service account JSON file | + +## API reference + +### `vertexText(model, config?)` + +Creates a Gemini chat adapter on Vertex. + +### `vertexSummarize(model, config?)` + +Creates a Gemini summarize adapter on Vertex. + +### `vertexImage(model, config?)` + +Creates a Gemini image adapter on Vertex. + +### `vertexEmbedding(model, config?)` + +Creates a Gemini embedding adapter on Vertex. + +### `vertexSpeech(model, config?)` + +Creates a Gemini text-to-speech adapter on Vertex. Experimental. + +### `vertexAudio(model, config?)` + +Creates a Gemini Lyria audio adapter on Vertex. Experimental. + +### `vertexVideo(model, config?)` + +Creates a Gemini video adapter on Vertex. Experimental. `config.allowUrlFetch` is the same opt-in as the Gemini video adapter. + +`config` accepts `project`, `location`, `apiKey`, `googleAuthOptions`, `httpOptions`, and the other `@google/genai` client fields. The factory always sets `vertexai: true`. + +## Claude on Vertex + +Use [`anthropicVertexText`](./anthropic#claude-on-vertex) from `@tanstack/ai-anthropic/vertex`. diff --git a/docs/config.json b/docs/config.json index 77e575ef17..ff4a80999f 100644 --- a/docs/config.json +++ b/docs/config.json @@ -888,7 +888,7 @@ "label": "Anthropic", "to": "adapters/anthropic", "addedAt": "2026-04-15", - "updatedAt": "2026-07-23" + "updatedAt": "2026-08-19" }, { "label": "Google Gemini", @@ -896,6 +896,11 @@ "addedAt": "2026-04-15", "updatedAt": "2026-08-18" }, + { + "label": "Google Vertex AI", + "to": "adapters/vertex", + "addedAt": "2026-08-19" + }, { "label": "Ollama", "to": "adapters/ollama", diff --git a/packages/ai-anthropic/package.json b/packages/ai-anthropic/package.json index 248313cac9..85f8c27804 100644 --- a/packages/ai-anthropic/package.json +++ b/packages/ai-anthropic/package.json @@ -41,6 +41,10 @@ "./tools": { "types": "./dist/esm/tools/index.d.ts", "import": "./dist/esm/tools/index.js" + }, + "./vertex": { + "types": "./dist/esm/vertex/index.d.ts", + "import": "./dist/esm/vertex/index.js" } }, "files": [ @@ -62,9 +66,15 @@ "@tanstack/ai-utils": "workspace:^" }, "peerDependencies": { + "@anthropic-ai/vertex-sdk": "^0.19.0", "@tanstack/ai": "workspace:^", "zod": "^4.0.0" }, + "peerDependenciesMeta": { + "@anthropic-ai/vertex-sdk": { + "optional": true + } + }, "devDependencies": { "@anthropic-ai/sdk-v112": "npm:@anthropic-ai/sdk@0.112.4", "@anthropic-ai/vertex-sdk": "^0.19.0", diff --git a/packages/ai-anthropic/src/vertex/auth.ts b/packages/ai-anthropic/src/vertex/auth.ts new file mode 100644 index 0000000000..842bb5eb41 --- /dev/null +++ b/packages/ai-anthropic/src/vertex/auth.ts @@ -0,0 +1,63 @@ +import type { AnthropicVertex } from '@anthropic-ai/vertex-sdk' + +export class AnthropicVertexAuthError extends Error { + constructor(message: string) { + super(message) + this.name = 'AnthropicVertexAuthError' + } +} + +type VertexSdkOptions = NonNullable< + ConstructorParameters[0] +> + +/** + * Public Vertex config for Claude. `project` and `location` match the Gemini + * Vertex factories so one auth object works for both. + */ +export type AnthropicVertexConfig = Omit< + VertexSdkOptions, + 'projectId' | 'region' +> & { + project?: string + location?: string +} + +function readEnv(name: string): string | undefined { + if (typeof process === 'undefined' || process.env === undefined) { + return undefined + } + const value = process.env[name] + if (value === undefined || value.length === 0) { + return undefined + } + return value +} + +export function resolveAnthropicVertexOptions( + config: AnthropicVertexConfig = {}, +): VertexSdkOptions { + const { project, location, ...rest } = config + const projectId = + project ?? + readEnv('GOOGLE_CLOUD_PROJECT') ?? + readEnv('GOOGLE_VERTEX_PROJECT') ?? + readEnv('ANTHROPIC_VERTEX_PROJECT_ID') + const region = + location ?? + readEnv('GOOGLE_CLOUD_LOCATION') ?? + readEnv('GOOGLE_VERTEX_LOCATION') ?? + readEnv('CLOUD_ML_REGION') + + if (region === undefined) { + throw new AnthropicVertexAuthError( + 'Anthropic Vertex needs a location. Pass location on the factory, or set GOOGLE_CLOUD_LOCATION, GOOGLE_VERTEX_LOCATION, or CLOUD_ML_REGION.', + ) + } + + return { + ...rest, + projectId: projectId ?? null, + region, + } +} diff --git a/packages/ai-anthropic/src/vertex/index.ts b/packages/ai-anthropic/src/vertex/index.ts new file mode 100644 index 0000000000..7dde8fa2a2 --- /dev/null +++ b/packages/ai-anthropic/src/vertex/index.ts @@ -0,0 +1,25 @@ +import { AnthropicVertex } from '@anthropic-ai/vertex-sdk' +import { createAnthropicChatWithClient } from '../adapters/text' +import { resolveAnthropicVertexOptions } from './auth' +import type { AnthropicTextAdapter } from '../adapters/text' +import type { AnthropicChatModel } from '../model-meta' +import type { AnthropicVertexConfig } from './auth' + +export { + AnthropicVertexAuthError, + resolveAnthropicVertexOptions, + type AnthropicVertexConfig, +} from './auth' + +/** + * Creates an Anthropic chat adapter that talks to Claude on Vertex AI. + * + * Install `@anthropic-ai/vertex-sdk` next to `@tanstack/ai-anthropic`. + */ +export function anthropicVertexText( + model: TModel, + config: AnthropicVertexConfig = {}, +): AnthropicTextAdapter { + const client = new AnthropicVertex(resolveAnthropicVertexOptions(config)) + return createAnthropicChatWithClient(model, client) +} diff --git a/packages/ai-anthropic/tests/vertex-auth.test.ts b/packages/ai-anthropic/tests/vertex-auth.test.ts new file mode 100644 index 0000000000..8658d52600 --- /dev/null +++ b/packages/ai-anthropic/tests/vertex-auth.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AnthropicVertexAuthError, + resolveAnthropicVertexOptions, +} from '../src/vertex/auth' + +describe('resolveAnthropicVertexOptions', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('maps project and location onto AnthropicVertex options', () => { + const options = resolveAnthropicVertexOptions({ + project: 'my-project', + location: 'europe-west1', + }) + + expect(options).toEqual({ + projectId: 'my-project', + region: 'europe-west1', + }) + }) + + it('reads Google Cloud env vars', () => { + vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'env-project') + vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'us-east5') + + const options = resolveAnthropicVertexOptions() + + expect(options.projectId).toBe('env-project') + expect(options.region).toBe('us-east5') + }) + + it('throws when location is missing', () => { + expect(() => + resolveAnthropicVertexOptions({ project: 'my-project' }), + ).toThrow(AnthropicVertexAuthError) + expect(() => + resolveAnthropicVertexOptions({ project: 'my-project' }), + ).toThrow(/needs a location/) + }) + + it('allows a missing project so ADC can fill it later', () => { + const options = resolveAnthropicVertexOptions({ + location: 'europe-west1', + }) + + expect(options.projectId).toBeNull() + expect(options.region).toBe('europe-west1') + }) +}) diff --git a/packages/ai-anthropic/tests/vertex-factory.test.ts b/packages/ai-anthropic/tests/vertex-factory.test.ts new file mode 100644 index 0000000000..690e77a077 --- /dev/null +++ b/packages/ai-anthropic/tests/vertex-factory.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { anthropicVertexText } from '../src/vertex' + +const mocks = vi.hoisted(() => { + return { + constructorSpy: vi.fn<(options: Record) => void>(), + create: vi.fn(), + } +}) + +vi.mock('@anthropic-ai/vertex-sdk', () => { + class MockAnthropicVertex { + public beta = { + messages: { + create: mocks.create, + }, + } + + constructor(options: Record) { + mocks.constructorSpy(options) + } + } + + return { + AnthropicVertex: MockAnthropicVertex, + } +}) + +describe('anthropicVertexText', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('constructs AnthropicVertex and returns an anthropic adapter', () => { + const adapter = anthropicVertexText('claude-sonnet-5', { + project: 'my-project', + location: 'europe-west1', + }) + + expect(adapter.name).toBe('anthropic') + expect(adapter.model).toBe('claude-sonnet-5') + expect(mocks.constructorSpy).toHaveBeenCalledExactlyOnceWith({ + projectId: 'my-project', + region: 'europe-west1', + }) + }) +}) diff --git a/packages/ai-anthropic/vite.config.ts b/packages/ai-anthropic/vite.config.ts index 8e22991081..2bd03a7798 100644 --- a/packages/ai-anthropic/vite.config.ts +++ b/packages/ai-anthropic/vite.config.ts @@ -30,7 +30,7 @@ const config = defineConfig({ export default mergeConfig( config, tanstackViteConfig({ - entry: ['./src/index.ts', './src/tools/index.ts'], + entry: ['./src/index.ts', './src/tools/index.ts', './src/vertex/index.ts'], srcDir: './src', cjs: false, }), diff --git a/packages/ai-gemini/src/index.ts b/packages/ai-gemini/src/index.ts index 7e1e426b11..d88b0ee5fa 100644 --- a/packages/ai-gemini/src/index.ts +++ b/packages/ai-gemini/src/index.ts @@ -146,6 +146,7 @@ export type { GeminiTTSVoice } from './model-meta' // Type Exports // =========================== +export type { GeminiClientConfig } from './utils/client' export type { GeminiChatModelProviderOptionsByName, GeminiChatModelToolCapabilitiesByName, diff --git a/packages/ai-gemini/src/utils/client.ts b/packages/ai-gemini/src/utils/client.ts index fb7ccb6c0b..0628f6e800 100644 --- a/packages/ai-gemini/src/utils/client.ts +++ b/packages/ai-gemini/src/utils/client.ts @@ -2,18 +2,25 @@ import { GoogleGenAI } from '@google/genai' import { generateId as _generateId, getApiKeyFromEnv } from '@tanstack/ai-utils' import type { GoogleGenAIOptions } from '@google/genai' -export interface GeminiClientConfig extends GoogleGenAIOptions { - apiKey: string -} +export type GeminiClientConfig = GoogleGenAIOptions /** - * Creates a Google Generative AI client instance + * Creates a Google Generative AI client instance. + * + * AI Studio mode needs `apiKey`. Vertex / Enterprise mode (`vertexai` or + * `enterprise`) uses project, location, and Google Cloud credentials instead. */ export function createGeminiClient(config: GeminiClientConfig): GoogleGenAI { - return new GoogleGenAI({ - ...config, - apiKey: config.apiKey, - }) + const vertexMode = config.vertexai === true || config.enterprise === true + if ( + !vertexMode && + (config.apiKey === undefined || config.apiKey.length === 0) + ) { + throw new Error( + 'A Gemini API key is required when vertexai and enterprise are not set. Pass apiKey, or set GOOGLE_API_KEY or GEMINI_API_KEY.', + ) + } + return new GoogleGenAI(config) } /** diff --git a/packages/ai-gemini/tests/client.test.ts b/packages/ai-gemini/tests/client.test.ts new file mode 100644 index 0000000000..d64394ccf0 --- /dev/null +++ b/packages/ai-gemini/tests/client.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createGeminiClient } from '../src/utils/client' + +const mocks = vi.hoisted(() => { + return { + constructorSpy: vi.fn<(options: Record) => void>(), + } +}) + +vi.mock('@google/genai', () => { + class MockGoogleGenAI { + constructor(options: Record) { + mocks.constructorSpy(options) + } + } + + return { + GoogleGenAI: MockGoogleGenAI, + } +}) + +describe('createGeminiClient', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('passes an API key through in AI Studio mode', () => { + createGeminiClient({ apiKey: 'studio-key' }) + + expect(mocks.constructorSpy).toHaveBeenCalledExactlyOnceWith({ + apiKey: 'studio-key', + }) + }) + + it('throws in AI Studio mode when apiKey is missing', () => { + expect(() => createGeminiClient({})).toThrow(/A Gemini API key is required/) + expect(mocks.constructorSpy).not.toHaveBeenCalled() + }) + + it('does not force apiKey in Vertex mode', () => { + createGeminiClient({ + vertexai: true, + project: 'my-project', + location: 'europe-west1', + }) + + expect(mocks.constructorSpy).toHaveBeenCalledExactlyOnceWith({ + vertexai: true, + project: 'my-project', + location: 'europe-west1', + }) + }) + + it('does not force apiKey in enterprise mode', () => { + createGeminiClient({ + enterprise: true, + project: 'my-project', + location: 'europe-west1', + }) + + expect(mocks.constructorSpy).toHaveBeenCalledExactlyOnceWith({ + enterprise: true, + project: 'my-project', + location: 'europe-west1', + }) + }) +}) diff --git a/packages/ai-vertex/LICENSE b/packages/ai-vertex/LICENSE new file mode 100644 index 0000000000..308cb68dc0 --- /dev/null +++ b/packages/ai-vertex/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Tanner Linsley + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/ai-vertex/README.md b/packages/ai-vertex/README.md new file mode 100644 index 0000000000..add3900eb6 --- /dev/null +++ b/packages/ai-vertex/README.md @@ -0,0 +1,32 @@ +# @tanstack/ai-vertex + +Google Vertex AI adapter for [TanStack AI](https://tanstack.com/ai). + +Use this package to run Gemini models on Vertex. That gives you regional +endpoints and Google Cloud credentials (ADC, service accounts, or Vertex +express API keys). + +Claude on Vertex lives in [`@tanstack/ai-anthropic/vertex`](https://tanstack.com/ai/latest/docs/adapters/anthropic). + +## Install + +```bash +pnpm add @tanstack/ai @tanstack/ai-vertex +``` + +## Usage + +```ts +import { chat } from '@tanstack/ai' +import { vertexText } from '@tanstack/ai-vertex' + +const stream = chat({ + adapter: vertexText('gemini-3.7-flash', { + project: 'my-project', + location: 'europe-west1', + }), + messages: [{ role: 'user', content: 'Hello' }], +}) +``` + +See the [Vertex adapter docs](https://tanstack.com/ai/latest/docs/adapters/vertex). diff --git a/packages/ai-vertex/package.json b/packages/ai-vertex/package.json new file mode 100644 index 0000000000..b4e4b6c352 --- /dev/null +++ b/packages/ai-vertex/package.json @@ -0,0 +1,66 @@ +{ + "name": "@tanstack/ai-vertex", + "version": "0.1.0", + "description": "Google Vertex AI adapter for TanStack AI. Runs Gemini models on Vertex with regional endpoints and Google Cloud credentials.", + "author": "Tanner Linsley", + "license": "MIT", + "homepage": "https://tanstack.com/ai", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-vertex" + }, + "bugs": { + "url": "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/TanStack/ai/issues" + }, + "funding": { + "type": "github", + "url": "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/sponsors/tannerlinsley" + }, + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "oxlint src --type-aware --fix", + "test:build": "publint --strict", + "test:oxlint": "oxlint src --type-aware", + "test:lib": "vitest run", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "keywords": [ + "ai", + "ai-sdk", + "typescript", + "tanstack", + "vertex", + "vertex-ai", + "gemini", + "google", + "adapter" + ], + "dependencies": { + "@tanstack/ai-gemini": "workspace:^" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:^" + }, + "devDependencies": { + "@google/genai": "^2.10.0", + "@tanstack/ai": "workspace:*", + "@vitest/coverage-v8": "4.1.10", + "vite": "^8.2.1" + } +} diff --git a/packages/ai-vertex/src/auth.ts b/packages/ai-vertex/src/auth.ts new file mode 100644 index 0000000000..b7578d560b --- /dev/null +++ b/packages/ai-vertex/src/auth.ts @@ -0,0 +1,59 @@ +import { VertexAuthError } from './errors' +import type { GeminiClientConfig } from '@tanstack/ai-gemini' + +export type VertexClientConfig = Omit< + GeminiClientConfig, + 'vertexai' | 'enterprise' +> + +export type VertexVideoConfig = VertexClientConfig & { + allowUrlFetch?: boolean +} + +function readEnv(name: string): string | undefined { + if (typeof process === 'undefined' || process.env === undefined) { + return undefined + } + const value = process.env[name] + if (value === undefined || value.length === 0) { + return undefined + } + return value +} + +/** + * Resolves Vertex Gemini client options. + * + * Factory fields win. Then env. Then ADC inside `@google/genai`. + * Does not read `GEMINI_API_KEY` or `GOOGLE_API_KEY`. + */ +export function resolveVertexGeminiOptions( + config: VertexClientConfig = {}, +): GeminiClientConfig { + const project = + config.project ?? + readEnv('GOOGLE_CLOUD_PROJECT') ?? + readEnv('GOOGLE_VERTEX_PROJECT') + const location = + config.location ?? + readEnv('GOOGLE_CLOUD_LOCATION') ?? + readEnv('GOOGLE_VERTEX_LOCATION') + const apiKey = config.apiKey ?? readEnv('GOOGLE_VERTEX_API_KEY') + + if ( + apiKey === undefined && + (project === undefined || location === undefined) + ) { + throw new VertexAuthError( + 'Vertex Gemini needs project and location, or an express apiKey. Pass project and location on the factory, or set GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION. For express mode, pass apiKey or set GOOGLE_VERTEX_API_KEY.', + ) + } + + return { + ...config, + vertexai: true, + ...(project === undefined ? {} : { project }), + ...(location === undefined ? {} : { location }), + ...(apiKey === undefined ? {} : { apiKey }), + } +} diff --git a/packages/ai-vertex/src/errors.ts b/packages/ai-vertex/src/errors.ts new file mode 100644 index 0000000000..ff6a6f11da --- /dev/null +++ b/packages/ai-vertex/src/errors.ts @@ -0,0 +1,6 @@ +export class VertexAuthError extends Error { + constructor(message: string) { + super(message) + this.name = 'VertexAuthError' + } +} diff --git a/packages/ai-vertex/src/index.ts b/packages/ai-vertex/src/index.ts new file mode 100644 index 0000000000..9091064008 --- /dev/null +++ b/packages/ai-vertex/src/index.ts @@ -0,0 +1,115 @@ +import { ChatStreamSummarizeAdapter } from '@tanstack/ai/adapters' +import { + GeminiAudioAdapter, + GeminiEmbeddingAdapter, + GeminiImageAdapter, + GeminiTTSAdapter, + GeminiTextAdapter, + GeminiVideoAdapter, +} from '@tanstack/ai-gemini' +import { resolveVertexGeminiOptions } from './auth' +import type { + GeminiAudioModel, + GeminiEmbeddingModel, + GeminiImageModel, + GeminiTTSModels, + GeminiTextModel, + GeminiVideoModel, +} from '@tanstack/ai-gemini' +import type { VertexClientConfig, VertexVideoConfig } from './auth' + +export { VertexAuthError } from './errors' +export { + resolveVertexGeminiOptions, + type VertexClientConfig, + type VertexVideoConfig, +} from './auth' + +type GeminiTTSModel = (typeof GeminiTTSModels)[number] + +function createVertex(config: VertexClientConfig = {}) { + const resolved = resolveVertexGeminiOptions(config) + return { + text(model: TModel) { + return new GeminiTextAdapter(resolved, model) + }, + summarize(model: TModel) { + return new ChatStreamSummarizeAdapter( + new GeminiTextAdapter(resolved, model), + model, + 'gemini', + ) + }, + image(model: TModel) { + return new GeminiImageAdapter(resolved, model) + }, + embedding(model: TModel) { + return new GeminiEmbeddingAdapter(resolved, model) + }, + speech(model: TModel) { + return new GeminiTTSAdapter(resolved, model) + }, + audio(model: TModel) { + return new GeminiAudioAdapter(resolved, model) + }, + video( + model: TModel, + videoConfig?: Pick, + ) { + return new GeminiVideoAdapter( + { ...resolved, allowUrlFetch: videoConfig?.allowUrlFetch }, + model, + ) + }, + } +} + +export function vertexText( + model: TModel, + config: VertexClientConfig = {}, +): GeminiTextAdapter { + return createVertex(config).text(model) +} + +export function vertexSummarize( + model: TModel, + config: VertexClientConfig = {}, +): ChatStreamSummarizeAdapter { + return createVertex(config).summarize(model) +} + +export function vertexImage( + model: TModel, + config: VertexClientConfig = {}, +): GeminiImageAdapter { + return createVertex(config).image(model) +} + +export function vertexEmbedding( + model: TModel, + config: VertexClientConfig = {}, +): GeminiEmbeddingAdapter { + return createVertex(config).embedding(model) +} + +export function vertexSpeech( + model: TModel, + config: VertexClientConfig = {}, +): GeminiTTSAdapter { + return createVertex(config).speech(model) +} + +export function vertexAudio( + model: TModel, + config: VertexClientConfig = {}, +): GeminiAudioAdapter { + return createVertex(config).audio(model) +} + +export function vertexVideo( + model: TModel, + config: VertexVideoConfig = {}, +): GeminiVideoAdapter { + const { allowUrlFetch, ...client } = config + return createVertex(client).video(model, { allowUrlFetch }) +} diff --git a/packages/ai-vertex/tests/auth.test.ts b/packages/ai-vertex/tests/auth.test.ts new file mode 100644 index 0000000000..41d635bd88 --- /dev/null +++ b/packages/ai-vertex/tests/auth.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveVertexGeminiOptions } from '../src/auth' +import { VertexAuthError } from '../src/errors' + +describe('resolveVertexGeminiOptions', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('uses factory project and location and sets vertexai', () => { + const options = resolveVertexGeminiOptions({ + project: 'my-project', + location: 'europe-west1', + }) + + expect(options).toEqual({ + project: 'my-project', + location: 'europe-west1', + vertexai: true, + }) + }) + + it('reads project and location from the environment', () => { + vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'env-project') + vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'us-central1') + + const options = resolveVertexGeminiOptions() + + expect(options).toEqual({ + project: 'env-project', + location: 'us-central1', + vertexai: true, + }) + }) + + it('lets factory fields win over env', () => { + vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'env-project') + vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'us-central1') + + const options = resolveVertexGeminiOptions({ + project: 'factory-project', + location: 'europe-west1', + }) + + expect(options.project).toBe('factory-project') + expect(options.location).toBe('europe-west1') + }) + + it('accepts an express apiKey without project or location', () => { + const options = resolveVertexGeminiOptions({ + apiKey: 'express-key', + }) + + expect(options).toEqual({ + apiKey: 'express-key', + vertexai: true, + }) + }) + + it('reads GOOGLE_VERTEX_API_KEY for express mode', () => { + vi.stubEnv('GOOGLE_VERTEX_API_KEY', 'env-express-key') + + const options = resolveVertexGeminiOptions() + + expect(options.apiKey).toBe('env-express-key') + expect(options.vertexai).toBe(true) + }) + + it('does not read GEMINI_API_KEY', () => { + vi.stubEnv('GEMINI_API_KEY', 'studio-key') + + expect(() => resolveVertexGeminiOptions()).toThrow(VertexAuthError) + }) + + it('throws when project, location, and express apiKey are all missing', () => { + expect(() => resolveVertexGeminiOptions()).toThrow(VertexAuthError) + expect(() => resolveVertexGeminiOptions()).toThrow( + /project and location, or an express apiKey/, + ) + }) + + it('forwards googleAuthOptions', () => { + const googleAuthOptions = { + keyFilename: '/path/to/sa.json', + } + + const options = resolveVertexGeminiOptions({ + project: 'my-project', + location: 'europe-west1', + googleAuthOptions, + }) + + expect(options.googleAuthOptions).toBe(googleAuthOptions) + }) +}) diff --git a/packages/ai-vertex/tests/factories.test.ts b/packages/ai-vertex/tests/factories.test.ts new file mode 100644 index 0000000000..54c9c86b84 --- /dev/null +++ b/packages/ai-vertex/tests/factories.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + vertexAudio, + vertexEmbedding, + vertexImage, + vertexSpeech, + vertexSummarize, + vertexText, + vertexVideo, +} from '../src' + +const mocks = vi.hoisted(() => { + return { + constructorSpy: vi.fn<(options: Record) => void>(), + } +}) + +vi.mock('@google/genai', async (importOriginal) => { + const actual = await importOriginal() + + class MockGoogleGenAI { + constructor(options: Record) { + mocks.constructorSpy(options) + } + } + + return { + ...actual, + GoogleGenAI: MockGoogleGenAI, + } +}) + +const auth = { + project: 'my-project', + location: 'europe-west1', +} as const + +describe('vertex factories', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('builds a Gemini text adapter with Vertex client options', () => { + const adapter = vertexText('gemini-3.7-flash', auth) + + expect(adapter.name).toBe('gemini') + expect(adapter.model).toBe('gemini-3.7-flash') + expect(mocks.constructorSpy).toHaveBeenCalledExactlyOnceWith({ + project: 'my-project', + location: 'europe-west1', + vertexai: true, + }) + }) + + it('builds summarize, image, embedding, speech, audio, and video adapters', () => { + expect(vertexSummarize('gemini-3.7-flash', auth).name).toBe('gemini') + expect(vertexImage('gemini-3.1-flash-image', auth).name).toBe('gemini') + expect(vertexEmbedding('gemini-embedding-001', auth).name).toBe('gemini') + expect(vertexSpeech('gemini-3.1-flash-tts-preview', auth).name).toBe( + 'gemini', + ) + expect(vertexAudio('lyria-3-pro-preview', auth).name).toBe('gemini') + expect(vertexVideo('veo-3.1-generate-preview', auth).name).toBe('gemini') + + expect(mocks.constructorSpy).toHaveBeenCalledTimes(6) + for (const call of mocks.constructorSpy.mock.calls) { + expect(call[0]).toMatchObject({ + project: 'my-project', + location: 'europe-west1', + vertexai: true, + }) + } + }) +}) diff --git a/packages/ai-vertex/tsconfig.json b/packages/ai-vertex/tsconfig.json new file mode 100644 index 0000000000..c38689f4ea --- /dev/null +++ b/packages/ai-vertex/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-vertex/vite.config.ts b/packages/ai-vertex/vite.config.ts new file mode 100644 index 0000000000..77bcc2e60b --- /dev/null +++ b/packages/ai-vertex/vite.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cdba65fb8..769da569cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2645,6 +2645,25 @@ importers: specifier: ^8.2.1 version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + packages/ai-vertex: + dependencies: + '@tanstack/ai-gemini': + specifier: workspace:^ + version: link:../ai-gemini + devDependencies: + '@google/genai': + specifier: ^2.10.0 + version: 2.10.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.10) + vite: + specifier: ^8.2.1 + version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + packages/ai-vue: dependencies: '@tanstack/ai-client': From 886f8d118b7d4774dbb71e1efe186ab83f5bef27 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 19 Aug 2026 16:21:16 +0200 Subject: [PATCH 03/10] docs: complete Vertex adapter docs and cross-links Add the client useChat half, point Gemini readers to Vertex, and list Vertex on the overview, comparison, and Vercel migration pages. --- docs/adapters/gemini.md | 2 + docs/adapters/vertex.md | 46 +++++++++++++++++++++- docs/comparison/vercel-ai-sdk.md | 4 +- docs/config.json | 11 +++--- docs/getting-started/overview.md | 1 + docs/migration/migration-from-vercel-ai.md | 1 + 6 files changed, 57 insertions(+), 8 deletions(-) diff --git a/docs/adapters/gemini.md b/docs/adapters/gemini.md index d41a619004..7ff253e8f0 100644 --- a/docs/adapters/gemini.md +++ b/docs/adapters/gemini.md @@ -24,6 +24,8 @@ For a full working example with image generation, see the [media generation exam npm install @tanstack/ai-gemini ``` +Need Gemini on Vertex AI (regional endpoints and Google Cloud credentials)? Use the [Vertex adapter](./vertex). + ## Basic Usage ```typescript diff --git a/docs/adapters/vertex.md b/docs/adapters/vertex.md index 8703490fd2..2b09d0f754 100644 --- a/docs/adapters/vertex.md +++ b/docs/adapters/vertex.md @@ -111,7 +111,11 @@ const adapter = vertexText("gemini-3.7-flash", { Or set `GOOGLE_VERTEX_API_KEY`. -## Example: chat on the server +## Example: server and client + +Keep Vertex credentials on the server. The browser only talks to your route. + +Server: ```typescript import { chat, toServerSentEventsResponse } from "@tanstack/ai"; @@ -132,6 +136,46 @@ export async function POST(request: Request) { } ``` +Client: + +```tsx +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; + +export function Chat() { + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("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/api/chat"), + }); + + return ( +
{ + event.preventDefault(); + const form = event.currentTarget; + const input = new FormData(form).get("text"); + if (typeof input === "string" && input.trim()) { + sendMessage(input); + form.reset(); + } + }} + > + {messages.map((message) => ( +
+ {message.role}:{" "} + {message.parts + .filter((part) => part.type === "text") + .map((part) => part.content) + .join("")} +
+ ))} + + +
+ ); +} +``` + +`useChat` does not know this is Vertex. It only consumes the SSE stream from your server. + ## Other Gemini activities Every factory uses the same auth object. diff --git a/docs/comparison/vercel-ai-sdk.md b/docs/comparison/vercel-ai-sdk.md index ef110b9a14..922eb37672 100644 --- a/docs/comparison/vercel-ai-sdk.md +++ b/docs/comparison/vercel-ai-sdk.md @@ -35,7 +35,7 @@ Versions referenced below: TanStack AI as of this writing; Vercel AI SDK `ai@7.x |---------|------------|---------------| | License | MIT | Apache 2.0 | | Hosting | Works anywhere | Works anywhere | -| Providers | 15 official LLM adapters (OpenAI, Anthropic, Gemini, Grok, Groq, OpenRouter, Ollama, Bedrock, BytePlus, Mistral, Cohere, ElevenLabs, fal, Vercel Gateway, `openaiCompatible`) plus 5 harness adapters; community adapters for more | ~38 first-party provider packages (plus community); 100+ models via AI Gateway | +| Providers | 16 official LLM adapters (OpenAI, Anthropic, Gemini, Vertex, Grok, Groq, OpenRouter, Ollama, Bedrock, BytePlus, Mistral, Cohere, ElevenLabs, fal, Vercel Gateway, `openaiCompatible`) plus 5 harness adapters; community adapters for more | ~38 first-party provider packages (plus community); 100+ models via AI Gateway | | Framework Hooks | React, Solid, Svelte, Vue, Preact, Angular (+ React Native) | React, Vue, Svelte, Angular (Solid is community-maintained) | | Generation UI Hooks | One hook per activity: chat, structured output, image, audio, speech, transcription, summarize, video, realtime | `useChat`, `useCompletion`, `useObject` | | Wire Protocol | Native AG-UI events end to end | Proprietary UI Message Stream; AG-UI via external translation layer | @@ -754,7 +754,7 @@ Set `debug: true` on any activity and the pipeline prints itself: raw provider c TanStack AI publishes an open adapter specification. Official LLM adapters: -- OpenAI, Anthropic, Gemini, Grok, Groq, OpenRouter, Ollama +- OpenAI, Anthropic, Gemini, Vertex, Grok, Groq, OpenRouter, Ollama - Bedrock, BytePlus, Mistral, Cohere, ElevenLabs, fal - Vercel AI Gateway, and any OpenAI-compatible endpoint via `openaiCompatible` diff --git a/docs/config.json b/docs/config.json index ff4a80999f..a62a8d520d 100644 --- a/docs/config.json +++ b/docs/config.json @@ -13,7 +13,7 @@ "label": "Overview", "to": "getting-started/overview", "addedAt": "2026-04-15", - "updatedAt": "2026-08-07" + "updatedAt": "2026-08-19" }, { "label": "Quick Start: React", @@ -66,7 +66,7 @@ "label": "TanStack AI vs Vercel AI SDK", "to": "comparison/vercel-ai-sdk", "addedAt": "2026-04-15", - "updatedAt": "2026-08-12" + "updatedAt": "2026-08-19" } ] }, @@ -807,7 +807,7 @@ "label": "From Vercel AI SDK", "to": "migration/migration-from-vercel-ai", "addedAt": "2026-04-20", - "updatedAt": "2026-07-22" + "updatedAt": "2026-08-19" }, { "label": "AG-UI Client Compliance", @@ -894,12 +894,13 @@ "label": "Google Gemini", "to": "adapters/gemini", "addedAt": "2026-04-15", - "updatedAt": "2026-08-18" + "updatedAt": "2026-08-19" }, { "label": "Google Vertex AI", "to": "adapters/vertex", - "addedAt": "2026-08-19" + "addedAt": "2026-08-19", + "updatedAt": "2026-08-19" }, { "label": "Ollama", diff --git a/docs/getting-started/overview.md b/docs/getting-started/overview.md index 6627624fc3..c9c6ddff60 100644 --- a/docs/getting-started/overview.md +++ b/docs/getting-started/overview.md @@ -109,6 +109,7 @@ With the help of adapters, TanStack AI can connect to various LLM providers. Ava - **@tanstack/ai-openai** - OpenAI (GPT series) - **@tanstack/ai-anthropic** - Anthropic (Claude) - **@tanstack/ai-gemini** - Google Gemini +- **@tanstack/ai-vertex** - Gemini on Google Vertex AI - **@tanstack/ai-ollama** - Ollama (local models) - **@tanstack/ai-groq** - Groq - **@tanstack/ai-grok** - xAI Grok diff --git a/docs/migration/migration-from-vercel-ai.md b/docs/migration/migration-from-vercel-ai.md index 8e4ecb1c83..6a6034dc75 100644 --- a/docs/migration/migration-from-vercel-ai.md +++ b/docs/migration/migration-from-vercel-ai.md @@ -41,6 +41,7 @@ TanStack AI provides several advantages: | `@ai-sdk/openai` | `@tanstack/ai-openai` | | `@ai-sdk/anthropic` | `@tanstack/ai-anthropic` | | `@ai-sdk/google` | `@tanstack/ai-gemini` | +| `@ai-sdk/google-vertex` | `@tanstack/ai-vertex` (Gemini) and `@tanstack/ai-anthropic/vertex` (Claude) | | `@ai-sdk/react` | `@tanstack/ai-react` | | `@ai-sdk/vue` | `@tanstack/ai-vue` | | `@ai-sdk/solid` | `@tanstack/ai-solid` | From 8e037e86131657c8f8652dc76eff9594337debb4 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 19 Aug 2026 16:21:54 +0200 Subject: [PATCH 04/10] chore: add trailing newline to Vertex changeset --- .changeset/vertex-gemini.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/vertex-gemini.md b/.changeset/vertex-gemini.md index e2ebb043af..b47d252662 100644 --- a/.changeset/vertex-gemini.md +++ b/.changeset/vertex-gemini.md @@ -4,4 +4,4 @@ --- Add `@tanstack/ai-vertex` for Gemini on Vertex AI, and allow the Gemini -client to start without an API key when Vertex or Enterprise mode is on. \ No newline at end of file +client to start without an API key when Vertex or Enterprise mode is on. From 75d640719d8c81aab319fc16d0d01b7e79b9bcab Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 19 Aug 2026 16:59:25 +0200 Subject: [PATCH 05/10] test(e2e): add Vertex Gemini to the Playwright matrix Wire vertexText and vertexSummarize through dummy Vertex auth so aimock hits /v1/projects/.../generateContent. Chat, tools, structured output, multimodal, and summarize now run as vertex. --- pnpm-lock.yaml | 3 ++ testing/e2e/README.md | 17 +++++++++- testing/e2e/package.json | 1 + testing/e2e/src/lib/feature-support.ts | 23 +++++++++++++ testing/e2e/src/lib/features.ts | 6 ++-- testing/e2e/src/lib/providers.ts | 13 ++++++++ testing/e2e/src/lib/types.ts | 2 ++ testing/e2e/src/lib/vertex-e2e.ts | 44 +++++++++++++++++++++++++ testing/e2e/src/routes/api.summarize.ts | 7 ++++ testing/e2e/tests/test-matrix.ts | 1 + 10 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 testing/e2e/src/lib/vertex-e2e.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 769da569cb..129c1785bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2906,6 +2906,9 @@ importers: '@tanstack/ai-vercel-gateway': specifier: workspace:* version: link:../../packages/ai-vercel-gateway + '@tanstack/ai-vertex': + specifier: workspace:* + version: link:../../packages/ai-vertex '@tanstack/devtools-event-bus': specifier: ^0.4.1 version: 0.4.1 diff --git a/testing/e2e/README.md b/testing/e2e/README.md index dfc086a174..a1b7fed23f 100644 --- a/testing/e2e/README.md +++ b/testing/e2e/README.md @@ -4,7 +4,7 @@ End-to-end tests for TanStack AI using Playwright and [aimock](https://github.co **Architecture:** Playwright drives a TanStack Start app (`testing/e2e/`) which routes requests through provider adapters pointing at aimock. Fixtures define mock responses. No real API keys needed. All scenarios (including tool execution flows) use aimock fixtures. Tests run in parallel with per-test `X-Test-Id` isolation. -**Providers tested:** openai, anthropic, gemini, ollama, groq, grok, openrouter, openrouter-responses, vercel-gateway, vercel-gateway-responses, bedrock, bedrock-responses, openai-compatible, mistral, byteplus, elevenlabs, llmgateway +**Providers tested:** openai, anthropic, gemini, vertex, ollama, groq, grok, openrouter, openrouter-responses, vercel-gateway, vercel-gateway-responses, bedrock, bedrock-responses, openai-compatible, mistral, byteplus, elevenlabs, llmgateway > **Claude Code (`@tanstack/ai-claude-code`) is excluded from the standard matrix.** It's a harness adapter that spawns the Claude Code runtime as a subprocess, so aimock's per-test `X-Test-Id` header isolation can't be injected into its requests. It's covered by unit tests in the package plus a gated live smoke test in `tests/claude-code.spec.ts` — run it with `CLAUDE_CODE_E2E=1` and an `ANTHROPIC_API_KEY` (or a local `claude login`). @@ -262,6 +262,20 @@ await waitForAssistantText(page, 'Fender Stratocaster') 3. **Add to `tests/test-matrix.ts`** — mirror the support matrix 4. **No fixture changes needed** — aimock translates to correct wire format +### Vertex (Gemini on Vertex AI) + +`vertex` is the Gemini adapter with Vertex auth and the Vertex request path. It is not a new protocol. + +The factory in `src/lib/providers.ts` calls `vertexText` with `vertexE2eConfig()` from `src/lib/vertex-e2e.ts`: + +- `project` + `location` so `@google/genai` posts to `/v1/projects/{p}/locations/{l}/publishers/google/models/{m}:(generateContent|streamGenerateContent)`. aimock already serves that path. +- A dummy `googleAuthOptions.authClient` so the SDK does not look for Application Default Credentials in CI. +- `apiVersion: 'v1'`. The SDK default for Vertex is `v1beta1`, and aimock's Vertex handler only matches `/v1/…`. + +Chat, tools, structured output, multimodal image, and summarize reuse the existing Gemini fixtures. Media, embedding, TTS, video, and Gemini Interactions stay off the Vertex row: those Gemini e2e mounts live under `/v1beta`, and Vertex uses a different path. + +Claude on Vertex (`anthropicVertexText`) is not in this matrix. That SDK talks OAuth and a different Vertex URL that aimock does not mock. + ### Bedrock Converse coverage gap The `bedrock` and `bedrock-responses` providers in this matrix use `createBedrockText` with a `baseURL` pointing at aimock — they speak Bedrock's **OpenAI-compatible** endpoint, which aimock's OpenAI replay handles fine. @@ -295,6 +309,7 @@ Three endpoints have no aimock equivalent and are mounted in `global-setup.ts`, - Groq: `LLMOCK_BASE` (SDK appends `/openai/v1/` internally) + `defaultHeaders` - Anthropic: `LLMOCK_BASE` + `defaultHeaders` - Gemini: `httpOptions: { baseUrl: LLMOCK_BASE, headers }` +- Vertex: `vertexE2eConfig(LLMOCK_BASE, headers)` (`project` + `location` + dummy auth + `apiVersion: 'v1'`) - Ollama: `{ host: LLMOCK_BASE, headers }` (config object) - OpenRouter: `serverURL` with `?testId=` query param (SDK doesn't support headers) - BytePlus: `LLMOCK_BASE + /api/v3` + `defaultHeaders` for Ark (chat, image, video); bare `LLMOCK_BASE` + `defaultHeaders` for Seed Speech (TTS, ASR) diff --git a/testing/e2e/package.json b/testing/e2e/package.json index 469453c11d..935fe37ac3 100644 --- a/testing/e2e/package.json +++ b/testing/e2e/package.json @@ -26,6 +26,7 @@ "@tanstack/ai-client": "workspace:*", "@tanstack/ai-elevenlabs": "workspace:*", "@tanstack/ai-gemini": "workspace:*", + "@tanstack/ai-vertex": "workspace:*", "@tanstack/ai-grok": "workspace:*", "@tanstack/ai-groq": "workspace:*", "@tanstack/ai-llmgateway": "workspace:*", diff --git a/testing/e2e/src/lib/feature-support.ts b/testing/e2e/src/lib/feature-support.ts index d178c48dff..5c7e98279e 100644 --- a/testing/e2e/src/lib/feature-support.ts +++ b/testing/e2e/src/lib/feature-support.ts @@ -12,6 +12,7 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', 'ollama', 'groq', 'grok', @@ -28,6 +29,7 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', 'ollama', 'groq', 'grok', @@ -49,6 +51,7 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', 'mistral', 'byteplus', 'llmgateway', @@ -57,6 +60,7 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', 'ollama', 'groq', 'grok', @@ -73,6 +77,7 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', 'ollama', 'groq', 'grok', @@ -91,6 +96,7 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', 'groq', 'grok', 'bedrock', @@ -103,6 +109,7 @@ export const matrix: Record> = { 'llmgateway', ]), // Gemini excluded: approval flow timing issues with Gemini's streaming format + // Vertex uses the same Gemini stream, so it is excluded for the same reason. 'tool-approval': new Set([ 'openai', 'anthropic', @@ -123,6 +130,7 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', 'groq', 'grok', 'bedrock', @@ -138,6 +146,7 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', 'ollama', 'groq', 'grok', @@ -187,6 +196,7 @@ export const matrix: Record> = { 'multi-turn-structured': new Set([ 'openai', 'gemini', + 'vertex', 'ollama', 'groq', 'grok', @@ -202,6 +212,7 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', 'ollama', 'groq', 'grok', @@ -224,6 +235,7 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', 'grok', 'openrouter', 'openrouter-responses', @@ -237,6 +249,7 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', 'grok', 'openrouter', 'byteplus', @@ -250,6 +263,7 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', 'grok', 'openrouter', 'byteplus', @@ -263,6 +277,7 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', 'ollama', 'groq', 'grok', @@ -277,6 +292,7 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', 'ollama', 'groq', 'grok', @@ -296,6 +312,10 @@ export const matrix: Record> = { // `embedding` field, not the `embeddings: number[][]` shape the ollama SDK // `embed()` expects; Mistral because its SDK Zod-validates the response and // requires an `id` field aimock's OpenAI-format builder omits. + // Vertex excluded: aimock's Vertex handler only covers + // generateContent / streamGenerateContent. Vertex embed uses a + // different path (`:predict` / `:batchEmbedContents` under + // /v1/projects/…), so it cannot reuse the Gemini /v1beta mount. embedding: new Set([ 'openai', 'gemini', @@ -322,6 +342,9 @@ export const matrix: Record> = { 'image-to-image': new Set(['openai']), // byteplus excluded: BytePlus has no music/audio generation product — // Seed Speech is TTS + ASR only. + // Vertex excluded for the same media-path reason as embedding: the + // Gemini TTS / Veo / Lyria / Interactions mounts live under /v1beta. + // Vertex posts those activities to /v1/projects/… instead. 'audio-gen': new Set(['gemini', 'elevenlabs']), // byteplus excluded: no sound-effects endpoint (see audio-gen above). 'sound-effects': new Set(['elevenlabs']), diff --git a/testing/e2e/src/lib/features.ts b/testing/e2e/src/lib/features.ts index 286a35802b..e14bf9f72a 100644 --- a/testing/e2e/src/lib/features.ts +++ b/testing/e2e/src/lib/features.ts @@ -91,13 +91,15 @@ export const featureConfigs: Record = { // Pins #605 native-combined-mode: `outputSchema` + `tools` + `stream: true` // in a single chat call. Default openai (gpt-4o) and anthropic // (claude-sonnet-4-5) are already in their combined-mode-capable sets; - // gemini and grok need overrides to gated models so the engine takes the - // native path instead of the legacy `runStructuredFinalization` round-trip. + // gemini, vertex, and grok need overrides to gated models so the engine + // takes the native path instead of the legacy + // `runStructuredFinalization` round-trip. 'agentic-structured-stream': { tools: [getGuitars], modelOptions: {}, modelOverrides: { gemini: 'gemini-3-flash-preview', + vertex: 'gemini-3-flash-preview', grok: 'grok-build-0.1', // Reports combined tools+schema support, so the engine takes the // native path here too. diff --git a/testing/e2e/src/lib/providers.ts b/testing/e2e/src/lib/providers.ts index 2dd7fae869..2abcab9254 100644 --- a/testing/e2e/src/lib/providers.ts +++ b/testing/e2e/src/lib/providers.ts @@ -4,6 +4,8 @@ import Anthropic from '@anthropic-ai/sdk' import { createAnthropicChatWithClient } from '@tanstack/ai-anthropic' import { createGeminiChat } from '@tanstack/ai-gemini' import { createGeminiTextInteractions } from '@tanstack/ai-gemini/experimental' +import { vertexText } from '@tanstack/ai-vertex' +import { vertexE2eConfig } from '@/lib/vertex-e2e' import { createOllamaChat } from '@tanstack/ai-ollama' import { createGroqText } from '@tanstack/ai-groq' import { createGrokText } from '@tanstack/ai-grok' @@ -29,6 +31,7 @@ const defaultModels: Record = { openai: 'gpt-4o', anthropic: 'claude-sonnet-4-5', gemini: 'gemini-2.5-flash', + vertex: 'gemini-2.5-flash', ollama: 'mistral', groq: 'llama-3.3-70b-versatile', grok: 'grok-build-0.1', @@ -119,6 +122,16 @@ export function createTextAdapter( }, }), }), + // Gemini on Vertex. Dummy ADC + project/location so the SDK posts + // `/v1/projects/{p}/locations/{l}/publishers/google/models/{m}:…`, + // which aimock already serves. See vertex-e2e.ts. + vertex: () => + createChatOptions({ + adapter: vertexText( + model as 'gemini-2.5-flash', + vertexE2eConfig(base, testHeaders), + ), + }), ollama: () => createChatOptions({ adapter: createOllamaChat( diff --git a/testing/e2e/src/lib/types.ts b/testing/e2e/src/lib/types.ts index e91f9806fd..088b9fac5b 100644 --- a/testing/e2e/src/lib/types.ts +++ b/testing/e2e/src/lib/types.ts @@ -4,6 +4,7 @@ export type Provider = | 'openai' | 'anthropic' | 'gemini' + | 'vertex' | 'ollama' | 'grok' | 'groq' @@ -55,6 +56,7 @@ export const ALL_PROVIDERS: Provider[] = [ 'openai', 'anthropic', 'gemini', + 'vertex', 'ollama', 'grok', 'groq', diff --git a/testing/e2e/src/lib/vertex-e2e.ts b/testing/e2e/src/lib/vertex-e2e.ts new file mode 100644 index 0000000000..3482c6ca4d --- /dev/null +++ b/testing/e2e/src/lib/vertex-e2e.ts @@ -0,0 +1,44 @@ +import type { VertexClientConfig } from '@tanstack/ai-vertex' + +const E2E_PROJECT = 'e2e-project' +const E2E_LOCATION = 'us-central1' + +// `@google/genai` defaults Vertex to `v1beta1`. aimock's Vertex handler only +// matches `/v1/projects/{p}/locations/{l}/publishers/google/models/{m}:…`. +const E2E_API_VERSION = 'v1' + +type VertexAuthClient = NonNullable< + NonNullable['authClient'] +> + +/** + * Dummy Google auth so Vertex mode does not try Application Default + * Credentials in CI. aimock does not check the bearer token. + * + * Project + location keep the Vertex request path + * `/v1/projects/{p}/locations/{l}/publishers/google/models/{m}:streamGenerateContent`, + * which aimock already serves. + */ +class E2eVertexAuthClient { + async getRequestHeaders() { + return new Headers({ Authorization: 'Bearer e2e-dummy' }) + } +} + +export function vertexE2eConfig( + baseUrl: string, + headers?: Record, +): VertexClientConfig { + return { + project: E2E_PROJECT, + location: E2E_LOCATION, + apiVersion: E2E_API_VERSION, + httpOptions: { baseUrl, headers, apiVersion: E2E_API_VERSION }, + googleAuthOptions: { + // GoogleAuthOptions.authClient is the abstract AuthClient class from + // google-auth-library. GoogleAuth only calls getRequestHeaders() on + // the cached client. This package does not depend on that library. + authClient: new E2eVertexAuthClient() as VertexAuthClient, + }, + } +} \ No newline at end of file diff --git a/testing/e2e/src/routes/api.summarize.ts b/testing/e2e/src/routes/api.summarize.ts index c9118f33fe..7bc5c29f08 100644 --- a/testing/e2e/src/routes/api.summarize.ts +++ b/testing/e2e/src/routes/api.summarize.ts @@ -3,6 +3,8 @@ import { summarize, toServerSentEventsResponse } from '@tanstack/ai' import { createOpenaiSummarize } from '@tanstack/ai-openai' import { createAnthropicSummarize } from '@tanstack/ai-anthropic' import { createGeminiSummarize } from '@tanstack/ai-gemini' +import { vertexSummarize } from '@tanstack/ai-vertex' +import { vertexE2eConfig } from '@/lib/vertex-e2e' import { createOllamaSummarize } from '@tanstack/ai-ollama' import { createGroqSummarize } from '@tanstack/ai-groq' import { createGrokSummarize } from '@tanstack/ai-grok' @@ -69,6 +71,11 @@ function createSummarizeAdapter( createGeminiSummarize(DUMMY_KEY, 'gemini-2.5-flash', { httpOptions: { baseUrl: llmockBase(aimockPort), headers }, }), + vertex: () => + vertexSummarize( + 'gemini-2.5-flash', + vertexE2eConfig(llmockBase(aimockPort), headers), + ), ollama: () => createOllamaSummarize('mistral', llmockBase(aimockPort)), groq: () => createGroqSummarize('llama-3.3-70b-versatile', DUMMY_KEY, { diff --git a/testing/e2e/tests/test-matrix.ts b/testing/e2e/tests/test-matrix.ts index 1b5474b194..9decc0226a 100644 --- a/testing/e2e/tests/test-matrix.ts +++ b/testing/e2e/tests/test-matrix.ts @@ -17,6 +17,7 @@ export const providers: Provider[] = [ 'openai', 'anthropic', 'gemini', + 'vertex', 'ollama', 'groq', 'grok', From dec728ac39d35bc9963f9068e5314b0eecca861a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:03:40 +0000 Subject: [PATCH 06/10] ci: apply automated fixes --- testing/e2e/src/lib/vertex-e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/e2e/src/lib/vertex-e2e.ts b/testing/e2e/src/lib/vertex-e2e.ts index 3482c6ca4d..1ec199718d 100644 --- a/testing/e2e/src/lib/vertex-e2e.ts +++ b/testing/e2e/src/lib/vertex-e2e.ts @@ -41,4 +41,4 @@ export function vertexE2eConfig( authClient: new E2eVertexAuthClient() as VertexAuthClient, }, } -} \ No newline at end of file +} From 280a0908ef04728f3bdbb3511c2dd694b3796e4a Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 19 Aug 2026 17:21:27 +0200 Subject: [PATCH 07/10] fix(e2e): sort Vertex dependency for sherif --- testing/e2e/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/e2e/package.json b/testing/e2e/package.json index 935fe37ac3..238077ecea 100644 --- a/testing/e2e/package.json +++ b/testing/e2e/package.json @@ -26,7 +26,6 @@ "@tanstack/ai-client": "workspace:*", "@tanstack/ai-elevenlabs": "workspace:*", "@tanstack/ai-gemini": "workspace:*", - "@tanstack/ai-vertex": "workspace:*", "@tanstack/ai-grok": "workspace:*", "@tanstack/ai-groq": "workspace:*", "@tanstack/ai-llmgateway": "workspace:*", @@ -41,6 +40,7 @@ "@tanstack/ai-react-ui": "workspace:*", "@tanstack/ai-sandbox": "workspace:*", "@tanstack/ai-vercel-gateway": "workspace:*", + "@tanstack/ai-vertex": "workspace:*", "@tanstack/devtools-event-bus": "^0.4.1", "@tanstack/nitro-v2-vite-plugin": "^1.155.0", "@tanstack/react-ai-devtools": "workspace:*", From 9f780e156707e353d0a10a4aa78790af3bc54a6a Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 19 Aug 2026 18:36:24 +0200 Subject: [PATCH 08/10] feat: add Vertex AI support for Grok and Mistral Add grokVertexText, grokVertexSummarize, and mistralVertexText. Vertex factories accept only the chat models in the Google partner catalog. anthropicVertexText uses the same rule for Claude. --- .changeset/vertex-grok-mistral.md | 11 ++ docs/adapters/anthropic.md | 3 + docs/adapters/grok.md | 44 +++++++ docs/adapters/mistral.md | 42 ++++++ docs/adapters/vertex.md | 11 ++ docs/config.json | 2 +- docs/getting-started/overview.md | 2 +- docs/migration/migration-from-vercel-ai.md | 2 +- packages/ai-anthropic/src/index.ts | 2 + packages/ai-anthropic/src/model-meta.ts | 22 ++++ packages/ai-anthropic/src/vertex/index.ts | 8 +- .../ai-anthropic/tests/vertex-factory.test.ts | 13 +- packages/ai-grok/package.json | 11 ++ packages/ai-grok/src/adapters/text.ts | 3 +- packages/ai-grok/src/index.ts | 2 + packages/ai-grok/src/model-meta.ts | 74 ++++++++++- packages/ai-grok/src/vertex/auth.ts | 124 ++++++++++++++++++ packages/ai-grok/src/vertex/index.ts | 91 +++++++++++++ packages/ai-grok/tests/vertex-auth.test.ts | 109 +++++++++++++++ packages/ai-grok/tests/vertex-factory.test.ts | 109 +++++++++++++++ packages/ai-grok/vite.config.ts | 2 +- packages/ai-mistral/package.json | 11 ++ packages/ai-mistral/src/adapters/text.ts | 14 +- packages/ai-mistral/src/index.ts | 7 +- packages/ai-mistral/src/model-meta.ts | 70 ++++++++++ packages/ai-mistral/src/utils/client.ts | 47 ++++++- packages/ai-mistral/src/vertex/auth.ts | 121 +++++++++++++++++ packages/ai-mistral/src/vertex/index.ts | 50 +++++++ packages/ai-mistral/tests/vertex-auth.test.ts | 97 ++++++++++++++ .../ai-mistral/tests/vertex-factory.test.ts | 90 +++++++++++++ packages/ai-mistral/vite.config.ts | 2 +- pnpm-lock.yaml | 6 + testing/e2e/README.md | 16 ++- testing/e2e/src/lib/feature-support.ts | 32 ++++- testing/e2e/src/lib/features.ts | 1 + testing/e2e/src/lib/providers.ts | 31 ++++- testing/e2e/src/lib/types.ts | 4 + testing/e2e/src/lib/vertex-e2e.ts | 4 + testing/e2e/src/routes/api.summarize.ts | 11 +- testing/e2e/tests/test-matrix.ts | 2 + 40 files changed, 1278 insertions(+), 25 deletions(-) create mode 100644 .changeset/vertex-grok-mistral.md create mode 100644 packages/ai-grok/src/vertex/auth.ts create mode 100644 packages/ai-grok/src/vertex/index.ts create mode 100644 packages/ai-grok/tests/vertex-auth.test.ts create mode 100644 packages/ai-grok/tests/vertex-factory.test.ts create mode 100644 packages/ai-mistral/src/vertex/auth.ts create mode 100644 packages/ai-mistral/src/vertex/index.ts create mode 100644 packages/ai-mistral/tests/vertex-auth.test.ts create mode 100644 packages/ai-mistral/tests/vertex-factory.test.ts diff --git a/.changeset/vertex-grok-mistral.md b/.changeset/vertex-grok-mistral.md new file mode 100644 index 0000000000..561e5940f1 --- /dev/null +++ b/.changeset/vertex-grok-mistral.md @@ -0,0 +1,11 @@ +--- +'@tanstack/ai-grok': minor +'@tanstack/ai-mistral': minor +'@tanstack/ai-anthropic': minor +--- + +Add Vertex AI factories for Grok (`grokVertexText`, `grokVertexSummarize`) +and Mistral (`mistralVertexText`) on `@tanstack/ai-grok/vertex` and +`@tanstack/ai-mistral/vertex`. Vertex factories accept only the chat +models in the Google partner catalog. `anthropicVertexText` now uses +the same Vertex Claude catalog. diff --git a/docs/adapters/anthropic.md b/docs/adapters/anthropic.md index c3a5f1a983..5859d3be63 100644 --- a/docs/adapters/anthropic.md +++ b/docs/adapters/anthropic.md @@ -86,6 +86,9 @@ const stream = chat({ }); ``` +`anthropicVertexText` accepts only the Claude models in the Vertex catalog. +It does not accept Anthropic-only ids such as `claude-opus-5-fast`. + `project` and `location` use the same names as `@tanstack/ai-vertex`, so one auth object works for Gemini and Claude. diff --git a/docs/adapters/grok.md b/docs/adapters/grok.md index 41dc55a4ca..254eeccd4c 100644 --- a/docs/adapters/grok.md +++ b/docs/adapters/grok.md @@ -61,6 +61,50 @@ const config: Omit = { const adapter = createGrokText("grok-build-0.1", process.env.XAI_API_KEY!, config); ``` +## Grok on Vertex + +Use `@tanstack/ai-grok/vertex` when Grok must run on Vertex AI. That path +uses Google Cloud credentials and Vertex regional or global endpoints. + +```bash +npm install @tanstack/ai-grok google-auth-library +``` + +```typescript +import { chat } from "@tanstack/ai"; +import { grokVertexText } from "@tanstack/ai-grok/vertex"; + +const stream = chat({ + adapter: grokVertexText("grok-4.3", { + project: "my-project", + location: "global", + }), + messages: [{ role: "user", content: "Hello!" }], +}); +``` + +`project` and `location` use the same names as `@tanstack/ai-vertex`. If you +omit `location`, the factory uses `global`. + +`grokVertexText` accepts only the Grok chat models that Vertex lists: + +- `grok-4.3` +- `grok-4.20-reasoning` +- `grok-4.20-non-reasoning` +- `grok-4.1-fast-reasoning` +- `grok-4.1-fast-non-reasoning` + +xAI API models such as `grok-4.6` and `grok-build-0.1` are not on Vertex. + +The adapter sends the Vertex model id `xai/grok-4.3`. Install +`google-auth-library` for Application Default Credentials, or pass +`authClient` or `getAccessToken`. + +Use `grokVertexSummarize` from the same entry when you need summarize on +Vertex. + +Gemini on Vertex lives in [`@tanstack/ai-vertex`](./vertex). + ## Example: Chat Completion ```typescript diff --git a/docs/adapters/mistral.md b/docs/adapters/mistral.md index c2b53a7a69..9f6ddeabbd 100644 --- a/docs/adapters/mistral.md +++ b/docs/adapters/mistral.md @@ -73,6 +73,48 @@ const adapter = createMistralText( ); ``` +## Mistral on Vertex + +Use `@tanstack/ai-mistral/vertex` when Mistral must run on Vertex AI. That +path uses Google Cloud credentials and the publisher `rawPredict` endpoint. + +Mistral on Vertex is regional only. Use `us-central1` or `europe-west4`. + +```bash +npm install @tanstack/ai-mistral google-auth-library +``` + +```typescript +import { chat } from "@tanstack/ai"; +import { mistralVertexText } from "@tanstack/ai-mistral/vertex"; + +const stream = chat({ + adapter: mistralVertexText("mistral-medium-3", { + project: "my-project", + location: "europe-west4", + }), + messages: [{ role: "user", content: "Hello!" }], +}); +``` + +`project` and `location` use the same names as `@tanstack/ai-vertex`. +`location` is required. + +`mistralVertexText` accepts only the Mistral chat models that Vertex lists: + +- `mistral-medium-3` +- `mistral-small-2503` +- `codestral-2` + +Mistral API aliases such as `mistral-large-latest` and `mistral-medium-latest` +are not Vertex model ids. Vertex also lists `mistral-ocr-2505`, but that +model is OCR, not chat. + +Install `google-auth-library` for Application Default Credentials, or pass +`authClient` or `getAccessToken`. + +Gemini on Vertex lives in [`@tanstack/ai-vertex`](./vertex). + ## Example: Chat Completion ```typescript diff --git a/docs/adapters/vertex.md b/docs/adapters/vertex.md index 2b09d0f754..3ea0daf7a0 100644 --- a/docs/adapters/vertex.md +++ b/docs/adapters/vertex.md @@ -8,6 +8,9 @@ keywords: - vertex - vertex ai - gemini + - grok + - xai + - mistral - google cloud - regional - adapter @@ -250,3 +253,11 @@ Creates a Gemini video adapter on Vertex. Experimental. `config.allowUrlFetch` i ## Claude on Vertex Use [`anthropicVertexText`](./anthropic#claude-on-vertex) from `@tanstack/ai-anthropic/vertex`. + +## Grok on Vertex + +Use [`grokVertexText`](./grok#grok-on-vertex) from `@tanstack/ai-grok/vertex`. Vertex Grok uses the OpenAI-compatible Responses endpoint. Use `grokVertexSummarize` from the same entry for summarize. The factory accepts only the Grok chat models in the Vertex catalog (`grok-4.3`, `grok-4.20-reasoning`, `grok-4.20-non-reasoning`, `grok-4.1-fast-reasoning`, `grok-4.1-fast-non-reasoning`). + +## Mistral on Vertex + +Use [`mistralVertexText`](./mistral#mistral-on-vertex) from `@tanstack/ai-mistral/vertex`. Vertex Mistral uses the publisher `rawPredict` path. It is regional only (`us-central1` or `europe-west4`). The factory accepts only the Mistral chat models in the Vertex catalog (`mistral-medium-3`, `mistral-small-2503`, `codestral-2`). diff --git a/docs/config.json b/docs/config.json index a62a8d520d..a61d9ca2f8 100644 --- a/docs/config.json +++ b/docs/config.json @@ -924,7 +924,7 @@ "label": "Mistral", "to": "adapters/mistral", "addedAt": "2026-06-30", - "updatedAt": "2026-07-22" + "updatedAt": "2026-08-19" }, { "label": "Cohere", diff --git a/docs/getting-started/overview.md b/docs/getting-started/overview.md index c9c6ddff60..98aaeca783 100644 --- a/docs/getting-started/overview.md +++ b/docs/getting-started/overview.md @@ -109,7 +109,7 @@ With the help of adapters, TanStack AI can connect to various LLM providers. Ava - **@tanstack/ai-openai** - OpenAI (GPT series) - **@tanstack/ai-anthropic** - Anthropic (Claude) - **@tanstack/ai-gemini** - Google Gemini -- **@tanstack/ai-vertex** - Gemini on Google Vertex AI +- **@tanstack/ai-vertex** - Gemini on Google Vertex AI. Claude, Grok, and Mistral on Vertex live on `@tanstack/ai-anthropic/vertex`, `@tanstack/ai-grok/vertex`, and `@tanstack/ai-mistral/vertex`. - **@tanstack/ai-ollama** - Ollama (local models) - **@tanstack/ai-groq** - Groq - **@tanstack/ai-grok** - xAI Grok diff --git a/docs/migration/migration-from-vercel-ai.md b/docs/migration/migration-from-vercel-ai.md index 6a6034dc75..c9fb7c12bd 100644 --- a/docs/migration/migration-from-vercel-ai.md +++ b/docs/migration/migration-from-vercel-ai.md @@ -41,7 +41,7 @@ TanStack AI provides several advantages: | `@ai-sdk/openai` | `@tanstack/ai-openai` | | `@ai-sdk/anthropic` | `@tanstack/ai-anthropic` | | `@ai-sdk/google` | `@tanstack/ai-gemini` | -| `@ai-sdk/google-vertex` | `@tanstack/ai-vertex` (Gemini) and `@tanstack/ai-anthropic/vertex` (Claude) | +| `@ai-sdk/google-vertex` | `@tanstack/ai-vertex` (Gemini), `@tanstack/ai-anthropic/vertex` (Claude), `@tanstack/ai-grok/vertex` (Grok), and `@tanstack/ai-mistral/vertex` (Mistral) | | `@ai-sdk/react` | `@tanstack/ai-react` | | `@ai-sdk/vue` | `@tanstack/ai-vue` | | `@ai-sdk/solid` | `@tanstack/ai-solid` | diff --git a/packages/ai-anthropic/src/index.ts b/packages/ai-anthropic/src/index.ts index 5a68819584..4890d488bc 100644 --- a/packages/ai-anthropic/src/index.ts +++ b/packages/ai-anthropic/src/index.ts @@ -28,12 +28,14 @@ export { export type { AnthropicChatModel, + AnthropicVertexChatModel, AnthropicChatModelProviderOptionsByName, AnthropicChatModelToolCapabilitiesByName, AnthropicModelInputModalitiesByName, } from './model-meta' export { ANTHROPIC_MODELS, + ANTHROPIC_VERTEX_CHAT_MODELS, ANTHROPIC_COMBINED_TOOLS_AND_SCHEMA_MODELS, } from './model-meta' export type { diff --git a/packages/ai-anthropic/src/model-meta.ts b/packages/ai-anthropic/src/model-meta.ts index 5dc0c80f18..1cdfa01654 100644 --- a/packages/ai-anthropic/src/model-meta.ts +++ b/packages/ai-anthropic/src/model-meta.ts @@ -577,6 +577,28 @@ export const ANTHROPIC_MODELS = [ CLAUDE_SONNET_5.id, ] as const +/** + * Claude chat models on Vertex AI / Gemini Enterprise Agent Platform. + * This list is the Google partner catalog, not the full Anthropic API catalog. + * Source: https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/partner-models/use-partner-models + */ +export const ANTHROPIC_VERTEX_CHAT_MODELS = [ + CLAUDE_OPUS_5.id, + CLAUDE_SONNET_5.id, + CLAUDE_FABLE_5.id, + CLAUDE_OPUS_4_8.id, + CLAUDE_OPUS_4_7.id, + CLAUDE_OPUS_4_6.id, + CLAUDE_SONNET_4_6.id, + CLAUDE_OPUS_4_5.id, + CLAUDE_SONNET_4_5.id, + CLAUDE_OPUS_4_1.id, + CLAUDE_HAIKU_4_5.id, +] as const + +export type AnthropicVertexChatModel = + (typeof ANTHROPIC_VERTEX_CHAT_MODELS)[number] + /** * Fallback `max_tokens` ceiling for a model whose metadata carries no * `max_output_tokens` (e.g. an unrecognized model id). Anthropic's Messages diff --git a/packages/ai-anthropic/src/vertex/index.ts b/packages/ai-anthropic/src/vertex/index.ts index 7dde8fa2a2..e5fae11c8a 100644 --- a/packages/ai-anthropic/src/vertex/index.ts +++ b/packages/ai-anthropic/src/vertex/index.ts @@ -2,7 +2,7 @@ import { AnthropicVertex } from '@anthropic-ai/vertex-sdk' import { createAnthropicChatWithClient } from '../adapters/text' import { resolveAnthropicVertexOptions } from './auth' import type { AnthropicTextAdapter } from '../adapters/text' -import type { AnthropicChatModel } from '../model-meta' +import type { AnthropicVertexChatModel } from '../model-meta' import type { AnthropicVertexConfig } from './auth' export { @@ -10,13 +10,17 @@ export { resolveAnthropicVertexOptions, type AnthropicVertexConfig, } from './auth' +export { + ANTHROPIC_VERTEX_CHAT_MODELS, + type AnthropicVertexChatModel, +} from '../model-meta' /** * Creates an Anthropic chat adapter that talks to Claude on Vertex AI. * * Install `@anthropic-ai/vertex-sdk` next to `@tanstack/ai-anthropic`. */ -export function anthropicVertexText( +export function anthropicVertexText( model: TModel, config: AnthropicVertexConfig = {}, ): AnthropicTextAdapter { diff --git a/packages/ai-anthropic/tests/vertex-factory.test.ts b/packages/ai-anthropic/tests/vertex-factory.test.ts index 690e77a077..1ab7e77f88 100644 --- a/packages/ai-anthropic/tests/vertex-factory.test.ts +++ b/packages/ai-anthropic/tests/vertex-factory.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { anthropicVertexText } from '../src/vertex' +import { + ANTHROPIC_VERTEX_CHAT_MODELS, + anthropicVertexText, +} from '../src/vertex' const mocks = vi.hoisted(() => { return { @@ -26,6 +29,14 @@ vi.mock('@anthropic-ai/vertex-sdk', () => { } }) +describe('ANTHROPIC_VERTEX_CHAT_MODELS', () => { + it('does not include Anthropic-only model ids', () => { + expect(ANTHROPIC_VERTEX_CHAT_MODELS).toContain('claude-sonnet-5') + expect(ANTHROPIC_VERTEX_CHAT_MODELS).toContain('claude-opus-5') + expect(ANTHROPIC_VERTEX_CHAT_MODELS).not.toContain('claude-opus-5-fast') + }) +}) + describe('anthropicVertexText', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/packages/ai-grok/package.json b/packages/ai-grok/package.json index c00588a7aa..8ce2cfe5ca 100644 --- a/packages/ai-grok/package.json +++ b/packages/ai-grok/package.json @@ -28,6 +28,10 @@ "./tools": { "types": "./dist/esm/tools/index.d.ts", "import": "./dist/esm/tools/index.js" + }, + "./vertex": { + "types": "./dist/esm/vertex/index.d.ts", + "import": "./dist/esm/vertex/index.js" } }, "files": [ @@ -66,10 +70,17 @@ "devDependencies": { "@tanstack/ai": "workspace:*", "@vitest/coverage-v8": "4.1.10", + "google-auth-library": "^10.5.0", "vite": "^8.2.1" }, "peerDependencies": { "@tanstack/ai": "workspace:^", + "google-auth-library": "^10.5.0", "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "google-auth-library": { + "optional": true + } } } diff --git a/packages/ai-grok/src/adapters/text.ts b/packages/ai-grok/src/adapters/text.ts index b3c4945a8c..07af1f5ee3 100644 --- a/packages/ai-grok/src/adapters/text.ts +++ b/packages/ai-grok/src/adapters/text.ts @@ -5,6 +5,7 @@ import { convertToolsToProviderFormat } from '../tools' import type { GROK_CHAT_MODELS, GrokChatModelToolCapabilitiesByName, + GrokTextAdapterModel, ResolveInputModalities, ResolveProviderOptions, } from '../model-meta' @@ -42,7 +43,7 @@ export type { ExternalTextProviderOptions as GrokTextProviderOptions } from '../ * typing through the 5th generic of the base class. */ export class GrokTextAdapter< - TModel extends (typeof GROK_CHAT_MODELS)[number], + TModel extends GrokTextAdapterModel, // Use `Record` (not `unknown`) to match the OpenAI text // adapter: the resolved Grok provider options are a type-alias intersection // with no explicit index signature, which is assignable to diff --git a/packages/ai-grok/src/index.ts b/packages/ai-grok/src/index.ts index e1c935f734..b578fcd620 100644 --- a/packages/ai-grok/src/index.ts +++ b/packages/ai-grok/src/index.ts @@ -94,6 +94,7 @@ export type { ResolveProviderOptions, ResolveInputModalities, GrokChatModel, + GrokVertexChatModel, GrokImageModel, GrokVideoModel, GrokTTSModel, @@ -102,6 +103,7 @@ export type { } from './model-meta' export { GROK_CHAT_MODELS, + GROK_VERTEX_CHAT_MODELS, GROK_IMAGE_MODELS, GROK_VIDEO_MODELS, GROK_TTS_MODELS, diff --git a/packages/ai-grok/src/model-meta.ts b/packages/ai-grok/src/model-meta.ts index 35feac479f..10a8b79900 100644 --- a/packages/ai-grok/src/model-meta.ts +++ b/packages/ai-grok/src/model-meta.ts @@ -233,8 +233,54 @@ const GROK_BUILD_0_1 = { }, } as const satisfies ModelMeta +// Vertex Model Garden IDs. These are not the xAI API catalog. +// Source: https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/partner-models/grok +const GROK_4_20_REASONING = { + name: 'grok-4.20-reasoning', + context_window: 1_000_000, + supports: { + input: ['text', 'image'], + output: ['text'], + capabilities: ['reasoning', 'structured_outputs', 'tool_calling'], + tools: [] as const, + }, +} as const satisfies ModelMeta + +const GROK_4_20_NON_REASONING = { + name: 'grok-4.20-non-reasoning', + context_window: 1_000_000, + supports: { + input: ['text', 'image'], + output: ['text'], + capabilities: ['structured_outputs', 'tool_calling'], + tools: [] as const, + }, +} as const satisfies ModelMeta + +const GROK_4_1_FAST_REASONING = { + name: 'grok-4.1-fast-reasoning', + context_window: 2_000_000, + supports: { + input: ['text', 'image'], + output: ['text'], + capabilities: ['reasoning', 'structured_outputs', 'tool_calling'], + tools: [] as const, + }, +} as const satisfies ModelMeta + +const GROK_4_1_FAST_NON_REASONING = { + name: 'grok-4.1-fast-non-reasoning', + context_window: 2_000_000, + supports: { + input: ['text', 'image'], + output: ['text'], + capabilities: ['structured_outputs', 'tool_calling'], + tools: [] as const, + }, +} as const satisfies ModelMeta + /** - * Grok chat models supported by the Responses adapter. + * Grok chat models supported by the xAI Responses adapter. */ export const GROK_CHAT_MODELS = [ GROK_4_5.name, @@ -243,6 +289,18 @@ export const GROK_CHAT_MODELS = [ GROK_4_3.name, ] as const +/** + * Grok chat models on Vertex AI / Gemini Enterprise Agent Platform. + * This list is the Google partner catalog, not the xAI API catalog. + */ +export const GROK_VERTEX_CHAT_MODELS = [ + GROK_4_3.name, + GROK_4_20_REASONING.name, + GROK_4_20_NON_REASONING.name, + GROK_4_1_FAST_REASONING.name, + GROK_4_1_FAST_NON_REASONING.name, +] as const + /** * Grok Image Generation Models */ @@ -349,6 +407,8 @@ export const GROK_DEFAULT_REALTIME_MODEL: GrokRealtimeModel = 'grok-voice-think-fast-2.0' export type GrokChatModel = (typeof GROK_CHAT_MODELS)[number] +export type GrokVertexChatModel = (typeof GROK_VERTEX_CHAT_MODELS)[number] +export type GrokTextAdapterModel = GrokChatModel | GrokVertexChatModel export type GrokImageModel = (typeof GROK_IMAGE_MODELS)[number] export type GrokVideoModel = (typeof GROK_VIDEO_MODELS)[number] export type GrokTTSModel = (typeof GROK_TTS_MODELS)[number] @@ -364,6 +424,10 @@ export type GrokModelInputModalitiesByName = { [GROK_BUILD_0_1.name]: typeof GROK_BUILD_0_1.supports.input [GROK_4_5.name]: typeof GROK_4_5.supports.input [GROK_4_6.name]: typeof GROK_4_6.supports.input + [GROK_4_20_REASONING.name]: typeof GROK_4_20_REASONING.supports.input + [GROK_4_20_NON_REASONING.name]: typeof GROK_4_20_NON_REASONING.supports.input + [GROK_4_1_FAST_REASONING.name]: typeof GROK_4_1_FAST_REASONING.supports.input + [GROK_4_1_FAST_NON_REASONING.name]: typeof GROK_4_1_FAST_NON_REASONING.supports.input } /** @@ -374,6 +438,10 @@ export type GrokModelInputModalitiesByName = { export type GrokChatModelToolCapabilitiesByName = { [GROK_4_3.name]: typeof GROK_4_3.supports.tools [GROK_BUILD_0_1.name]: typeof GROK_BUILD_0_1.supports.tools + [GROK_4_20_REASONING.name]: typeof GROK_4_20_REASONING.supports.tools + [GROK_4_20_NON_REASONING.name]: typeof GROK_4_20_NON_REASONING.supports.tools + [GROK_4_1_FAST_REASONING.name]: typeof GROK_4_1_FAST_REASONING.supports.tools + [GROK_4_1_FAST_NON_REASONING.name]: typeof GROK_4_1_FAST_NON_REASONING.supports.tools } export type GrokProviderOptions = GrokTextProviderOptions @@ -384,6 +452,10 @@ export type GrokProviderOptions = GrokTextProviderOptions export type GrokChatModelProviderOptionsByName = { [GROK_4_3.name]: GrokProviderOptions [GROK_BUILD_0_1.name]: GrokBuildProviderOptions + [GROK_4_20_REASONING.name]: GrokProviderOptions + [GROK_4_20_NON_REASONING.name]: GrokProviderOptions + [GROK_4_1_FAST_REASONING.name]: GrokProviderOptions + [GROK_4_1_FAST_NON_REASONING.name]: GrokProviderOptions } // =========================== diff --git a/packages/ai-grok/src/vertex/auth.ts b/packages/ai-grok/src/vertex/auth.ts new file mode 100644 index 0000000000..cff1b9a990 --- /dev/null +++ b/packages/ai-grok/src/vertex/auth.ts @@ -0,0 +1,124 @@ +export class GrokVertexAuthError extends Error { + constructor(message: string) { + super(message) + this.name = 'GrokVertexAuthError' + } +} + +export type VertexAuthClient = { + getRequestHeaders: (url?: string | URL) => Promise +} + +/** + * Public Vertex config for Grok. `project` and `location` match the Gemini + * Vertex factories so one auth object works for both. + * + * Default location is `global`. Grok on Vertex uses the OpenAI-compatible + * Responses endpoint under `/endpoints/openapi`. + */ +export type GrokVertexConfig = { + project?: string + location?: string + /** Override the OpenAI-compatible Vertex base URL. Used by e2e. */ + baseURL?: string + getAccessToken?: () => Promise + authClient?: VertexAuthClient + defaultHeaders?: Record +} + +function readEnv(name: string): string | undefined { + if (typeof process === 'undefined' || process.env === undefined) { + return undefined + } + const value = process.env[name] + if (value === undefined || value.length === 0) { + return undefined + } + return value +} + +export function toVertexGrokModelId(model: string): string { + if (model.startsWith('xai/')) { + return model + } + return `xai/${model}` +} + +export function resolveGrokVertexProject( + config: GrokVertexConfig, +): string | undefined { + return ( + config.project ?? + readEnv('GOOGLE_CLOUD_PROJECT') ?? + readEnv('GOOGLE_VERTEX_PROJECT') + ) +} + +export function resolveGrokVertexLocation(config: GrokVertexConfig): string { + return ( + config.location ?? + readEnv('GOOGLE_CLOUD_LOCATION') ?? + readEnv('GOOGLE_VERTEX_LOCATION') ?? + 'global' + ) +} + +export function resolveGrokVertexBaseURL(config: GrokVertexConfig): string { + if (config.baseURL !== undefined && config.baseURL.length > 0) { + return config.baseURL.replace(/\/+$/, '') + } + + const project = resolveGrokVertexProject(config) + if (project === undefined) { + throw new GrokVertexAuthError( + 'Grok Vertex needs a project, or a baseURL. Pass project on the factory, or set GOOGLE_CLOUD_PROJECT or GOOGLE_VERTEX_PROJECT.', + ) + } + + const location = resolveGrokVertexLocation(config) + if (location === 'global') { + return `https://aiplatform.googleapis.com/v1/projects/${project}/locations/global/endpoints/openapi` + } + return `https://${location}-aiplatform.googleapis.com/v1/projects/${project}/locations/${location}/endpoints/openapi` +} + +export async function resolveGrokVertexAccessToken( + config: GrokVertexConfig, +): Promise { + if (config.getAccessToken !== undefined) { + return config.getAccessToken() + } + + if (config.authClient !== undefined) { + const headers = await config.authClient.getRequestHeaders() + const authorization = headers.get('Authorization') + if (authorization === null || !authorization.startsWith('Bearer ')) { + throw new GrokVertexAuthError( + 'Grok Vertex authClient.getRequestHeaders() must return an Authorization Bearer token.', + ) + } + return authorization.slice('Bearer '.length) + } + + try { + const { GoogleAuth } = await import('google-auth-library') + const auth = new GoogleAuth({ + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }) + const client = await auth.getClient() + const token = await client.getAccessToken() + if (token.token === null || token.token === undefined) { + throw new GrokVertexAuthError( + 'Grok Vertex could not load a Google access token from Application Default Credentials.', + ) + } + return token.token + } catch (error) { + if (error instanceof GrokVertexAuthError) { + throw error + } + throw new GrokVertexAuthError( + 'Grok Vertex needs google-auth-library, or pass authClient or getAccessToken. Install google-auth-library next to @tanstack/ai-grok.', + ) + } +} diff --git a/packages/ai-grok/src/vertex/index.ts b/packages/ai-grok/src/vertex/index.ts new file mode 100644 index 0000000000..2b68abdbe4 --- /dev/null +++ b/packages/ai-grok/src/vertex/index.ts @@ -0,0 +1,91 @@ +import { ChatStreamSummarizeAdapter } from '@tanstack/ai/adapters' +import { GrokTextAdapter } from '../adapters/text' +import { + resolveGrokVertexAccessToken, + resolveGrokVertexBaseURL, + toVertexGrokModelId, +} from './auth' +import type { TextOptions } from '@tanstack/ai' +import type { InferTextProviderOptions } from '@tanstack/ai/adapters' +import type { ResponseCreateParams } from 'openai/resources/responses/responses' +import type { GrokVertexChatModel, ResolveProviderOptions } from '../model-meta' +import type { GrokVertexConfig } from './auth' + +export { + GrokVertexAuthError, + resolveGrokVertexAccessToken, + resolveGrokVertexBaseURL, + resolveGrokVertexLocation, + resolveGrokVertexProject, + toVertexGrokModelId, + type GrokVertexConfig, + type VertexAuthClient, +} from './auth' +export { + GROK_VERTEX_CHAT_MODELS, + type GrokVertexChatModel, +} from '../model-meta' + +class GrokVertexTextAdapter< + TModel extends GrokVertexChatModel, +> extends GrokTextAdapter { + protected override mapOptionsToRequest( + options: TextOptions>, + ): Omit { + const request = super.mapOptionsToRequest(options) + return { + ...request, + model: toVertexGrokModelId(this.model), + } + } +} + +/** + * Creates a Grok chat adapter that talks to xAI Grok on Vertex AI. + * + * Install `google-auth-library` next to `@tanstack/ai-grok` for Application + * Default Credentials. Or pass `authClient` or `getAccessToken`. + */ +export function grokVertexText( + model: TModel, + config: GrokVertexConfig = {}, +): GrokTextAdapter { + const baseURL = resolveGrokVertexBaseURL(config) + + return new GrokVertexTextAdapter( + { + apiKey: 'vertex', + baseURL, + defaultHeaders: config.defaultHeaders, + fetch: async (input, init) => { + const token = await resolveGrokVertexAccessToken(config) + const headers = new Headers(init?.headers) + headers.set('Authorization', `Bearer ${token}`) + if (config.defaultHeaders) { + for (const [key, value] of Object.entries(config.defaultHeaders)) { + headers.set(key, value) + } + } + return fetch(input, { ...init, headers }) + }, + }, + model, + ) +} + +/** + * Creates a Grok summarize adapter that talks to xAI Grok on Vertex AI. + */ +export function grokVertexSummarize( + model: TModel, + config: GrokVertexConfig = {}, +): ChatStreamSummarizeAdapter< + TModel, + InferTextProviderOptions> +> { + return new ChatStreamSummarizeAdapter( + grokVertexText(model, config), + model, + 'grok', + ) +} diff --git a/packages/ai-grok/tests/vertex-auth.test.ts b/packages/ai-grok/tests/vertex-auth.test.ts new file mode 100644 index 0000000000..0c3908c8db --- /dev/null +++ b/packages/ai-grok/tests/vertex-auth.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + GrokVertexAuthError, + resolveGrokVertexAccessToken, + resolveGrokVertexBaseURL, + resolveGrokVertexProject, + toVertexGrokModelId, +} from '../src/vertex/auth' + +describe('resolveGrokVertexBaseURL', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('builds the global OpenAI-compatible Vertex URL', () => { + expect( + resolveGrokVertexBaseURL({ + project: 'my-project', + location: 'global', + }), + ).toBe( + 'https://aiplatform.googleapis.com/v1/projects/my-project/locations/global/endpoints/openapi', + ) + }) + + it('defaults location to global', () => { + expect(resolveGrokVertexBaseURL({ project: 'my-project' })).toContain( + '/locations/global/endpoints/openapi', + ) + }) + + it('builds a regional Vertex URL', () => { + expect( + resolveGrokVertexBaseURL({ + project: 'my-project', + location: 'us-central1', + }), + ).toBe( + 'https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/endpoints/openapi', + ) + }) + + it('lets an explicit baseURL win', () => { + expect( + resolveGrokVertexBaseURL({ + baseURL: 'http://127.0.0.1:4010/v1/', + }), + ).toBe('http://127.0.0.1:4010/v1') + }) + + it('throws when project and baseURL are both missing', () => { + expect(() => resolveGrokVertexBaseURL({})).toThrow(GrokVertexAuthError) + }) +}) + +describe('toVertexGrokModelId', () => { + it('prefixes a bare Grok model id', () => { + expect(toVertexGrokModelId('grok-4.3')).toBe('xai/grok-4.3') + }) + + it('keeps an already-prefixed id', () => { + expect(toVertexGrokModelId('xai/grok-4.3')).toBe('xai/grok-4.3') + }) +}) + +describe('resolveGrokVertexAccessToken', () => { + it('uses getAccessToken when provided', async () => { + await expect( + resolveGrokVertexAccessToken({ + getAccessToken: async () => 'token-from-fn', + }), + ).resolves.toBe('token-from-fn') + }) + + it('reads a Bearer token from authClient', async () => { + await expect( + resolveGrokVertexAccessToken({ + authClient: { + async getRequestHeaders() { + return new Headers({ Authorization: 'Bearer token-from-client' }) + }, + }, + }), + ).resolves.toBe('token-from-client') + }) + + it('throws when authClient has no Bearer token', async () => { + await expect( + resolveGrokVertexAccessToken({ + authClient: { + async getRequestHeaders() { + return new Headers() + }, + }, + }), + ).rejects.toThrow(GrokVertexAuthError) + }) +}) + +describe('resolveGrokVertexProject', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('reads GOOGLE_CLOUD_PROJECT when project is omitted', () => { + vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'env-project') + expect(resolveGrokVertexProject({})).toBe('env-project') + }) +}) diff --git a/packages/ai-grok/tests/vertex-factory.test.ts b/packages/ai-grok/tests/vertex-factory.test.ts new file mode 100644 index 0000000000..1667830c81 --- /dev/null +++ b/packages/ai-grok/tests/vertex-factory.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolveDebugOption } from '@tanstack/ai/adapter-internals' +import { + GROK_VERTEX_CHAT_MODELS, + grokVertexSummarize, + grokVertexText, +} from '../src/vertex' + +const testLogger = resolveDebugOption(false) + +function createAsyncIterable(chunks: Array): AsyncIterable { + return { + [Symbol.asyncIterator]() { + let index = 0 + return { + async next() { + if (index < chunks.length) { + return { value: chunks[index++]!, done: false } + } + return { value: undefined as T, done: true } + }, + } + }, + } +} + +describe('GROK_VERTEX_CHAT_MODELS', () => { + it('matches the Google Vertex Grok catalog', () => { + expect(GROK_VERTEX_CHAT_MODELS).toEqual([ + 'grok-4.3', + 'grok-4.20-reasoning', + 'grok-4.20-non-reasoning', + 'grok-4.1-fast-reasoning', + 'grok-4.1-fast-non-reasoning', + ]) + expect(GROK_VERTEX_CHAT_MODELS).not.toContain('grok-4.6') + expect(GROK_VERTEX_CHAT_MODELS).not.toContain('grok-4.5') + expect(GROK_VERTEX_CHAT_MODELS).not.toContain('grok-build-0.1') + }) +}) + +describe('grokVertexText', () => { + it('returns a Grok adapter for a Vertex project and location', () => { + const adapter = grokVertexText('grok-4.3', { + project: 'my-project', + location: 'global', + getAccessToken: async () => 'e2e-dummy', + }) + + expect(adapter.name).toBe('grok') + expect(adapter.model).toBe('grok-4.3') + }) + + it('sends the Vertex xai/ model id on the Responses request', async () => { + const adapter = grokVertexText('grok-4.3', { + project: 'my-project', + location: 'global', + getAccessToken: async () => 'e2e-dummy', + }) + + const mockCreate = vi.fn().mockResolvedValue( + createAsyncIterable([ + { + type: 'response.created', + response: { id: 'resp_123', model: 'xai/grok-4.3' }, + }, + { + type: 'response.completed', + response: { + id: 'resp_123', + model: 'xai/grok-4.3', + output: [], + }, + }, + ]), + ) + ;(adapter as any).client = { + responses: { + create: mockCreate, + }, + } + + for await (const _chunk of adapter.chatStream({ + model: 'grok-4.3', + messages: [{ role: 'user', content: 'Hello' }], + logger: testLogger, + })) { + // Exhaust the stream so the adapter sends the request. + } + + expect(mockCreate.mock.calls[0]?.[0]).toMatchObject({ + model: 'xai/grok-4.3', + stream: true, + }) + }) +}) + +describe('grokVertexSummarize', () => { + it('returns a Grok summarize adapter', () => { + const adapter = grokVertexSummarize('grok-4.3', { + project: 'my-project', + location: 'global', + getAccessToken: async () => 'e2e-dummy', + }) + + expect(adapter.name).toBe('grok') + expect(adapter.model).toBe('grok-4.3') + }) +}) diff --git a/packages/ai-grok/vite.config.ts b/packages/ai-grok/vite.config.ts index 0e7e7eaea6..4f32bca735 100644 --- a/packages/ai-grok/vite.config.ts +++ b/packages/ai-grok/vite.config.ts @@ -29,7 +29,7 @@ const config = defineConfig({ export default mergeConfig( config, tanstackViteConfig({ - entry: ['./src/index.ts', './src/tools/index.ts'], + entry: ['./src/index.ts', './src/tools/index.ts', './src/vertex/index.ts'], srcDir: './src', cjs: false, }), diff --git a/packages/ai-mistral/package.json b/packages/ai-mistral/package.json index a5b59ebdb4..703e301b91 100644 --- a/packages/ai-mistral/package.json +++ b/packages/ai-mistral/package.json @@ -24,6 +24,10 @@ "./adapters/embedding": { "types": "./dist/esm/adapters/embedding.d.ts", "import": "./dist/esm/adapters/embedding.js" + }, + "./vertex": { + "types": "./dist/esm/vertex/index.d.ts", + "import": "./dist/esm/vertex/index.js" } }, "files": [ @@ -49,12 +53,19 @@ "devDependencies": { "@tanstack/ai": "workspace:*", "@vitest/coverage-v8": "4.1.10", + "google-auth-library": "^10.5.0", "vite": "^8.2.1" }, "peerDependencies": { "@tanstack/ai": "workspace:^", + "google-auth-library": "^10.5.0", "zod": "^4.0.0" }, + "peerDependenciesMeta": { + "google-auth-library": { + "optional": true + } + }, "dependencies": { "@mistralai/mistralai": "2.2.0" } diff --git a/packages/ai-mistral/src/adapters/text.ts b/packages/ai-mistral/src/adapters/text.ts index ad915c145b..b046c69033 100644 --- a/packages/ai-mistral/src/adapters/text.ts +++ b/packages/ai-mistral/src/adapters/text.ts @@ -20,6 +20,7 @@ import type { MISTRAL_CHAT_MODELS, MistralChatModelProviderOptionsByName, MistralModelInputModalitiesByName, + MistralTextAdapterModel, } from '../model-meta' import type { StructuredOutputOptions, @@ -160,7 +161,7 @@ interface MistralRawChunk { * Tree-shakeable adapter for Mistral chat/text completion functionality. */ export class MistralTextAdapter< - TModel extends (typeof MISTRAL_CHAT_MODELS)[number], + TModel extends MistralTextAdapterModel, TProviderOptions extends Record = ResolveProviderOptions, TInputModalities extends ReadonlyArray = ResolveInputModalities, @@ -708,13 +709,18 @@ export class MistralTextAdapter< const serverURL = (config.serverURL ?? 'https://api.mistral.ai') .replace(/\/+$/, '') .replace(/\/v1$/, '') - const url = `${serverURL}/v1/chat/completions` + const url = + config.resolveRequestUrl?.(true) ?? `${serverURL}/v1/chat/completions` const body = this.toWireBody(params) + const accessToken = + config.getAccessToken === undefined + ? config.apiKey + : await config.getAccessToken() const headers: Record = { 'Content-Type': 'application/json', - Authorization: `Bearer ${config.apiKey}`, + Authorization: `Bearer ${accessToken}`, ...config.defaultHeaders, } @@ -906,7 +912,7 @@ export class MistralTextAdapter< } return { - model: options.model, + model: this.rawConfig.requestModel ?? options.model, messages: messages, temperature: modelOptions?.temperature ?? undefined, maxTokens: modelOptions?.max_tokens ?? undefined, diff --git a/packages/ai-mistral/src/index.ts b/packages/ai-mistral/src/index.ts index 4e9b675ce6..1b993a1ab3 100644 --- a/packages/ai-mistral/src/index.ts +++ b/packages/ai-mistral/src/index.ts @@ -34,11 +34,16 @@ export type { ResolveProviderOptions, ResolveInputModalities, MistralChatModels, + MistralVertexChatModel, MistralEmbeddingModel, MistralEmbeddingModelProviderOptionsByName, MistralEmbeddingModelInputModalitiesByName, } from './model-meta' -export { MISTRAL_CHAT_MODELS, MISTRAL_EMBEDDING_MODELS } from './model-meta' +export { + MISTRAL_CHAT_MODELS, + MISTRAL_EMBEDDING_MODELS, + MISTRAL_VERTEX_CHAT_MODELS, +} from './model-meta' export type { MistralTextMetadata, MistralImageMetadata, diff --git a/packages/ai-mistral/src/model-meta.ts b/packages/ai-mistral/src/model-meta.ts index fe2a43a252..9c0fe276a6 100644 --- a/packages/ai-mistral/src/model-meta.ts +++ b/packages/ai-mistral/src/model-meta.ts @@ -216,6 +216,56 @@ const OPEN_MISTRAL_NEMO = { }, } as const satisfies ModelMeta +// Vertex Model Garden IDs. These are not the Mistral API catalog. +// Source: https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/partner-models/mistral +const MISTRAL_MEDIUM_3 = { + name: 'mistral-medium-3', + context_window: 131_072, + max_completion_tokens: 8_192, + pricing: { + input: { normal: 0.4 }, + output: { normal: 2 }, + }, + supports: { + input: ['text', 'image', 'document'], + output: ['text'], + endpoints: ['chat'], + features: ['streaming', 'tools', 'json_object', 'json_schema', 'vision'], + }, +} as const satisfies ModelMeta + +const MISTRAL_SMALL_2503 = { + name: 'mistral-small-2503', + context_window: 131_072, + max_completion_tokens: 8_192, + pricing: { + input: { normal: 0.1 }, + output: { normal: 0.3 }, + }, + supports: { + input: ['text', 'image', 'document'], + output: ['text'], + endpoints: ['chat'], + features: ['streaming', 'tools', 'json_object', 'json_schema', 'vision'], + }, +} as const satisfies ModelMeta + +const CODESTRAL_2 = { + name: 'codestral-2', + context_window: 131_072, + max_completion_tokens: 8_192, + pricing: { + input: { normal: 0.3 }, + output: { normal: 0.9 }, + }, + supports: { + input: ['text'], + output: ['text'], + endpoints: ['chat'], + features: ['streaming', 'tools', 'json_object', 'json_schema', 'code'], + }, +} as const satisfies ModelMeta + /** * All supported Mistral chat model identifiers. */ @@ -238,6 +288,20 @@ export const MISTRAL_CHAT_MODELS = [ */ export type MistralChatModels = (typeof MISTRAL_CHAT_MODELS)[number] +/** + * Mistral chat models on Vertex AI / Gemini Enterprise Agent Platform. + * This list is the Google partner catalog, not the Mistral API catalog. + * OCR (`mistral-ocr-2505`) is not a chat model. + */ +export const MISTRAL_VERTEX_CHAT_MODELS = [ + MISTRAL_MEDIUM_3.name, + MISTRAL_SMALL_2503.name, + CODESTRAL_2.name, +] as const + +export type MistralVertexChatModel = (typeof MISTRAL_VERTEX_CHAT_MODELS)[number] +export type MistralTextAdapterModel = MistralChatModels | MistralVertexChatModel + /** * Type-only map from Mistral chat model name to its supported input modalities. */ @@ -253,6 +317,9 @@ export type MistralModelInputModalitiesByName = { [MAGISTRAL_MEDIUM_LATEST.name]: typeof MAGISTRAL_MEDIUM_LATEST.supports.input [MAGISTRAL_SMALL_LATEST.name]: typeof MAGISTRAL_SMALL_LATEST.supports.input [OPEN_MISTRAL_NEMO.name]: typeof OPEN_MISTRAL_NEMO.supports.input + [MISTRAL_MEDIUM_3.name]: typeof MISTRAL_MEDIUM_3.supports.input + [MISTRAL_SMALL_2503.name]: typeof MISTRAL_SMALL_2503.supports.input + [CODESTRAL_2.name]: typeof CODESTRAL_2.supports.input } /** @@ -270,6 +337,9 @@ export type MistralChatModelProviderOptionsByName = { [MAGISTRAL_MEDIUM_LATEST.name]: MistralReasoningProviderOptions [MAGISTRAL_SMALL_LATEST.name]: MistralReasoningProviderOptions [OPEN_MISTRAL_NEMO.name]: MistralTextProviderOptions + [MISTRAL_MEDIUM_3.name]: MistralVisionProviderOptions + [MISTRAL_SMALL_2503.name]: MistralVisionProviderOptions + [CODESTRAL_2.name]: MistralTextProviderOptions } /** diff --git a/packages/ai-mistral/src/utils/client.ts b/packages/ai-mistral/src/utils/client.ts index f619571ddf..88e46a620f 100644 --- a/packages/ai-mistral/src/utils/client.ts +++ b/packages/ai-mistral/src/utils/client.ts @@ -12,22 +12,57 @@ export interface MistralClientConfig { /** Optional default headers to include with every request. */ defaultHeaders?: Record + + /** + * Optional Google / Vertex access token. When set, it replaces + * `apiKey` on the Authorization header. + */ + getAccessToken?: () => Promise + + /** + * Optional chat completions URL. Vertex uses this for + * `:rawPredict` and `:streamRawPredict`. + */ + resolveRequestUrl?: (stream: boolean) => string + + /** Optional model id sent on the wire. Vertex uses publisher model ids. */ + requestModel?: string } /** * Creates a Mistral SDK client instance. */ export function createMistralClient(config: MistralClientConfig): Mistral { - const { apiKey, serverURL, timeoutMs, defaultHeaders } = config + const { + apiKey, + serverURL, + timeoutMs, + defaultHeaders, + getAccessToken, + resolveRequestUrl, + } = config + + const needsHook = + (defaultHeaders !== undefined && Object.keys(defaultHeaders).length > 0) || + getAccessToken !== undefined || + resolveRequestUrl !== undefined let httpClient: HTTPClient | undefined - if (defaultHeaders && Object.keys(defaultHeaders).length > 0) { + if (needsHook) { httpClient = new HTTPClient() - httpClient.addHook('beforeRequest', (req) => { - for (const [key, value] of Object.entries(defaultHeaders)) { - req.headers.set(key, value) + httpClient.addHook('beforeRequest', async (req) => { + const nextUrl = + resolveRequestUrl === undefined ? req.url : resolveRequestUrl(false) + const next = new Request(nextUrl, req) + if (defaultHeaders) { + for (const [key, value] of Object.entries(defaultHeaders)) { + next.headers.set(key, value) + } + } + if (getAccessToken !== undefined) { + next.headers.set('Authorization', `Bearer ${await getAccessToken()}`) } - return req + return next }) } diff --git a/packages/ai-mistral/src/vertex/auth.ts b/packages/ai-mistral/src/vertex/auth.ts new file mode 100644 index 0000000000..deb6a152cd --- /dev/null +++ b/packages/ai-mistral/src/vertex/auth.ts @@ -0,0 +1,121 @@ +export class MistralVertexAuthError extends Error { + constructor(message: string) { + super(message) + this.name = 'MistralVertexAuthError' + } +} + +export type VertexAuthClient = { + getRequestHeaders: (url?: string | URL) => Promise +} + +/** + * Public Vertex config for Mistral. `project` and `location` match the + * Gemini Vertex factories so one auth object works for both. + * + * Mistral on Vertex is regional only (`us-central1`, `europe-west4`). + * There is no global endpoint. + */ +export type MistralVertexConfig = { + project?: string + location?: string + /** + * Override the chat completions URL. When set, the Vertex + * `:rawPredict` / `:streamRawPredict` rewrite is skipped. Used by e2e. + */ + resolveRequestUrl?: (stream: boolean) => string + getAccessToken?: () => Promise + authClient?: VertexAuthClient + defaultHeaders?: Record +} + +function readEnv(name: string): string | undefined { + if (typeof process === 'undefined' || process.env === undefined) { + return undefined + } + const value = process.env[name] + if (value === undefined || value.length === 0) { + return undefined + } + return value +} + +export function resolveMistralVertexProject( + config: MistralVertexConfig, +): string | undefined { + return ( + config.project ?? + readEnv('GOOGLE_CLOUD_PROJECT') ?? + readEnv('GOOGLE_VERTEX_PROJECT') + ) +} + +export function resolveMistralVertexLocation( + config: MistralVertexConfig, +): string { + const location = + config.location ?? + readEnv('GOOGLE_CLOUD_LOCATION') ?? + readEnv('GOOGLE_VERTEX_LOCATION') + if (location === undefined) { + throw new MistralVertexAuthError( + 'Mistral Vertex needs a location. Pass location on the factory, or set GOOGLE_CLOUD_LOCATION or GOOGLE_VERTEX_LOCATION. Use us-central1 or europe-west4.', + ) + } + return location +} + +export function resolveMistralVertexModelUrl( + model: string, + config: MistralVertexConfig, +): string { + const project = resolveMistralVertexProject(config) + if (project === undefined) { + throw new MistralVertexAuthError( + 'Mistral Vertex needs a project. Pass project on the factory, or set GOOGLE_CLOUD_PROJECT or GOOGLE_VERTEX_PROJECT.', + ) + } + const location = resolveMistralVertexLocation(config) + return `https://${location}-aiplatform.googleapis.com/v1/projects/${project}/locations/${location}/publishers/mistralai/models/${model}` +} + +export async function resolveMistralVertexAccessToken( + config: MistralVertexConfig, +): Promise { + if (config.getAccessToken !== undefined) { + return config.getAccessToken() + } + + if (config.authClient !== undefined) { + const headers = await config.authClient.getRequestHeaders() + const authorization = headers.get('Authorization') + if (authorization === null || !authorization.startsWith('Bearer ')) { + throw new MistralVertexAuthError( + 'Mistral Vertex authClient.getRequestHeaders() must return an Authorization Bearer token.', + ) + } + return authorization.slice('Bearer '.length) + } + + try { + const { GoogleAuth } = await import('google-auth-library') + const auth = new GoogleAuth({ + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }) + const client = await auth.getClient() + const token = await client.getAccessToken() + if (token.token === null || token.token === undefined) { + throw new MistralVertexAuthError( + 'Mistral Vertex could not load a Google access token from Application Default Credentials.', + ) + } + return token.token + } catch (error) { + if (error instanceof MistralVertexAuthError) { + throw error + } + throw new MistralVertexAuthError( + 'Mistral Vertex needs google-auth-library, or pass authClient or getAccessToken. Install google-auth-library next to @tanstack/ai-mistral.', + ) + } +} diff --git a/packages/ai-mistral/src/vertex/index.ts b/packages/ai-mistral/src/vertex/index.ts new file mode 100644 index 0000000000..e9f6e4f571 --- /dev/null +++ b/packages/ai-mistral/src/vertex/index.ts @@ -0,0 +1,50 @@ +import { MistralTextAdapter } from '../adapters/text' +import { + resolveMistralVertexAccessToken, + resolveMistralVertexModelUrl, +} from './auth' +import type { MistralVertexChatModel } from '../model-meta' +import type { MistralVertexConfig } from './auth' + +export { + MistralVertexAuthError, + resolveMistralVertexAccessToken, + resolveMistralVertexLocation, + resolveMistralVertexModelUrl, + resolveMistralVertexProject, + type MistralVertexConfig, + type VertexAuthClient, +} from './auth' +export { + MISTRAL_VERTEX_CHAT_MODELS, + type MistralVertexChatModel, +} from '../model-meta' + +/** + * Creates a Mistral chat adapter that talks to Mistral on Vertex AI. + * + * Install `google-auth-library` next to `@tanstack/ai-mistral` for + * Application Default Credentials. Or pass `authClient` or `getAccessToken`. + */ +export function mistralVertexText( + model: TModel, + config: MistralVertexConfig = {}, +): MistralTextAdapter { + const resolveRequestUrl = + config.resolveRequestUrl ?? + ((stream: boolean) => { + const modelUrl = resolveMistralVertexModelUrl(model, config) + return `${modelUrl}:${stream ? 'streamRawPredict' : 'rawPredict'}` + }) + + return new MistralTextAdapter( + { + apiKey: 'vertex', + getAccessToken: () => resolveMistralVertexAccessToken(config), + resolveRequestUrl, + requestModel: model, + defaultHeaders: config.defaultHeaders, + }, + model, + ) +} diff --git a/packages/ai-mistral/tests/vertex-auth.test.ts b/packages/ai-mistral/tests/vertex-auth.test.ts new file mode 100644 index 0000000000..d6810d3793 --- /dev/null +++ b/packages/ai-mistral/tests/vertex-auth.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + MistralVertexAuthError, + resolveMistralVertexAccessToken, + resolveMistralVertexLocation, + resolveMistralVertexModelUrl, + resolveMistralVertexProject, +} from '../src/vertex/auth' + +describe('resolveMistralVertexLocation', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('reads location from the factory', () => { + expect( + resolveMistralVertexLocation({ + project: 'my-project', + location: 'europe-west4', + }), + ).toBe('europe-west4') + }) + + it('throws when location is missing', () => { + expect(() => + resolveMistralVertexLocation({ project: 'my-project' }), + ).toThrow(MistralVertexAuthError) + }) +}) + +describe('resolveMistralVertexModelUrl', () => { + it('builds the publisher rawPredict host', () => { + expect( + resolveMistralVertexModelUrl('mistral-medium-3', { + project: 'my-project', + location: 'us-central1', + }), + ).toBe( + 'https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/mistralai/models/mistral-medium-3', + ) + }) +}) + +describe('resolveMistralVertexProject', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('throws when project is missing', () => { + expect(() => + resolveMistralVertexModelUrl('mistral-medium-3', { + location: 'us-central1', + }), + ).toThrow(MistralVertexAuthError) + }) + + it('reads GOOGLE_CLOUD_PROJECT', () => { + vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'env-project') + expect(resolveMistralVertexProject({ location: 'us-central1' })).toBe( + 'env-project', + ) + }) +}) + +describe('resolveMistralVertexAccessToken', () => { + it('uses getAccessToken when provided', async () => { + await expect( + resolveMistralVertexAccessToken({ + getAccessToken: async () => 'token-from-fn', + }), + ).resolves.toBe('token-from-fn') + }) + + it('reads a Bearer token from authClient', async () => { + await expect( + resolveMistralVertexAccessToken({ + authClient: { + async getRequestHeaders() { + return new Headers({ Authorization: 'Bearer token-from-client' }) + }, + }, + }), + ).resolves.toBe('token-from-client') + }) + + it('throws when authClient has no Bearer token', async () => { + await expect( + resolveMistralVertexAccessToken({ + authClient: { + async getRequestHeaders() { + return new Headers() + }, + }, + }), + ).rejects.toThrow(MistralVertexAuthError) + }) +}) diff --git a/packages/ai-mistral/tests/vertex-factory.test.ts b/packages/ai-mistral/tests/vertex-factory.test.ts new file mode 100644 index 0000000000..33afb0fb73 --- /dev/null +++ b/packages/ai-mistral/tests/vertex-factory.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MISTRAL_VERTEX_CHAT_MODELS, mistralVertexText } from '../src/vertex' +import type { TextOptions } from '@tanstack/ai' +import type { MistralTextProviderOptions } from '../src/adapters/text' + +function chatOpts( + opts: Partial> & { + model: string + messages: Array<{ role: 'user'; content: string }> + }, +): TextOptions { + return opts as unknown as TextOptions +} + +describe('MISTRAL_VERTEX_CHAT_MODELS', () => { + it('matches the Google Vertex Mistral chat catalog', () => { + expect(MISTRAL_VERTEX_CHAT_MODELS).toEqual([ + 'mistral-medium-3', + 'mistral-small-2503', + 'codestral-2', + ]) + expect(MISTRAL_VERTEX_CHAT_MODELS).not.toContain('mistral-large-latest') + expect(MISTRAL_VERTEX_CHAT_MODELS).not.toContain('mistral-medium-latest') + expect(MISTRAL_VERTEX_CHAT_MODELS).not.toContain('magistral-medium-latest') + }) +}) + +describe('mistralVertexText', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('returns a Mistral adapter for a Vertex project and location', () => { + const adapter = mistralVertexText('mistral-medium-3', { + project: 'my-project', + location: 'us-central1', + getAccessToken: async () => 'e2e-dummy', + }) + + expect(adapter.name).toBe('mistral') + expect(adapter.model).toBe('mistral-medium-3') + }) + + it('does not require project when resolveRequestUrl is set', () => { + expect(() => + mistralVertexText('mistral-medium-3', { + resolveRequestUrl: () => 'http://127.0.0.1:4010/v1/chat/completions', + getAccessToken: async () => 'e2e-dummy', + }), + ).not.toThrow() + }) + + it('posts to streamRawPredict with the Vertex wire model', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + new Response('data: [DONE]\n\n', { + headers: { 'Content-Type': 'text/event-stream' }, + }), + ) + vi.stubGlobal('fetch', fetchSpy) + + const adapter = mistralVertexText('mistral-medium-3', { + project: 'my-project', + location: 'europe-west4', + getAccessToken: async () => 'vertex-token', + }) + + for await (const _chunk of adapter.chatStream( + chatOpts({ + model: 'mistral-medium-3', + messages: [{ role: 'user', content: 'Hello' }], + }), + )) { + // Exhaust the stream so the adapter sends the request. + } + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const [url, init] = fetchSpy.mock.calls[0] as [ + string, + { headers: Record; body: string }, + ] + expect(url).toBe( + 'https://europe-west4-aiplatform.googleapis.com/v1/projects/my-project/locations/europe-west4/publishers/mistralai/models/mistral-medium-3:streamRawPredict', + ) + expect(init.headers.Authorization).toBe('Bearer vertex-token') + expect(JSON.parse(init.body)).toMatchObject({ + model: 'mistral-medium-3', + stream: true, + }) + }) +}) diff --git a/packages/ai-mistral/vite.config.ts b/packages/ai-mistral/vite.config.ts index 77bcc2e60b..afd61fd42e 100644 --- a/packages/ai-mistral/vite.config.ts +++ b/packages/ai-mistral/vite.config.ts @@ -29,7 +29,7 @@ const config = defineConfig({ export default mergeConfig( config, tanstackViteConfig({ - entry: ['./src/index.ts'], + entry: ['./src/index.ts', './src/vertex/index.ts'], srcDir: './src', cjs: false, }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 129c1785bd..d88c56aa37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1941,6 +1941,9 @@ importers: '@vitest/coverage-v8': specifier: 4.1.10 version: 4.1.10(vitest@4.1.10) + google-auth-library: + specifier: ^10.5.0 + version: 10.5.0 vite: specifier: ^8.2.1 version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) @@ -2158,6 +2161,9 @@ importers: '@vitest/coverage-v8': specifier: 4.1.10 version: 4.1.10(vitest@4.1.10) + google-auth-library: + specifier: ^10.5.0 + version: 10.5.0 vite: specifier: ^8.2.1 version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) diff --git a/testing/e2e/README.md b/testing/e2e/README.md index a1b7fed23f..b048820ab2 100644 --- a/testing/e2e/README.md +++ b/testing/e2e/README.md @@ -4,7 +4,7 @@ End-to-end tests for TanStack AI using Playwright and [aimock](https://github.co **Architecture:** Playwright drives a TanStack Start app (`testing/e2e/`) which routes requests through provider adapters pointing at aimock. Fixtures define mock responses. No real API keys needed. All scenarios (including tool execution flows) use aimock fixtures. Tests run in parallel with per-test `X-Test-Id` isolation. -**Providers tested:** openai, anthropic, gemini, vertex, ollama, groq, grok, openrouter, openrouter-responses, vercel-gateway, vercel-gateway-responses, bedrock, bedrock-responses, openai-compatible, mistral, byteplus, elevenlabs, llmgateway +**Providers tested:** openai, anthropic, gemini, vertex, vertex-grok, vertex-mistral, ollama, groq, grok, openrouter, openrouter-responses, vercel-gateway, vercel-gateway-responses, bedrock, bedrock-responses, openai-compatible, mistral, byteplus, elevenlabs, llmgateway > **Claude Code (`@tanstack/ai-claude-code`) is excluded from the standard matrix.** It's a harness adapter that spawns the Claude Code runtime as a subprocess, so aimock's per-test `X-Test-Id` header isolation can't be injected into its requests. It's covered by unit tests in the package plus a gated live smoke test in `tests/claude-code.spec.ts` — run it with `CLAUDE_CODE_E2E=1` and an `ANTHROPIC_API_KEY` (or a local `claude login`). @@ -276,6 +276,18 @@ Chat, tools, structured output, multimodal image, and summarize reuse the existi Claude on Vertex (`anthropicVertexText`) is not in this matrix. That SDK talks OAuth and a different Vertex URL that aimock does not mock. +### Vertex Grok and Vertex Mistral + +`vertex-grok` is the Grok Responses adapter with Vertex auth. The factory in `src/lib/providers.ts` calls `grokVertexText` with: + +- `baseURL` pointed at aimock `/v1`, so the OpenAI client posts `/v1/responses` like the xAI Grok row +- a dummy `authClient` so the factory does not look for Application Default Credentials +- the factory still prefixes the wire model with `xai/` + +`vertex-mistral` is the Mistral chat adapter with Vertex auth. The factory calls `mistralVertexText` with `resolveRequestUrl` pointed at aimock `/v1/chat/completions`. That skips the Vertex publisher `:rawPredict` rewrite. + +Both rows reuse the existing Grok and Mistral fixtures. Image, TTS, transcription, and embedding stay off these rows. + ### Bedrock Converse coverage gap The `bedrock` and `bedrock-responses` providers in this matrix use `createBedrockText` with a `baseURL` pointing at aimock — they speak Bedrock's **OpenAI-compatible** endpoint, which aimock's OpenAI replay handles fine. @@ -310,6 +322,8 @@ Three endpoints have no aimock equivalent and are mounted in `global-setup.ts`, - Anthropic: `LLMOCK_BASE` + `defaultHeaders` - Gemini: `httpOptions: { baseUrl: LLMOCK_BASE, headers }` - Vertex: `vertexE2eConfig(LLMOCK_BASE, headers)` (`project` + `location` + dummy auth + `apiVersion: 'v1'`) +- Vertex Grok: `grokVertexText` with `baseURL: LLMOCK_OPENAI`, dummy `authClient`, and `defaultHeaders` +- Vertex Mistral: `mistralVertexText` with `resolveRequestUrl` → `LLMOCK_BASE/v1/chat/completions`, dummy `authClient`, and `defaultHeaders` - Ollama: `{ host: LLMOCK_BASE, headers }` (config object) - OpenRouter: `serverURL` with `?testId=` query param (SDK doesn't support headers) - BytePlus: `LLMOCK_BASE + /api/v3` + `defaultHeaders` for Ark (chat, image, video); bare `LLMOCK_BASE` + `defaultHeaders` for Seed Speech (TTS, ASR) diff --git a/testing/e2e/src/lib/feature-support.ts b/testing/e2e/src/lib/feature-support.ts index 5c7e98279e..76fe8c414a 100644 --- a/testing/e2e/src/lib/feature-support.ts +++ b/testing/e2e/src/lib/feature-support.ts @@ -13,6 +13,8 @@ export const matrix: Record> = { 'anthropic', 'gemini', 'vertex', + 'vertex-grok', + 'vertex-mistral', 'ollama', 'groq', 'grok', @@ -30,6 +32,8 @@ export const matrix: Record> = { 'anthropic', 'gemini', 'vertex', + 'vertex-grok', + 'vertex-mistral', 'ollama', 'groq', 'grok', @@ -61,6 +65,8 @@ export const matrix: Record> = { 'anthropic', 'gemini', 'vertex', + 'vertex-grok', + 'vertex-mistral', 'ollama', 'groq', 'grok', @@ -78,6 +84,8 @@ export const matrix: Record> = { 'anthropic', 'gemini', 'vertex', + 'vertex-grok', + 'vertex-mistral', 'ollama', 'groq', 'grok', @@ -97,6 +105,8 @@ export const matrix: Record> = { 'anthropic', 'gemini', 'vertex', + 'vertex-grok', + 'vertex-mistral', 'groq', 'grok', 'bedrock', @@ -116,6 +126,8 @@ export const matrix: Record> = { 'ollama', 'groq', 'grok', + 'vertex-grok', + 'vertex-mistral', 'bedrock', 'bedrock-responses', 'openrouter', @@ -131,6 +143,8 @@ export const matrix: Record> = { 'anthropic', 'gemini', 'vertex', + 'vertex-grok', + 'vertex-mistral', 'groq', 'grok', 'bedrock', @@ -147,6 +161,8 @@ export const matrix: Record> = { 'anthropic', 'gemini', 'vertex', + 'vertex-grok', + 'vertex-mistral', 'ollama', 'groq', 'grok', @@ -167,6 +183,7 @@ export const matrix: Record> = { 'openai', 'groq', 'grok', + 'vertex-grok', 'bedrock', 'bedrock-responses', 'openrouter', @@ -197,6 +214,7 @@ export const matrix: Record> = { 'openai', 'gemini', 'vertex', + 'vertex-grok', 'ollama', 'groq', 'grok', @@ -213,6 +231,8 @@ export const matrix: Record> = { 'anthropic', 'gemini', 'vertex', + 'vertex-grok', + 'vertex-mistral', 'ollama', 'groq', 'grok', @@ -237,6 +257,7 @@ export const matrix: Record> = { 'gemini', 'vertex', 'grok', + 'vertex-grok', 'openrouter', 'openrouter-responses', 'byteplus', @@ -244,13 +265,16 @@ export const matrix: Record> = { // Bedrock excluded: the default e2e model (openai.gpt-oss-120b) is text-only // (input: ['text'], no vision) — image input isn't supported, so the // multimodal request never carries the image and the description comes back empty. - // Mistral excluded: mistral-large-latest is text-only; vision requires pixtral + // Mistral API default e2e model (mistral-large-latest) is text-only. + // vertex-mistral uses mistral-medium-3, which accepts image input. 'multimodal-image': new Set([ 'openai', 'anthropic', 'gemini', 'vertex', 'grok', + 'vertex-grok', + 'vertex-mistral', 'openrouter', 'byteplus', 'llmgateway', @@ -265,6 +289,8 @@ export const matrix: Record> = { 'gemini', 'vertex', 'grok', + 'vertex-grok', + 'vertex-mistral', 'openrouter', 'byteplus', 'llmgateway', @@ -278,6 +304,8 @@ export const matrix: Record> = { 'anthropic', 'gemini', 'vertex', + 'vertex-grok', + 'vertex-mistral', 'ollama', 'groq', 'grok', @@ -293,6 +321,8 @@ export const matrix: Record> = { 'anthropic', 'gemini', 'vertex', + 'vertex-grok', + 'vertex-mistral', 'ollama', 'groq', 'grok', diff --git a/testing/e2e/src/lib/features.ts b/testing/e2e/src/lib/features.ts index e14bf9f72a..3d72d85c9e 100644 --- a/testing/e2e/src/lib/features.ts +++ b/testing/e2e/src/lib/features.ts @@ -101,6 +101,7 @@ export const featureConfigs: Record = { gemini: 'gemini-3-flash-preview', vertex: 'gemini-3-flash-preview', grok: 'grok-build-0.1', + 'vertex-grok': 'grok-4.3', // Reports combined tools+schema support, so the engine takes the // native path here too. byteplus: BYTEPLUS_STRUCTURED_MODEL, diff --git a/testing/e2e/src/lib/providers.ts b/testing/e2e/src/lib/providers.ts index 2abcab9254..814b8e644b 100644 --- a/testing/e2e/src/lib/providers.ts +++ b/testing/e2e/src/lib/providers.ts @@ -5,7 +5,9 @@ import { createAnthropicChatWithClient } from '@tanstack/ai-anthropic' import { createGeminiChat } from '@tanstack/ai-gemini' import { createGeminiTextInteractions } from '@tanstack/ai-gemini/experimental' import { vertexText } from '@tanstack/ai-vertex' -import { vertexE2eConfig } from '@/lib/vertex-e2e' +import { grokVertexText } from '@tanstack/ai-grok/vertex' +import { mistralVertexText } from '@tanstack/ai-mistral/vertex' +import { vertexE2eAuthClient, vertexE2eConfig } from '@/lib/vertex-e2e' import { createOllamaChat } from '@tanstack/ai-ollama' import { createGroqText } from '@tanstack/ai-groq' import { createGrokText } from '@tanstack/ai-grok' @@ -32,6 +34,8 @@ const defaultModels: Record = { anthropic: 'claude-sonnet-4-5', gemini: 'gemini-2.5-flash', vertex: 'gemini-2.5-flash', + 'vertex-grok': 'grok-4.3', + 'vertex-mistral': 'mistral-medium-3', ollama: 'mistral', groq: 'llama-3.3-70b-versatile', grok: 'grok-build-0.1', @@ -132,6 +136,31 @@ export function createTextAdapter( vertexE2eConfig(base, testHeaders), ), }), + // Grok on Vertex. Dummy ADC + aimock `/v1` so the OpenAI Responses + // client hits the same path as the xAI Grok row. The factory still + // prefixes the wire model with `xai/`. + 'vertex-grok': () => + createChatOptions({ + adapter: grokVertexText(model as 'grok-4.3', { + project: 'e2e-project', + location: 'global', + baseURL: openaiUrl, + authClient: vertexE2eAuthClient(), + defaultHeaders: testHeaders, + }), + }), + // Mistral on Vertex. `resolveRequestUrl` skips the publisher + // `:rawPredict` rewrite and posts to aimock `/v1/chat/completions`. + 'vertex-mistral': () => + createChatOptions({ + adapter: mistralVertexText(model as 'mistral-medium-3', { + project: 'e2e-project', + location: 'us-central1', + authClient: vertexE2eAuthClient(), + defaultHeaders: testHeaders, + resolveRequestUrl: () => `${base}/v1/chat/completions`, + }), + }), ollama: () => createChatOptions({ adapter: createOllamaChat( diff --git a/testing/e2e/src/lib/types.ts b/testing/e2e/src/lib/types.ts index 088b9fac5b..635988233c 100644 --- a/testing/e2e/src/lib/types.ts +++ b/testing/e2e/src/lib/types.ts @@ -5,6 +5,8 @@ export type Provider = | 'anthropic' | 'gemini' | 'vertex' + | 'vertex-grok' + | 'vertex-mistral' | 'ollama' | 'grok' | 'groq' @@ -57,6 +59,8 @@ export const ALL_PROVIDERS: Provider[] = [ 'anthropic', 'gemini', 'vertex', + 'vertex-grok', + 'vertex-mistral', 'ollama', 'grok', 'groq', diff --git a/testing/e2e/src/lib/vertex-e2e.ts b/testing/e2e/src/lib/vertex-e2e.ts index 1ec199718d..80cc0d99a7 100644 --- a/testing/e2e/src/lib/vertex-e2e.ts +++ b/testing/e2e/src/lib/vertex-e2e.ts @@ -25,6 +25,10 @@ class E2eVertexAuthClient { } } +export function vertexE2eAuthClient() { + return new E2eVertexAuthClient() +} + export function vertexE2eConfig( baseUrl: string, headers?: Record, diff --git a/testing/e2e/src/routes/api.summarize.ts b/testing/e2e/src/routes/api.summarize.ts index 7bc5c29f08..039886c851 100644 --- a/testing/e2e/src/routes/api.summarize.ts +++ b/testing/e2e/src/routes/api.summarize.ts @@ -4,10 +4,11 @@ import { createOpenaiSummarize } from '@tanstack/ai-openai' import { createAnthropicSummarize } from '@tanstack/ai-anthropic' import { createGeminiSummarize } from '@tanstack/ai-gemini' import { vertexSummarize } from '@tanstack/ai-vertex' -import { vertexE2eConfig } from '@/lib/vertex-e2e' +import { vertexE2eAuthClient, vertexE2eConfig } from '@/lib/vertex-e2e' import { createOllamaSummarize } from '@tanstack/ai-ollama' import { createGroqSummarize } from '@tanstack/ai-groq' import { createGrokSummarize } from '@tanstack/ai-grok' +import { grokVertexSummarize } from '@tanstack/ai-grok/vertex' import { createLLMGatewaySummarize } from '@tanstack/ai-llmgateway' import { createOpenRouterSummarize } from '@tanstack/ai-openrouter' import { createVercelGatewaySummarize } from '@tanstack/ai-vercel-gateway' @@ -87,6 +88,14 @@ function createSummarizeAdapter( baseURL: openaiUrl(aimockPort), defaultHeaders: headers, }), + 'vertex-grok': () => + grokVertexSummarize('grok-4.3', { + project: 'e2e-project', + location: 'global', + baseURL: openaiUrl(aimockPort), + authClient: vertexE2eAuthClient(), + defaultHeaders: headers, + }), llmgateway: () => createLLMGatewaySummarize('gpt-5.6-terra', DUMMY_KEY, { baseURL: openaiUrl(aimockPort), diff --git a/testing/e2e/tests/test-matrix.ts b/testing/e2e/tests/test-matrix.ts index 9decc0226a..afa9962d13 100644 --- a/testing/e2e/tests/test-matrix.ts +++ b/testing/e2e/tests/test-matrix.ts @@ -18,6 +18,8 @@ export const providers: Provider[] = [ 'anthropic', 'gemini', 'vertex', + 'vertex-grok', + 'vertex-mistral', 'ollama', 'groq', 'grok', From 4d56ad142af89ef92b912e8577a0bfdd81b14528 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 19 Aug 2026 18:45:14 +0200 Subject: [PATCH 09/10] fix(vertex): reject invalid Mistral regions and report ADC errors Mistral Vertex only accepts us-central1 and europe-west4. Auth errors now distinguish a missing google-auth-library from a failed ADC token. Grok Vertex rejects xAI server tools and the factory test checks the Bearer rewrite. --- packages/ai-grok/src/vertex/auth.ts | 23 ++++- packages/ai-grok/src/vertex/index.ts | 40 ++++++++- packages/ai-grok/tests/vertex-factory.test.ts | 86 ++++++++----------- packages/ai-mistral/src/vertex/auth.ts | 36 +++++++- packages/ai-mistral/tests/vertex-auth.test.ts | 15 ++++ 5 files changed, 140 insertions(+), 60 deletions(-) diff --git a/packages/ai-grok/src/vertex/auth.ts b/packages/ai-grok/src/vertex/auth.ts index cff1b9a990..f6b1fac397 100644 --- a/packages/ai-grok/src/vertex/auth.ts +++ b/packages/ai-grok/src/vertex/auth.ts @@ -1,10 +1,21 @@ export class GrokVertexAuthError extends Error { - constructor(message: string) { - super(message) + constructor(message: string, options?: ErrorOptions) { + super(message, options) this.name = 'GrokVertexAuthError' } } +function isMissingGoogleAuthLibrary(error: unknown): boolean { + if (!(error instanceof Error) || !('code' in error)) { + return false + } + const code = error.code + if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND') { + return false + } + return error.message.includes('google-auth-library') +} + export type VertexAuthClient = { getRequestHeaders: (url?: string | URL) => Promise } @@ -117,8 +128,14 @@ export async function resolveGrokVertexAccessToken( if (error instanceof GrokVertexAuthError) { throw error } + if (isMissingGoogleAuthLibrary(error)) { + throw new GrokVertexAuthError( + 'Grok Vertex needs google-auth-library, or pass authClient or getAccessToken. Install google-auth-library next to @tanstack/ai-grok.', + ) + } throw new GrokVertexAuthError( - 'Grok Vertex needs google-auth-library, or pass authClient or getAccessToken. Install google-auth-library next to @tanstack/ai-grok.', + 'Grok Vertex could not load a Google access token from Application Default Credentials.', + { cause: error }, ) } } diff --git a/packages/ai-grok/src/vertex/index.ts b/packages/ai-grok/src/vertex/index.ts index 2b68abdbe4..da6ba7e4d7 100644 --- a/packages/ai-grok/src/vertex/index.ts +++ b/packages/ai-grok/src/vertex/index.ts @@ -8,7 +8,11 @@ import { import type { TextOptions } from '@tanstack/ai' import type { InferTextProviderOptions } from '@tanstack/ai/adapters' import type { ResponseCreateParams } from 'openai/resources/responses/responses' -import type { GrokVertexChatModel, ResolveProviderOptions } from '../model-meta' +import type { + GrokVertexChatModel, + ResolveInputModalities, + ResolveProviderOptions, +} from '../model-meta' import type { GrokVertexConfig } from './auth' export { @@ -26,13 +30,38 @@ export { type GrokVertexChatModel, } from '../model-meta' +const VERTEX_UNSUPPORTED_GROK_SERVER_TOOLS = new Set([ + 'web_search', + 'x_search', + 'file_search', + 'mcp', +]) + class GrokVertexTextAdapter< TModel extends GrokVertexChatModel, -> extends GrokTextAdapter { +> extends GrokTextAdapter< + TModel, + ResolveProviderOptions, + ResolveInputModalities, + readonly [] +> { protected override mapOptionsToRequest( options: TextOptions>, ): Omit { const request = super.mapOptionsToRequest(options) + const tools = request.tools + if (tools !== undefined) { + for (const tool of tools) { + if (tool === null || typeof tool !== 'object' || !('type' in tool)) { + continue + } + if (VERTEX_UNSUPPORTED_GROK_SERVER_TOOLS.has(String(tool.type))) { + throw new Error( + 'Grok Vertex does not support xAI server tools (web_search, x_search, file_search, mcp). Use a function tool.', + ) + } + } + } return { ...request, model: toVertexGrokModelId(this.model), @@ -49,7 +78,12 @@ class GrokVertexTextAdapter< export function grokVertexText( model: TModel, config: GrokVertexConfig = {}, -): GrokTextAdapter { +): GrokTextAdapter< + TModel, + ResolveProviderOptions, + ResolveInputModalities, + readonly [] +> { const baseURL = resolveGrokVertexBaseURL(config) return new GrokVertexTextAdapter( diff --git a/packages/ai-grok/tests/vertex-factory.test.ts b/packages/ai-grok/tests/vertex-factory.test.ts index 1667830c81..667ec70713 100644 --- a/packages/ai-grok/tests/vertex-factory.test.ts +++ b/packages/ai-grok/tests/vertex-factory.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { resolveDebugOption } from '@tanstack/ai/adapter-internals' import { GROK_VERTEX_CHAT_MODELS, @@ -8,22 +8,6 @@ import { const testLogger = resolveDebugOption(false) -function createAsyncIterable(chunks: Array): AsyncIterable { - return { - [Symbol.asyncIterator]() { - let index = 0 - return { - async next() { - if (index < chunks.length) { - return { value: chunks[index++]!, done: false } - } - return { value: undefined as T, done: true } - }, - } - }, - } -} - describe('GROK_VERTEX_CHAT_MODELS', () => { it('matches the Google Vertex Grok catalog', () => { expect(GROK_VERTEX_CHAT_MODELS).toEqual([ @@ -51,46 +35,46 @@ describe('grokVertexText', () => { expect(adapter.model).toBe('grok-4.3') }) - it('sends the Vertex xai/ model id on the Responses request', async () => { - const adapter = grokVertexText('grok-4.3', { - project: 'my-project', - location: 'global', - getAccessToken: async () => 'e2e-dummy', - }) + afterEach(() => { + vi.unstubAllGlobals() + }) - const mockCreate = vi.fn().mockResolvedValue( - createAsyncIterable([ - { - type: 'response.created', - response: { id: 'resp_123', model: 'xai/grok-4.3' }, - }, - { - type: 'response.completed', - response: { - id: 'resp_123', - model: 'xai/grok-4.3', - output: [], - }, - }, - ]), + it('sends a Bearer token and the Vertex xai/ model id', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + new Response('data: [DONE]\n\n', { + headers: { 'Content-Type': 'text/event-stream' }, + }), ) - ;(adapter as any).client = { - responses: { - create: mockCreate, - }, - } + vi.stubGlobal('fetch', fetchSpy) - for await (const _chunk of adapter.chatStream({ - model: 'grok-4.3', - messages: [{ role: 'user', content: 'Hello' }], - logger: testLogger, - })) { - // Exhaust the stream so the adapter sends the request. + const adapter = grokVertexText('grok-4.3', { + baseURL: 'http://vertex.test/v1', + getAccessToken: async () => 'vertex-token', + }) + + try { + for await (const _chunk of adapter.chatStream({ + model: 'grok-4.3', + messages: [{ role: 'user', content: 'Hello' }], + logger: testLogger, + })) { + // Exhaust the stream so the adapter sends the request. + } + } catch { + // The fixture is only a DONE event. The request still went out. } - expect(mockCreate.mock.calls[0]?.[0]).toMatchObject({ + expect(fetchSpy).toHaveBeenCalled() + const [url, init] = fetchSpy.mock.calls[0] as [ + string | URL | Request, + { headers?: HeadersInit; body?: string }, + ] + expect(String(url)).toContain('http://vertex.test/v1/responses') + expect(new Headers(init.headers).get('Authorization')).toBe( + 'Bearer vertex-token', + ) + expect(JSON.parse(String(init.body))).toMatchObject({ model: 'xai/grok-4.3', - stream: true, }) }) }) diff --git a/packages/ai-mistral/src/vertex/auth.ts b/packages/ai-mistral/src/vertex/auth.ts index deb6a152cd..49199a0491 100644 --- a/packages/ai-mistral/src/vertex/auth.ts +++ b/packages/ai-mistral/src/vertex/auth.ts @@ -1,10 +1,29 @@ export class MistralVertexAuthError extends Error { - constructor(message: string) { - super(message) + constructor(message: string, options?: ErrorOptions) { + super(message, options) this.name = 'MistralVertexAuthError' } } +const MISTRAL_VERTEX_LOCATIONS = ['us-central1', 'europe-west4'] as const + +function isMistralVertexLocation( + location: string, +): location is (typeof MISTRAL_VERTEX_LOCATIONS)[number] { + return (MISTRAL_VERTEX_LOCATIONS as ReadonlyArray).includes(location) +} + +function isMissingGoogleAuthLibrary(error: unknown): boolean { + if (!(error instanceof Error) || !('code' in error)) { + return false + } + const code = error.code + if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND') { + return false + } + return error.message.includes('google-auth-library') +} + export type VertexAuthClient = { getRequestHeaders: (url?: string | URL) => Promise } @@ -62,6 +81,11 @@ export function resolveMistralVertexLocation( 'Mistral Vertex needs a location. Pass location on the factory, or set GOOGLE_CLOUD_LOCATION or GOOGLE_VERTEX_LOCATION. Use us-central1 or europe-west4.', ) } + if (!isMistralVertexLocation(location)) { + throw new MistralVertexAuthError( + 'Mistral Vertex location must be us-central1 or europe-west4. There is no global endpoint.', + ) + } return location } @@ -114,8 +138,14 @@ export async function resolveMistralVertexAccessToken( if (error instanceof MistralVertexAuthError) { throw error } + if (isMissingGoogleAuthLibrary(error)) { + throw new MistralVertexAuthError( + 'Mistral Vertex needs google-auth-library, or pass authClient or getAccessToken. Install google-auth-library next to @tanstack/ai-mistral.', + ) + } throw new MistralVertexAuthError( - 'Mistral Vertex needs google-auth-library, or pass authClient or getAccessToken. Install google-auth-library next to @tanstack/ai-mistral.', + 'Mistral Vertex could not load a Google access token from Application Default Credentials.', + { cause: error }, ) } } diff --git a/packages/ai-mistral/tests/vertex-auth.test.ts b/packages/ai-mistral/tests/vertex-auth.test.ts index d6810d3793..cab4aaa4b1 100644 --- a/packages/ai-mistral/tests/vertex-auth.test.ts +++ b/packages/ai-mistral/tests/vertex-auth.test.ts @@ -26,6 +26,21 @@ describe('resolveMistralVertexLocation', () => { resolveMistralVertexLocation({ project: 'my-project' }), ).toThrow(MistralVertexAuthError) }) + + it('throws for global and other non-Mistral regions', () => { + expect(() => + resolveMistralVertexLocation({ + project: 'my-project', + location: 'global', + }), + ).toThrow(/us-central1 or europe-west4/) + expect(() => + resolveMistralVertexLocation({ + project: 'my-project', + location: 'us-east1', + }), + ).toThrow(MistralVertexAuthError) + }) }) describe('resolveMistralVertexModelUrl', () => { From ee03f59baac897f89c4b2098c89f1916c1debf06 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 21 Aug 2026 11:22:24 +0200 Subject: [PATCH 10/10] fix(vertex): treat empty factory credentials as absent Empty project, location, and apiKey values no longer skip env fallbacks. Vertex auth tests now stub host credential env vars so missing-credential cases cannot pass from ambient ADC values. --- packages/ai-anthropic/src/vertex/auth.ts | 17 ++++--- .../ai-anthropic/tests/vertex-auth.test.ts | 37 ++++++++++++++- packages/ai-grok/src/vertex/auth.ts | 17 ++++--- packages/ai-grok/tests/vertex-auth.test.ts | 43 +++++++++++++++++- packages/ai-mistral/src/vertex/auth.ts | 17 ++++--- packages/ai-mistral/tests/vertex-auth.test.ts | 43 +++++++++++++++++- packages/ai-vertex/src/auth.ts | 19 ++++---- packages/ai-vertex/tests/auth.test.ts | 45 ++++++++++++++++++- 8 files changed, 205 insertions(+), 33 deletions(-) diff --git a/packages/ai-anthropic/src/vertex/auth.ts b/packages/ai-anthropic/src/vertex/auth.ts index 842bb5eb41..a7829c4a7a 100644 --- a/packages/ai-anthropic/src/vertex/auth.ts +++ b/packages/ai-anthropic/src/vertex/auth.ts @@ -23,28 +23,31 @@ export type AnthropicVertexConfig = Omit< location?: string } -function readEnv(name: string): string | undefined { - if (typeof process === 'undefined' || process.env === undefined) { - return undefined - } - const value = process.env[name] +function nonEmpty(value: string | undefined): string | undefined { if (value === undefined || value.length === 0) { return undefined } return value } +function readEnv(name: string): string | undefined { + if (typeof process === 'undefined' || process.env === undefined) { + return undefined + } + return nonEmpty(process.env[name]) +} + export function resolveAnthropicVertexOptions( config: AnthropicVertexConfig = {}, ): VertexSdkOptions { const { project, location, ...rest } = config const projectId = - project ?? + nonEmpty(project) ?? readEnv('GOOGLE_CLOUD_PROJECT') ?? readEnv('GOOGLE_VERTEX_PROJECT') ?? readEnv('ANTHROPIC_VERTEX_PROJECT_ID') const region = - location ?? + nonEmpty(location) ?? readEnv('GOOGLE_CLOUD_LOCATION') ?? readEnv('GOOGLE_VERTEX_LOCATION') ?? readEnv('CLOUD_ML_REGION') diff --git a/packages/ai-anthropic/tests/vertex-auth.test.ts b/packages/ai-anthropic/tests/vertex-auth.test.ts index 8658d52600..083f338721 100644 --- a/packages/ai-anthropic/tests/vertex-auth.test.ts +++ b/packages/ai-anthropic/tests/vertex-auth.test.ts @@ -1,10 +1,25 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AnthropicVertexAuthError, resolveAnthropicVertexOptions, } from '../src/vertex/auth' +const VERTEX_ENV = [ + 'GOOGLE_CLOUD_PROJECT', + 'GOOGLE_VERTEX_PROJECT', + 'ANTHROPIC_VERTEX_PROJECT_ID', + 'GOOGLE_CLOUD_LOCATION', + 'GOOGLE_VERTEX_LOCATION', + 'CLOUD_ML_REGION', +] as const + describe('resolveAnthropicVertexOptions', () => { + beforeEach(() => { + for (const name of VERTEX_ENV) { + vi.stubEnv(name, '') + } + }) + afterEach(() => { vi.unstubAllEnvs() }) @@ -40,6 +55,26 @@ describe('resolveAnthropicVertexOptions', () => { ).toThrow(/needs a location/) }) + it('treats empty factory location as absent and falls back to env', () => { + vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'us-east5') + + const options = resolveAnthropicVertexOptions({ + project: 'my-project', + location: '', + }) + + expect(options.region).toBe('us-east5') + }) + + it('throws when factory location is empty and env is empty', () => { + expect(() => + resolveAnthropicVertexOptions({ + project: 'my-project', + location: '', + }), + ).toThrow(/needs a location/) + }) + it('allows a missing project so ADC can fill it later', () => { const options = resolveAnthropicVertexOptions({ location: 'europe-west1', diff --git a/packages/ai-grok/src/vertex/auth.ts b/packages/ai-grok/src/vertex/auth.ts index f6b1fac397..71e23f7275 100644 --- a/packages/ai-grok/src/vertex/auth.ts +++ b/packages/ai-grok/src/vertex/auth.ts @@ -37,17 +37,20 @@ export type GrokVertexConfig = { defaultHeaders?: Record } -function readEnv(name: string): string | undefined { - if (typeof process === 'undefined' || process.env === undefined) { - return undefined - } - const value = process.env[name] +function nonEmpty(value: string | undefined): string | undefined { if (value === undefined || value.length === 0) { return undefined } return value } +function readEnv(name: string): string | undefined { + if (typeof process === 'undefined' || process.env === undefined) { + return undefined + } + return nonEmpty(process.env[name]) +} + export function toVertexGrokModelId(model: string): string { if (model.startsWith('xai/')) { return model @@ -59,7 +62,7 @@ export function resolveGrokVertexProject( config: GrokVertexConfig, ): string | undefined { return ( - config.project ?? + nonEmpty(config.project) ?? readEnv('GOOGLE_CLOUD_PROJECT') ?? readEnv('GOOGLE_VERTEX_PROJECT') ) @@ -67,7 +70,7 @@ export function resolveGrokVertexProject( export function resolveGrokVertexLocation(config: GrokVertexConfig): string { return ( - config.location ?? + nonEmpty(config.location) ?? readEnv('GOOGLE_CLOUD_LOCATION') ?? readEnv('GOOGLE_VERTEX_LOCATION') ?? 'global' diff --git a/packages/ai-grok/tests/vertex-auth.test.ts b/packages/ai-grok/tests/vertex-auth.test.ts index 0c3908c8db..fde56defd9 100644 --- a/packages/ai-grok/tests/vertex-auth.test.ts +++ b/packages/ai-grok/tests/vertex-auth.test.ts @@ -1,13 +1,31 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { GrokVertexAuthError, resolveGrokVertexAccessToken, resolveGrokVertexBaseURL, + resolveGrokVertexLocation, resolveGrokVertexProject, toVertexGrokModelId, } from '../src/vertex/auth' +const VERTEX_ENV = [ + 'GOOGLE_CLOUD_PROJECT', + 'GOOGLE_VERTEX_PROJECT', + 'GOOGLE_CLOUD_LOCATION', + 'GOOGLE_VERTEX_LOCATION', +] as const + +function clearVertexEnv() { + for (const name of VERTEX_ENV) { + vi.stubEnv(name, '') + } +} + describe('resolveGrokVertexBaseURL', () => { + beforeEach(() => { + clearVertexEnv() + }) + afterEach(() => { vi.unstubAllEnvs() }) @@ -98,6 +116,10 @@ describe('resolveGrokVertexAccessToken', () => { }) describe('resolveGrokVertexProject', () => { + beforeEach(() => { + clearVertexEnv() + }) + afterEach(() => { vi.unstubAllEnvs() }) @@ -106,4 +128,23 @@ describe('resolveGrokVertexProject', () => { vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'env-project') expect(resolveGrokVertexProject({})).toBe('env-project') }) + + it('treats empty factory project as absent and falls back to env', () => { + vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'env-project') + expect(resolveGrokVertexProject({ project: '' })).toBe('env-project') + }) +}) + +describe('resolveGrokVertexLocation', () => { + beforeEach(() => { + clearVertexEnv() + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('defaults to global when factory location is empty', () => { + expect(resolveGrokVertexLocation({ location: '' })).toBe('global') + }) }) diff --git a/packages/ai-mistral/src/vertex/auth.ts b/packages/ai-mistral/src/vertex/auth.ts index 49199a0491..347031b2f9 100644 --- a/packages/ai-mistral/src/vertex/auth.ts +++ b/packages/ai-mistral/src/vertex/auth.ts @@ -48,22 +48,25 @@ export type MistralVertexConfig = { defaultHeaders?: Record } -function readEnv(name: string): string | undefined { - if (typeof process === 'undefined' || process.env === undefined) { - return undefined - } - const value = process.env[name] +function nonEmpty(value: string | undefined): string | undefined { if (value === undefined || value.length === 0) { return undefined } return value } +function readEnv(name: string): string | undefined { + if (typeof process === 'undefined' || process.env === undefined) { + return undefined + } + return nonEmpty(process.env[name]) +} + export function resolveMistralVertexProject( config: MistralVertexConfig, ): string | undefined { return ( - config.project ?? + nonEmpty(config.project) ?? readEnv('GOOGLE_CLOUD_PROJECT') ?? readEnv('GOOGLE_VERTEX_PROJECT') ) @@ -73,7 +76,7 @@ export function resolveMistralVertexLocation( config: MistralVertexConfig, ): string { const location = - config.location ?? + nonEmpty(config.location) ?? readEnv('GOOGLE_CLOUD_LOCATION') ?? readEnv('GOOGLE_VERTEX_LOCATION') if (location === undefined) { diff --git a/packages/ai-mistral/tests/vertex-auth.test.ts b/packages/ai-mistral/tests/vertex-auth.test.ts index cab4aaa4b1..a9fa91efeb 100644 --- a/packages/ai-mistral/tests/vertex-auth.test.ts +++ b/packages/ai-mistral/tests/vertex-auth.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MistralVertexAuthError, resolveMistralVertexAccessToken, @@ -7,7 +7,24 @@ import { resolveMistralVertexProject, } from '../src/vertex/auth' +const VERTEX_ENV = [ + 'GOOGLE_CLOUD_PROJECT', + 'GOOGLE_VERTEX_PROJECT', + 'GOOGLE_CLOUD_LOCATION', + 'GOOGLE_VERTEX_LOCATION', +] as const + +function clearVertexEnv() { + for (const name of VERTEX_ENV) { + vi.stubEnv(name, '') + } +} + describe('resolveMistralVertexLocation', () => { + beforeEach(() => { + clearVertexEnv() + }) + afterEach(() => { vi.unstubAllEnvs() }) @@ -27,6 +44,16 @@ describe('resolveMistralVertexLocation', () => { ).toThrow(MistralVertexAuthError) }) + it('treats empty factory location as absent and falls back to env', () => { + vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'europe-west4') + expect( + resolveMistralVertexLocation({ + project: 'my-project', + location: '', + }), + ).toBe('europe-west4') + }) + it('throws for global and other non-Mistral regions', () => { expect(() => resolveMistralVertexLocation({ @@ -57,6 +84,10 @@ describe('resolveMistralVertexModelUrl', () => { }) describe('resolveMistralVertexProject', () => { + beforeEach(() => { + clearVertexEnv() + }) + afterEach(() => { vi.unstubAllEnvs() }) @@ -75,6 +106,16 @@ describe('resolveMistralVertexProject', () => { 'env-project', ) }) + + it('treats empty factory project as absent and falls back to env', () => { + vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'env-project') + expect( + resolveMistralVertexProject({ + project: '', + location: 'us-central1', + }), + ).toBe('env-project') + }) }) describe('resolveMistralVertexAccessToken', () => { diff --git a/packages/ai-vertex/src/auth.ts b/packages/ai-vertex/src/auth.ts index b7578d560b..5ef1edf117 100644 --- a/packages/ai-vertex/src/auth.ts +++ b/packages/ai-vertex/src/auth.ts @@ -10,17 +10,20 @@ export type VertexVideoConfig = VertexClientConfig & { allowUrlFetch?: boolean } -function readEnv(name: string): string | undefined { - if (typeof process === 'undefined' || process.env === undefined) { - return undefined - } - const value = process.env[name] +function nonEmpty(value: string | undefined): string | undefined { if (value === undefined || value.length === 0) { return undefined } return value } +function readEnv(name: string): string | undefined { + if (typeof process === 'undefined' || process.env === undefined) { + return undefined + } + return nonEmpty(process.env[name]) +} + /** * Resolves Vertex Gemini client options. * @@ -31,14 +34,14 @@ export function resolveVertexGeminiOptions( config: VertexClientConfig = {}, ): GeminiClientConfig { const project = - config.project ?? + nonEmpty(config.project) ?? readEnv('GOOGLE_CLOUD_PROJECT') ?? readEnv('GOOGLE_VERTEX_PROJECT') const location = - config.location ?? + nonEmpty(config.location) ?? readEnv('GOOGLE_CLOUD_LOCATION') ?? readEnv('GOOGLE_VERTEX_LOCATION') - const apiKey = config.apiKey ?? readEnv('GOOGLE_VERTEX_API_KEY') + const apiKey = nonEmpty(config.apiKey) ?? readEnv('GOOGLE_VERTEX_API_KEY') if ( apiKey === undefined && diff --git a/packages/ai-vertex/tests/auth.test.ts b/packages/ai-vertex/tests/auth.test.ts index 41d635bd88..fe515e57e5 100644 --- a/packages/ai-vertex/tests/auth.test.ts +++ b/packages/ai-vertex/tests/auth.test.ts @@ -1,8 +1,22 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { resolveVertexGeminiOptions } from '../src/auth' import { VertexAuthError } from '../src/errors' +const VERTEX_ENV = [ + 'GOOGLE_CLOUD_PROJECT', + 'GOOGLE_VERTEX_PROJECT', + 'GOOGLE_CLOUD_LOCATION', + 'GOOGLE_VERTEX_LOCATION', + 'GOOGLE_VERTEX_API_KEY', +] as const + describe('resolveVertexGeminiOptions', () => { + beforeEach(() => { + for (const name of VERTEX_ENV) { + vi.stubEnv(name, '') + } + }) + afterEach(() => { vi.unstubAllEnvs() }) @@ -79,6 +93,35 @@ describe('resolveVertexGeminiOptions', () => { ) }) + it('treats empty factory credentials as absent and falls back to env', () => { + vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'env-project') + vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'us-central1') + vi.stubEnv('GOOGLE_VERTEX_API_KEY', 'env-express-key') + + const options = resolveVertexGeminiOptions({ + project: '', + location: '', + apiKey: '', + }) + + expect(options).toEqual({ + project: 'env-project', + location: 'us-central1', + apiKey: 'env-express-key', + vertexai: true, + }) + }) + + it('throws when factory credentials are empty and env is empty', () => { + expect(() => + resolveVertexGeminiOptions({ + project: '', + location: '', + apiKey: '', + }), + ).toThrow(VertexAuthError) + }) + it('forwards googleAuthOptions', () => { const googleAuthOptions = { keyFilename: '/path/to/sa.json',