extract resolveModel() to run before agent selection
model resolution was duplicated inside each agent (opentoad, claude) and PULLFROG_MODEL override was not considered when choosing the agent. now resolveModel() runs first in main.ts, its result feeds into resolveAgent() for agent selection, and the resolved model is passed to the agent via ctx.resolvedModel. agents only handle their own fallback (opentoad: auto-select via opencode models, claude: strip provider prefix). also removes the hardcoded anthropic/claude-sonnet test runner default since ANTHROPIC_API_KEY is no longer in CI. Made-with: Cursor
This commit is contained in:
committed by
pullfrog[bot]
parent
1f1e3995f9
commit
b1f9878877
+8
-27
@@ -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,
|
||||
|
||||
+8
-42
@@ -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,
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface AgentResult {
|
||||
*/
|
||||
export interface AgentRunContext {
|
||||
payload: ResolvedPayload;
|
||||
resolvedModel?: string | undefined;
|
||||
mcpServerUrl: string;
|
||||
tmpdir: string;
|
||||
instructions: ResolvedInstructions;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<MainResult> {
|
||||
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<MainResult> {
|
||||
|
||||
const agentPromise = agent.run({
|
||||
payload,
|
||||
resolvedModel,
|
||||
mcpServerUrl: mcpHttpServer.url,
|
||||
tmpdir,
|
||||
instructions,
|
||||
|
||||
@@ -305,11 +305,6 @@ async function runTestForAgent(ctx: RunContext): Promise<ValidationResult> {
|
||||
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<string, string> | undefined;
|
||||
if (testConfig.fileAgentEnv) {
|
||||
|
||||
+1
-1
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
+31
-4
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user