Skip to content
8 changes: 6 additions & 2 deletions packages/ai/src/providers/google-vertex-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,18 @@ export const requireProject = (value: string | undefined) => {
})
}

export const apiKey = (input: ApiKeyOptions) => {
export const apiKey = (input: ApiKeyOptions & { readonly project?: string; readonly location?: string }) => {
Comment thread
rekram1-node marked this conversation as resolved.
if (input.apiKey !== undefined && (input.accessToken !== undefined || input.auth !== undefined))
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
})
if (input.accessToken !== undefined || input.auth !== undefined) return undefined
return input.apiKey ?? process.env.GOOGLE_VERTEX_API_KEY
if (input.apiKey !== undefined) return input.apiKey
// Same precedence as Google's Gen AI SDK: an explicit project or location selects Google credentials,
// so an ambient API key cannot replace them.
if (input.project !== undefined || input.location !== undefined) return undefined
return process.env.GOOGLE_VERTEX_API_KEY
}

const adc = (project?: string) => {
Expand Down
150 changes: 132 additions & 18 deletions packages/core/src/plugin/provider/google-vertex.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,38 @@
import { Effect } from "effect"
import { Effect, Option, Schema, Stream } from "effect"
import path from "node:path"
import { define } from "@opencode/plugin/effect/plugin"
import { FSUtil } from "@opencode/util/fs-util"
import { Global } from "@opencode/util/global"
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Provider } from "../../provider.js"
import { configuredSettings } from "./configured.js"

const ADC_METHOD = "google-adc"

const decodeADCFile = Schema.decodeUnknownOption(
Schema.fromJsonString(Schema.Struct({ quota_project_id: Schema.optional(Schema.String) })),
)

function resolveProject(options: Record<string, any>) {
// models.dev advertises GOOGLE_VERTEX_PROJECT for Vertex, while Google SDKs
// and ADC examples commonly use the broader Google Cloud project aliases.
return (
const project =
options.project ??
process.env.GOOGLE_VERTEX_PROJECT ??
process.env.GOOGLE_CLOUD_PROJECT ??
process.env.GCP_PROJECT ??
process.env.GCLOUD_PROJECT
)
return typeof project === "string" ? project : undefined
}

function resolveLocation(options: Record<string, any>) {
return (
const location =
options.location ??
process.env.GOOGLE_VERTEX_LOCATION ??
process.env.GOOGLE_CLOUD_LOCATION ??
process.env.VERTEX_LOCATION ??
"global"
)
process.env.VERTEX_LOCATION
return typeof location === "string" ? location : undefined
}

function vertexEndpoint(location: string) {
Expand All @@ -41,6 +52,102 @@ function replaceVertexVars(value: string, project: string | undefined, location:
export const GoogleVertexPlugin = define({
id: "opencode.provider.google.vertex",
effect: Effect.fn(function* (ctx) {
const fs = yield* FSUtil.Service
const credentials = yield* Credential.Service
const bus = yield* Bus.Service
const read = (file: string) => fs.readFileStringSafe(file).pipe(Effect.orElseSucceed(() => undefined))
// Same lookup as gcloud itself. Only project IDs are read; nothing here contacts Google.
const gcloud =
process.env.CLOUDSDK_CONFIG ??
(process.platform === "win32" && process.env.APPDATA
? path.join(process.env.APPDATA, "gcloud")
: path.join(Global.Path.home, ".config", "gcloud"))
const active = `config_${(yield* read(path.join(gcloud, "active_config")))?.trim() || "default"}`
const configs = (yield* fs
.readDirectory(path.join(gcloud, "configurations"))
.pipe(Effect.orElseSucceed((): string[] => [])))
.filter((name) => name.startsWith("config_"))
.toSorted((a, b) => Number(b === active) - Number(a === active) || a.localeCompare(b))
const contents = yield* Effect.forEach(configs, (name) => read(path.join(gcloud, "configurations", name)))
const adcFile = yield* read(path.join(gcloud, "application_default_credentials.json"))
const configured = (yield* configuredSettings(Provider.ID.googleVertex)) ?? {}
const projects = Array.from(
new Set(
[
resolveProject(configured),
...contents.map((content) => content?.match(/^project\s*=\s*(\S+)/m)?.[1]),
Option.getOrUndefined(decodeADCFile(adcFile ?? ""))?.quota_project_id,
].filter((project): project is string => Boolean(project)),
),
)
// Credentials work in every location; locations differ in which models they serve. `global` serves the most.
const locations = Array.from(
new Set([
resolveLocation(configured) ?? "global",
"global",
"us",
"eu",
...contents.flatMap((content) =>
Array.from((content ?? "").matchAll(/^region\s*=\s*(\S+)/gm), (match) => match[1]),
),
]),
)

const load = Effect.fn("GoogleVertexPlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active(Provider.ID.googleVertex)
// Reads the stored value rather than resolving the connection, so startup never refreshes anything.
const stored =
connection?.type === "credential" ? yield* credentials.get(Credential.ID.make(connection.id)) : undefined
if (stored?.value.type !== "external" || stored.value.methodID !== ADC_METHOD) return {}
return { project: stored.value.metadata?.project, location: stored.value.metadata?.location }
})
const selected = { settings: yield* load() }
const settingsFor = (provider: { id: string; integrationID?: string; settings?: Record<string, unknown> }) => ({
...provider.settings,
...((provider.integrationID ?? provider.id) === Provider.ID.googleVertex ? selected.settings : {}),
})

yield* ctx.integration.transform((editor) => {
editor.method.update({
integrationID: Provider.ID.googleVertex,
method: {
id: ADC_METHOD,
type: "external",
label: "Google Cloud credentials (gcloud auth or environment)",
form: [
{
key: "project",
type: "string",
title: "Google Cloud project",
description: projects.length
? `Found ${projects.length} project${projects.length === 1 ? "" : "s"} in your gcloud configuration.`
: "No projects found in your gcloud configuration.",
required: true,
minLength: 1,
pattern: "\\S",
placeholder: "Project ID",
custom: true,
default: projects[0],
options: projects.map((project) => ({ value: project, label: project })),
},
{
key: "location",
type: "string",
title: "Location",
description:
"global serves the most models. Use us, eu, or a single region for data residency or regional quota.",
required: true,
minLength: 1,
pattern: "\\S",
placeholder: "Location",
custom: true,
default: locations[0],
options: locations.map((location) => ({ value: location, label: location })),
},
],
},
})
})
yield* ctx.provider.transform((evt) => {
for (const item of evt.list()) {
if (
Expand All @@ -51,14 +158,15 @@ export const GoogleVertexPlugin = define({
)
)
continue
const project = resolveProject(item.provider.settings ?? {})
const location = String(resolveLocation(item.provider.settings ?? {}))
const settings = settingsFor(item.provider)
const project = resolveProject(settings)
const location = resolveLocation(settings) ?? "global"
evt.update(item.provider.id, (provider) => {
// Vertex authenticates through ADC rather than a key credential, so a
// resolvable project is what makes the provider usable.
if (project && provider.activation === "auto") provider.activation = "enabled"
provider.settings = {
...provider.settings,
...settingsFor(provider),
...(project ? { project } : {}),
location,
...(typeof provider.settings?.baseURL === "string"
Expand All @@ -78,8 +186,9 @@ export const GoogleVertexPlugin = define({
)
)
continue
const project = resolveProject(item.provider.settings ?? {})
const location = String(resolveLocation(item.provider.settings ?? {}))
const settings = settingsFor(item.provider)
const project = resolveProject(settings)
const location = resolveLocation(settings) ?? "global"
for (const model of models.list(item.provider.id)) {
if (typeof model.settings?.baseURL !== "string") continue
models.update(item.provider.id, model.id, (draft) => {
Expand All @@ -91,12 +200,17 @@ export const GoogleVertexPlugin = define({
}
}
})
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
if (evt.model.providerID !== Provider.ID.googleVertex) return
evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim())
}),
yield* bus.subscribe([Credential.Event.Updated, Credential.Event.Switched]).pipe(
Stream.runForEach(() =>
Effect.gen(function* () {
const next = yield* load()
if (JSON.stringify(next) === JSON.stringify(selected.settings)) return
selected.settings = next
yield* ctx.provider.reload()
yield* ctx.model.reload()
}),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
20 changes: 0 additions & 20 deletions packages/core/test/plugin/provider-google-vertex.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { AISDK } from "@opencode/core/aisdk"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Model } from "@opencode/core/model"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { GoogleVertexPlugin } from "@opencode/core/plugin/provider/google-vertex"
import { Provider } from "@opencode/core/provider"
import { fakeSelectorSdk } from "../fixture/selector"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"

Expand Down Expand Up @@ -303,22 +301,4 @@ describe("GoogleVertexPlugin", () => {
}),
),
)

it.effect("trims model IDs before selecting language models", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
yield* aisdk.runLanguage({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make(" gemini-2.5-pro ")),
modelID: Model.ID.make(" gemini-2.5-pro "),
package: "aisdk:test-provider",
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {},
})
expect(calls).toEqual(["languageModel:gemini-2.5-pro"])
}),
)
})
Loading