fix(provider): support CLOUDSDK_CONFIG for Google Vertex ADC (#54164)

Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
This commit is contained in:
Henri-Maxime Ducoulombier
2026-10-10 00:04:40 +02:00
committed by GitHub
Unverified
parent 9f282f8c2c
commit c9c5985e9d
3 changed files with 88 additions and 5 deletions
@@ -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<AnyAuthClient> | 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(
@@ -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(
@@ -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"))