diff --git a/packages/ai/README.md b/packages/ai/README.md index 7ff40c3dff7..988d6011d4d 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -32,6 +32,11 @@ Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployme `Google.configure({ apiKey }).interactions(modelID)` selects the Interactions API; `.model(modelID)` still selects GenerateContent. The package entrypoint is `@opencode/ai/providers/google/interactions`. +For Vertex AI, use `GoogleVertexInteractions.configure({ project, location: "global" }).model(modelID)` or +`@opencode/ai/providers/google-vertex/interactions`. It uses Application Default Credentials by default, accepts +an explicit `accessToken` or `auth` override, and supports Express mode with `apiKey`. Vertex metadata and raw usage +live under `providerMetadata.vertex`. The default is still full-history replay with `store: false`. + ```ts const model = Google.configure({ apiKey }).interactions("gemini-3.8-flash") const response = yield* LLM.generate({ @@ -1316,13 +1321,14 @@ APIs have separate entrypoints: - `@opencode/ai/providers/openai-compatible/responses` - `@opencode/ai/providers/anthropic-compatible` - `@opencode/ai/providers/google-vertex/gemini` +- `@opencode/ai/providers/google-vertex/interactions` - `@opencode/ai/providers/google-vertex/chat` - `@opencode/ai/providers/google-vertex/responses` - `@opencode/ai/providers/google-vertex/messages` OpenAI Responses has one semantic route and uses HTTP by default. Advanced callers may supply a per-call WebSocket channel executor through `StreamOptions`; transport policy does not change provider settings, model identity, or route identity. The provider-neutral Open Responses implementation owns the reusable WebSocket request and event contract, while each provider opts in with its own handshake and connection policy. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; the Responses adapter at `providers/openai-compatible/responses` uses the provider-neutral Open Responses protocol. OpenAI Responses extends that baseline with OpenAI tools, event variants, metadata, and defaults. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths. -Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages are separate API entrypoints. All accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present. Vertex Chat targets MaaS models through the OpenAI-compatible Chat Completions endpoint, while Vertex Responses targets Grok models and defaults `store` to `false` as required by Vertex. `providers/google-vertex` remains the default alias for `providers/google-vertex/gemini`. +Vertex Gemini, Vertex Interactions, Vertex Chat, Vertex Responses, and Vertex Messages are separate API entrypoints. All accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini and Interactions instead select express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present (explicit `project` or `location` selects ADC over an ambient key). Vertex Chat targets MaaS models through the OpenAI-compatible Chat Completions endpoint, while Vertex Responses targets Grok models and defaults `store` to `false` as required by Vertex. `providers/google-vertex` remains the default alias for `providers/google-vertex/gemini`. Tuned Vertex Gemini deployments use model ids shaped like `endpoints/1234567890` and require OAuth or ADC; Vertex express-mode API keys support publisher models only. diff --git a/packages/ai/src/providers/google-vertex-interactions.ts b/packages/ai/src/providers/google-vertex-interactions.ts new file mode 100644 index 00000000000..34ad5b57b35 --- /dev/null +++ b/packages/ai/src/providers/google-vertex-interactions.ts @@ -0,0 +1,105 @@ +import type { ProviderPackage } from "../provider-package.js" +import { GoogleInteractions } from "../protocols/google-interactions.js" +import { Auth } from "../route/auth.js" +import { Route, type RouteDefaultsInput } from "../route/client.js" +import { Endpoint } from "../route/endpoint.js" +import { Framing } from "../route/framing.js" +import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js" +import { GoogleVertexShared } from "./google-vertex-shared.js" + +export type GoogleInteractionsOptionsInput = GoogleInteractions.OptionsInput + +export const id = ProviderID.make("google-vertex") + +export type Config = RouteDefaultsInput & + GoogleVertexShared.ApiKeyOptions & { + readonly baseURL?: string + readonly location?: string + readonly project?: string + readonly providerOptions?: GoogleInteractions.ProviderOptionsInput + } + +export type Settings = ProviderPackage.Settings & + GoogleInteractions.ProviderOptionsInput & + ( + | { readonly accessToken?: string; readonly apiKey?: never } + | { readonly accessToken?: never; readonly apiKey?: string } + ) & { + readonly baseURL?: string + readonly location?: string + readonly project?: string + } + +const route = Route.make({ + id: "google-vertex-interactions", + provider: id, + providerMetadataKey: "vertex", + protocol: GoogleInteractions.protocol, + endpoint: Endpoint.path("/interactions", { query: { alt: "sse" } }), + auth: Auth.none, + headers: ({ request }): Record => { + const serviceTier = request.providerOptions?.serviceTier + return typeof serviceTier === "string" ? { "x-vertex-ai-llm-shared-request-type": serviceTier } : {} + }, + framing: Framing.sse, +}) + +export const routes = [route] + +const configuredRoute = (input: Config) => { + const { + accessToken: _accessToken, + apiKey: _apiKey, + auth: _auth, + baseURL, + location: inputLocation, + project: inputProject, + ...rest + } = input + const apiKey = GoogleVertexShared.apiKey(input) + const location = GoogleVertexShared.location(inputLocation, "global") + const project = GoogleVertexShared.project(inputProject) + return route.with({ + ...rest, + endpoint: { + baseURL: + baseURL ?? + `https://${GoogleVertexShared.host(location)}/v1beta1/${apiKey === undefined ? `projects/${GoogleVertexShared.requireProject(project)}/` : ""}locations/${location}`, + }, + auth: apiKey === undefined ? GoogleVertexShared.oauth(input, project) : Auth.header("x-goog-api-key", apiKey), + }) +} + +export const configure = (input: Config = {}) => { + const route = configuredRoute(input) + return { + id, + model: (modelID: string | ModelID) => route.model({ id: modelID }), + configure, + } +} + +export const provider = { + id, + configure, +} + +export const model: ProviderPackage.Definition["model"] = ( + modelID, + { accessToken, apiKey, baseURL, body, headers, location, project, ...providerOptions }, +) => { + if (apiKey !== undefined && accessToken !== undefined) + throw new ProviderConfigurationError({ + provider: id, + message: "Google Vertex apiKey cannot be combined with accessToken or auth", + }) + return configure({ + ...(apiKey === undefined ? { accessToken } : { apiKey }), + baseURL, + headers, + http: body === undefined ? undefined : { body }, + location, + project, + providerOptions, + }).model(modelID) +} diff --git a/packages/ai/src/providers/google-vertex/interactions.ts b/packages/ai/src/providers/google-vertex/interactions.ts new file mode 100644 index 00000000000..2391fd1da50 --- /dev/null +++ b/packages/ai/src/providers/google-vertex/interactions.ts @@ -0,0 +1,2 @@ +export { model } from "../google-vertex-interactions.js" +export type { Settings } from "../google-vertex-interactions.js" diff --git a/packages/ai/src/providers/index.ts b/packages/ai/src/providers/index.ts index 91e4ce4c9e7..35c829f705d 100644 --- a/packages/ai/src/providers/index.ts +++ b/packages/ai/src/providers/index.ts @@ -22,6 +22,7 @@ export * as Fireworks from "./fireworks.js" export * as Google from "./google.js" export * as GoogleVertex from "./google-vertex.js" export * as GoogleVertexChat from "./google-vertex-chat.js" +export * as GoogleVertexInteractions from "./google-vertex-interactions.js" export * as GoogleVertexMessages from "./google-vertex-messages.js" export * as GoogleVertexMistral from "./google-vertex-mistral.js" export * as GoogleVertexResponses from "./google-vertex-responses.js" diff --git a/packages/ai/test/provider-options/google-vertex-interactions.types.ts b/packages/ai/test/provider-options/google-vertex-interactions.types.ts new file mode 100644 index 00000000000..c873d6ebfe6 --- /dev/null +++ b/packages/ai/test/provider-options/google-vertex-interactions.types.ts @@ -0,0 +1,29 @@ +import { LLM } from "../../src/index.js" +import { GoogleVertexInteractions } from "../../src/providers.js" +import { Auth } from "../../src/route/auth.js" +import { model } from "../../src/providers/google-vertex/interactions.js" + +const selected = GoogleVertexInteractions.configure({ project: "project" }).model("gemini-3.8-flash") + +LLM.request({ + model: selected, + prompt: "Hello", + providerOptions: { thinkingLevel: "low", thinkingSummaries: "auto", serviceTier: "flex", store: false }, +}) + +LLM.request({ + model: selected, + prompt: "Hello", + // @ts-expect-error Interactions store must be boolean. + providerOptions: { store: "yes" }, +}) + +GoogleVertexInteractions.configure({ auth: Auth.bearer("token"), project: "project" }) +GoogleVertexInteractions.configure({ apiKey: "key" }) +// @ts-expect-error API keys and OAuth credentials are mutually exclusive. +GoogleVertexInteractions.configure({ apiKey: "key", accessToken: "token" }) +// @ts-expect-error API keys and auth overrides are mutually exclusive. +GoogleVertexInteractions.configure({ apiKey: "key", auth: Auth.bearer("token") }) +model("gemini-3.8-flash", { project: "project", thinkingLevel: "low" }) +// @ts-expect-error Package settings also reject conflicting credentials. +model("gemini-3.8-flash", { apiKey: "key", accessToken: "token" }) diff --git a/packages/ai/test/provider-package.test.ts b/packages/ai/test/provider-package.test.ts index bab154f9112..77151a3a249 100644 --- a/packages/ai/test/provider-package.test.ts +++ b/packages/ai/test/provider-package.test.ts @@ -451,6 +451,29 @@ describe("provider package entrypoints", () => { }) }) + test("maps Vertex Interactions package settings onto the shared protocol", async () => { + const GoogleVertexInteractions = await import("@opencode/ai/providers/google-vertex/interactions") + const selected = GoogleVertexInteractions.model("gemini-3.8-flash", { + accessToken: "fixture", + project: "vertex-project", + location: "global", + headers: { "x-application": "opencode" }, + body: { generation_config: { temperature: 0.5 } }, + thinkingLevel: "low", + store: false, + }) + expect(selected.route.id).toBe("google-vertex-interactions") + expect(selected.route.protocol).toBe("google-interactions") + expect(selected.route.endpoint).toMatchObject({ + baseURL: "https://aiplatform.googleapis.com/v1beta1/projects/vertex-project/locations/global", + path: "/interactions", + query: { alt: "sse" }, + }) + expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) + expect(selected.route.defaults.http?.body).toEqual({ generation_config: { temperature: 0.5 } }) + expect(selected.route.defaults.providerOptions).toEqual({ thinkingLevel: "low", store: false }) + }) + test("rejects conflicting Vertex auth settings at runtime", async () => { const GoogleVertex = await import("@opencode/ai/providers/google-vertex") const GoogleVertexChat = await import("@opencode/ai/providers/google-vertex/chat") diff --git a/packages/ai/test/provider/google-vertex-interactions.test.ts b/packages/ai/test/provider/google-vertex-interactions.test.ts new file mode 100644 index 00000000000..cbd2f0bb4a4 --- /dev/null +++ b/packages/ai/test/provider/google-vertex-interactions.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Redacted } from "effect" +import { HttpClientRequest } from "effect/http" +import { LLM, Message, ToolCallPart } from "../../src/index.js" +import { GoogleVertexInteractions } from "../../src/providers.js" +import { LLMClient } from "../../src/route.js" +import { Auth } from "../../src/route/auth.js" +import { compileRequest } from "../../src/route/client.js" +import { it } from "../lib/effect.js" +import { dynamicResponse, fixedResponse } from "../lib/http.js" +import { sseEvents } from "../lib/sse.js" + +describe("Google Vertex Interactions", () => { + it.effect("streams through the global project endpoint with OAuth and Vertex service tiers", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.request({ + model: GoogleVertexInteractions.configure({ + accessToken: "vertex-token", + location: "global", + project: "vertex-project", + }).model("gemini-3.8-flash"), + system: "Be concise.", + prompt: "Say hello.", + providerOptions: { serviceTier: "flex", thinkingLevel: "low" }, + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(request.url).toBe( + "https://aiplatform.googleapis.com/v1beta1/projects/vertex-project/locations/global/interactions?alt=sse", + ) + expect(request.method).toBe("POST") + expect(request.headers.get("authorization")).toBe("Bearer vertex-token") + expect(request.headers.has("x-goog-api-key")).toBe(false) + expect(request.headers.get("x-vertex-ai-llm-shared-request-type")).toBe("flex") + expect(yield* Effect.promise(() => request.json())).toMatchObject({ + model: "gemini-3.8-flash", + input: [{ type: "user_input", content: [{ type: "text", text: "Say hello." }] }], + system_instruction: "Be concise.", + stream: true, + store: false, + service_tier: "flex", + generation_config: { thinking_level: "low" }, + }) + return input.respond( + sseEvents( + { event_type: "interaction.created", interaction: { id: "interaction_1" } }, + { event_type: "interaction.status_update", interaction_id: "interaction_1", status: "in_progress" }, + { event_type: "step.start", index: 0, step: { type: "model_output", content: [] } }, + { event_type: "step.delta", index: 0, delta: { type: "text", text: "Hello." } }, + { event_type: "step.stop", index: 0 }, + { + event_type: "interaction.completed", + interaction: { + id: "interaction_1", + status: "completed", + usage: { total_input_tokens: 10, total_cached_tokens: 4, total_output_tokens: 2 }, + }, + }, + ) + "event: done\ndata: {}\n\n", + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + + expect(response.text).toBe("Hello.") + expect(response.usage?.cacheReadInputTokens).toBe(4) + expect(response.usage?.providerMetadata).toEqual({ + vertex: { total_input_tokens: 10, total_cached_tokens: 4, total_output_tokens: 2 }, + }) + expect(response.events.at(-1)?.providerMetadata).toEqual({ vertex: { interactionId: "interaction_1" } }) + }), + ) + + it.effect("uses the project-free Express endpoint with API-key auth", () => + Effect.gen(function* () { + yield* LLMClient.generate( + LLM.request({ + model: GoogleVertexInteractions.configure({ apiKey: "vertex-key" }).model("gemini-3.8-flash"), + prompt: "Say hello.", + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.sync(() => { + expect(input.request.url).toBe( + "https://aiplatform.googleapis.com/v1beta1/locations/global/interactions?alt=sse", + ) + expect(input.request.headers["x-goog-api-key"]).toBe("vertex-key") + expect(input.request.headers).not.toHaveProperty("authorization") + expect(input.request.headers).not.toHaveProperty("x-vertex-ai-llm-shared-request-type") + return input.respond( + sseEvents({ event_type: "interaction.completed", interaction: { status: "completed" } }), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + }), + ) + + it.effect("resolves an auth override on every request and honors a custom base URL without a project", () => + Effect.gen(function* () { + const tokens = ["first-token", "refreshed-token"] + const model = GoogleVertexInteractions.configure({ + baseURL: "https://vertex.test/v1beta1/locations/global", + auth: Auth.effect(Effect.sync(() => Redacted.make(tokens.shift() ?? "unexpected-token"))).bearer(), + }).model("gemini-3.8-flash") + const headers: string[] = [] + yield* Effect.forEach(["First.", "Second."], (prompt) => LLMClient.generate(LLM.request({ model, prompt }))).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.sync(() => { + expect(input.request.url).toBe("https://vertex.test/v1beta1/locations/global/interactions?alt=sse") + headers.push(input.request.headers.authorization) + return input.respond( + sseEvents({ event_type: "interaction.completed", interaction: { status: "completed" } }), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + expect(headers).toEqual(["Bearer first-token", "Bearer refreshed-token"]) + }), + ) + + test("uses the jurisdictional host and lazily configures ADC", () => { + const model = GoogleVertexInteractions.configure({ project: "vertex-project", location: "eu" }).model( + "gemini-3.8-flash", + ) + expect(model.route.endpoint.baseURL).toBe( + "https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/vertex-project/locations/eu", + ) + expect(model.provider).toBe("google-vertex") + expect(model.route.protocol).toBe("google-interactions") + }) + + test("rejects conflicting credentials", () => { + expect(() => + // @ts-expect-error Exercise invalid credentials from untyped callers. + GoogleVertexInteractions.configure({ apiKey: "key", accessToken: "token" }), + ).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth") + expect(() => + // @ts-expect-error Exercise invalid credentials from untyped callers. + GoogleVertexInteractions.configure({ apiKey: "key", auth: Auth.bearer("token") }), + ).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth") + expect(() => + // @ts-expect-error Exercise invalid credentials from untyped callers. + GoogleVertexInteractions.model("gemini-3.8-flash", { apiKey: "key", accessToken: "token" }), + ).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth") + }) + + test("requires a project unless a custom base URL or API key is provided", () => { + expect(() => GoogleVertexInteractions.configure({ project: "", accessToken: "token" })).toThrow( + "Google Vertex requires a project when baseURL is not configured", + ) + }) + + it.effect("round-trips tool signatures under Vertex metadata without enabling storage", () => + Effect.gen(function* () { + const model = GoogleVertexInteractions.configure({ + accessToken: "vertex-token", + project: "vertex-project", + }).model("gemini-3.8-flash") + const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Check the weather." })).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { + event_type: "step.start", + index: 0, + step: { type: "function_call", id: "call_1", name: "lookup", arguments: { city: "Paris" } }, + }, + { event_type: "step.delta", index: 0, delta: { type: "thought_signature", signature: "tool_sig" } }, + { event_type: "step.stop", index: 0 }, + { event_type: "interaction.completed", interaction: { id: "interaction_1", status: "requires_action" } }, + ), + ), + ), + ) + const call = response.toolCalls[0] + expect(call.providerMetadata).toEqual({ vertex: { interactionSignature: "tool_sig" } }) + const prepared = yield* compileRequest( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make(call)]), + Message.tool({ id: call.id, name: call.name, result: { weather: "sunny" } }), + ], + }), + ) + expect(prepared.body).toMatchObject({ + store: false, + input: [ + { type: "function_call", id: "call_1", name: "lookup", arguments: { city: "Paris" }, signature: "tool_sig" }, + { type: "function_result", call_id: "call_1", name: "lookup", result: { weather: "sunny" } }, + ], + }) + expect(prepared.body.previous_interaction_id).toBeUndefined() + }), + ) +}) diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 7b1f76a67c2..bc6ade949a2 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -78,6 +78,10 @@ const builtins = new Map Promise>([ ["@opencode/ai/providers/google/interactions", () => import("@opencode/ai/providers/google/interactions")], ["@opencode/ai/providers/google-vertex", () => import("@opencode/ai/providers/google-vertex")], ["@opencode/ai/providers/google-vertex/gemini", () => import("@opencode/ai/providers/google-vertex/gemini")], + [ + "@opencode/ai/providers/google-vertex/interactions", + () => import("@opencode/ai/providers/google-vertex/interactions"), + ], ["@opencode/ai/providers/google-vertex/chat", () => import("@opencode/ai/providers/google-vertex/chat")], ["@opencode/ai/providers/google-vertex/responses", () => import("@opencode/ai/providers/google-vertex/responses")], ["@opencode/ai/providers/google-vertex/messages", () => import("@opencode/ai/providers/google-vertex/messages")], diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index d556c9e84e7..1dfdee22381 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -981,6 +981,7 @@ describe("ModelResolver", () => { const packages = [ ["@opencode/ai/providers/google-vertex", "accessToken"], ["@opencode/ai/providers/google-vertex/gemini", "accessToken"], + ["@opencode/ai/providers/google-vertex/interactions", "accessToken"], ["@opencode/ai/providers/google-vertex/chat", "accessToken"], ["@opencode/ai/providers/google-vertex/responses", "accessToken"], ["@opencode/ai/providers/google-vertex/messages", "accessToken"], diff --git a/packages/core/test/provider.test.ts b/packages/core/test/provider.test.ts index 5e33f03416b..83620393537 100644 --- a/packages/core/test/provider.test.ts +++ b/packages/core/test/provider.test.ts @@ -16,6 +16,7 @@ describe("Provider", () => { "@opencode/ai/providers/fireworks", "@opencode/ai/providers/google-vertex", "@opencode/ai/providers/google-vertex/gemini", + "@opencode/ai/providers/google-vertex/interactions", "@opencode/ai/providers/google-vertex/chat", "@opencode/ai/providers/google-vertex/responses", "@opencode/ai/providers/google-vertex/messages",