diff --git a/agents/claude.ts b/agents/claude.ts index 86566a3..a567b30 100644 --- a/agents/claude.ts +++ b/agents/claude.ts @@ -14,7 +14,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { performance } from "node:perf_hooks"; import { ghPullfrogMcpName } from "../external.ts"; -import { resolveModelSlug } from "../models.ts"; + import { getIdleMs, markActivity } from "../utils/activity.ts"; import { log } from "../utils/cli.ts"; import { installFromNpmTarball } from "../utils/install.ts"; @@ -52,32 +52,12 @@ function writeMcpConfig(ctx: AgentRunContext): string { return configPath; } -// ── model resolution ───────────────────────────────────────────────────────── +// ── model helpers ───────────────────────────────────────────────────────────── -function resolveClaudeModel(modelSlug: string | undefined): string | undefined { - // 1. explicit env var override - const envModel = process.env.PULLFROG_MODEL?.trim(); - if (envModel) { - const slashIndex = envModel.indexOf("/"); - const cliModel = slashIndex > 0 ? envModel.slice(slashIndex + 1) : envModel; - log.info(`» model: ${cliModel} (override via PULLFROG_MODEL)`); - return cliModel; - } - - if (!modelSlug) return undefined; - - // 2. resolve slug to concrete specifier (e.g. "anthropic/claude-opus" → "anthropic/claude-opus-4-6") - // then strip the "anthropic/" prefix to get the Claude CLI model name - const resolved = resolveModelSlug(modelSlug); - if (resolved) { - const slashIndex = resolved.indexOf("/"); - const cliModel = slashIndex > 0 ? resolved.slice(slashIndex + 1) : resolved; - log.info(`» model: ${cliModel} (resolved from ${modelSlug})`); - return cliModel; - } - - log.warning(`» unknown model slug "${modelSlug}" — letting Claude Code auto-select`); - return undefined; +// claude CLI expects bare model names (e.g. "claude-sonnet-4-6"), not provider-prefixed specifiers +function stripProviderPrefix(specifier: string): string { + const slashIndex = specifier.indexOf("/"); + return slashIndex > 0 ? specifier.slice(slashIndex + 1) : specifier; } // ── NDJSON event types ───────────────────────────────────────────────────────── @@ -488,7 +468,8 @@ export const claude = agent({ run: async (ctx) => { const cliPath = await installClaudeCli(); - const model = ctx.payload.proxyModel ?? resolveClaudeModel(ctx.payload.model); + const specifier = ctx.payload.proxyModel ?? ctx.resolvedModel; + const model = specifier ? stripProviderPrefix(specifier) : undefined; const homeEnv = { HOME: ctx.tmpdir, diff --git a/agents/opentoad.ts b/agents/opentoad.ts index 8768fcc..264bfd6 100644 --- a/agents/opentoad.ts +++ b/agents/opentoad.ts @@ -15,7 +15,7 @@ import { mkdirSync } from "node:fs"; import { join } from "node:path"; import { performance } from "node:perf_hooks"; import { ghPullfrogMcpName } from "../external.ts"; -import { modelAliases, resolveCliModel } from "../models.ts"; +import { modelAliases } from "../models.ts"; import { getIdleMs, markActivity } from "../utils/activity.ts"; import { log } from "../utils/cli.ts"; import { installFromNpmTarball } from "../utils/install.ts"; @@ -74,13 +74,11 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s return JSON.stringify(config); } -// ── model resolution (see wiki/model-resolution.md) ───────────────────────────── +// ── model auto-select fallback ────────────────────────────────────────────────── // -// priority: -// 1. PULLFROG_MODEL env var (explicit override) -// 2. explicit slug from repo config / payload -// 3. auto-select: `opencode models` → preferred aliases first, then secondary -// 4. undefined → let OpenCode decide +// steps 1–2 of model resolution (PULLFROG_MODEL env, slug resolution) are handled +// by resolveModel() in utils/agent.ts before the agent runs. this fallback only +// handles step 3: auto-select via `opencode models`. function getOpenCodeModels(cliPath: string): string[] { try { @@ -104,35 +102,8 @@ function getOpenCodeModels(cliPath: string): string[] { const AUTO_SELECT_WARNING = "select a model explicitly in the Pullfrog console (https://pullfrog.com/console) to avoid this."; -function resolveOpenCodeModel(ctx: { - cliPath: string; - modelSlug?: string | undefined; -}): string | undefined { - // 1. explicit env var override - const envModel = process.env.PULLFROG_MODEL?.trim(); - if (envModel) { - log.info(`» model: ${envModel} (override via PULLFROG_MODEL)`); - return envModel; - } - - // 2. explicit slug from repo config / payload - if (ctx.modelSlug) { - const resolved = resolveCliModel(ctx.modelSlug); - if (resolved) { - if (resolved !== ctx.modelSlug) { - log.info(`» model: ${ctx.modelSlug} (resolved to ${resolved})`); - } else { - log.info(`» model: ${resolved}`); - } - return resolved; - } - log.warning(`» unknown model slug "${ctx.modelSlug}" — falling through to auto-select`); - } - - // 3. auto-select: ask OpenCode what's available, pick our best curated match. - // `opencode models` returns `provider/model-id` specifiers matching our resolve values exactly. - // two-pass: preferred (top-tier per provider) first, then secondary models. - const availableModels = getOpenCodeModels(ctx.cliPath); +function autoSelectModel(cliPath: string): string | undefined { + const availableModels = getOpenCodeModels(cliPath); const availableSet = new Set(availableModels); if (availableSet.size > 0) { log.debug(`» opencode models (${availableSet.size}): ${availableModels.join(", ")}`); @@ -625,12 +596,7 @@ export const opentoad = agent({ run: async (ctx) => { const cliPath = await installOpencodeCli(); - const model = - ctx.payload.proxyModel ?? - resolveOpenCodeModel({ - cliPath, - modelSlug: ctx.payload.model, - }); + const model = ctx.payload.proxyModel ?? ctx.resolvedModel ?? autoSelectModel(cliPath); const homeEnv = { HOME: ctx.tmpdir, diff --git a/agents/shared.ts b/agents/shared.ts index 0f3db38..4b734f7 100644 --- a/agents/shared.ts +++ b/agents/shared.ts @@ -31,6 +31,7 @@ export interface AgentResult { */ export interface AgentRunContext { payload: ResolvedPayload; + resolvedModel?: string | undefined; mcpServerUrl: string; tmpdir: string; instructions: ResolvedInstructions; diff --git a/entry b/entry index 6aa7b5f..67075ad 100755 --- a/entry +++ b/entry @@ -149422,24 +149422,9 @@ function writeMcpConfig(ctx) { ); return configPath; } -function resolveClaudeModel(modelSlug) { - const envModel = process.env.PULLFROG_MODEL?.trim(); - if (envModel) { - const slashIndex = envModel.indexOf("/"); - const cliModel = slashIndex > 0 ? envModel.slice(slashIndex + 1) : envModel; - log.info(`\xBB model: ${cliModel} (override via PULLFROG_MODEL)`); - return cliModel; - } - if (!modelSlug) return void 0; - const resolved = resolveModelSlug(modelSlug); - if (resolved) { - const slashIndex = resolved.indexOf("/"); - const cliModel = slashIndex > 0 ? resolved.slice(slashIndex + 1) : resolved; - log.info(`\xBB model: ${cliModel} (resolved from ${modelSlug})`); - return cliModel; - } - log.warning(`\xBB unknown model slug "${modelSlug}" \u2014 letting Claude Code auto-select`); - return void 0; +function stripProviderPrefix(specifier) { + const slashIndex = specifier.indexOf("/"); + return slashIndex > 0 ? specifier.slice(slashIndex + 1) : specifier; } async function runClaude(params) { const startTime = performance6.now(); @@ -149687,7 +149672,8 @@ var claude = agent({ install: installClaudeCli, run: async (ctx) => { const cliPath = await installClaudeCli(); - const model = ctx.payload.proxyModel ?? resolveClaudeModel(ctx.payload.model); + const specifier = ctx.payload.proxyModel ?? ctx.resolvedModel; + const model = specifier ? stripProviderPrefix(specifier) : void 0; const homeEnv = { HOME: ctx.tmpdir, XDG_CONFIG_HOME: join10(ctx.tmpdir, ".config") @@ -149788,25 +149774,8 @@ function getOpenCodeModels(cliPath) { } } var AUTO_SELECT_WARNING = "select a model explicitly in the Pullfrog console (https://pullfrog.com/console) to avoid this."; -function resolveOpenCodeModel(ctx) { - const envModel = process.env.PULLFROG_MODEL?.trim(); - if (envModel) { - log.info(`\xBB model: ${envModel} (override via PULLFROG_MODEL)`); - return envModel; - } - if (ctx.modelSlug) { - const resolved = resolveCliModel(ctx.modelSlug); - if (resolved) { - if (resolved !== ctx.modelSlug) { - log.info(`\xBB model: ${ctx.modelSlug} (resolved to ${resolved})`); - } else { - log.info(`\xBB model: ${resolved}`); - } - return resolved; - } - log.warning(`\xBB unknown model slug "${ctx.modelSlug}" \u2014 falling through to auto-select`); - } - const availableModels = getOpenCodeModels(ctx.cliPath); +function autoSelectModel(cliPath) { + const availableModels = getOpenCodeModels(cliPath); const availableSet = new Set(availableModels); if (availableSet.size > 0) { log.debug(`\xBB opencode models (${availableSet.size}): ${availableModels.join(", ")}`); @@ -150122,10 +150091,7 @@ var opentoad = agent({ install: installOpencodeCli, run: async (ctx) => { const cliPath = await installOpencodeCli(); - const model = ctx.payload.proxyModel ?? resolveOpenCodeModel({ - cliPath, - modelSlug: ctx.payload.model - }); + const model = ctx.payload.proxyModel ?? ctx.resolvedModel ?? autoSelectModel(cliPath); const homeEnv = { HOME: ctx.tmpdir, XDG_CONFIG_HOME: join11(ctx.tmpdir, ".config") @@ -150170,6 +150136,22 @@ function hasEnvVar(name) { function hasClaudeCodeAuth() { return hasEnvVar("CLAUDE_CODE_OAUTH_TOKEN") || hasEnvVar("ANTHROPIC_API_KEY"); } +function resolveModel(ctx) { + const envModel = process.env.PULLFROG_MODEL?.trim(); + if (envModel) { + log.info(`\xBB model: ${envModel} (override via PULLFROG_MODEL)`); + return envModel; + } + if (ctx.slug) { + const resolved = resolveCliModel(ctx.slug); + if (resolved) { + log.info(`\xBB model: ${resolved} (resolved from ${ctx.slug})`); + return resolved; + } + log.warning(`\xBB unknown model slug "${ctx.slug}" \u2014 agent will auto-select`); + } + return void 0; +} function resolveAgent(ctx) { const envAgent = process.env.PULLFROG_AGENT?.trim(); if (envAgent) { @@ -150179,7 +150161,7 @@ function resolveAgent(ctx) { } log.warning(`\xBB unknown PULLFROG_AGENT="${envAgent}" \u2014 falling through to auto-select`); } - if (ctx?.model) { + if (ctx.model) { try { const provider2 = getModelProvider(ctx.model); if (provider2 === "anthropic" && hasClaudeCodeAuth()) { @@ -151497,10 +151479,11 @@ async function main() { const tmpdir2 = createTempDirectory(); const gitAuthServer = __using(_stack, await startGitAuthServer(tmpdir2), true); setGitAuthServer(gitAuthServer); - const agent2 = resolveAgent({ model: payload.proxyModel ? void 0 : payload.model }); + const resolvedModel = payload.proxyModel ? void 0 : resolveModel({ slug: payload.model }); + const agent2 = resolveAgent({ model: resolvedModel }); validateAgentApiKey({ agent: agent2, - model: payload.proxyModel ?? payload.model, + model: payload.proxyModel ?? resolvedModel ?? payload.model, owner: runContext.repo.owner, name: runContext.repo.name }); @@ -151577,6 +151560,7 @@ ${instructions.user}` : null, toolState.todoTracker = todoTracker; const agentPromise = agent2.run({ payload, + resolvedModel, mcpServerUrl: mcpHttpServer.url, tmpdir: tmpdir2, instructions, diff --git a/main.ts b/main.ts index 5bf02fb..6e1e38f 100644 --- a/main.ts +++ b/main.ts @@ -15,7 +15,7 @@ import { DEFAULT_ACTIVITY_CHECK_INTERVAL_MS, DEFAULT_ACTIVITY_TIMEOUT_MS, } from "./utils/activity.ts"; -import { resolveAgent } from "./utils/agent.ts"; +import { resolveAgent, resolveModel } from "./utils/agent.ts"; import { apiFetch } from "./utils/apiFetch.ts"; import { validateAgentApiKey } from "./utils/apiKeys.ts"; import { resolveBody } from "./utils/body.ts"; @@ -249,11 +249,12 @@ export async function main(): Promise { await using gitAuthServer = await startGitAuthServer(tmpdir); setGitAuthServer(gitAuthServer); - const agent = resolveAgent({ model: payload.proxyModel ? undefined : payload.model }); + const resolvedModel = payload.proxyModel ? undefined : resolveModel({ slug: payload.model }); + const agent = resolveAgent({ model: resolvedModel }); validateAgentApiKey({ agent, - model: payload.proxyModel ?? payload.model, + model: payload.proxyModel ?? resolvedModel ?? payload.model, owner: runContext.repo.owner, name: runContext.repo.name, }); @@ -341,6 +342,7 @@ export async function main(): Promise { const agentPromise = agent.run({ payload, + resolvedModel, mcpServerUrl: mcpHttpServer.url, tmpdir, instructions, diff --git a/test/run.ts b/test/run.ts index 3f66515..d1c8560 100644 --- a/test/run.ts +++ b/test/run.ts @@ -305,11 +305,6 @@ async function runTestForAgent(ctx: RunContext): Promise { env.PULLFROG_TEST_REPO_SETUP = testConfig.repoSetup; } - // use anthropic sonnet to avoid google quota issues and gemini doom-looping - if (ctx.agent === "opentoad") { - env.PULLFROG_MODEL ??= "anthropic/claude-sonnet-4-6"; - } - // build file-based env vars for MCP servers that don't inherit parent env let fileEnv: Record | undefined; if (testConfig.fileAgentEnv) { diff --git a/utils/agent.test.ts b/utils/agent.test.ts index f12e8c4..9f27d19 100644 --- a/utils/agent.test.ts +++ b/utils/agent.test.ts @@ -3,7 +3,7 @@ import { resolveAgent } from "./agent.ts"; describe("resolveAgent", () => { it("returns opentoad", () => { - const agent = resolveAgent(); + const agent = resolveAgent({}); expect(agent.name).toBe("opentoad"); }); }); diff --git a/utils/agent.ts b/utils/agent.ts index 098adf0..79a29b0 100644 --- a/utils/agent.ts +++ b/utils/agent.ts @@ -1,6 +1,6 @@ import type { Agent } from "../agents/index.ts"; import { agents } from "../agents/index.ts"; -import { getModelProvider } from "../models.ts"; +import { getModelProvider, resolveCliModel } from "../models.ts"; import { log } from "./cli.ts"; function hasEnvVar(name: string): boolean { @@ -12,7 +12,34 @@ function hasClaudeCodeAuth(): boolean { return hasEnvVar("CLAUDE_CODE_OAUTH_TOKEN") || hasEnvVar("ANTHROPIC_API_KEY"); } -export function resolveAgent(ctx?: { model?: string | undefined }): Agent { +/** + * resolve the effective model for this run. + * + * priority: + * 1. PULLFROG_MODEL env var (explicit specifier override) + * 2. slug from repo config / payload → alias registry + * 3. undefined — agent will auto-select + */ +export function resolveModel(ctx: { slug?: string | undefined }): string | undefined { + const envModel = process.env.PULLFROG_MODEL?.trim(); + if (envModel) { + log.info(`» model: ${envModel} (override via PULLFROG_MODEL)`); + return envModel; + } + + if (ctx.slug) { + const resolved = resolveCliModel(ctx.slug); + if (resolved) { + log.info(`» model: ${resolved} (resolved from ${ctx.slug})`); + return resolved; + } + log.warning(`» unknown model slug "${ctx.slug}" — agent will auto-select`); + } + + return undefined; +} + +export function resolveAgent(ctx: { model?: string | undefined }): Agent { // 1. explicit env var override (escape hatch) const envAgent = process.env.PULLFROG_AGENT?.trim(); if (envAgent) { @@ -24,7 +51,7 @@ export function resolveAgent(ctx?: { model?: string | undefined }): Agent { } // 2. if model is Anthropic and Claude Code credentials are available, use Claude Code - if (ctx?.model) { + if (ctx.model) { try { const provider = getModelProvider(ctx.model); if (provider === "anthropic" && hasClaudeCodeAuth()) { @@ -32,7 +59,7 @@ export function resolveAgent(ctx?: { model?: string | undefined }): Agent { return agents.claude; } } catch { - // invalid model slug format — fall through + // invalid model format — fall through } }