utils/agent.ts
Rama main · réplica de lectura del repositorio original.
import type { Agent } from "../agents/index.ts";
import { agents } from "../agents/index.ts";
import {
AZURE_DEPLOYMENT_ENV,
AZURE_PROVIDER,
BEDROCK_MODEL_ID_ENV,
CLAUDE_CODE_ONLY_CREDENTIALS,
getModelProvider,
isBedrockAnthropicId,
isVertexAnthropicId,
OPENAI_COMPATIBLE_FIELDS,
OPENAI_COMPATIBLE_PROVIDER,
openAICompatibleEnv,
openAICompatibleSetName,
resolveCliModel,
resolveDisplayAlias,
VERTEX_MODEL_ID_ENV,
} from "../models.ts";
import { BYOK_SLUG_MARKER } from "./apiKeys.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];
return typeof val === "string" && val.length > 0;
}
/** every credential claude-code can run on, including `ANTHROPIC_AUTH_TOKEN` —
* the gateway variable it sends as `Authorization: Bearer`. opencode cannot use
* that one, so leaving it out routed a gateway-only run to the harness that had
* no way to authenticate it. */
function hasClaudeCodeAuth(): boolean {
return (
hasEnvVar("CLAUDE_CODE_OAUTH_TOKEN") ||
hasEnvVar("ANTHROPIC_API_KEY") ||
hasEnvVar("ANTHROPIC_AUTH_TOKEN")
);
}
/** either credential the codex CLI can run on: the ChatGPT-subscription blob
* `pullfrog auth codex` stores, or a plain OpenAI API key. */
function hasCodexAuth(): boolean {
return hasEnvVar("CODEX_AUTH_JSON") || hasEnvVar("OPENAI_API_KEY");
}
function hasBedrockAuth(): boolean {
return (
hasEnvVar("AWS_BEARER_TOKEN_BEDROCK") ||
(hasEnvVar("AWS_ACCESS_KEY_ID") && hasEnvVar("AWS_SECRET_ACCESS_KEY"))
);
}
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
* sentinel stored in `resolve`. shared between PULLFROG_MODEL override
* and repo-config slug resolution so both paths get the same routing
* semantics — without this helper, `PULLFROG_MODEL=bedrock/byok` would
* leak the literal sentinel string `"bedrock"` downstream.
*/
function resolveSlug(slug: string): string | undefined {
const alias = resolveDisplayAlias(slug);
if (alias?.routing === "bedrock") {
const bedrockId = process.env[BEDROCK_MODEL_ID_ENV]?.trim();
if (!bedrockId) {
throw new Error(
`\`${BEDROCK_MODEL_ID_ENV}\` ${BYOK_SLUG_MARKER} \`${slug}\`. ` +
"Set it in the workflow's `env:` block to a Bedrock model ID from the AWS console. " +
"[Amazon Bedrock setup guide](https://docs.pullfrog.com/bedrock)"
);
}
return bedrockId;
}
if (alias?.routing === "vertex") {
const vertexId = process.env[VERTEX_MODEL_ID_ENV]?.trim();
if (!vertexId) {
throw new Error(
`\`${VERTEX_MODEL_ID_ENV}\` ${BYOK_SLUG_MARKER} \`${slug}\`. ` +
"Set it in the workflow's `env:` block to a Vertex AI model ID from Model Garden. " +
"[Google Vertex AI setup guide](https://docs.pullfrog.com/vertex)"
);
}
return vertexId;
}
if (alias?.routing === "azure") {
const deployment = process.env[AZURE_DEPLOYMENT_ENV]?.trim();
if (!deployment) {
throw new Error(
`\`${AZURE_DEPLOYMENT_ENV}\` ${BYOK_SLUG_MARKER} \`${slug}\`. ` +
"Set it to your deployment's name, not the model it serves, on the Azure OpenAI provider in Pullfrog or in the workflow's `env:` block. " +
"[Azure OpenAI setup guide](https://docs.pullfrog.com/azure)"
);
}
return `${AZURE_PROVIDER}/${deployment}`;
}
const set = heldOpenAICompatibleSet(slug);
if (alias?.routing === "openai-compatible" || set) {
const modelEnv = openAICompatibleEnv(set).model;
const modelId = process.env[modelEnv]?.trim();
if (!modelId) {
throw new Error(
`\`${modelEnv}\` ${BYOK_SLUG_MARKER} \`${slug}\`. ` +
"Set it to the model ID your endpoint serves, on the OpenAI-compatible provider in Pullfrog or in the workflow's `env:` block. " +
"[OpenAI-compatible setup guide](https://docs.pullfrog.com/openai-compatible)"
);
}
return `${OPENAI_COMPATIBLE_PROVIDER}/${modelId}`;
}
return resolveCliModel(slug);
}
/**
* the named set `slug` picks, when this runner holds any of its values. holding none, the slug
* is a raw model id on the default endpoint, exactly as it was before sets existed.
*/
function heldOpenAICompatibleSet(slug: string): string | undefined {
const set = openAICompatibleSetName(slug);
return set && Object.values(openAICompatibleEnv(set)).some(hasEnvVar) ? set : undefined;
}
/**
* make the run's named set (`openai-compatible/<name>`) its endpoint: the set's values
* replace the bare `OPENAI_COMPATIBLE_*` that validation and the opencode provider read. a value
* the set lacks is cleared, never inherited, so the default endpoint's key cannot reach the set's
* URL. returns the set, so setup errors can name its own variables.
*/
export function activateOpenAICompatibleSet(ctx: { slug: string | undefined }): string | undefined {
const slug = process.env.PULLFROG_MODEL?.trim() || ctx.slug?.trim();
const set = slug ? heldOpenAICompatibleSet(slug) : undefined;
if (!set) return undefined;
const from = openAICompatibleEnv(set);
const to = openAICompatibleEnv();
for (const field of OPENAI_COMPATIBLE_FIELDS) {
const value = process.env[from[field]];
if (value === undefined) delete process.env[to[field]];
else process.env[to[field]] = value;
}
log.info(`» using the OpenAI-compatible endpoint "${set}"`);
return set;
}
/**
* resolve the effective model for this run.
*
* priority:
* 1. PULLFROG_MODEL env var — resolved through the alias registry first,
* so values like "anthropic/claude-opus" become "anthropic/claude-opus-4-7".
* raw specifiers (e.g. "anthropic/claude-opus-4-6") pass through unchanged.
* always wins — bypasses Bedrock routing entirely. to test a different
* Bedrock model, change `BEDROCK_MODEL_ID`, not `PULLFROG_MODEL`.
* 2. slug from repo config / payload → alias registry. routing slugs
* (e.g. `bedrock/byok`) defer to a separate env var (`BEDROCK_MODEL_ID`).
* a non-curated value with a slash passes through unchanged as a raw
* models.dev specifier (same as `PULLFROG_MODEL`), not auto-selected away.
* 3. undefined (no slug, or a bare-word non-specifier) — agent auto-selects.
*/
export function resolveModel(ctx: { slug?: string | undefined }): string | undefined {
const envModel = process.env.PULLFROG_MODEL?.trim();
if (envModel) {
return resolveSlug(envModel) ?? envModel;
}
const slug = ctx.slug?.trim();
if (slug) {
const resolved = resolveSlug(slug);
if (resolved) {
return resolved;
}
// not a curated alias: a slashed value passes through as a raw models.dev
// specifier (explicit picks honored); a bare word isn't a valid specifier
// and downstream would misread it as a Bedrock/Vertex backend ID — auto-select.
if (slug.includes("/")) {
log.info(`» "${slug}" is not a curated alias — passing through as a raw model specifier`);
return slug;
}
log.warning(`» unknown model slug "${slug}" — agent will auto-select`);
}
return undefined;
}
export function resolveAgent(ctx: {
model?: string | undefined;
/** set on router / OSS runs, where the model is served by OpenRouter. those
* pin no `model`, so without this the auto-select branches below would read
* a stored Anthropic or OpenAI credential as the routing signal for a run
* that isn't using it. */
proxyModel?: string | undefined;
/** the account opted in to the EXPERIMENTAL codex harness, already ANDed
* server-side with the global kill switch. false — the default for every
* account — routes OpenAI models to opencode exactly as before the harness
* existed. see wiki/codex-agent.md. */
codexAgent?: boolean | undefined;
}): Agent {
// 1. explicit env var override (escape hatch). deliberately NOT gated on the
// opt-in: an operator who sets PULLFROG_AGENT in their own workflow is
// asking for a specific harness, which is what the escape hatch is for.
const envAgent = process.env.PULLFROG_AGENT?.trim();
if (envAgent) {
if (envAgent in agents) {
return agents[envAgent as keyof typeof agents];
}
log.warning(`» unknown PULLFROG_AGENT="${envAgent}" — falling through to auto-select`);
}
// 2. proxy runs are OpenRouter-served; only opencode speaks that provider.
if (ctx.proxyModel) return agents.opencode;
// 3. Bedrock routing: when BEDROCK_MODEL_ID is the resolved model, route
// Anthropic IDs through claude-code (which supports Bedrock natively
// once CLAUDE_CODE_USE_BEDROCK=1) and everything else through opencode's
// `amazon-bedrock` provider.
if (ctx.model && hasBedrockAuth() && process.env[BEDROCK_MODEL_ID_ENV]?.trim() === ctx.model) {
return isBedrockAnthropicId(ctx.model) ? agents.claude : agents.opencode;
}
// 4. 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;
}
// 5. each vendor's own harness wins for its own models when the matching
// credential is present — claude-code for Anthropic, codex for OpenAI.
if (ctx.model) {
try {
const provider = getModelProvider(ctx.model);
if (provider === "anthropic" && hasClaudeCodeAuth()) return agents.claude;
if (provider === "openai" && ctx.codexAgent && hasCodexAuth()) return agents.codex;
} catch {
// invalid model format — fall through
}
}
// 6. auto-select with no configured model. an account whose only Anthropic
// credential is one claude-code alone can present (the `ANTHROPIC_AUTH_TOKEN`
// gateway variable, or a `CLAUDE_CODE_OAUTH_TOKEN` subscription) has to take
// claude-code: opencode cannot use either, so `autoSelectModel` would rank a
// catalog it has no way to authenticate and the run dies on a missing key.
// the model is still unknown here — auto-select runs INSIDE the harness — so
// this is the last point at which the credential can pick the harness that
// matches it. a plain API key stays on opencode, whose ranking across
// providers beats guessing from whichever env var we tested first — which is
// also why a Codex/OpenAI credential yields to any Anthropic one.
if (!ctx.model) {
if (!hasEnvVar("ANTHROPIC_API_KEY") && CLAUDE_CODE_ONLY_CREDENTIALS.some(hasEnvVar)) {
return agents.claude;
}
if (ctx.codexAgent && hasCodexAuth() && !hasClaudeCodeAuth()) return agents.codex;
}
// 7. default: OpenCode (universal, supports all providers)
return agents.opencode;
}