From 61b574a263e0c7d4193f5fc5619c34901f471ae1 Mon Sep 17 00:00:00 2001 From: Felix Wu Date: Thu, 23 Jul 2026 15:42:08 +0200 Subject: [PATCH] feat(ai-anthropic): accept preconfigured messages clients --- .changeset/anthropic-client-injection.md | 6 +++ README.md | 2 +- docs/adapters/anthropic.md | 52 +++++++++++++++++- docs/config.json | 2 +- packages/ai-anthropic/package.json | 2 + packages/ai-anthropic/src/adapters/text.ts | 53 +++++++++++++++++-- packages/ai-anthropic/src/index.ts | 3 ++ packages/ai-anthropic/src/utils/client.ts | 21 ++++++++ .../tests/anthropic-adapter.test.ts | 30 +++++++++++ .../client-injection-type-safety.test.ts | 18 +++++++ pnpm-lock.yaml | 43 +++++++++++++++ testing/e2e/package.json | 1 + testing/e2e/src/lib/providers.ts | 15 ++++-- 13 files changed, 235 insertions(+), 13 deletions(-) create mode 100644 .changeset/anthropic-client-injection.md create mode 100644 packages/ai-anthropic/tests/client-injection-type-safety.test.ts diff --git a/.changeset/anthropic-client-injection.md b/.changeset/anthropic-client-injection.md new file mode 100644 index 000000000..2f32f02d6 --- /dev/null +++ b/.changeset/anthropic-client-injection.md @@ -0,0 +1,6 @@ +--- +'@tanstack/ai-anthropic': minor +--- + +Add a client-injection factory for Anthropic-compatible clients with custom +authentication and transport. diff --git a/README.md b/README.md index 7b2ac19aa..64fdc85c6 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ Official adapters include: | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | | [`@tanstack/ai-openrouter`](https://tanstack.com/ai/latest/docs/adapters/openrouter) | 300+ models through one OpenRouter API, with per-request cost tracking | | [`@tanstack/ai-openai`](https://tanstack.com/ai/latest/docs/adapters/openai) | OpenAI chat, image, video, speech, transcription, realtime, and provider tools | -| [`@tanstack/ai-anthropic`](https://tanstack.com/ai/latest/docs/adapters/anthropic) | Anthropic Claude chat, thinking, tools, and structured outputs | +| [`@tanstack/ai-anthropic`](https://tanstack.com/ai/latest/docs/adapters/anthropic) | Anthropic Claude chat, thinking, tools, structured outputs, and custom clients | | [`@tanstack/ai-gemini`](https://tanstack.com/ai/latest/docs/adapters/gemini) | Google Gemini chat, image, speech, and audio generation | | [`@tanstack/ai-ollama`](https://tanstack.com/ai/latest/docs/adapters/ollama) | Local Ollama models | | [`@tanstack/ai-grok`](https://tanstack.com/ai/latest/docs/adapters/grok) | xAI Grok chat, images, and realtime | diff --git a/docs/adapters/anthropic.md b/docs/adapters/anthropic.md index 17f154447..f1ec1ed14 100644 --- a/docs/adapters/anthropic.md +++ b/docs/adapters/anthropic.md @@ -61,7 +61,46 @@ const config: Omit = { const adapter = createAnthropicChat("claude-sonnet-4-6", process.env.ANTHROPIC_API_KEY!, config); ``` - + +## Custom Anthropic Client + +Use `createAnthropicChatWithClient` when authentication or transport is +provided by an Anthropic-compatible client. The adapter only requires the +client's `beta.messages.create` capability; message mapping, streaming, tools, +media, usage, and structured output still follow the same TanStack adapter +path. + +For example, Anthropic's Vertex client can discover Google Cloud credentials +through Application Default Credentials: + +```bash +npm install @tanstack/ai-anthropic @anthropic-ai/vertex-sdk +``` + +```typescript +import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"; +import { + createAnthropicChatWithClient, + type AnthropicMessagesClient, +} from "@tanstack/ai-anthropic"; + +const projectId = process.env.GOOGLE_CLOUD_PROJECT; + +if (!projectId) { + throw new Error("GOOGLE_CLOUD_PROJECT is required"); +} + +const client = new AnthropicVertex({ + projectId, + region: "eu", +}) satisfies AnthropicMessagesClient; + +const adapter = createAnthropicChatWithClient("claude-sonnet-5", client); +``` + +The injected client must implement the Anthropic Beta Messages protocol. +Endpoint-specific model and feature support remains the caller's +responsibility. ## Example: Chat Completion @@ -257,7 +296,7 @@ ANTHROPIC_API_KEY=sk-ant-... ## API Reference -Every factory pair follows the same shape: the short factory (`anthropicText`, `anthropicSummarize`) reads `ANTHROPIC_API_KEY` from the environment, while `createAnthropicChat` / `createAnthropicSummarize` take an explicit API key. Both take `model` as the first argument. +Every factory pair follows the same shape: the short factory (`anthropicText`, `anthropicSummarize`) reads `ANTHROPIC_API_KEY` from the environment, while `createAnthropicChat` / `createAnthropicSummarize` take an explicit API key. Both take `model` as the first argument. For custom authentication or transport, `createAnthropicChatWithClient` accepts an Anthropic-compatible Messages client instead. ### `anthropicText(model, config?)` / `createAnthropicChat(model, apiKey, config?)` @@ -268,6 +307,15 @@ Creates an Anthropic chat adapter. - `model` - Claude model id (e.g. `"claude-sonnet-5"`, `"claude-fable-5"`, `"claude-opus-4-8"`) - `config?.baseURL` - Custom base URL (optional) +### `createAnthropicChatWithClient(model, client)` + +Creates an Anthropic chat adapter using an injected client. + +**Parameters:** + +- `model` - Claude model id +- `client` - Client exposing `beta.messages.create` + ### `anthropicSummarize(model, config?)` / `createAnthropicSummarize(model, apiKey, config?)` Creates an Anthropic summarization adapter. diff --git a/docs/config.json b/docs/config.json index de57b5fb6..1d94f6be0 100644 --- a/docs/config.json +++ b/docs/config.json @@ -640,7 +640,7 @@ "label": "Anthropic", "to": "adapters/anthropic", "addedAt": "2026-04-15", - "updatedAt": "2026-07-04" + "updatedAt": "2026-07-23" }, { "label": "Google Gemini", diff --git a/packages/ai-anthropic/package.json b/packages/ai-anthropic/package.json index 4f6702cbb..e4451fb8b 100644 --- a/packages/ai-anthropic/package.json +++ b/packages/ai-anthropic/package.json @@ -66,6 +66,8 @@ "zod": "^4.0.0" }, "devDependencies": { + "@anthropic-ai/sdk-v112": "npm:@anthropic-ai/sdk@0.112.4", + "@anthropic-ai/vertex-sdk": "^0.19.0", "@tanstack/ai": "workspace:*", "@vitest/coverage-v8": "4.0.14", "zod": "^4.2.0" diff --git a/packages/ai-anthropic/src/adapters/text.ts b/packages/ai-anthropic/src/adapters/text.ts index d431f5486..a6afdc5d5 100644 --- a/packages/ai-anthropic/src/adapters/text.ts +++ b/packages/ai-anthropic/src/adapters/text.ts @@ -64,7 +64,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 @@ -202,6 +205,10 @@ export function computeAnthropicBetas( */ export interface AnthropicTextConfig extends AnthropicClientConfig {} +export type AnthropicTextAdapterConfig = + | AnthropicTextConfig + | { client: AnthropicMessagesClient } + /** * Anthropic-specific provider options for text/chat */ @@ -234,6 +241,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 // =========================== @@ -266,11 +291,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( @@ -1468,6 +1496,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 468edaf5f..5a6881958 100644 --- a/packages/ai-anthropic/src/index.ts +++ b/packages/ai-anthropic/src/index.ts @@ -7,9 +7,12 @@ export { AnthropicTextAdapter, anthropicText, createAnthropicChat, + createAnthropicChatWithClient, + type AnthropicTextAdapterConfig, type AnthropicTextConfig, type AnthropicTextProviderOptions, } from './adapters/text' +export type { AnthropicMessagesClient } from './utils/client' export type { AnthropicSystemPromptMetadata } from './text/text-provider-options' // Summarize - thin factory functions over @tanstack/ai's ChatStreamSummarizeAdapter diff --git a/packages/ai-anthropic/src/utils/client.ts b/packages/ai-anthropic/src/utils/client.ts index d07d2b2af..ee1013ce8 100644 --- a/packages/ai-anthropic/src/utils/client.ts +++ b/packages/ai-anthropic/src/utils/client.ts @@ -6,6 +6,27 @@ export interface AnthropicClientConfig extends ClientOptions { apiKey: string } +type AnyAnthropicMessagesCreate = ( + params: never, + ...args: Array +) => unknown + +/** + * The minimal Anthropic client surface used by the text adapter. + * + * The callable is intentionally type-erased because alternative Anthropic + * clients can depend on a different 0.x release of the Anthropic SDK. Their + * request and response declarations may drift even when the runtime Messages + * protocol remains compatible. + */ +export interface AnthropicMessagesClient { + readonly beta: { + readonly messages: { + readonly create: AnyAnthropicMessagesCreate + } + } +} + /** * Creates an Anthropic SDK client instance */ diff --git a/packages/ai-anthropic/tests/anthropic-adapter.test.ts b/packages/ai-anthropic/tests/anthropic-adapter.test.ts index 43d3ded61..a1238ebd1 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 { ANTHROPIC_MAX_NONSTREAMING_TOKENS } from '../src/model-meta' @@ -77,6 +78,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 000000000..57392a90c --- /dev/null +++ b/packages/ai-anthropic/tests/client-injection-type-safety.test.ts @@ -0,0 +1,18 @@ +import { expectTypeOf, it } from 'vitest' +import type AnthropicSdkV112 from '@anthropic-ai/sdk-v112' +import type { AnthropicVertex } from '@anthropic-ai/vertex-sdk' +import type { AnthropicMessagesClient } from '../src' + +type V112MessagesClient = { + readonly beta: { + readonly messages: Pick + } +} + +it('accepts the official Vertex client', () => { + expectTypeOf().toExtend() +}) + +it('accepts clients backed by a newer Anthropic SDK version', () => { + expectTypeOf().toExtend() +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45e6aa0f4..f9d26526a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1486,6 +1486,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 @@ -2567,6 +2573,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(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.19))(vite@8.1.4(@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.21.0)(yaml@2.9.0))) @@ -3015,6 +3024,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 @@ -3024,6 +3042,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'} @@ -16478,6 +16499,13 @@ snapshots: '@angular/core': 21.2.17(@angular/compiler@21.2.17)(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 @@ -16485,6 +16513,21 @@ snapshots: optionalDependencies: zod: 4.2.1 + '@anthropic-ai/sdk@0.97.1(zod@4.3.6)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 + optionalDependencies: + zod: 4.3.6 + + '@anthropic-ai/vertex-sdk@0.19.0(zod@4.2.1)': + dependencies: + '@anthropic-ai/sdk': 0.112.4(zod@4.2.1) + google-auth-library: 10.5.0 + transitivePeerDependencies: + - supports-color + - zod + '@apidevtools/json-schema-ref-parser@11.9.3': dependencies: '@jsdevtools/ono': 7.1.3 diff --git a/testing/e2e/package.json b/testing/e2e/package.json index b84b22a92..1d07a2250 100644 --- a/testing/e2e/package.json +++ b/testing/e2e/package.json @@ -12,6 +12,7 @@ "postinstall": "playwright install chromium" }, "dependencies": { + "@anthropic-ai/sdk": "^0.97.1", "@copilotkit/aimock": "^1.34.0", "@modelcontextprotocol/sdk": "^1.29.0", "@openrouter/sdk": "0.13.20", diff --git a/testing/e2e/src/lib/providers.ts b/testing/e2e/src/lib/providers.ts index fa186a19e..79ea1f4ee 100644 --- a/testing/e2e/src/lib/providers.ts +++ b/testing/e2e/src/lib/providers.ts @@ -1,6 +1,7 @@ import { createChatOptions } from '@tanstack/ai' import { createOpenaiChat } from '@tanstack/ai-openai' -import { createAnthropicChat } from '@tanstack/ai-anthropic' +import Anthropic from '@anthropic-ai/sdk' +import { createAnthropicChatWithClient } from '@tanstack/ai-anthropic' import { createGeminiChat } from '@tanstack/ai-gemini' import { createGeminiTextInteractions } from '@tanstack/ai-gemini/experimental' import { createOllamaChat } from '@tanstack/ai-ollama' @@ -84,10 +85,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({