diff --git a/packages/ai/src/providers/google-vertex-shared.ts b/packages/ai/src/providers/google-vertex-shared.ts index 8c2bf89759f..7588058687b 100644 --- a/packages/ai/src/providers/google-vertex-shared.ts +++ b/packages/ai/src/providers/google-vertex-shared.ts @@ -1,4 +1,4 @@ -import type { AnyAuthClient } from "google-auth-library" +import type { AnyAuthClient, GoogleAuthOptions } from "google-auth-library" import { Effect, Redacted } from "effect" import { Auth, MissingCredentialError } from "../route/auth.js" import { ProviderConfigurationError, ProviderID } from "../schema/index.js" @@ -6,6 +6,31 @@ import { ProviderConfigurationError, ProviderID } from "../schema/index.js" const SCOPE = "https://www.googleapis.com/auth/cloud-platform" const id = ProviderID.make("google-vertex") +export const loadADCClient = async (project?: string) => { + const { GoogleAuth } = await import("google-auth-library") + // google-auth-library 10.5.0 ignores CLOUDSDK_CONFIG during ADC file discovery. + // Fix this in shipped AI code: a repository-level Bun dependency patch would not + // reach npm or Bun consumers of the published @opencode/ai package. + // Override only the well-known-file lookup: keyFilename bypasses ADC's quota-project + // preparation. Keep explicit credentials and metadata fallback in Google's ADC chain. + class CloudSDKAuth extends GoogleAuth { + override async _tryGetApplicationCredentialsFromWellKnownFile(options?: GoogleAuthOptions["clientOptions"]) { + const config = process.env.CLOUDSDK_CONFIG + if (!config) return super._tryGetApplicationCredentialsFromWellKnownFile(options) + // Keep local filesystem helpers lazy for explicitly authenticated Worker callers; + // this ADC-file lookup itself requires a Node-compatible filesystem. + const { existsSync } = await import("node:fs") + const { homedir } = await import("node:os") + const { join } = await import("node:path") + const directory = config === "~" ? homedir() : config.startsWith("~/") ? join(homedir(), config.slice(2)) : config + const file = join(directory, "application_default_credentials.json") + if (!existsSync(file)) return null + return this._getApplicationCredentialsFromFilePath(file, options) + } + } + return new CloudSDKAuth({ projectId: project, scopes: [SCOPE] }).getClient() +} + export type OAuthOptions = | { readonly accessToken?: string; readonly auth?: never } | { readonly accessToken?: never; readonly auth?: Auth.Definition } @@ -61,9 +86,7 @@ const adc = (project?: string) => { let client: Promise | undefined const loadClient = () => { if (client) return client - client = import("google-auth-library").then(({ GoogleAuth }) => - new GoogleAuth({ projectId: project, scopes: [SCOPE] }).getClient(), - ) + client = loadADCClient(project) return client } return Auth.effect( diff --git a/packages/ai/test/provider/google-vertex.test.ts b/packages/ai/test/provider/google-vertex.test.ts index e09aeac3d67..2f3da7b3a8a 100644 --- a/packages/ai/test/provider/google-vertex.test.ts +++ b/packages/ai/test/provider/google-vertex.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { HttpClientRequest } from "effect/http" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" import { LanguageModel, LLM, Message, ToolCallPart } from "../../src/index.js" import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers.js" import { LLMClient } from "../../src/route.js" @@ -11,6 +14,57 @@ import { deltaChunk, finishChunk } from "../lib/openai-chunks.js" import { sseEvents } from "../lib/sse.js" describe("Google Vertex providers", () => { + test("loads CLOUDSDK_CONFIG credentials and preserves quota-project overrides", async () => { + const directory = await mkdtemp(join(tmpdir(), "vertex-adc-")) + try { + await Bun.write( + join(directory, "application_default_credentials.json"), + JSON.stringify({ + type: "authorized_user", + client_id: "fixture-client", + client_secret: "fixture-secret", + refresh_token: "fixture-refresh-token", + quota_project_id: "file-quota", + }), + ) + // Isolate Google's environment and credential caches; no token refresh or model call. + const child = Bun.spawn( + [ + process.execPath, + "-e", + `import { GoogleVertexShared } from ${JSON.stringify(join(import.meta.dir, "../../src/providers/google-vertex-shared.ts"))}; + const client = await GoogleVertexShared.loadADCClient("fixture-project"); + console.log(JSON.stringify({ + custom: client.credentials.refresh_token === "fixture-refresh-token", + quota: client.quotaProjectId, + }));`, + ], + { + env: { + ...process.env, + HOME: directory, + APPDATA: directory, + CLOUDSDK_CONFIG: directory, + GOOGLE_APPLICATION_CREDENTIALS: undefined, + google_application_credentials: undefined, + GOOGLE_CLOUD_QUOTA_PROJECT: "environment-quota", + METADATA_SERVER_DETECTION: "none", + }, + stdout: "pipe", + stderr: "pipe", + }, + ) + const result = JSON.parse(await new Response(child.stdout).text()) + expect({ exitCode: await child.exited, stderr: await new Response(child.stderr).text() }).toEqual({ + exitCode: 0, + stderr: "", + }) + expect(result).toEqual({ custom: true, quota: "environment-quota" }) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + it.effect("sends Gemini requests to the global Vertex endpoint", () => Effect.gen(function* () { const response = yield* LLMClient.generate( diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index 12f56735c10..964f15fd7e4 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -58,7 +58,13 @@ export const GoogleVertexPlugin = define({ 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.env.CLOUDSDK_CONFIG + ? process.env.CLOUDSDK_CONFIG === "~" + ? Global.Path.home + : process.env.CLOUDSDK_CONFIG.startsWith("~/") + ? path.join(Global.Path.home, process.env.CLOUDSDK_CONFIG.slice(2)) + : process.env.CLOUDSDK_CONFIG + : undefined) ?? (process.platform === "win32" && process.env.APPDATA ? path.join(process.env.APPDATA, "gcloud") : path.join(Global.Path.home, ".config", "gcloud"))