Add Vertex AI routing support (#753)

* add Vertex AI routing support

* include Vertex smokes in action CI
This commit is contained in:
Colin McDonnell
2026-05-20 16:49:08 +00:00
committed by pullfrog[bot]
parent 09344a9ec9
commit c43ed65c3b
15 changed files with 532 additions and 47 deletions
+67
View File
@@ -1,5 +1,9 @@
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resolveAgent, resolveModel } from "./agent.ts";
import { cleanupVertexCredentials, materializeVertexCredentials } from "./vertex.ts";
const savedEnv = { ...process.env };
@@ -12,6 +16,12 @@ const STRIPPED = [
/^AWS_SESSION_TOKEN$/,
/^AWS_REGION$/,
/^BEDROCK_MODEL_ID$/,
/^GOOGLE_APPLICATION_CREDENTIALS$/,
/^GOOGLE_CLOUD_PROJECT$/,
/^VERTEX_SERVICE_ACCOUNT_JSON$/,
/^VERTEX_LOCATION$/,
/^VERTEX_MODEL_ID$/,
/^PULLFROG_SECRET_HOME$/,
/^PULLFROG_MODEL$/,
/^PULLFROG_AGENT$/,
];
@@ -83,6 +93,20 @@ describe("resolveAgent", () => {
expect(resolveAgent({ model: "us.anthropic.claude-opus-4-7" }).name).toBe("opencode");
});
});
describe("vertex routing", () => {
it("routes Anthropic Vertex IDs to claude", () => {
process.env.VERTEX_SERVICE_ACCOUNT_JSON = "{}";
process.env.VERTEX_MODEL_ID = "claude-opus-4-1@20250805";
expect(resolveAgent({ model: "claude-opus-4-1@20250805" }).name).toBe("claude");
});
it("routes Gemini Vertex IDs to opencode", () => {
process.env.VERTEX_SERVICE_ACCOUNT_JSON = "{}";
process.env.VERTEX_MODEL_ID = "gemini-2.5-pro";
expect(resolveAgent({ model: "gemini-2.5-pro" }).name).toBe("opencode");
});
});
});
describe("resolveModel", () => {
@@ -128,4 +152,47 @@ describe("resolveModel", () => {
process.env.PULLFROG_MODEL = "bedrock/byok";
expect(() => resolveModel({ slug: "openai/gpt" })).toThrow("BEDROCK_MODEL_ID");
});
it("resolves vertex/byok to VERTEX_MODEL_ID", () => {
process.env.VERTEX_MODEL_ID = "claude-opus-4-1@20250805";
expect(resolveModel({ slug: "vertex/byok" })).toBe("claude-opus-4-1@20250805");
});
it("throws when vertex/byok is selected without VERTEX_MODEL_ID", () => {
expect(() => resolveModel({ slug: "vertex/byok" })).toThrow("VERTEX_MODEL_ID");
});
it("PULLFROG_MODEL=vertex/byok defers to VERTEX_MODEL_ID, not the sentinel", () => {
process.env.PULLFROG_MODEL = "vertex/byok";
process.env.VERTEX_MODEL_ID = "gemini-2.5-pro";
expect(resolveModel({ slug: "openai/gpt" })).toBe("gemini-2.5-pro");
});
});
describe("materializeVertexCredentials", () => {
it("writes service-account JSON outside tmpdir and defaults project from project_id", () => {
const dir = mkdtempSync(join(tmpdir(), "vertex-creds-test-"));
process.env.VERTEX_MODEL_ID = "claude-opus-4-1@20250805";
process.env.PULLFROG_SECRET_HOME = dir;
process.env.VERTEX_SERVICE_ACCOUNT_JSON = JSON.stringify({
project_id: "test-project",
client_email: "pullfrog@test-project.iam.gserviceaccount.com",
});
try {
const credentials = materializeVertexCredentials({ model: "claude-opus-4-1@20250805" });
if (!credentials) throw new Error("expected vertex credentials");
expect(credentials.credentialsPath).toContain(join(dir, ".pullfrog", "secrets"));
expect(process.env.GOOGLE_APPLICATION_CREDENTIALS).toBe(credentials.credentialsPath);
expect(process.env.GOOGLE_CLOUD_PROJECT).toBe("test-project");
expect(readFileSync(credentials.credentialsPath, "utf8")).toBe(
process.env.VERTEX_SERVICE_ACCOUNT_JSON
);
expect(statSync(credentials.credentialsPath).mode & 0o777).toBe(0o600);
cleanupVertexCredentials(credentials);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+28 -3
View File
@@ -4,10 +4,13 @@ import {
BEDROCK_MODEL_ID_ENV,
getModelProvider,
isBedrockAnthropicId,
isVertexAnthropicId,
resolveCliModel,
resolveDisplayAlias,
VERTEX_MODEL_ID_ENV,
} from "../models.ts";
import { log } from "./cli.ts";
import { VERTEX_SERVICE_ACCOUNT_JSON_ENV } from "./vertex.ts";
function hasEnvVar(name: string): boolean {
const val = process.env[name];
@@ -25,6 +28,10 @@ function hasBedrockAuth(): boolean {
);
}
function hasVertexAuth(): boolean {
return hasEnvVar(VERTEX_SERVICE_ACCOUNT_JSON_ENV);
}
/**
* resolve a single slug to its CLI-ready model string. routing aliases
* (e.g. `bedrock/byok`) defer to their backing env var instead of the
@@ -40,12 +47,23 @@ function resolveSlug(slug: string): string | undefined {
if (!bedrockId) {
throw new Error(
`${BEDROCK_MODEL_ID_ENV} env var is required when the model is set to "${slug}". ` +
`set it to an AWS Bedrock model ID (e.g. "us.anthropic.claude-opus-4-7", "amazon.nova-pro-v1:0"). ` +
`set it to an AWS Bedrock model ID from the Bedrock console. ` +
`see https://docs.pullfrog.com/bedrock for setup.`
);
}
return bedrockId;
}
if (alias?.routing === "vertex") {
const vertexId = process.env[VERTEX_MODEL_ID_ENV]?.trim();
if (!vertexId) {
throw new Error(
`${VERTEX_MODEL_ID_ENV} env var is required when the model is set to "${slug}". ` +
`set it to a Google Vertex AI model ID from Model Garden. ` +
`see https://docs.pullfrog.com/vertex for setup.`
);
}
return vertexId;
}
return resolveCliModel(slug);
}
@@ -97,7 +115,14 @@ export function resolveAgent(ctx: { model?: string | undefined }): Agent {
return isBedrockAnthropicId(ctx.model) ? agents.claude : agents.opencode;
}
// 3. if model is Anthropic and Claude Code credentials are available, use Claude Code
// 3. Vertex routing: same shape as Bedrock, but Anthropic Vertex IDs are
// anchored `claude-*` IDs and non-Anthropic models use opencode's
// `google-vertex` provider.
if (ctx.model && hasVertexAuth() && process.env[VERTEX_MODEL_ID_ENV]?.trim() === ctx.model) {
return isVertexAnthropicId(ctx.model) ? agents.claude : agents.opencode;
}
// 4. if model is Anthropic and Claude Code credentials are available, use Claude Code
if (ctx.model) {
try {
const provider = getModelProvider(ctx.model);
@@ -109,6 +134,6 @@ export function resolveAgent(ctx: { model?: string | undefined }): Agent {
}
}
// 4. default: OpenCode (universal, supports all providers)
// 5. default: OpenCode (universal, supports all providers)
return agents.opencode;
}
+75
View File
@@ -22,6 +22,11 @@ const STRIPPED_PREFIXES_OR_NAMES = [
/^AWS_SESSION_TOKEN$/,
/^AWS_REGION$/,
/^BEDROCK_MODEL_ID$/,
/^GOOGLE_APPLICATION_CREDENTIALS$/,
/^GOOGLE_CLOUD_PROJECT$/,
/^VERTEX_SERVICE_ACCOUNT_JSON$/,
/^VERTEX_LOCATION$/,
/^VERTEX_MODEL_ID$/,
];
beforeEach(() => {
@@ -156,6 +161,76 @@ describe("validateAgentApiKey", () => {
).toThrow("AWS_BEARER_TOKEN_BEDROCK");
});
});
describe("vertex routing slug", () => {
it("passes with service-account JSON + project + location + model id", () => {
process.env.VERTEX_SERVICE_ACCOUNT_JSON = "{}";
process.env.GOOGLE_CLOUD_PROJECT = "test-project";
process.env.VERTEX_LOCATION = "us-east5";
process.env.VERTEX_MODEL_ID = "claude-opus-4-1@20250805";
expect(() => validateAgentApiKey({ ...base, model: "vertex/byok" })).not.toThrow();
});
it("passes when project is derivable from service-account JSON", () => {
process.env.VERTEX_SERVICE_ACCOUNT_JSON = JSON.stringify({ project_id: "test-project" });
process.env.VERTEX_LOCATION = "us-east5";
process.env.VERTEX_MODEL_ID = "gemini-2.5-pro";
expect(() => validateAgentApiKey({ ...base, model: "vertex/byok" })).not.toThrow();
});
it("throws when VERTEX_MODEL_ID is missing", () => {
process.env.VERTEX_SERVICE_ACCOUNT_JSON = "{}";
process.env.GOOGLE_CLOUD_PROJECT = "test-project";
process.env.VERTEX_LOCATION = "us-east5";
expect(() => validateAgentApiKey({ ...base, model: "vertex/byok" })).toThrow(
"VERTEX_MODEL_ID"
);
});
it("throws when VERTEX_LOCATION is missing", () => {
process.env.VERTEX_SERVICE_ACCOUNT_JSON = "{}";
process.env.GOOGLE_CLOUD_PROJECT = "test-project";
process.env.VERTEX_MODEL_ID = "claude-opus-4-1@20250805";
expect(() => validateAgentApiKey({ ...base, model: "vertex/byok" })).toThrow(
"VERTEX_LOCATION"
);
});
it("throws when GOOGLE_CLOUD_PROJECT is missing and not derivable", () => {
process.env.VERTEX_SERVICE_ACCOUNT_JSON = "{}";
process.env.VERTEX_LOCATION = "us-east5";
process.env.VERTEX_MODEL_ID = "claude-opus-4-1@20250805";
expect(() => validateAgentApiKey({ ...base, model: "vertex/byok" })).toThrow(
"GOOGLE_CLOUD_PROJECT"
);
});
it("throws when no auth path is set", () => {
process.env.GOOGLE_CLOUD_PROJECT = "test-project";
process.env.VERTEX_LOCATION = "us-east5";
process.env.VERTEX_MODEL_ID = "claude-opus-4-1@20250805";
expect(() => validateAgentApiKey({ ...base, model: "vertex/byok" })).toThrow(
"VERTEX_SERVICE_ACCOUNT_JSON"
);
});
it("accepts a raw Vertex model ID (post-resolveModel) without throwing", () => {
process.env.VERTEX_SERVICE_ACCOUNT_JSON = "{}";
process.env.GOOGLE_CLOUD_PROJECT = "test-project";
process.env.VERTEX_LOCATION = "us-east5";
process.env.VERTEX_MODEL_ID = "gemini-2.5-pro";
expect(() => validateAgentApiKey({ ...base, model: "gemini-2.5-pro" })).not.toThrow();
});
it("throws on raw Vertex model ID when auth is missing", () => {
process.env.GOOGLE_CLOUD_PROJECT = "test-project";
process.env.VERTEX_LOCATION = "us-east5";
process.env.VERTEX_MODEL_ID = "gemini-2.5-pro";
expect(() => validateAgentApiKey({ ...base, model: "gemini-2.5-pro" })).toThrow(
"VERTEX_SERVICE_ACCOUNT_JSON"
);
});
});
});
describe("isApiKeyAuthError", () => {
+52 -5
View File
@@ -3,8 +3,15 @@ import {
getModelEnvVars,
providers,
resolveDisplayAlias,
VERTEX_MODEL_ID_ENV,
} from "../models.ts";
import { getApiUrl } from "./apiUrl.ts";
import {
GOOGLE_CLOUD_PROJECT_ENV,
readProjectIdFromVertexServiceAccountJson,
VERTEX_LOCATION_ENV,
VERTEX_SERVICE_ACCOUNT_JSON_ENV,
} from "./vertex.ts";
const knownApiKeys: Set<string> = new Set(
Object.values(providers).flatMap((p) => [...p.envVars, ...(p.managedCredentials ?? [])])
@@ -45,6 +52,21 @@ add the missing secret(s) to your GitHub repository at ${githubSecretsUrl}, then
for full setup instructions, see https://docs.pullfrog.com/bedrock`;
}
function buildVertexSetupError(params: { owner: string; name: string; missing: string[] }): string {
const githubSecretsUrl = `https://github.com/${params.owner}/${params.name}/settings/secrets/actions`;
return `Google Vertex AI model selected but required configuration is missing: ${params.missing.join(", ")}.
add the missing secret(s) to your GitHub repository at ${githubSecretsUrl}, then reference them in your workflow's \`env:\` block:
${VERTEX_SERVICE_ACCOUNT_JSON_ENV}: \${{ secrets.${VERTEX_SERVICE_ACCOUNT_JSON_ENV} }}
${GOOGLE_CLOUD_PROJECT_ENV}: my-project
${VERTEX_LOCATION_ENV}: global
${VERTEX_MODEL_ID_ENV}: <vertex-model-id>
for full setup instructions, see https://docs.pullfrog.com/vertex`;
}
function hasEnvVar(name: string): boolean {
const value = process.env[name];
return typeof value === "string" && value.length > 0;
@@ -73,6 +95,23 @@ function validateBedrockSetup(params: { owner: string; name: string }): void {
}
}
function validateVertexSetup(params: { owner: string; name: string }): void {
const hasAuth = hasEnvVar(VERTEX_SERVICE_ACCOUNT_JSON_ENV);
const hasProject =
hasEnvVar(GOOGLE_CLOUD_PROJECT_ENV) ||
readProjectIdFromVertexServiceAccountJson() !== undefined;
const missing: string[] = [];
if (!hasAuth) missing.push(VERTEX_SERVICE_ACCOUNT_JSON_ENV);
if (!hasProject) missing.push(GOOGLE_CLOUD_PROJECT_ENV);
if (!hasEnvVar(VERTEX_LOCATION_ENV)) missing.push(VERTEX_LOCATION_ENV);
if (!hasEnvVar(VERTEX_MODEL_ID_ENV)) missing.push(VERTEX_MODEL_ID_ENV);
if (missing.length > 0) {
throw new Error(buildVertexSetupError({ owner: params.owner, name: params.name, missing }));
}
}
export function validateAgentApiKey(params: {
agent: { name: string };
model: string | undefined;
@@ -90,15 +129,23 @@ export function validateAgentApiKey(params: {
validateBedrockSetup({ owner: params.owner, name: params.name });
return;
}
if (alias?.routing === "vertex") {
validateVertexSetup({ owner: params.owner, name: params.name });
return;
}
// upstream `resolveModel` translates `bedrock/byok` into the raw Bedrock
// model ID (e.g. `us.anthropic.claude-opus-4-6-v1`), which has no `/`
// upstream `resolveModel` translates routing slugs into raw backend
// model IDs (e.g. `us.anthropic.claude-opus-4-6-v1`), which have no `/`
// and so isn't parseable as `provider/model`. these IDs only reach this
// function via routing aliases, so re-run the bedrock setup check rather
// function via routing aliases, so re-run the matching setup check rather
// than falling through to `getModelEnvVars` (which would throw inside
// parseModel). resolveModel itself already enforced BEDROCK_MODEL_ID,
// but auth + region are still validated here.
// parseModel). resolveModel itself already enforced the model-id env var,
// but auth + location/region are still validated here.
if (!params.model.includes("/")) {
if (process.env[VERTEX_MODEL_ID_ENV]?.trim() === params.model) {
validateVertexSetup({ owner: params.owner, name: params.name });
return;
}
validateBedrockSetup({ owner: params.owner, name: params.name });
return;
}
+84
View File
@@ -0,0 +1,84 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { VERTEX_MODEL_ID_ENV } from "../models.ts";
export const VERTEX_SERVICE_ACCOUNT_JSON_ENV = "VERTEX_SERVICE_ACCOUNT_JSON";
export const GOOGLE_APPLICATION_CREDENTIALS_ENV = "GOOGLE_APPLICATION_CREDENTIALS";
export const GOOGLE_CLOUD_PROJECT_ENV = "GOOGLE_CLOUD_PROJECT";
export const VERTEX_LOCATION_ENV = "VERTEX_LOCATION";
export type VertexCredentials = {
credentialsPath: string;
secretDir: string;
};
function hasEnvVar(name: string): boolean {
const value = process.env[name];
return typeof value === "string" && value.length > 0;
}
export function isVertexRoute(model: string | undefined): boolean {
const vertexId = process.env[VERTEX_MODEL_ID_ENV]?.trim();
return model !== undefined && vertexId !== undefined && vertexId === model;
}
export function readProjectIdFromVertexServiceAccountJson(): string | undefined {
const blob = process.env[VERTEX_SERVICE_ACCOUNT_JSON_ENV];
if (!blob) return undefined;
try {
const parsed: unknown = JSON.parse(blob);
if (!parsed || typeof parsed !== "object" || !("project_id" in parsed)) {
return undefined;
}
const projectId = parsed.project_id;
return typeof projectId === "string" && projectId.length > 0 ? projectId : undefined;
} catch {
return undefined;
}
}
function createSecretDir(): string {
const base = process.env.PULLFROG_SECRET_HOME || process.env.HOME || homedir();
const secretDir = join(base, ".pullfrog", "secrets", randomUUID());
mkdirSync(secretDir, { recursive: true, mode: 0o700 });
return secretDir;
}
export function materializeVertexCredentials(params: {
model: string | undefined;
}): VertexCredentials | undefined {
if (!isVertexRoute(params.model)) return undefined;
const blob = process.env[VERTEX_SERVICE_ACCOUNT_JSON_ENV];
if (!blob) return undefined;
const secretDir = createSecretDir();
const credentialsPath = join(secretDir, "vertex-sa.json");
writeFileSync(credentialsPath, blob, { mode: 0o600 });
process.env[GOOGLE_APPLICATION_CREDENTIALS_ENV] = credentialsPath;
const projectId = readProjectIdFromVertexServiceAccountJson();
if (projectId && !hasEnvVar(GOOGLE_CLOUD_PROJECT_ENV)) {
process.env[GOOGLE_CLOUD_PROJECT_ENV] = projectId;
}
return { credentialsPath, secretDir };
}
export function cleanupVertexCredentials(credentials: VertexCredentials | undefined): void {
if (!credentials) return;
rmSync(credentials.secretDir, { recursive: true, force: true });
}
export function applyClaudeVertexEnv(env: Record<string, string | undefined>): void {
env.CLAUDE_CODE_USE_VERTEX = "1";
env.ANTHROPIC_VERTEX_PROJECT_ID ??= env[GOOGLE_CLOUD_PROJECT_ENV];
env.CLOUD_ML_REGION ??= env[VERTEX_LOCATION_ENV];
}
export function resolveVertexOpenCodeModel(model: string | undefined): string | undefined {
return isVertexRoute(model) && model ? `google-vertex/${model}` : undefined;
}