feat(ai): add Vertex AI Interactions route (#54175)

This commit is contained in:
Aiden Cline
2026-10-09 14:03:59 -05:00
committed by GitHub
Unverified
parent 229b422bd6
commit d2905b8dda
10 changed files with 382 additions and 1 deletions
+7 -1
View File
@@ -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.
@@ -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<string, string> => {
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<GoogleInteractions.ProviderOptionsInput>({ id: modelID }),
configure,
}
}
export const provider = {
id,
configure,
}
export const model: ProviderPackage.Definition<Settings, GoogleInteractions.ProviderOptionsInput>["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)
}
@@ -0,0 +1,2 @@
export { model } from "../google-vertex-interactions.js"
export type { Settings } from "../google-vertex-interactions.js"
+1
View File
@@ -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"
@@ -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" })
+23
View File
@@ -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")
@@ -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()
}),
)
})
+4
View File
@@ -78,6 +78,10 @@ const builtins = new Map<string, () => Promise<unknown>>([
["@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")],
@@ -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"],
+1
View File
@@ -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",