diff --git a/.changeset/anthropic-client-injection.md b/.changeset/anthropic-client-injection.md new file mode 100644 index 0000000000..f17c509df7 --- /dev/null +++ b/.changeset/anthropic-client-injection.md @@ -0,0 +1,7 @@ +--- +'@tanstack/ai-anthropic': minor +--- + +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..b47d252662 --- /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. 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/README.md b/README.md index 82723d1ab0..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, and structured outputs | +| [`@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 17f1544478..5859d3be63 100644 --- a/docs/adapters/anthropic.md +++ b/docs/adapters/anthropic.md @@ -61,7 +61,68 @@ const config: Omit = { const adapter = createAnthropicChat("claude-sonnet-4-6", process.env.ANTHROPIC_API_KEY!, config); ``` - + +## Claude on Vertex + +Use `@tanstack/ai-anthropic/vertex` when Claude must run on Vertex AI. That +is the path for regional endpoints and Google Cloud credentials. + +Install the Vertex SDK next to the Anthropic adapter: + +```bash +npm install @tanstack/ai-anthropic @anthropic-ai/vertex-sdk +``` + +```typescript +import { chat } from "@tanstack/ai"; +import { anthropicVertexText } from "@tanstack/ai-anthropic/vertex"; + +const stream = chat({ + adapter: anthropicVertexText("claude-sonnet-5", { + project: "my-project", + location: "europe-west1", + }), + messages: [{ role: "user", content: "Hello!" }], +}); +``` + +`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. + +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: "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 stays the caller's job. ## Example: Chat Completion @@ -257,7 +318,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 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?)` @@ -268,6 +329,26 @@ 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. + +**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/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/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 new file mode 100644 index 0000000000..3ea0daf7a0 --- /dev/null +++ b/docs/adapters/vertex.md @@ -0,0 +1,263 @@ +--- +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 + - grok + - xai + - mistral + - 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: server and client + +Keep Vertex credentials on the server. The browser only talks to your route. + +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); +} +``` + +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. + +```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`. + +## 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/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 3dd794fdfc..a61d9ca2f8 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", @@ -888,13 +888,19 @@ "label": "Anthropic", "to": "adapters/anthropic", "addedAt": "2026-04-15", - "updatedAt": "2026-07-04" + "updatedAt": "2026-08-19" }, { "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", + "updatedAt": "2026-08-19" }, { "label": "Ollama", @@ -918,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 6627624fc3..98aaeca783 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. 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 8e4ecb1c83..c9fb7c12bd 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), `@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/package.json b/packages/ai-anthropic/package.json index 05a2fca785..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,10 +66,18 @@ "@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", "@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..4890d488bc 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 @@ -25,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/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/src/vertex/auth.ts b/packages/ai-anthropic/src/vertex/auth.ts new file mode 100644 index 0000000000..a7829c4a7a --- /dev/null +++ b/packages/ai-anthropic/src/vertex/auth.ts @@ -0,0 +1,66 @@ +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 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 = + nonEmpty(project) ?? + readEnv('GOOGLE_CLOUD_PROJECT') ?? + readEnv('GOOGLE_VERTEX_PROJECT') ?? + readEnv('ANTHROPIC_VERTEX_PROJECT_ID') + const region = + nonEmpty(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..e5fae11c8a --- /dev/null +++ b/packages/ai-anthropic/src/vertex/index.ts @@ -0,0 +1,29 @@ +import { AnthropicVertex } from '@anthropic-ai/vertex-sdk' +import { createAnthropicChatWithClient } from '../adapters/text' +import { resolveAnthropicVertexOptions } from './auth' +import type { AnthropicTextAdapter } from '../adapters/text' +import type { AnthropicVertexChatModel } from '../model-meta' +import type { AnthropicVertexConfig } from './auth' + +export { + AnthropicVertexAuthError, + 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( + model: TModel, + config: AnthropicVertexConfig = {}, +): AnthropicTextAdapter { + const client = new AnthropicVertex(resolveAnthropicVertexOptions(config)) + return createAnthropicChatWithClient(model, client) +} 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/packages/ai-anthropic/tests/vertex-auth.test.ts b/packages/ai-anthropic/tests/vertex-auth.test.ts new file mode 100644 index 0000000000..083f338721 --- /dev/null +++ b/packages/ai-anthropic/tests/vertex-auth.test.ts @@ -0,0 +1,86 @@ +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() + }) + + 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('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', + }) + + 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..1ab7e77f88 --- /dev/null +++ b/packages/ai-anthropic/tests/vertex-factory.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + ANTHROPIC_VERTEX_CHAT_MODELS, + 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('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() + }) + + 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-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..71e23f7275 --- /dev/null +++ b/packages/ai-grok/src/vertex/auth.ts @@ -0,0 +1,144 @@ +export class GrokVertexAuthError extends Error { + 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 +} + +/** + * 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 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 + } + return `xai/${model}` +} + +export function resolveGrokVertexProject( + config: GrokVertexConfig, +): string | undefined { + return ( + nonEmpty(config.project) ?? + readEnv('GOOGLE_CLOUD_PROJECT') ?? + readEnv('GOOGLE_VERTEX_PROJECT') + ) +} + +export function resolveGrokVertexLocation(config: GrokVertexConfig): string { + return ( + nonEmpty(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 + } + 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 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 new file mode 100644 index 0000000000..da6ba7e4d7 --- /dev/null +++ b/packages/ai-grok/src/vertex/index.ts @@ -0,0 +1,125 @@ +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, + ResolveInputModalities, + 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' + +const VERTEX_UNSUPPORTED_GROK_SERVER_TOOLS = new Set([ + 'web_search', + 'x_search', + 'file_search', + 'mcp', +]) + +class GrokVertexTextAdapter< + TModel extends GrokVertexChatModel, +> 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), + } + } +} + +/** + * 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< + TModel, + ResolveProviderOptions, + ResolveInputModalities, + readonly [] +> { + 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..fde56defd9 --- /dev/null +++ b/packages/ai-grok/tests/vertex-auth.test.ts @@ -0,0 +1,150 @@ +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() + }) + + 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', () => { + beforeEach(() => { + clearVertexEnv() + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('reads GOOGLE_CLOUD_PROJECT when project is omitted', () => { + 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-grok/tests/vertex-factory.test.ts b/packages/ai-grok/tests/vertex-factory.test.ts new file mode 100644 index 0000000000..667ec70713 --- /dev/null +++ b/packages/ai-grok/tests/vertex-factory.test.ts @@ -0,0 +1,93 @@ +import { afterEach, 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) + +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') + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + 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' }, + }), + ) + vi.stubGlobal('fetch', fetchSpy) + + 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(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', + }) + }) +}) + +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..347031b2f9 --- /dev/null +++ b/packages/ai-mistral/src/vertex/auth.ts @@ -0,0 +1,154 @@ +export class MistralVertexAuthError extends Error { + 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 +} + +/** + * 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 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 ( + nonEmpty(config.project) ?? + readEnv('GOOGLE_CLOUD_PROJECT') ?? + readEnv('GOOGLE_VERTEX_PROJECT') + ) +} + +export function resolveMistralVertexLocation( + config: MistralVertexConfig, +): string { + const location = + nonEmpty(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.', + ) + } + if (!isMistralVertexLocation(location)) { + throw new MistralVertexAuthError( + 'Mistral Vertex location must be us-central1 or europe-west4. There is no global endpoint.', + ) + } + 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 + } + 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 could not load a Google access token from Application Default Credentials.', + { cause: error }, + ) + } +} 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..a9fa91efeb --- /dev/null +++ b/packages/ai-mistral/tests/vertex-auth.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + MistralVertexAuthError, + resolveMistralVertexAccessToken, + resolveMistralVertexLocation, + resolveMistralVertexModelUrl, + 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() + }) + + 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) + }) + + 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({ + project: 'my-project', + location: 'global', + }), + ).toThrow(/us-central1 or europe-west4/) + expect(() => + resolveMistralVertexLocation({ + project: 'my-project', + location: 'us-east1', + }), + ).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', () => { + beforeEach(() => { + clearVertexEnv() + }) + + 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', + ) + }) + + 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', () => { + 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/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..5ef1edf117 --- /dev/null +++ b/packages/ai-vertex/src/auth.ts @@ -0,0 +1,62 @@ +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 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. + * + * 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 = + nonEmpty(config.project) ?? + readEnv('GOOGLE_CLOUD_PROJECT') ?? + readEnv('GOOGLE_VERTEX_PROJECT') + const location = + nonEmpty(config.location) ?? + readEnv('GOOGLE_CLOUD_LOCATION') ?? + readEnv('GOOGLE_VERTEX_LOCATION') + const apiKey = nonEmpty(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..fe515e57e5 --- /dev/null +++ b/packages/ai-vertex/tests/auth.test.ts @@ -0,0 +1,138 @@ +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() + }) + + 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('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', + } + + 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 976ceb5479..d88c56aa37 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 @@ -1935,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) @@ -2152,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) @@ -2639,6 +2651,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': @@ -2797,6 +2828,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) @@ -2878,6 +2912,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 @@ -3271,6 +3308,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 +3326,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 +17163,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 +17177,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/README.md b/testing/e2e/README.md index dfc086a174..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, 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`). @@ -262,6 +262,32 @@ 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. + +### 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. @@ -295,6 +321,9 @@ 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'`) +- 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/package.json b/testing/e2e/package.json index 90a09fa03c..238077ecea 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", @@ -39,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:*", diff --git a/testing/e2e/src/lib/feature-support.ts b/testing/e2e/src/lib/feature-support.ts index d178c48dff..76fe8c414a 100644 --- a/testing/e2e/src/lib/feature-support.ts +++ b/testing/e2e/src/lib/feature-support.ts @@ -12,6 +12,9 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', + 'vertex-grok', + 'vertex-mistral', 'ollama', 'groq', 'grok', @@ -28,6 +31,9 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', + 'vertex-grok', + 'vertex-mistral', 'ollama', 'groq', 'grok', @@ -49,6 +55,7 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', 'mistral', 'byteplus', 'llmgateway', @@ -57,6 +64,9 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', + 'vertex-grok', + 'vertex-mistral', 'ollama', 'groq', 'grok', @@ -73,6 +83,9 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', + 'vertex-grok', + 'vertex-mistral', 'ollama', 'groq', 'grok', @@ -91,6 +104,9 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', + 'vertex-grok', + 'vertex-mistral', 'groq', 'grok', 'bedrock', @@ -103,12 +119,15 @@ 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', 'ollama', 'groq', 'grok', + 'vertex-grok', + 'vertex-mistral', 'bedrock', 'bedrock-responses', 'openrouter', @@ -123,6 +142,9 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', + 'vertex-grok', + 'vertex-mistral', 'groq', 'grok', 'bedrock', @@ -138,6 +160,9 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', + 'vertex-grok', + 'vertex-mistral', 'ollama', 'groq', 'grok', @@ -158,6 +183,7 @@ export const matrix: Record> = { 'openai', 'groq', 'grok', + 'vertex-grok', 'bedrock', 'bedrock-responses', 'openrouter', @@ -187,6 +213,8 @@ export const matrix: Record> = { 'multi-turn-structured': new Set([ 'openai', 'gemini', + 'vertex', + 'vertex-grok', 'ollama', 'groq', 'grok', @@ -202,6 +230,9 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', + 'vertex-grok', + 'vertex-mistral', 'ollama', 'groq', 'grok', @@ -224,7 +255,9 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', 'grok', + 'vertex-grok', 'openrouter', 'openrouter-responses', 'byteplus', @@ -232,12 +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', @@ -250,7 +287,10 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', 'grok', + 'vertex-grok', + 'vertex-mistral', 'openrouter', 'byteplus', 'llmgateway', @@ -263,6 +303,9 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', + 'vertex-grok', + 'vertex-mistral', 'ollama', 'groq', 'grok', @@ -277,6 +320,9 @@ export const matrix: Record> = { 'openai', 'anthropic', 'gemini', + 'vertex', + 'vertex-grok', + 'vertex-mistral', 'ollama', 'groq', 'grok', @@ -296,6 +342,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 +372,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..3d72d85c9e 100644 --- a/testing/e2e/src/lib/features.ts +++ b/testing/e2e/src/lib/features.ts @@ -91,14 +91,17 @@ 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', + '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 6f03ca0428..814b8e644b 100644 --- a/testing/e2e/src/lib/providers.ts +++ b/testing/e2e/src/lib/providers.ts @@ -1,8 +1,13 @@ 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 { vertexText } from '@tanstack/ai-vertex' +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' @@ -28,6 +33,9 @@ const defaultModels: Record = { openai: 'gpt-4o', 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', @@ -100,10 +108,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({ @@ -114,6 +126,41 @@ 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), + ), + }), + // 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 e91f9806fd..635988233c 100644 --- a/testing/e2e/src/lib/types.ts +++ b/testing/e2e/src/lib/types.ts @@ -4,6 +4,9 @@ export type Provider = | 'openai' | 'anthropic' | 'gemini' + | 'vertex' + | 'vertex-grok' + | 'vertex-mistral' | 'ollama' | 'grok' | 'groq' @@ -55,6 +58,9 @@ export const ALL_PROVIDERS: Provider[] = [ 'openai', '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 new file mode 100644 index 0000000000..80cc0d99a7 --- /dev/null +++ b/testing/e2e/src/lib/vertex-e2e.ts @@ -0,0 +1,48 @@ +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 vertexE2eAuthClient() { + return new E2eVertexAuthClient() +} + +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, + }, + } +} diff --git a/testing/e2e/src/routes/api.summarize.ts b/testing/e2e/src/routes/api.summarize.ts index c9118f33fe..039886c851 100644 --- a/testing/e2e/src/routes/api.summarize.ts +++ b/testing/e2e/src/routes/api.summarize.ts @@ -3,9 +3,12 @@ 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 { 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' @@ -69,6 +72,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, { @@ -80,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 1b5474b194..afa9962d13 100644 --- a/testing/e2e/tests/test-matrix.ts +++ b/testing/e2e/tests/test-matrix.ts @@ -17,6 +17,9 @@ export const providers: Provider[] = [ 'openai', 'anthropic', 'gemini', + 'vertex', + 'vertex-grok', + 'vertex-mistral', 'ollama', 'groq', 'grok',