Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb22cb3ae3 | |||
| c43ed65c3b |
@@ -38,6 +38,8 @@ jobs:
|
|||||||
skill-invoke-opencode,
|
skill-invoke-opencode,
|
||||||
smoke,
|
smoke,
|
||||||
token-exfil,
|
token-exfil,
|
||||||
|
vertex-claude,
|
||||||
|
vertex-opencode,
|
||||||
]
|
]
|
||||||
exclude:
|
exclude:
|
||||||
- agent: claude
|
- agent: claude
|
||||||
@@ -61,6 +63,10 @@ jobs:
|
|||||||
AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
|
AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
|
||||||
AWS_REGION: us-east-1
|
AWS_REGION: us-east-1
|
||||||
BEDROCK_MODEL_ID: us.anthropic.claude-opus-4-6-v1
|
BEDROCK_MODEL_ID: us.anthropic.claude-opus-4-6-v1
|
||||||
|
VERTEX_SERVICE_ACCOUNT_JSON: ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }}
|
||||||
|
GOOGLE_CLOUD_PROJECT: pullfrog
|
||||||
|
VERTEX_LOCATION: global
|
||||||
|
VERTEX_MODEL_ID: gemini-2.5-flash
|
||||||
PULLFROG_MODEL: ${{ vars.PULLFROG_MODEL }}
|
PULLFROG_MODEL: ${{ vars.PULLFROG_MODEL }}
|
||||||
# CI smoke-testing shortcut only — production stores this in Pullfrog's
|
# CI smoke-testing shortcut only — production stores this in Pullfrog's
|
||||||
# per-org secret store (Postgres), set via `pullfrog auth codex`. GH
|
# per-org secret store (Postgres), set via `pullfrog auth codex`. GH
|
||||||
|
|||||||
+61
-29
@@ -16,7 +16,12 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import { pullfrogMcpName } from "../external.ts";
|
import { pullfrogMcpName } from "../external.ts";
|
||||||
import { BEDROCK_MODEL_ID_ENV, isBedrockAnthropicId } from "../models.ts";
|
import {
|
||||||
|
BEDROCK_MODEL_ID_ENV,
|
||||||
|
isBedrockAnthropicId,
|
||||||
|
isVertexAnthropicId,
|
||||||
|
VERTEX_MODEL_ID_ENV,
|
||||||
|
} from "../models.ts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getIdleMs,
|
getIdleMs,
|
||||||
@@ -39,6 +44,7 @@ import {
|
|||||||
import { ThinkingTimer } from "../utils/timer.ts";
|
import { ThinkingTimer } from "../utils/timer.ts";
|
||||||
import type { TodoTracker } from "../utils/todoTracking.ts";
|
import type { TodoTracker } from "../utils/todoTracking.ts";
|
||||||
import { getDevDependencyVersion } from "../utils/version.ts";
|
import { getDevDependencyVersion } from "../utils/version.ts";
|
||||||
|
import { applyClaudeVertexEnv } from "../utils/vertex.ts";
|
||||||
import {
|
import {
|
||||||
buildLearningsReflectionPrompt,
|
buildLearningsReflectionPrompt,
|
||||||
runPostRunRetryLoop,
|
runPostRunRetryLoop,
|
||||||
@@ -825,36 +831,50 @@ const MANAGED_SETTINGS_PATH = `${MANAGED_SETTINGS_DIR}/managed-settings.json`;
|
|||||||
// tail, sed) and survives bypassPermissions mode. See wiki/codex-auth.md.
|
// tail, sed) and survives bypassPermissions mode. See wiki/codex-auth.md.
|
||||||
const CODEX_AUTH_DENY_PATH = "~/.local/share/opencode/auth.json";
|
const CODEX_AUTH_DENY_PATH = "~/.local/share/opencode/auth.json";
|
||||||
|
|
||||||
const managedSettings = {
|
function buildManagedSettings(ctx: AgentRunContext) {
|
||||||
allowManagedPermissionRulesOnly: true,
|
const secretDenyPaths = ctx.secretDenyPaths ?? [];
|
||||||
allowManagedHooksOnly: true,
|
const toolDeny = secretDenyPaths.flatMap((path) => [
|
||||||
permissions: {
|
`Read(${path}/**)`,
|
||||||
deny: [
|
`Read(/${path}/**)`,
|
||||||
"Read(//proc/**)",
|
`Grep(${path}/**)`,
|
||||||
"Read(//sys/**)",
|
`Grep(/${path}/**)`,
|
||||||
"Grep(//proc/**)",
|
`Edit(${path}/**)`,
|
||||||
"Grep(//sys/**)",
|
`Edit(/${path}/**)`,
|
||||||
"Edit(//proc/**)",
|
`Glob(${path}/**)`,
|
||||||
"Edit(//sys/**)",
|
`Glob(/${path}/**)`,
|
||||||
"Glob(//proc/**)",
|
]);
|
||||||
"Glob(//sys/**)",
|
return {
|
||||||
`Read(${CODEX_AUTH_DENY_PATH})`,
|
allowManagedPermissionRulesOnly: true,
|
||||||
`Grep(${CODEX_AUTH_DENY_PATH})`,
|
allowManagedHooksOnly: true,
|
||||||
`Edit(${CODEX_AUTH_DENY_PATH})`,
|
permissions: {
|
||||||
`Glob(${CODEX_AUTH_DENY_PATH})`,
|
deny: [
|
||||||
],
|
"Read(//proc/**)",
|
||||||
},
|
"Read(//sys/**)",
|
||||||
sandbox: {
|
"Grep(//proc/**)",
|
||||||
filesystem: {
|
"Grep(//sys/**)",
|
||||||
denyRead: ["/proc", "/sys", CODEX_AUTH_DENY_PATH],
|
"Edit(//proc/**)",
|
||||||
|
"Edit(//sys/**)",
|
||||||
|
"Glob(//proc/**)",
|
||||||
|
"Glob(//sys/**)",
|
||||||
|
`Read(${CODEX_AUTH_DENY_PATH})`,
|
||||||
|
`Grep(${CODEX_AUTH_DENY_PATH})`,
|
||||||
|
`Edit(${CODEX_AUTH_DENY_PATH})`,
|
||||||
|
`Glob(${CODEX_AUTH_DENY_PATH})`,
|
||||||
|
...toolDeny,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
sandbox: {
|
||||||
};
|
filesystem: {
|
||||||
|
denyRead: ["/proc", "/sys", CODEX_AUTH_DENY_PATH, ...secretDenyPaths],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function installManagedSettings(): void {
|
function installManagedSettings(ctx: AgentRunContext): void {
|
||||||
if (process.env.CI !== "true") return;
|
if (process.env.CI !== "true") return;
|
||||||
|
|
||||||
const content = JSON.stringify(managedSettings, null, 2);
|
const content = JSON.stringify(buildManagedSettings(ctx), null, 2);
|
||||||
try {
|
try {
|
||||||
execFileSync("sudo", ["mkdir", "-p", MANAGED_SETTINGS_DIR]);
|
execFileSync("sudo", ["mkdir", "-p", MANAGED_SETTINGS_DIR]);
|
||||||
execFileSync("sudo", ["tee", MANAGED_SETTINGS_PATH], {
|
execFileSync("sudo", ["tee", MANAGED_SETTINGS_PATH], {
|
||||||
@@ -887,11 +907,19 @@ export const claude = agent({
|
|||||||
bedrockModelId !== undefined &&
|
bedrockModelId !== undefined &&
|
||||||
bedrockModelId === specifier &&
|
bedrockModelId === specifier &&
|
||||||
isBedrockAnthropicId(specifier);
|
isBedrockAnthropicId(specifier);
|
||||||
|
const vertexModelId = process.env[VERTEX_MODEL_ID_ENV]?.trim();
|
||||||
|
const isVertexRoute =
|
||||||
|
specifier !== undefined &&
|
||||||
|
vertexModelId !== undefined &&
|
||||||
|
vertexModelId === specifier &&
|
||||||
|
isVertexAnthropicId(specifier);
|
||||||
const model = !specifier
|
const model = !specifier
|
||||||
? undefined
|
? undefined
|
||||||
: isBedrockRoute
|
: isBedrockRoute
|
||||||
? specifier
|
? specifier
|
||||||
: stripProviderPrefix(specifier);
|
: isVertexRoute
|
||||||
|
? undefined
|
||||||
|
: stripProviderPrefix(specifier);
|
||||||
|
|
||||||
const homeEnv = {
|
const homeEnv = {
|
||||||
HOME: ctx.tmpdir,
|
HOME: ctx.tmpdir,
|
||||||
@@ -913,7 +941,7 @@ export const claude = agent({
|
|||||||
const mcpConfigPath = writeMcpConfig(ctx);
|
const mcpConfigPath = writeMcpConfig(ctx);
|
||||||
const effort = resolveEffort(model);
|
const effort = resolveEffort(model);
|
||||||
|
|
||||||
installManagedSettings();
|
installManagedSettings(ctx);
|
||||||
|
|
||||||
// base args shared between initial run and continue runs
|
// base args shared between initial run and continue runs
|
||||||
const baseArgs = [
|
const baseArgs = [
|
||||||
@@ -967,6 +995,10 @@ export const claude = agent({
|
|||||||
if (isBedrockRoute) {
|
if (isBedrockRoute) {
|
||||||
env.CLAUDE_CODE_USE_BEDROCK = "1";
|
env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||||
}
|
}
|
||||||
|
if (isVertexRoute) {
|
||||||
|
applyClaudeVertexEnv(env);
|
||||||
|
env.ANTHROPIC_MODEL = specifier;
|
||||||
|
}
|
||||||
|
|
||||||
// claude-code's `Vw()` resolver prefers ANTHROPIC_API_KEY over the OAuth
|
// claude-code's `Vw()` resolver prefers ANTHROPIC_API_KEY over the OAuth
|
||||||
// token when both are set, so we strip the API key to fall through to the
|
// token when both are set, so we strip the API key to fall through to the
|
||||||
|
|||||||
+9
-1
@@ -40,6 +40,7 @@ import {
|
|||||||
import { ThinkingTimer } from "../utils/timer.ts";
|
import { ThinkingTimer } from "../utils/timer.ts";
|
||||||
import type { TodoTracker } from "../utils/todoTracking.ts";
|
import type { TodoTracker } from "../utils/todoTracking.ts";
|
||||||
import { getDevDependencyVersion } from "../utils/version.ts";
|
import { getDevDependencyVersion } from "../utils/version.ts";
|
||||||
|
import { resolveVertexOpenCodeModel } from "../utils/vertex.ts";
|
||||||
import {
|
import {
|
||||||
PULLFROG_BUS_EVENT_TYPE,
|
PULLFROG_BUS_EVENT_TYPE,
|
||||||
PULLFROG_OPENCODE_PLUGIN_FILENAME,
|
PULLFROG_OPENCODE_PLUGIN_FILENAME,
|
||||||
@@ -1130,7 +1131,14 @@ export const opencode = agent({
|
|||||||
const bedrockModelId = process.env[BEDROCK_MODEL_ID_ENV]?.trim();
|
const bedrockModelId = process.env[BEDROCK_MODEL_ID_ENV]?.trim();
|
||||||
const isBedrockRoute =
|
const isBedrockRoute =
|
||||||
rawModel !== undefined && bedrockModelId !== undefined && bedrockModelId === rawModel;
|
rawModel !== undefined && bedrockModelId !== undefined && bedrockModelId === rawModel;
|
||||||
const model = isBedrockRoute ? `amazon-bedrock/${rawModel}` : rawModel;
|
let model = rawModel;
|
||||||
|
if (isBedrockRoute) {
|
||||||
|
model = `amazon-bedrock/${rawModel}`;
|
||||||
|
}
|
||||||
|
const vertexModel = resolveVertexOpenCodeModel(rawModel);
|
||||||
|
if (vertexModel) {
|
||||||
|
model = vertexModel;
|
||||||
|
}
|
||||||
|
|
||||||
const homeEnv = {
|
const homeEnv = {
|
||||||
HOME: ctx.tmpdir,
|
HOME: ctx.tmpdir,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
} from "../utils/subprocess.ts";
|
} from "../utils/subprocess.ts";
|
||||||
import type { TodoTracker } from "../utils/todoTracking.ts";
|
import type { TodoTracker } from "../utils/todoTracking.ts";
|
||||||
import { getDevDependencyVersion } from "../utils/version.ts";
|
import { getDevDependencyVersion } from "../utils/version.ts";
|
||||||
|
import { resolveVertexOpenCodeModel } from "../utils/vertex.ts";
|
||||||
import {
|
import {
|
||||||
PULLFROG_BUS_EVENT_TYPE,
|
PULLFROG_BUS_EVENT_TYPE,
|
||||||
PULLFROG_OPENCODE_PLUGIN_FILENAME,
|
PULLFROG_OPENCODE_PLUGIN_FILENAME,
|
||||||
@@ -906,7 +907,8 @@ export const opencode = agent({
|
|||||||
const bedrockModelId = process.env[BEDROCK_MODEL_ID_ENV]?.trim();
|
const bedrockModelId = process.env[BEDROCK_MODEL_ID_ENV]?.trim();
|
||||||
const isBedrockRoute =
|
const isBedrockRoute =
|
||||||
rawModel !== undefined && bedrockModelId !== undefined && bedrockModelId === rawModel;
|
rawModel !== undefined && bedrockModelId !== undefined && bedrockModelId === rawModel;
|
||||||
const model = isBedrockRoute ? `amazon-bedrock/${rawModel}` : rawModel;
|
const vertexModel = resolveVertexOpenCodeModel(rawModel);
|
||||||
|
const model = vertexModel ?? (isBedrockRoute ? `amazon-bedrock/${rawModel}` : rawModel);
|
||||||
|
|
||||||
const homeEnv = {
|
const homeEnv = {
|
||||||
HOME: ctx.tmpdir,
|
HOME: ctx.tmpdir,
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ export interface AgentRunContext {
|
|||||||
resolvedModel?: string | undefined;
|
resolvedModel?: string | undefined;
|
||||||
mcpServerUrl: string;
|
mcpServerUrl: string;
|
||||||
tmpdir: string;
|
tmpdir: string;
|
||||||
|
/** harness-owned secret paths that agent filesystem tools must never read. */
|
||||||
|
secretDenyPaths?: string[] | undefined;
|
||||||
instructions: ResolvedInstructions;
|
instructions: ResolvedInstructions;
|
||||||
todoTracker?: TodoTracker | undefined;
|
todoTracker?: TodoTracker | undefined;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ import { resolveTimeoutMs, TIMEOUT_DISABLED } from "./utils/time.ts";
|
|||||||
import { Timer } from "./utils/timer.ts";
|
import { Timer } from "./utils/timer.ts";
|
||||||
import { createTodoTracker } from "./utils/todoTracking.ts";
|
import { createTodoTracker } from "./utils/todoTracking.ts";
|
||||||
import { getJobToken, resolveTokens } from "./utils/token.ts";
|
import { getJobToken, resolveTokens } from "./utils/token.ts";
|
||||||
|
import {
|
||||||
|
cleanupVertexCredentials,
|
||||||
|
materializeVertexCredentials,
|
||||||
|
type VertexCredentials,
|
||||||
|
} from "./utils/vertex.ts";
|
||||||
import { resolveRun } from "./utils/workflow.ts";
|
import { resolveRun } from "./utils/workflow.ts";
|
||||||
|
|
||||||
export { Inputs } from "./utils/payload.ts";
|
export { Inputs } from "./utils/payload.ts";
|
||||||
@@ -177,6 +182,7 @@ export async function main(): Promise<MainResult> {
|
|||||||
let toolContext: ToolContext | undefined;
|
let toolContext: ToolContext | undefined;
|
||||||
let progressCallbackDisabled = false;
|
let progressCallbackDisabled = false;
|
||||||
let todoTracker: ReturnType<typeof createTodoTracker> | undefined;
|
let todoTracker: ReturnType<typeof createTodoTracker> | undefined;
|
||||||
|
let vertexCredentials: VertexCredentials | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (payload.cwd && process.cwd() !== payload.cwd) {
|
if (payload.cwd && process.cwd() !== payload.cwd) {
|
||||||
@@ -233,6 +239,8 @@ export async function main(): Promise<MainResult> {
|
|||||||
toolState.modelFallback = { from: fallback.from };
|
toolState.modelFallback = { from: fallback.from };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vertexCredentials = materializeVertexCredentials({ model: resolvedModel });
|
||||||
|
|
||||||
const agent = resolveAgent({ model: resolvedModel });
|
const agent = resolveAgent({ model: resolvedModel });
|
||||||
|
|
||||||
// surface the effective model in comment/review footers. payload.model is
|
// surface the effective model in comment/review footers. payload.model is
|
||||||
@@ -475,6 +483,7 @@ export async function main(): Promise<MainResult> {
|
|||||||
resolvedModel,
|
resolvedModel,
|
||||||
mcpServerUrl: mcpHttpServer.url,
|
mcpServerUrl: mcpHttpServer.url,
|
||||||
tmpdir,
|
tmpdir,
|
||||||
|
secretDenyPaths: vertexCredentials ? [vertexCredentials.secretDir] : [],
|
||||||
instructions,
|
instructions,
|
||||||
todoTracker,
|
todoTracker,
|
||||||
stopScript: runContext.repoSettings.stopScript,
|
stopScript: runContext.repoSettings.stopScript,
|
||||||
@@ -619,5 +628,6 @@ export async function main(): Promise<MainResult> {
|
|||||||
await patchWorkflowRunFields(toolContext, patch);
|
await patchWorkflowRunFields(toolContext, patch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
cleanupVertexCredentials(vertexCredentials);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
getModelEnvVars,
|
getModelEnvVars,
|
||||||
getModelProvider,
|
getModelProvider,
|
||||||
isBedrockAnthropicId,
|
isBedrockAnthropicId,
|
||||||
|
isVertexAnthropicId,
|
||||||
modelAliases,
|
modelAliases,
|
||||||
parseModel,
|
parseModel,
|
||||||
providers,
|
providers,
|
||||||
@@ -258,6 +259,20 @@ describe("isBedrockAnthropicId", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isVertexAnthropicId", () => {
|
||||||
|
it("matches Claude Vertex IDs by anchored prefix", () => {
|
||||||
|
expect(isVertexAnthropicId("claude-opus-4-1@20250805")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects Gemini IDs", () => {
|
||||||
|
expect(isVertexAnthropicId("gemini-2.5-pro")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores Anthropic substrings outside the prefix", () => {
|
||||||
|
expect(isVertexAnthropicId("publishers/anthropic/models/claude-opus-4-1")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("providers registry", () => {
|
describe("providers registry", () => {
|
||||||
it("every provider has envVars", () => {
|
it("every provider has envVars", () => {
|
||||||
for (const [key, config] of Object.entries(providers)) {
|
for (const [key, config] of Object.entries(providers)) {
|
||||||
|
|||||||
@@ -13,15 +13,17 @@
|
|||||||
*
|
*
|
||||||
* `"bedrock"` means the actual model ID comes from `BEDROCK_MODEL_ID`
|
* `"bedrock"` means the actual model ID comes from `BEDROCK_MODEL_ID`
|
||||||
* (an AWS-canonical Bedrock model ID like `us.anthropic.claude-opus-4-7`
|
* (an AWS-canonical Bedrock model ID like `us.anthropic.claude-opus-4-7`
|
||||||
* or `amazon.nova-pro-v1:0`). enterprise Bedrock customers self-select for
|
* or `amazon.nova-pro-v1:0`). `"vertex"` means the actual model ID comes
|
||||||
* version control — silent alias bumps would break compliance review,
|
* from `VERTEX_MODEL_ID` (a Vertex AI model ID like
|
||||||
* model-access enrollment, and provisioned-throughput contracts. so the
|
* `claude-opus-4-1@20250805` or `gemini-2.5-pro`). enterprise hosted-model
|
||||||
* single `bedrock/byok` entry is a routing slug, not a model alias: the
|
* customers self-select for version control — silent alias bumps would break
|
||||||
* harness reads `BEDROCK_MODEL_ID` and routes to claude-code (when the ID
|
* compliance review, model-access enrollment, and provisioned-throughput
|
||||||
* contains "anthropic") or opencode (everything else, with an
|
* contracts. so the single `bedrock/byok` and `vertex/byok` entries are
|
||||||
* `amazon-bedrock/` prefix).
|
* routing slugs, not model aliases: the harness reads the backend-specific
|
||||||
|
* env var and routes to claude-code for Anthropic IDs or opencode for
|
||||||
|
* everything else.
|
||||||
*/
|
*/
|
||||||
export type ModelRouting = "bedrock";
|
export type ModelRouting = "bedrock" | "vertex";
|
||||||
|
|
||||||
export interface ModelAlias {
|
export interface ModelAlias {
|
||||||
/** stable alias stored in DB, e.g. "anthropic/claude-opus" */
|
/** stable alias stored in DB, e.g. "anthropic/claude-opus" */
|
||||||
@@ -375,6 +377,25 @@ export const providers = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
vertex: provider({
|
||||||
|
displayName: "Google Vertex AI",
|
||||||
|
envVars: [
|
||||||
|
"VERTEX_SERVICE_ACCOUNT_JSON",
|
||||||
|
"GOOGLE_CLOUD_PROJECT",
|
||||||
|
"VERTEX_LOCATION",
|
||||||
|
"VERTEX_MODEL_ID",
|
||||||
|
],
|
||||||
|
models: {
|
||||||
|
// single routing entry — the actual Vertex AI model ID is read from
|
||||||
|
// VERTEX_MODEL_ID at run time. see ModelRouting docs for why we don't
|
||||||
|
// catalog individual Vertex models.
|
||||||
|
byok: {
|
||||||
|
displayName: "Google Vertex AI",
|
||||||
|
resolve: "vertex",
|
||||||
|
routing: "vertex",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
openrouter: provider({
|
openrouter: provider({
|
||||||
displayName: "OpenRouter",
|
displayName: "OpenRouter",
|
||||||
envVars: ["OPENROUTER_API_KEY"],
|
envVars: ["OPENROUTER_API_KEY"],
|
||||||
@@ -605,6 +626,9 @@ export function resolveOpenRouterModel(slug: string): string | undefined {
|
|||||||
/** env var that supplies the Bedrock model ID for the `bedrock/byok` slug. */
|
/** env var that supplies the Bedrock model ID for the `bedrock/byok` slug. */
|
||||||
export const BEDROCK_MODEL_ID_ENV = "BEDROCK_MODEL_ID";
|
export const BEDROCK_MODEL_ID_ENV = "BEDROCK_MODEL_ID";
|
||||||
|
|
||||||
|
/** env var that supplies the Vertex AI model ID for the `vertex/byok` slug. */
|
||||||
|
export const VERTEX_MODEL_ID_ENV = "VERTEX_MODEL_ID";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* the Bedrock model ID passed to claude-code or opencode is whatever the
|
* the Bedrock model ID passed to claude-code or opencode is whatever the
|
||||||
* user set in `BEDROCK_MODEL_ID` — Pullfrog never resolves or upgrades it.
|
* user set in `BEDROCK_MODEL_ID` — Pullfrog never resolves or upgrades it.
|
||||||
@@ -636,3 +660,13 @@ export function isBedrockAnthropicId(bedrockModelId: string): boolean {
|
|||||||
// foundation segment sits between `/` and `.` inside the resource name).
|
// foundation segment sits between `/` and `.` inside the resource name).
|
||||||
return bedrockModelId.toLowerCase().split(/[./:]/).includes("anthropic");
|
return bedrockModelId.toLowerCase().split(/[./:]/).includes("anthropic");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertex Anthropic model IDs start with the Claude family name, e.g.
|
||||||
|
* `claude-opus-4-1@20250805`. partner-model resource paths can contain the
|
||||||
|
* substring "anthropic" elsewhere, so the Bedrock segment check does not
|
||||||
|
* transfer — anchor on the model ID prefix instead.
|
||||||
|
*/
|
||||||
|
export function isVertexAnthropicId(vertexModelId: string): boolean {
|
||||||
|
return /^claude-/i.test(vertexModelId.trim());
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pullfrog",
|
"name": "pullfrog",
|
||||||
"version": "0.1.10",
|
"version": "0.1.11",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"pullfrog": "dist/cli.mjs",
|
"pullfrog": "dist/cli.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
|
||||||
|
import { defineFixture } from "../utils.ts";
|
||||||
|
|
||||||
|
const fixture = defineFixture(
|
||||||
|
{
|
||||||
|
prompt: `Call set_output with "VERTEX CLAUDE SMOKE PASSED".`,
|
||||||
|
},
|
||||||
|
{ localOnly: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
function validator(result: AgentResult): ValidationCheck[] {
|
||||||
|
const output = result.structuredOutput;
|
||||||
|
const setOutputCalled = output !== null;
|
||||||
|
const correctValue = setOutputCalled && /VERTEX CLAUDE SMOKE PASSED/i.test(output);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ name: "set_output", passed: setOutputCalled },
|
||||||
|
{ name: "correct_value", passed: correctValue },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const test: TestRunnerOptions = {
|
||||||
|
name: "vertex-claude",
|
||||||
|
agents: ["claude"],
|
||||||
|
fixture,
|
||||||
|
validator,
|
||||||
|
env: {
|
||||||
|
PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1",
|
||||||
|
PULLFROG_MODEL: "vertex/byok",
|
||||||
|
VERTEX_MODEL_ID: "claude-haiku-4-5@20251001",
|
||||||
|
VERTEX_LOCATION: "global",
|
||||||
|
},
|
||||||
|
coverage: [
|
||||||
|
"action/models.ts",
|
||||||
|
"action/main.ts",
|
||||||
|
"action/agents/claude.ts",
|
||||||
|
"action/utils/{agent,apiKeys,vertex}.ts",
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
|
||||||
|
import { defineFixture } from "../utils.ts";
|
||||||
|
|
||||||
|
const fixture = defineFixture(
|
||||||
|
{
|
||||||
|
prompt: `Call set_output with "VERTEX OPENCODE SMOKE PASSED".`,
|
||||||
|
},
|
||||||
|
{ localOnly: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
function validator(result: AgentResult): ValidationCheck[] {
|
||||||
|
const output = result.structuredOutput;
|
||||||
|
const setOutputCalled = output !== null;
|
||||||
|
const correctValue = setOutputCalled && /VERTEX OPENCODE SMOKE PASSED/i.test(output);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ name: "set_output", passed: setOutputCalled },
|
||||||
|
{ name: "correct_value", passed: correctValue },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const test: TestRunnerOptions = {
|
||||||
|
name: "vertex-opencode",
|
||||||
|
agents: ["opencode"],
|
||||||
|
fixture,
|
||||||
|
validator,
|
||||||
|
env: {
|
||||||
|
PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1",
|
||||||
|
PULLFROG_MODEL: "vertex/byok",
|
||||||
|
VERTEX_MODEL_ID: "gemini-2.5-flash",
|
||||||
|
VERTEX_LOCATION: "global",
|
||||||
|
},
|
||||||
|
coverage: [
|
||||||
|
"action/models.ts",
|
||||||
|
"action/main.ts",
|
||||||
|
"action/agents/opencode.ts",
|
||||||
|
"action/utils/{agent,apiKeys,vertex}.ts",
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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 { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { resolveAgent, resolveModel } from "./agent.ts";
|
import { resolveAgent, resolveModel } from "./agent.ts";
|
||||||
|
import { cleanupVertexCredentials, materializeVertexCredentials } from "./vertex.ts";
|
||||||
|
|
||||||
const savedEnv = { ...process.env };
|
const savedEnv = { ...process.env };
|
||||||
|
|
||||||
@@ -12,6 +16,12 @@ const STRIPPED = [
|
|||||||
/^AWS_SESSION_TOKEN$/,
|
/^AWS_SESSION_TOKEN$/,
|
||||||
/^AWS_REGION$/,
|
/^AWS_REGION$/,
|
||||||
/^BEDROCK_MODEL_ID$/,
|
/^BEDROCK_MODEL_ID$/,
|
||||||
|
/^GOOGLE_APPLICATION_CREDENTIALS$/,
|
||||||
|
/^GOOGLE_CLOUD_PROJECT$/,
|
||||||
|
/^VERTEX_SERVICE_ACCOUNT_JSON$/,
|
||||||
|
/^VERTEX_LOCATION$/,
|
||||||
|
/^VERTEX_MODEL_ID$/,
|
||||||
|
/^PULLFROG_SECRET_HOME$/,
|
||||||
/^PULLFROG_MODEL$/,
|
/^PULLFROG_MODEL$/,
|
||||||
/^PULLFROG_AGENT$/,
|
/^PULLFROG_AGENT$/,
|
||||||
];
|
];
|
||||||
@@ -83,6 +93,20 @@ describe("resolveAgent", () => {
|
|||||||
expect(resolveAgent({ model: "us.anthropic.claude-opus-4-7" }).name).toBe("opencode");
|
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", () => {
|
describe("resolveModel", () => {
|
||||||
@@ -128,4 +152,47 @@ describe("resolveModel", () => {
|
|||||||
process.env.PULLFROG_MODEL = "bedrock/byok";
|
process.env.PULLFROG_MODEL = "bedrock/byok";
|
||||||
expect(() => resolveModel({ slug: "openai/gpt" })).toThrow("BEDROCK_MODEL_ID");
|
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,
|
BEDROCK_MODEL_ID_ENV,
|
||||||
getModelProvider,
|
getModelProvider,
|
||||||
isBedrockAnthropicId,
|
isBedrockAnthropicId,
|
||||||
|
isVertexAnthropicId,
|
||||||
resolveCliModel,
|
resolveCliModel,
|
||||||
resolveDisplayAlias,
|
resolveDisplayAlias,
|
||||||
|
VERTEX_MODEL_ID_ENV,
|
||||||
} from "../models.ts";
|
} from "../models.ts";
|
||||||
import { log } from "./cli.ts";
|
import { log } from "./cli.ts";
|
||||||
|
import { VERTEX_SERVICE_ACCOUNT_JSON_ENV } from "./vertex.ts";
|
||||||
|
|
||||||
function hasEnvVar(name: string): boolean {
|
function hasEnvVar(name: string): boolean {
|
||||||
const val = process.env[name];
|
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
|
* 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
|
* (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) {
|
if (!bedrockId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${BEDROCK_MODEL_ID_ENV} env var is required when the model is set to "${slug}". ` +
|
`${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.`
|
`see https://docs.pullfrog.com/bedrock for setup.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return bedrockId;
|
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);
|
return resolveCliModel(slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +115,14 @@ export function resolveAgent(ctx: { model?: string | undefined }): Agent {
|
|||||||
return isBedrockAnthropicId(ctx.model) ? agents.claude : agents.opencode;
|
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) {
|
if (ctx.model) {
|
||||||
try {
|
try {
|
||||||
const provider = getModelProvider(ctx.model);
|
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;
|
return agents.opencode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ const STRIPPED_PREFIXES_OR_NAMES = [
|
|||||||
/^AWS_SESSION_TOKEN$/,
|
/^AWS_SESSION_TOKEN$/,
|
||||||
/^AWS_REGION$/,
|
/^AWS_REGION$/,
|
||||||
/^BEDROCK_MODEL_ID$/,
|
/^BEDROCK_MODEL_ID$/,
|
||||||
|
/^GOOGLE_APPLICATION_CREDENTIALS$/,
|
||||||
|
/^GOOGLE_CLOUD_PROJECT$/,
|
||||||
|
/^VERTEX_SERVICE_ACCOUNT_JSON$/,
|
||||||
|
/^VERTEX_LOCATION$/,
|
||||||
|
/^VERTEX_MODEL_ID$/,
|
||||||
];
|
];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -156,6 +161,76 @@ describe("validateAgentApiKey", () => {
|
|||||||
).toThrow("AWS_BEARER_TOKEN_BEDROCK");
|
).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", () => {
|
describe("isApiKeyAuthError", () => {
|
||||||
|
|||||||
+52
-5
@@ -3,8 +3,15 @@ import {
|
|||||||
getModelEnvVars,
|
getModelEnvVars,
|
||||||
providers,
|
providers,
|
||||||
resolveDisplayAlias,
|
resolveDisplayAlias,
|
||||||
|
VERTEX_MODEL_ID_ENV,
|
||||||
} from "../models.ts";
|
} from "../models.ts";
|
||||||
import { getApiUrl } from "./apiUrl.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(
|
const knownApiKeys: Set<string> = new Set(
|
||||||
Object.values(providers).flatMap((p) => [...p.envVars, ...(p.managedCredentials ?? [])])
|
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`;
|
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 {
|
function hasEnvVar(name: string): boolean {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
return typeof value === "string" && value.length > 0;
|
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: {
|
export function validateAgentApiKey(params: {
|
||||||
agent: { name: string };
|
agent: { name: string };
|
||||||
model: string | undefined;
|
model: string | undefined;
|
||||||
@@ -90,15 +129,23 @@ export function validateAgentApiKey(params: {
|
|||||||
validateBedrockSetup({ owner: params.owner, name: params.name });
|
validateBedrockSetup({ owner: params.owner, name: params.name });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (alias?.routing === "vertex") {
|
||||||
|
validateVertexSetup({ owner: params.owner, name: params.name });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// upstream `resolveModel` translates `bedrock/byok` into the raw Bedrock
|
// upstream `resolveModel` translates routing slugs into raw backend
|
||||||
// model ID (e.g. `us.anthropic.claude-opus-4-6-v1`), which has no `/`
|
// 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
|
// 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
|
// than falling through to `getModelEnvVars` (which would throw inside
|
||||||
// parseModel). resolveModel itself already enforced BEDROCK_MODEL_ID,
|
// parseModel). resolveModel itself already enforced the model-id env var,
|
||||||
// but auth + region are still validated here.
|
// but auth + location/region are still validated here.
|
||||||
if (!params.model.includes("/")) {
|
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 });
|
validateBedrockSetup({ owner: params.owner, name: params.name });
|
||||||
return;
|
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