From 0acb65fe1c21ea4ca5bf7f783155bb663609b5b7 Mon Sep 17 00:00:00 2001 From: sallyom Date: Mon, 9 Mar 2026 15:27:28 -0400 Subject: [PATCH] feat: allow injecting pre-built Anthropic client Add optional `client` field to AnthropicOptions so callers can pass alternative SDK clients (e.g. AnthropicVertex) that share the same messages.stream() API. When omitted, behavior is unchanged. Signed-off-by: sallyom --- packages/ai/src/providers/anthropic.ts | 52 +++++++++++------- .../test/anthropic-client-injection.test.ts | 54 +++++++++++++++++++ 2 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 packages/ai/test/anthropic-client-injection.test.ts diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index ba59f1478..6944573df 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -178,6 +178,12 @@ export interface AnthropicOptions extends StreamOptions { effort?: AnthropicEffort; interleavedThinking?: boolean; toolChoice?: "auto" | "any" | "none" | { type: "tool"; name: string }; + /** + * Pre-built Anthropic client instance. When provided, skips internal client + * construction entirely. Use this to inject alternative SDK clients such as + * `AnthropicVertex` that shares the same messaging API. + */ + client?: Anthropic; } function mergeHeaders(...headerSources: (Record | undefined)[]): Record { @@ -217,25 +223,35 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti }; try { - const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? ""; + let client: Anthropic; + let isOAuth: boolean; - let copilotDynamicHeaders: Record | undefined; - if (model.provider === "github-copilot") { - const hasImages = hasCopilotVisionInput(context.messages); - copilotDynamicHeaders = buildCopilotDynamicHeaders({ - messages: context.messages, - hasImages, - }); + if (options?.client) { + client = options.client; + isOAuth = false; + } else { + const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? ""; + + let copilotDynamicHeaders: Record | undefined; + if (model.provider === "github-copilot") { + const hasImages = hasCopilotVisionInput(context.messages); + copilotDynamicHeaders = buildCopilotDynamicHeaders({ + messages: context.messages, + hasImages, + }); + } + + const created = createClient( + model, + apiKey, + options?.interleavedThinking ?? true, + options?.headers, + copilotDynamicHeaders, + ); + client = created.client; + isOAuth = created.isOAuthToken; } - - const { client, isOAuthToken } = createClient( - model, - apiKey, - options?.interleavedThinking ?? true, - options?.headers, - copilotDynamicHeaders, - ); - let params = buildParams(model, context, isOAuthToken, options); + let params = buildParams(model, context, isOAuth, options); const nextParams = await options?.onPayload?.(params, model); if (nextParams !== undefined) { params = nextParams as MessageCreateParamsStreaming; @@ -290,7 +306,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti const block: Block = { type: "toolCall", id: event.content_block.id, - name: isOAuthToken + name: isOAuth ? fromClaudeCodeName(event.content_block.name, context.tools) : event.content_block.name, arguments: (event.content_block.input as Record) ?? {}, diff --git a/packages/ai/test/anthropic-client-injection.test.ts b/packages/ai/test/anthropic-client-injection.test.ts new file mode 100644 index 000000000..a7bf544c1 --- /dev/null +++ b/packages/ai/test/anthropic-client-injection.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { getModel } from "../src/models.js"; +import type { Context } from "../src/types.js"; + +describe("Anthropic client injection", () => { + const model = getModel("anthropic", "claude-sonnet-4-6"); + const context: Context = { + systemPrompt: "Test", + messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], + }; + + it("should use injected client and set isOAuth to false", async () => { + const { streamAnthropic } = await import("../src/providers/anthropic.js"); + + let capturedPayload: any = null; + const messagesStreamCalled = { value: false }; + + // Minimal mock: just enough to verify the injected client is used + const mockClient = { + messages: { + stream: () => { + messagesStreamCalled.value = true; + // Return async iterable that yields a stop event + const events = [ + { type: "message_start", message: { usage: { input_tokens: 0, output_tokens: 0 } } }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: {} }, + ]; + return { + [Symbol.asyncIterator]: async function* () { + yield* events; + }, + }; + }, + }, + }; + + const s = streamAnthropic(model, context, { + client: mockClient as any, + onPayload: (payload) => { + capturedPayload = payload; + }, + }); + + for await (const _event of s) { + // consume + } + + expect(messagesStreamCalled.value).toBe(true); + expect(capturedPayload).not.toBeNull(); + // isOAuth=false means no Claude Code system prompt identity is prepended + expect(capturedPayload.system).toHaveLength(1); + expect(capturedPayload.system[0].text).toBe("Test"); + }); +});