diff --git a/packages/ai/src/providers/google-vertex-shared.ts b/packages/ai/src/providers/google-vertex-shared.ts index 933f6f91ab4b..76fa753965cb 100644 --- a/packages/ai/src/providers/google-vertex-shared.ts +++ b/packages/ai/src/providers/google-vertex-shared.ts @@ -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 }) => { 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) => { diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index 8127babee133..1d6bfee69b5b 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -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) { // 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) { - 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) { @@ -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 }) => ({ + ...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 ( @@ -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" @@ -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) => { @@ -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 }), ) }), }) diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index 3f40731866bd..52577a8744c5 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -1,4 +1,3 @@ -import { AISDK } from "@opencode/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Model } from "@opencode/core/model" @@ -6,7 +5,6 @@ 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" @@ -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"]) - }), - ) })