Add Vertex AI routing support (#753)
* add Vertex AI routing support * include Vertex smokes in action CI
This commit is contained in:
committed by
pullfrog[bot]
parent
09344a9ec9
commit
c43ed65c3b
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user