diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 298760e..121add6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,7 +42,6 @@ jobs: MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} PULLFROG_MODEL: ${{ vars.PULLFROG_MODEL }} - OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }} steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 diff --git a/agents/opentoad.ts b/agents/opentoad.ts index ec2a781..c71c139 100644 --- a/agents/opentoad.ts +++ b/agents/opentoad.ts @@ -75,9 +75,9 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s // ── model resolution (see wiki/model-resolution.md) ───────────────────────────── // // priority: -// 1. PULLFROG_MODEL or OPENCODE_MODEL env var (explicit override) +// 1. PULLFROG_MODEL env var (explicit override) // 2. explicit slug from repo config / payload -// 3. auto-select: `opencode models` → recommended aliases first, then secondary +// 3. auto-select: `opencode models` → preferred aliases first, then secondary // 4. undefined → let OpenCode decide function getOpenCodeModels(cliPath: string): string[] { @@ -106,11 +106,10 @@ function resolveOpenCodeModel(ctx: { cliPath: string; modelSlug?: string | undefined; }): string | undefined { - // 1. explicit env var override (PULLFROG_MODEL takes precedence over OPENCODE_MODEL) - const envModel = process.env.PULLFROG_MODEL?.trim() || process.env.OPENCODE_MODEL?.trim(); + // 1. explicit env var override + const envModel = process.env.PULLFROG_MODEL?.trim(); if (envModel) { - const source = process.env.PULLFROG_MODEL?.trim() ? "PULLFROG_MODEL" : "OPENCODE_MODEL"; - log.info(`» model: ${envModel} (override via ${source})`); + log.info(`» model: ${envModel} (override via PULLFROG_MODEL)`); return envModel; } @@ -126,17 +125,17 @@ function resolveOpenCodeModel(ctx: { // 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: recommended (top-tier per provider) first, then secondary models. + // two-pass: preferred (top-tier per provider) first, then secondary models. const availableModels = getOpenCodeModels(ctx.cliPath); const availableSet = new Set(availableModels); if (availableSet.size > 0) { log.debug(`» opencode models (${availableSet.size}): ${availableModels.join(", ")}`); const match = - modelAliases.find((a) => a.recommended && availableSet.has(a.resolve)) ?? + modelAliases.find((a) => a.preferred && availableSet.has(a.resolve)) ?? modelAliases.find((a) => availableSet.has(a.resolve)); if (match) { log.info( - `» model: ${match.resolve} (auto-selected${match.recommended ? " — recommended" : ""} curated match)` + `» model: ${match.resolve} (auto-selected${match.preferred ? " — preferred" : ""} curated match)` ); log.warning(`» model auto-selected. ${AUTO_SELECT_WARNING}`); return match.resolve; @@ -622,10 +621,12 @@ export const opentoad = agent({ run: async (ctx) => { const cliPath = await installOpencodeCli(); - const model = resolveOpenCodeModel({ - cliPath, - modelSlug: ctx.payload.model, - }); + const model = + ctx.payload.proxyModel ?? + resolveOpenCodeModel({ + cliPath, + modelSlug: ctx.payload.model, + }); const tempHome = ctx.tmpdir; mkdirSync(join(tempHome, ".config", "opencode"), { recursive: true }); diff --git a/agents/shared.ts b/agents/shared.ts index c23d3b1..a55a444 100644 --- a/agents/shared.ts +++ b/agents/shared.ts @@ -45,7 +45,6 @@ export const agent = (input: Agent): Agent => { return { ...input, run: async (ctx: AgentRunContext): Promise => { - log.info(`» agent: ${input.name}`); if (ctx.payload.model) log.info(`» model: ${ctx.payload.model}`); if (ctx.payload.timeout) log.info(`» timeout: ${ctx.payload.timeout}`); log.info(`» push: ${ctx.payload.push}`); diff --git a/config.ts b/config.ts new file mode 100644 index 0000000..82fe3ec --- /dev/null +++ b/config.ts @@ -0,0 +1 @@ +// action-level constants shared across the action runtime diff --git a/entry b/entry index 0cbbde3..bb4e81a 100755 --- a/entry +++ b/entry @@ -19718,10 +19718,10 @@ var require_core = __commonJS({ (0, command_1.issueCommand)("set-env", { name }, convertedVal); } exports.exportVariable = exportVariable; - function setSecret2(secret) { + function setSecret3(secret) { (0, command_1.issueCommand)("add-mask", {}, secret); } - exports.setSecret = setSecret2; + exports.setSecret = setSecret3; function addPath(inputPath) { const filePath = process.env["GITHUB_PATH"] || ""; if (filePath) { @@ -19838,12 +19838,12 @@ Support boolean input list: \`true | True | TRUE | false | False | FALSE\``); return process.env[`STATE_${name}`] || ""; } exports.getState = getState; - function getIDToken2(aud) { + function getIDToken3(aud) { return __awaiter(this, void 0, void 0, function* () { return yield oidc_utils_1.OidcClient.getIDToken(aud); }); } - exports.getIDToken = getIDToken2; + exports.getIDToken = getIDToken3; var summary_1 = require_summary(); Object.defineProperty(exports, "summary", { enumerable: true, get: function() { return summary_1.summary; @@ -131925,19 +131925,40 @@ var providers = { "claude-opus": { displayName: "Claude Opus", resolve: "anthropic/claude-opus-4-6", - recommended: true + openRouterResolve: "openrouter/anthropic/claude-opus-4.6", + preferred: true }, - "claude-sonnet": { displayName: "Claude Sonnet", resolve: "anthropic/claude-sonnet-4-6" }, - "claude-haiku": { displayName: "Claude Haiku", resolve: "anthropic/claude-haiku-4-5" } + "claude-sonnet": { + displayName: "Claude Sonnet", + resolve: "anthropic/claude-sonnet-4-6", + openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6" + }, + "claude-haiku": { + displayName: "Claude Haiku", + resolve: "anthropic/claude-haiku-4-5", + openRouterResolve: "openrouter/anthropic/claude-haiku-4.5" + } } }), openai: provider({ displayName: "OpenAI", envVars: ["OPENAI_API_KEY"], models: { - "gpt-codex": { displayName: "GPT Codex", resolve: "openai/gpt-5.3-codex", recommended: true }, - "gpt-codex-mini": { displayName: "GPT Codex Mini", resolve: "openai/codex-mini-latest" }, - o3: { displayName: "O3", resolve: "openai/o3" } + "gpt-codex": { + displayName: "GPT Codex", + resolve: "openai/gpt-5.3-codex", + openRouterResolve: "openrouter/openai/gpt-5.3-codex", + preferred: true + }, + "gpt-codex-mini": { + displayName: "GPT Codex Mini", + resolve: "openai/codex-mini-latest", + openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini" + }, + o3: { + displayName: "O3", + resolve: "openai/o3" + } } }), google: provider({ @@ -131947,18 +131968,36 @@ var providers = { "gemini-pro": { displayName: "Gemini Pro", resolve: "google/gemini-3.1-pro-preview", - recommended: true + openRouterResolve: "openrouter/google/gemini-3.1-pro-preview", + preferred: true }, - "gemini-flash": { displayName: "Gemini Flash", resolve: "google/gemini-3-flash-preview" } + "gemini-flash": { + displayName: "Gemini Flash", + resolve: "google/gemini-3-flash-preview", + openRouterResolve: "openrouter/google/gemini-3-flash-preview" + } } }), xai: provider({ displayName: "xAI", envVars: ["XAI_API_KEY"], models: { - grok: { displayName: "Grok", resolve: "xai/grok-4", recommended: true }, - "grok-fast": { displayName: "Grok Fast", resolve: "xai/grok-4-fast" }, - "grok-code-fast": { displayName: "Grok Code Fast", resolve: "xai/grok-code-fast-1" } + grok: { + displayName: "Grok", + resolve: "xai/grok-4", + openRouterResolve: "openrouter/x-ai/grok-4", + preferred: true + }, + "grok-fast": { + displayName: "Grok Fast", + resolve: "xai/grok-4-fast", + openRouterResolve: "openrouter/x-ai/grok-4-fast" + }, + "grok-code-fast": { + displayName: "Grok Code Fast", + resolve: "xai/grok-code-fast-1", + openRouterResolve: "openrouter/x-ai/grok-code-fast-1" + } } }), deepseek: provider({ @@ -131968,16 +132007,26 @@ var providers = { "deepseek-reasoner": { displayName: "DeepSeek Reasoner", resolve: "deepseek/deepseek-reasoner", - recommended: true + openRouterResolve: "openrouter/deepseek/deepseek-v3.2", + preferred: true }, - "deepseek-chat": { displayName: "DeepSeek Chat", resolve: "deepseek/deepseek-chat" } + "deepseek-chat": { + displayName: "DeepSeek Chat", + resolve: "deepseek/deepseek-chat", + openRouterResolve: "openrouter/deepseek/deepseek-v3.2" + } } }), moonshotai: provider({ displayName: "Moonshot AI", envVars: ["MOONSHOT_API_KEY"], models: { - "kimi-k2": { displayName: "Kimi K2", resolve: "moonshotai/kimi-k2.5", recommended: true } + "kimi-k2": { + displayName: "Kimi K2", + resolve: "moonshotai/kimi-k2.5", + openRouterResolve: "openrouter/moonshotai/kimi-k2.5", + preferred: true + } } }), opencode: provider({ @@ -131987,18 +132036,50 @@ var providers = { "big-pickle": { displayName: "Big Pickle", resolve: "opencode/big-pickle", - recommended: true, + preferred: true, envVars: [], isFree: true }, - "claude-opus": { displayName: "Claude Opus", resolve: "opencode/claude-opus-4-6" }, - "claude-sonnet": { displayName: "Claude Sonnet", resolve: "opencode/claude-sonnet-4-6" }, - "claude-haiku": { displayName: "Claude Haiku", resolve: "opencode/claude-haiku-4-5" }, - "gpt-codex": { displayName: "GPT Codex", resolve: "opencode/gpt-5.3-codex" }, - "gpt-codex-mini": { displayName: "GPT Codex Mini", resolve: "opencode/gpt-5.1-codex-mini" }, - "gemini-pro": { displayName: "Gemini Pro", resolve: "opencode/gemini-3.1-pro" }, - "gemini-flash": { displayName: "Gemini Flash", resolve: "opencode/gemini-3-flash" }, - "kimi-k2": { displayName: "Kimi K2", resolve: "opencode/kimi-k2.5" }, + "claude-opus": { + displayName: "Claude Opus", + resolve: "opencode/claude-opus-4-6", + openRouterResolve: "openrouter/anthropic/claude-opus-4.6" + }, + "claude-sonnet": { + displayName: "Claude Sonnet", + resolve: "opencode/claude-sonnet-4-6", + openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6" + }, + "claude-haiku": { + displayName: "Claude Haiku", + resolve: "opencode/claude-haiku-4-5", + openRouterResolve: "openrouter/anthropic/claude-haiku-4.5" + }, + "gpt-codex": { + displayName: "GPT Codex", + resolve: "opencode/gpt-5.3-codex", + openRouterResolve: "openrouter/openai/gpt-5.3-codex" + }, + "gpt-codex-mini": { + displayName: "GPT Codex Mini", + resolve: "opencode/gpt-5.1-codex-mini", + openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini" + }, + "gemini-pro": { + displayName: "Gemini Pro", + resolve: "opencode/gemini-3.1-pro", + openRouterResolve: "openrouter/google/gemini-3.1-pro-preview" + }, + "gemini-flash": { + displayName: "Gemini Flash", + resolve: "opencode/gemini-3-flash", + openRouterResolve: "openrouter/google/gemini-3-flash-preview" + }, + "kimi-k2": { + displayName: "Kimi K2", + resolve: "opencode/kimi-k2.5", + openRouterResolve: "openrouter/moonshotai/kimi-k2.5" + }, "gpt-5-nano": { displayName: "GPT Nano", resolve: "opencode/gpt-5-nano", @@ -132032,36 +132113,59 @@ var providers = { "claude-opus": { displayName: "Claude Opus", resolve: "openrouter/anthropic/claude-opus-4.6", - recommended: true + openRouterResolve: "openrouter/anthropic/claude-opus-4.6", + preferred: true }, "claude-sonnet": { displayName: "Claude Sonnet", - resolve: "openrouter/anthropic/claude-sonnet-4.6" + resolve: "openrouter/anthropic/claude-sonnet-4.6", + openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6" }, "claude-haiku": { displayName: "Claude Haiku", - resolve: "openrouter/anthropic/claude-haiku-4.5" + resolve: "openrouter/anthropic/claude-haiku-4.5", + openRouterResolve: "openrouter/anthropic/claude-haiku-4.5" + }, + "gpt-codex": { + displayName: "GPT Codex", + resolve: "openrouter/openai/gpt-5.3-codex", + openRouterResolve: "openrouter/openai/gpt-5.3-codex" }, - "gpt-codex": { displayName: "GPT Codex", resolve: "openrouter/openai/gpt-5.3-codex" }, "gpt-codex-mini": { displayName: "GPT Codex Mini", - resolve: "openrouter/openai/gpt-5.1-codex-mini" + resolve: "openrouter/openai/gpt-5.1-codex-mini", + openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini" + }, + "o4-mini": { + displayName: "O4 Mini", + resolve: "openrouter/openai/o4-mini", + openRouterResolve: "openrouter/openai/o4-mini" }, - "o4-mini": { displayName: "O4 Mini", resolve: "openrouter/openai/o4-mini" }, "gemini-pro": { displayName: "Gemini Pro", - resolve: "openrouter/google/gemini-3.1-pro-preview" + resolve: "openrouter/google/gemini-3.1-pro-preview", + openRouterResolve: "openrouter/google/gemini-3.1-pro-preview" }, "gemini-flash": { displayName: "Gemini Flash", - resolve: "openrouter/google/gemini-3-flash-preview" + resolve: "openrouter/google/gemini-3-flash-preview", + openRouterResolve: "openrouter/google/gemini-3-flash-preview" + }, + grok: { + displayName: "Grok", + resolve: "openrouter/x-ai/grok-4", + openRouterResolve: "openrouter/x-ai/grok-4" }, - grok: { displayName: "Grok", resolve: "openrouter/x-ai/grok-4" }, "deepseek-chat": { displayName: "DeepSeek Chat", - resolve: "openrouter/deepseek/deepseek-chat-v3.1" + resolve: "openrouter/deepseek/deepseek-v3.2", + openRouterResolve: "openrouter/deepseek/deepseek-v3.2" }, - "kimi-k2": { displayName: "Kimi K2", resolve: "openrouter/moonshotai/kimi-k2.5" } + "kimi-k2": { + displayName: "Kimi K2", + resolve: "openrouter/moonshotai/kimi-k2.5", + openRouterResolve: "openrouter/moonshotai/kimi-k2.5" + } } }) }; @@ -132090,7 +132194,8 @@ var modelAliases = Object.entries(providers).flatMap( provider: providerKey, displayName: def.displayName, resolve: def.resolve, - recommended: def.recommended ?? false, + openRouterResolve: def.openRouterResolve, + preferred: def.preferred ?? false, isFree: def.isFree ?? false })) ); @@ -148805,7 +148910,6 @@ var agent = (input) => { return { ...input, run: async (ctx) => { - log.info(`\xBB agent: ${input.name}`); if (ctx.payload.model) log.info(`\xBB model: ${ctx.payload.model}`); if (ctx.payload.timeout) log.info(`\xBB timeout: ${ctx.payload.timeout}`); log.info(`\xBB push: ${ctx.payload.push}`); @@ -148865,10 +148969,9 @@ 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() || process.env.OPENCODE_MODEL?.trim(); + const envModel = process.env.PULLFROG_MODEL?.trim(); if (envModel) { - const source = process.env.PULLFROG_MODEL?.trim() ? "PULLFROG_MODEL" : "OPENCODE_MODEL"; - log.info(`\xBB model: ${envModel} (override via ${source})`); + log.info(`\xBB model: ${envModel} (override via PULLFROG_MODEL)`); return envModel; } if (ctx.modelSlug) { @@ -148883,10 +148986,10 @@ function resolveOpenCodeModel(ctx) { const availableSet = new Set(availableModels); if (availableSet.size > 0) { log.debug(`\xBB opencode models (${availableSet.size}): ${availableModels.join(", ")}`); - const match3 = modelAliases.find((a) => a.recommended && availableSet.has(a.resolve)) ?? modelAliases.find((a) => availableSet.has(a.resolve)); + const match3 = modelAliases.find((a) => a.preferred && availableSet.has(a.resolve)) ?? modelAliases.find((a) => availableSet.has(a.resolve)); if (match3) { log.info( - `\xBB model: ${match3.resolve} (auto-selected${match3.recommended ? " \u2014 recommended" : ""} curated match)` + `\xBB model: ${match3.resolve} (auto-selected${match3.preferred ? " \u2014 preferred" : ""} curated match)` ); log.warning(`\xBB model auto-selected. ${AUTO_SELECT_WARNING}`); return match3.resolve; @@ -149199,7 +149302,7 @@ var opentoad = agent({ install: installOpencodeCli, run: async (ctx) => { const cliPath = await installOpencodeCli(); - const model = resolveOpenCodeModel({ + const model = ctx.payload.proxyModel ?? resolveOpenCodeModel({ cliPath, modelSlug: ctx.payload.model }); @@ -149857,7 +149960,7 @@ import { isAbsolute, resolve } from "node:path"; // package.json var package_default = { name: "@pullfrog/pullfrog", - version: "0.0.181", + version: "0.0.182", type: "module", files: [ "index.js", @@ -150059,7 +150162,9 @@ function resolvePayload(resolvedPromptInput, repoSettings) { progressCommentId: jsonPayload?.progressCommentId, // permissions: inputs > repoSettings > fallbacks push: inputs.push ?? repoSettings.push ?? "restricted", - shell: resolvedShell + shell: resolvedShell, + // set by proxy logic in main.ts when routing through OpenRouter + proxyModel: void 0 }; } @@ -150174,7 +150279,8 @@ var defaultSettings = { }; var defaultRunContext = { settings: defaultSettings, - apiToken: "" + apiToken: "", + oss: false }; async function fetchRunContext(params) { const timeoutMs = 3e4; @@ -150205,7 +150311,9 @@ async function fetchRunContext(params) { setupScript: data.settings?.setupScript ?? null, postCheckoutScript: data.settings?.postCheckoutScript ?? null }, - apiToken: data.apiToken + apiToken: data.apiToken, + oss: data.oss ?? false, + proxyModel: data.proxyModel }; } catch { clearTimeout(timeoutId); @@ -150228,7 +150336,9 @@ async function resolveRunContextData(params) { data: repoResponse.data }, repoSettings: runContext.settings, - apiToken: runContext.apiToken + apiToken: runContext.apiToken, + oss: runContext.oss, + proxyModel: runContext.proxyModel }; } @@ -150367,6 +150477,48 @@ function resolveOutputSchema() { log.info("\xBB structured output schema provided \u2014 output will be required"); return parsed2; } +async function mintProxyKey(ctx) { + try { + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl; + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken; + const oidcToken = await core5.getIDToken("pullfrog-api"); + delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + const response = await apiFetch({ + path: "/api/proxy-token", + method: "POST", + headers: { Authorization: `Bearer ${oidcToken}` } + }); + if (!response.ok) { + log.warning(`proxy key mint failed (${response.status})`); + return null; + } + const data = await response.json(); + return data.key; + } catch (error49) { + log.warning(`proxy key mint error: ${error49 instanceof Error ? error49.message : String(error49)}`); + return null; + } finally { + delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + } +} +async function resolveProxyModel(ctx) { + if (process.env.PULLFROG_MODEL?.trim()) return; + if (ctx.oss && ctx.proxyModel) { + if (!ctx.oidcCredentials) { + log.warning("\xBB oss repo but no OIDC credentials available \u2014 skipping proxy"); + return; + } + const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials }); + if (!key) return; + process.env.OPENROUTER_API_KEY = key; + core5.setSecret(key); + ctx.payload.proxyModel = ctx.proxyModel; + log.info(`\xBB proxy: oss \u2192 ${ctx.proxyModel}`); + return; + } +} async function writeJobSummary(toolState) { const usageSummary = formatUsageSummary(toolState.usageEntries); const summaryParts = [toolState.lastProgressBody, usageSummary].filter(Boolean); @@ -150396,10 +150548,20 @@ async function main() { const payload = resolvePayload(resolvedPromptInput, runContext.repoSettings); toolState.model = payload.model; const tokenRef = __using(_stack2, await resolveTokens({ push: payload.push }), true); + const oidcCredentials = process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN ? { + requestUrl: process.env.ACTIONS_ID_TOKEN_REQUEST_URL, + requestToken: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN + } : null; if (payload.shell !== "enabled") { delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; } + await resolveProxyModel({ + payload, + oss: runContext.oss, + proxyModel: runContext.proxyModel, + oidcCredentials + }); const octokit = createOctokit(tokenRef.mcpToken); const runInfo = await resolveRun({ octokit }); let toolContext; @@ -150427,7 +150589,7 @@ async function main() { const agent2 = resolveAgent(); validateAgentApiKey({ agent: agent2, - model: payload.model, + model: payload.proxyModel ?? payload.model, owner: runContext.repo.owner, name: runContext.repo.name }); diff --git a/external.ts b/external.ts index 12a9257..d808768 100644 --- a/external.ts +++ b/external.ts @@ -12,6 +12,7 @@ export type { ModelAlias, ModelProvider, ProviderConfig } from "./models.ts"; export { getModelEnvVars, getModelProvider, + getProviderDisplayName, modelAliases, parseModel, providers, diff --git a/internal/index.ts b/internal/index.ts index 3103ac5..92dd5cc 100644 --- a/internal/index.ts +++ b/internal/index.ts @@ -18,6 +18,7 @@ export type { export { getModelEnvVars, getModelProvider, + getProviderDisplayName, ghPullfrogMcpName, modelAliases, parseModel, diff --git a/main.ts b/main.ts index 914535a..03646f3 100644 --- a/main.ts +++ b/main.ts @@ -15,6 +15,7 @@ import { DEFAULT_ACTIVITY_TIMEOUT_MS, } from "./utils/activity.ts"; import { resolveAgent } from "./utils/agent.ts"; +import { apiFetch } from "./utils/apiFetch.ts"; import { validateAgentApiKey } from "./utils/apiKeys.ts"; import { resolveBody } from "./utils/body.ts"; import { formatUsageSummary, log, writeSummary } from "./utils/cli.ts"; @@ -63,6 +64,71 @@ function resolveOutputSchema(): Record | undefined { return parsed as Record; } +import type { ResolvedPayload } from "./utils/payload.ts"; + +interface OidcCredentials { + requestUrl: string; + requestToken: string; +} + +async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise { + try { + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl; + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken; + const oidcToken = await core.getIDToken("pullfrog-api"); + delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + + const response = await apiFetch({ + path: "/api/proxy-token", + method: "POST", + headers: { Authorization: `Bearer ${oidcToken}` }, + }); + + if (!response.ok) { + log.warning(`proxy key mint failed (${response.status})`); + return null; + } + + const data = (await response.json()) as { key: string }; + return data.key; + } catch (error) { + log.warning(`proxy key mint error: ${error instanceof Error ? error.message : String(error)}`); + return null; + } finally { + delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + } +} + +async function resolveProxyModel(ctx: { + payload: ResolvedPayload; + oss: boolean; + proxyModel?: string | undefined; + oidcCredentials: OidcCredentials | null; +}): Promise { + // env override = BYOK escape hatch, don't proxy + if (process.env.PULLFROG_MODEL?.trim()) return; + + // OSS: server decided the model + if (ctx.oss && ctx.proxyModel) { + if (!ctx.oidcCredentials) { + log.warning("» oss repo but no OIDC credentials available — skipping proxy"); + return; + } + const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials }); + if (!key) return; + + process.env.OPENROUTER_API_KEY = key; + core.setSecret(key); + ctx.payload.proxyModel = ctx.proxyModel; + log.info(`» proxy: oss → ${ctx.proxyModel}`); + return; + } + + // managed billing will add its path here later +} + async function writeJobSummary(toolState: ToolState): Promise { const usageSummary = formatUsageSummary(toolState.usageEntries); const summaryParts = [toolState.lastProgressBody, usageSummary].filter(Boolean); @@ -105,17 +171,33 @@ export async function main(): Promise { const payload = resolvePayload(resolvedPromptInput, runContext.repoSettings); toolState.model = payload.model; - // resolve tokens: - // - gitToken: contents permission based on push setting (assumed exfiltratable) - // - mcpToken: full installation token (not exfiltratable via MCP tools) + // resolve tokens first — acquireNewToken needs OIDC env vars for token exchange await using tokenRef = await resolveTokens({ push: payload.push }); + // stash OIDC credentials in memory before wiping from process.env + // the agent's shell commands can't access JS variables, so this is safe + const oidcCredentials: OidcCredentials | null = + process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN + ? { + requestUrl: process.env.ACTIONS_ID_TOKEN_REQUEST_URL, + requestToken: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN, + } + : null; + // clear OIDC env vars in restricted mode to prevent agent from minting tokens if (payload.shell !== "enabled") { delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; } + // proxy decision: mint an OpenRouter key for OSS repos (or later, managed billing) + await resolveProxyModel({ + payload, + oss: runContext.oss, + proxyModel: runContext.proxyModel, + oidcCredentials, + }); + // create octokit with MCP token for GitHub API calls const octokit = createOctokit(tokenRef.mcpToken); @@ -152,7 +234,7 @@ export async function main(): Promise { validateAgentApiKey({ agent, - model: payload.model, + model: payload.proxyModel ?? payload.model, owner: runContext.repo.owner, name: runContext.repo.name, }); diff --git a/models.test.ts b/models.test.ts index de427c3..39f3911 100644 --- a/models.test.ts +++ b/models.test.ts @@ -96,10 +96,10 @@ describe("modelAliases registry", () => { } }); - it("has exactly one recommended model per provider", () => { + it("has exactly one preferred model per provider", () => { for (const providerKey of Object.keys(providers)) { - const recommended = modelAliases.filter((a) => a.provider === providerKey && a.recommended); - expect(recommended.length, `${providerKey} should have exactly 1 recommended model`).toBe(1); + const preferred = modelAliases.filter((a) => a.provider === providerKey && a.preferred); + expect(preferred.length, `${providerKey} should have exactly 1 preferred model`).toBe(1); } }); diff --git a/models.ts b/models.ts index 36b8770..a862f19 100644 --- a/models.ts +++ b/models.ts @@ -16,8 +16,10 @@ export interface ModelAlias { displayName: string; /** concrete models.dev specifier, e.g. "anthropic/claude-opus-4-6" */ resolve: string; + /** full models.dev specifier for the OpenRouter equivalent (undefined for free models) */ + openRouterResolve: string | undefined; /** top-tier pick for this provider — preferred during auto-select */ - recommended: boolean; + preferred: boolean; /** whether this alias is free and requires no API key */ isFree: boolean; } @@ -26,7 +28,9 @@ interface ModelDef { displayName: string; /** concrete models.dev specifier, e.g. "anthropic/claude-opus-4-6" */ resolve: string; - recommended?: boolean; + /** full models.dev specifier for the OpenRouter equivalent, e.g. "openrouter/anthropic/claude-opus-4.6" */ + openRouterResolve?: string; + preferred?: boolean; envVars?: readonly string[]; isFree?: boolean; } @@ -51,19 +55,40 @@ export const providers = { "claude-opus": { displayName: "Claude Opus", resolve: "anthropic/claude-opus-4-6", - recommended: true, + openRouterResolve: "openrouter/anthropic/claude-opus-4.6", + preferred: true, + }, + "claude-sonnet": { + displayName: "Claude Sonnet", + resolve: "anthropic/claude-sonnet-4-6", + openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6", + }, + "claude-haiku": { + displayName: "Claude Haiku", + resolve: "anthropic/claude-haiku-4-5", + openRouterResolve: "openrouter/anthropic/claude-haiku-4.5", }, - "claude-sonnet": { displayName: "Claude Sonnet", resolve: "anthropic/claude-sonnet-4-6" }, - "claude-haiku": { displayName: "Claude Haiku", resolve: "anthropic/claude-haiku-4-5" }, }, }), openai: provider({ displayName: "OpenAI", envVars: ["OPENAI_API_KEY"], models: { - "gpt-codex": { displayName: "GPT Codex", resolve: "openai/gpt-5.3-codex", recommended: true }, - "gpt-codex-mini": { displayName: "GPT Codex Mini", resolve: "openai/codex-mini-latest" }, - o3: { displayName: "O3", resolve: "openai/o3" }, + "gpt-codex": { + displayName: "GPT Codex", + resolve: "openai/gpt-5.3-codex", + openRouterResolve: "openrouter/openai/gpt-5.3-codex", + preferred: true, + }, + "gpt-codex-mini": { + displayName: "GPT Codex Mini", + resolve: "openai/codex-mini-latest", + openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini", + }, + o3: { + displayName: "O3", + resolve: "openai/o3", + }, }, }), google: provider({ @@ -73,18 +98,36 @@ export const providers = { "gemini-pro": { displayName: "Gemini Pro", resolve: "google/gemini-3.1-pro-preview", - recommended: true, + openRouterResolve: "openrouter/google/gemini-3.1-pro-preview", + preferred: true, + }, + "gemini-flash": { + displayName: "Gemini Flash", + resolve: "google/gemini-3-flash-preview", + openRouterResolve: "openrouter/google/gemini-3-flash-preview", }, - "gemini-flash": { displayName: "Gemini Flash", resolve: "google/gemini-3-flash-preview" }, }, }), xai: provider({ displayName: "xAI", envVars: ["XAI_API_KEY"], models: { - grok: { displayName: "Grok", resolve: "xai/grok-4", recommended: true }, - "grok-fast": { displayName: "Grok Fast", resolve: "xai/grok-4-fast" }, - "grok-code-fast": { displayName: "Grok Code Fast", resolve: "xai/grok-code-fast-1" }, + grok: { + displayName: "Grok", + resolve: "xai/grok-4", + openRouterResolve: "openrouter/x-ai/grok-4", + preferred: true, + }, + "grok-fast": { + displayName: "Grok Fast", + resolve: "xai/grok-4-fast", + openRouterResolve: "openrouter/x-ai/grok-4-fast", + }, + "grok-code-fast": { + displayName: "Grok Code Fast", + resolve: "xai/grok-code-fast-1", + openRouterResolve: "openrouter/x-ai/grok-code-fast-1", + }, }, }), deepseek: provider({ @@ -94,16 +137,26 @@ export const providers = { "deepseek-reasoner": { displayName: "DeepSeek Reasoner", resolve: "deepseek/deepseek-reasoner", - recommended: true, + openRouterResolve: "openrouter/deepseek/deepseek-v3.2", + preferred: true, + }, + "deepseek-chat": { + displayName: "DeepSeek Chat", + resolve: "deepseek/deepseek-chat", + openRouterResolve: "openrouter/deepseek/deepseek-v3.2", }, - "deepseek-chat": { displayName: "DeepSeek Chat", resolve: "deepseek/deepseek-chat" }, }, }), moonshotai: provider({ displayName: "Moonshot AI", envVars: ["MOONSHOT_API_KEY"], models: { - "kimi-k2": { displayName: "Kimi K2", resolve: "moonshotai/kimi-k2.5", recommended: true }, + "kimi-k2": { + displayName: "Kimi K2", + resolve: "moonshotai/kimi-k2.5", + openRouterResolve: "openrouter/moonshotai/kimi-k2.5", + preferred: true, + }, }, }), opencode: provider({ @@ -113,18 +166,50 @@ export const providers = { "big-pickle": { displayName: "Big Pickle", resolve: "opencode/big-pickle", - recommended: true, + preferred: true, envVars: [], isFree: true, }, - "claude-opus": { displayName: "Claude Opus", resolve: "opencode/claude-opus-4-6" }, - "claude-sonnet": { displayName: "Claude Sonnet", resolve: "opencode/claude-sonnet-4-6" }, - "claude-haiku": { displayName: "Claude Haiku", resolve: "opencode/claude-haiku-4-5" }, - "gpt-codex": { displayName: "GPT Codex", resolve: "opencode/gpt-5.3-codex" }, - "gpt-codex-mini": { displayName: "GPT Codex Mini", resolve: "opencode/gpt-5.1-codex-mini" }, - "gemini-pro": { displayName: "Gemini Pro", resolve: "opencode/gemini-3.1-pro" }, - "gemini-flash": { displayName: "Gemini Flash", resolve: "opencode/gemini-3-flash" }, - "kimi-k2": { displayName: "Kimi K2", resolve: "opencode/kimi-k2.5" }, + "claude-opus": { + displayName: "Claude Opus", + resolve: "opencode/claude-opus-4-6", + openRouterResolve: "openrouter/anthropic/claude-opus-4.6", + }, + "claude-sonnet": { + displayName: "Claude Sonnet", + resolve: "opencode/claude-sonnet-4-6", + openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6", + }, + "claude-haiku": { + displayName: "Claude Haiku", + resolve: "opencode/claude-haiku-4-5", + openRouterResolve: "openrouter/anthropic/claude-haiku-4.5", + }, + "gpt-codex": { + displayName: "GPT Codex", + resolve: "opencode/gpt-5.3-codex", + openRouterResolve: "openrouter/openai/gpt-5.3-codex", + }, + "gpt-codex-mini": { + displayName: "GPT Codex Mini", + resolve: "opencode/gpt-5.1-codex-mini", + openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini", + }, + "gemini-pro": { + displayName: "Gemini Pro", + resolve: "opencode/gemini-3.1-pro", + openRouterResolve: "openrouter/google/gemini-3.1-pro-preview", + }, + "gemini-flash": { + displayName: "Gemini Flash", + resolve: "opencode/gemini-3-flash", + openRouterResolve: "openrouter/google/gemini-3-flash-preview", + }, + "kimi-k2": { + displayName: "Kimi K2", + resolve: "opencode/kimi-k2.5", + openRouterResolve: "openrouter/moonshotai/kimi-k2.5", + }, "gpt-5-nano": { displayName: "GPT Nano", resolve: "opencode/gpt-5-nano", @@ -158,36 +243,59 @@ export const providers = { "claude-opus": { displayName: "Claude Opus", resolve: "openrouter/anthropic/claude-opus-4.6", - recommended: true, + openRouterResolve: "openrouter/anthropic/claude-opus-4.6", + preferred: true, }, "claude-sonnet": { displayName: "Claude Sonnet", resolve: "openrouter/anthropic/claude-sonnet-4.6", + openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6", }, "claude-haiku": { displayName: "Claude Haiku", resolve: "openrouter/anthropic/claude-haiku-4.5", + openRouterResolve: "openrouter/anthropic/claude-haiku-4.5", + }, + "gpt-codex": { + displayName: "GPT Codex", + resolve: "openrouter/openai/gpt-5.3-codex", + openRouterResolve: "openrouter/openai/gpt-5.3-codex", }, - "gpt-codex": { displayName: "GPT Codex", resolve: "openrouter/openai/gpt-5.3-codex" }, "gpt-codex-mini": { displayName: "GPT Codex Mini", resolve: "openrouter/openai/gpt-5.1-codex-mini", + openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini", + }, + "o4-mini": { + displayName: "O4 Mini", + resolve: "openrouter/openai/o4-mini", + openRouterResolve: "openrouter/openai/o4-mini", }, - "o4-mini": { displayName: "O4 Mini", resolve: "openrouter/openai/o4-mini" }, "gemini-pro": { displayName: "Gemini Pro", resolve: "openrouter/google/gemini-3.1-pro-preview", + openRouterResolve: "openrouter/google/gemini-3.1-pro-preview", }, "gemini-flash": { displayName: "Gemini Flash", resolve: "openrouter/google/gemini-3-flash-preview", + openRouterResolve: "openrouter/google/gemini-3-flash-preview", + }, + grok: { + displayName: "Grok", + resolve: "openrouter/x-ai/grok-4", + openRouterResolve: "openrouter/x-ai/grok-4", }, - grok: { displayName: "Grok", resolve: "openrouter/x-ai/grok-4" }, "deepseek-chat": { displayName: "DeepSeek Chat", - resolve: "openrouter/deepseek/deepseek-chat-v3.1", + resolve: "openrouter/deepseek/deepseek-v3.2", + openRouterResolve: "openrouter/deepseek/deepseek-v3.2", + }, + "kimi-k2": { + displayName: "Kimi K2", + resolve: "openrouter/moonshotai/kimi-k2.5", + openRouterResolve: "openrouter/moonshotai/kimi-k2.5", }, - "kimi-k2": { displayName: "Kimi K2", resolve: "openrouter/moonshotai/kimi-k2.5" }, }, }), } satisfies Record; @@ -208,6 +316,11 @@ export function getModelProvider(slug: string): string { return parseModel(slug).provider; } +export function getProviderDisplayName(slug: string): string | undefined { + const parsed = parseModel(slug); + return (providers as Record)[parsed.provider]?.displayName; +} + export function getModelEnvVars(slug: string): string[] { const parsed = parseModel(slug); const providerConfig = (providers as Record)[parsed.provider]; @@ -232,7 +345,8 @@ export const modelAliases: ModelAlias[] = Object.entries(providers).flatMap( provider: providerKey, displayName: def.displayName, resolve: def.resolve, - recommended: def.recommended ?? false, + openRouterResolve: def.openRouterResolve, + preferred: def.preferred ?? false, isFree: def.isFree ?? false, })) ); diff --git a/package.json b/package.json index b001fb0..9ae400f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pullfrog/pullfrog", - "version": "0.0.181", + "version": "0.0.182", "type": "module", "files": [ "index.js", diff --git a/post b/post index 47661a9..8f445c1 100755 --- a/post +++ b/post @@ -37521,19 +37521,40 @@ var providers = { "claude-opus": { displayName: "Claude Opus", resolve: "anthropic/claude-opus-4-6", - recommended: true + openRouterResolve: "openrouter/anthropic/claude-opus-4.6", + preferred: true }, - "claude-sonnet": { displayName: "Claude Sonnet", resolve: "anthropic/claude-sonnet-4-6" }, - "claude-haiku": { displayName: "Claude Haiku", resolve: "anthropic/claude-haiku-4-5" } + "claude-sonnet": { + displayName: "Claude Sonnet", + resolve: "anthropic/claude-sonnet-4-6", + openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6" + }, + "claude-haiku": { + displayName: "Claude Haiku", + resolve: "anthropic/claude-haiku-4-5", + openRouterResolve: "openrouter/anthropic/claude-haiku-4.5" + } } }), openai: provider({ displayName: "OpenAI", envVars: ["OPENAI_API_KEY"], models: { - "gpt-codex": { displayName: "GPT Codex", resolve: "openai/gpt-5.3-codex", recommended: true }, - "gpt-codex-mini": { displayName: "GPT Codex Mini", resolve: "openai/codex-mini-latest" }, - o3: { displayName: "O3", resolve: "openai/o3" } + "gpt-codex": { + displayName: "GPT Codex", + resolve: "openai/gpt-5.3-codex", + openRouterResolve: "openrouter/openai/gpt-5.3-codex", + preferred: true + }, + "gpt-codex-mini": { + displayName: "GPT Codex Mini", + resolve: "openai/codex-mini-latest", + openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini" + }, + o3: { + displayName: "O3", + resolve: "openai/o3" + } } }), google: provider({ @@ -37543,18 +37564,36 @@ var providers = { "gemini-pro": { displayName: "Gemini Pro", resolve: "google/gemini-3.1-pro-preview", - recommended: true + openRouterResolve: "openrouter/google/gemini-3.1-pro-preview", + preferred: true }, - "gemini-flash": { displayName: "Gemini Flash", resolve: "google/gemini-3-flash-preview" } + "gemini-flash": { + displayName: "Gemini Flash", + resolve: "google/gemini-3-flash-preview", + openRouterResolve: "openrouter/google/gemini-3-flash-preview" + } } }), xai: provider({ displayName: "xAI", envVars: ["XAI_API_KEY"], models: { - grok: { displayName: "Grok", resolve: "xai/grok-4", recommended: true }, - "grok-fast": { displayName: "Grok Fast", resolve: "xai/grok-4-fast" }, - "grok-code-fast": { displayName: "Grok Code Fast", resolve: "xai/grok-code-fast-1" } + grok: { + displayName: "Grok", + resolve: "xai/grok-4", + openRouterResolve: "openrouter/x-ai/grok-4", + preferred: true + }, + "grok-fast": { + displayName: "Grok Fast", + resolve: "xai/grok-4-fast", + openRouterResolve: "openrouter/x-ai/grok-4-fast" + }, + "grok-code-fast": { + displayName: "Grok Code Fast", + resolve: "xai/grok-code-fast-1", + openRouterResolve: "openrouter/x-ai/grok-code-fast-1" + } } }), deepseek: provider({ @@ -37564,16 +37603,26 @@ var providers = { "deepseek-reasoner": { displayName: "DeepSeek Reasoner", resolve: "deepseek/deepseek-reasoner", - recommended: true + openRouterResolve: "openrouter/deepseek/deepseek-v3.2", + preferred: true }, - "deepseek-chat": { displayName: "DeepSeek Chat", resolve: "deepseek/deepseek-chat" } + "deepseek-chat": { + displayName: "DeepSeek Chat", + resolve: "deepseek/deepseek-chat", + openRouterResolve: "openrouter/deepseek/deepseek-v3.2" + } } }), moonshotai: provider({ displayName: "Moonshot AI", envVars: ["MOONSHOT_API_KEY"], models: { - "kimi-k2": { displayName: "Kimi K2", resolve: "moonshotai/kimi-k2.5", recommended: true } + "kimi-k2": { + displayName: "Kimi K2", + resolve: "moonshotai/kimi-k2.5", + openRouterResolve: "openrouter/moonshotai/kimi-k2.5", + preferred: true + } } }), opencode: provider({ @@ -37583,18 +37632,50 @@ var providers = { "big-pickle": { displayName: "Big Pickle", resolve: "opencode/big-pickle", - recommended: true, + preferred: true, envVars: [], isFree: true }, - "claude-opus": { displayName: "Claude Opus", resolve: "opencode/claude-opus-4-6" }, - "claude-sonnet": { displayName: "Claude Sonnet", resolve: "opencode/claude-sonnet-4-6" }, - "claude-haiku": { displayName: "Claude Haiku", resolve: "opencode/claude-haiku-4-5" }, - "gpt-codex": { displayName: "GPT Codex", resolve: "opencode/gpt-5.3-codex" }, - "gpt-codex-mini": { displayName: "GPT Codex Mini", resolve: "opencode/gpt-5.1-codex-mini" }, - "gemini-pro": { displayName: "Gemini Pro", resolve: "opencode/gemini-3.1-pro" }, - "gemini-flash": { displayName: "Gemini Flash", resolve: "opencode/gemini-3-flash" }, - "kimi-k2": { displayName: "Kimi K2", resolve: "opencode/kimi-k2.5" }, + "claude-opus": { + displayName: "Claude Opus", + resolve: "opencode/claude-opus-4-6", + openRouterResolve: "openrouter/anthropic/claude-opus-4.6" + }, + "claude-sonnet": { + displayName: "Claude Sonnet", + resolve: "opencode/claude-sonnet-4-6", + openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6" + }, + "claude-haiku": { + displayName: "Claude Haiku", + resolve: "opencode/claude-haiku-4-5", + openRouterResolve: "openrouter/anthropic/claude-haiku-4.5" + }, + "gpt-codex": { + displayName: "GPT Codex", + resolve: "opencode/gpt-5.3-codex", + openRouterResolve: "openrouter/openai/gpt-5.3-codex" + }, + "gpt-codex-mini": { + displayName: "GPT Codex Mini", + resolve: "opencode/gpt-5.1-codex-mini", + openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini" + }, + "gemini-pro": { + displayName: "Gemini Pro", + resolve: "opencode/gemini-3.1-pro", + openRouterResolve: "openrouter/google/gemini-3.1-pro-preview" + }, + "gemini-flash": { + displayName: "Gemini Flash", + resolve: "opencode/gemini-3-flash", + openRouterResolve: "openrouter/google/gemini-3-flash-preview" + }, + "kimi-k2": { + displayName: "Kimi K2", + resolve: "opencode/kimi-k2.5", + openRouterResolve: "openrouter/moonshotai/kimi-k2.5" + }, "gpt-5-nano": { displayName: "GPT Nano", resolve: "opencode/gpt-5-nano", @@ -37628,36 +37709,59 @@ var providers = { "claude-opus": { displayName: "Claude Opus", resolve: "openrouter/anthropic/claude-opus-4.6", - recommended: true + openRouterResolve: "openrouter/anthropic/claude-opus-4.6", + preferred: true }, "claude-sonnet": { displayName: "Claude Sonnet", - resolve: "openrouter/anthropic/claude-sonnet-4.6" + resolve: "openrouter/anthropic/claude-sonnet-4.6", + openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6" }, "claude-haiku": { displayName: "Claude Haiku", - resolve: "openrouter/anthropic/claude-haiku-4.5" + resolve: "openrouter/anthropic/claude-haiku-4.5", + openRouterResolve: "openrouter/anthropic/claude-haiku-4.5" + }, + "gpt-codex": { + displayName: "GPT Codex", + resolve: "openrouter/openai/gpt-5.3-codex", + openRouterResolve: "openrouter/openai/gpt-5.3-codex" }, - "gpt-codex": { displayName: "GPT Codex", resolve: "openrouter/openai/gpt-5.3-codex" }, "gpt-codex-mini": { displayName: "GPT Codex Mini", - resolve: "openrouter/openai/gpt-5.1-codex-mini" + resolve: "openrouter/openai/gpt-5.1-codex-mini", + openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini" + }, + "o4-mini": { + displayName: "O4 Mini", + resolve: "openrouter/openai/o4-mini", + openRouterResolve: "openrouter/openai/o4-mini" }, - "o4-mini": { displayName: "O4 Mini", resolve: "openrouter/openai/o4-mini" }, "gemini-pro": { displayName: "Gemini Pro", - resolve: "openrouter/google/gemini-3.1-pro-preview" + resolve: "openrouter/google/gemini-3.1-pro-preview", + openRouterResolve: "openrouter/google/gemini-3.1-pro-preview" }, "gemini-flash": { displayName: "Gemini Flash", - resolve: "openrouter/google/gemini-3-flash-preview" + resolve: "openrouter/google/gemini-3-flash-preview", + openRouterResolve: "openrouter/google/gemini-3-flash-preview" + }, + grok: { + displayName: "Grok", + resolve: "openrouter/x-ai/grok-4", + openRouterResolve: "openrouter/x-ai/grok-4" }, - grok: { displayName: "Grok", resolve: "openrouter/x-ai/grok-4" }, "deepseek-chat": { displayName: "DeepSeek Chat", - resolve: "openrouter/deepseek/deepseek-chat-v3.1" + resolve: "openrouter/deepseek/deepseek-v3.2", + openRouterResolve: "openrouter/deepseek/deepseek-v3.2" }, - "kimi-k2": { displayName: "Kimi K2", resolve: "openrouter/moonshotai/kimi-k2.5" } + "kimi-k2": { + displayName: "Kimi K2", + resolve: "openrouter/moonshotai/kimi-k2.5", + openRouterResolve: "openrouter/moonshotai/kimi-k2.5" + } } }) }; @@ -37667,7 +37771,8 @@ var modelAliases = Object.entries(providers).flatMap( provider: providerKey, displayName: def.displayName, resolve: def.resolve, - recommended: def.recommended ?? false, + openRouterResolve: def.openRouterResolve, + preferred: def.preferred ?? false, isFree: def.isFree ?? false })) ); @@ -41451,7 +41556,7 @@ var core3 = __toESM(require_core(), 1); // package.json var package_default = { name: "@pullfrog/pullfrog", - version: "0.0.181", + version: "0.0.182", type: "module", files: [ "index.js", diff --git a/test/ci.test.ts b/test/ci.test.ts index 31d9d9d..851b369 100644 --- a/test/ci.test.ts +++ b/test/ci.test.ts @@ -64,7 +64,6 @@ const expectedAgentEnvVars = [ "GITHUB_TOKEN", ...new Set(Object.values(providers).flatMap((p) => [...p.envVars])), "PULLFROG_MODEL", - "OPENCODE_MODEL", ].sort(); const expectedAgnosticEnvVars = ["ANTHROPIC_API_KEY", "GITHUB_TOKEN"].sort(); diff --git a/test/models.test.ts b/test/models.test.ts index 5c6b5bb..d5eabd3 100644 --- a/test/models.test.ts +++ b/test/models.test.ts @@ -46,6 +46,82 @@ describe("models.dev validity", async () => { } }); +// ── openRouterResolve coverage ───────────────────────────────────────────────── + +// models that have no OpenRouter equivalent and require BYOK. +// add a model here ONLY when it genuinely doesn't exist on both models.dev and OpenRouter. +const BYOK_ONLY_MODELS = new Set(["openai/o3"]); + +describe("openRouterResolve completeness", () => { + for (const alias of modelAliases) { + if (alias.isFree) continue; + if (BYOK_ONLY_MODELS.has(alias.slug)) continue; + it(`${alias.slug} has openRouterResolve`, () => { + expect( + alias.openRouterResolve, + `non-free model "${alias.slug}" is missing openRouterResolve — add it or add to BYOK_ONLY_MODELS` + ).toBeDefined(); + }); + } + + for (const alias of modelAliases) { + if (!alias.isFree) continue; + it(`${alias.slug} (free) does not need openRouterResolve`, () => { + expect(alias.openRouterResolve).toBeUndefined(); + }); + } +}); + +describe("openRouterResolve models.dev validity", async () => { + const data = await api; + const seen = new Set(); + + for (const alias of modelAliases) { + if (!alias.openRouterResolve) continue; + if (seen.has(alias.openRouterResolve)) continue; + seen.add(alias.openRouterResolve); + + const parsed = parseResolve(alias.openRouterResolve); + + it(`${alias.openRouterResolve} exists on models.dev`, () => { + const providerData = data[parsed.provider]; + expect(providerData, `provider "${parsed.provider}" not found on models.dev`).toBeDefined(); + const model = providerData.models[parsed.modelId]; + expect( + model, + `model "${parsed.modelId}" not found under ${parsed.provider} on models.dev` + ).toBeDefined(); + }); + } +}); + +type OpenRouterModel = { id: string }; +type OpenRouterModelsResponse = { data: OpenRouterModel[] }; + +const openRouterApi = fetch("https://openrouter.ai/api/v1/models").then( + (r) => r.json() as Promise +); + +describe("openRouterResolve OpenRouter API validity", async () => { + const orData = await openRouterApi; + const orModelIds = new Set(orData.data.map((m) => m.id)); + const seen = new Set(); + + for (const alias of modelAliases) { + if (!alias.openRouterResolve) continue; + const orModelId = alias.openRouterResolve.slice("openrouter/".length); + if (seen.has(orModelId)) continue; + seen.add(orModelId); + + it(`${orModelId} exists on OpenRouter`, () => { + expect( + orModelIds.has(orModelId), + `model "${orModelId}" not found in OpenRouter API (/api/v1/models)` + ).toBe(true); + }); + } +}); + describe("latest model per provider snapshot", async () => { const data = await api; const providerKeys = Object.keys(providers) as ModelProvider[]; diff --git a/test/run.ts b/test/run.ts index 37608da..e6e9242 100644 --- a/test/run.ts +++ b/test/run.ts @@ -307,7 +307,7 @@ async function runTestForAgent(ctx: RunContext): Promise { // use anthropic sonnet to avoid google quota issues and gemini doom-looping if (ctx.agent === "opentoad") { - env.OPENCODE_MODEL ??= "anthropic/claude-sonnet-4-5"; + env.PULLFROG_MODEL ??= "anthropic/claude-sonnet-4-5"; } // build file-based env vars for MCP servers that don't inherit parent env diff --git a/utils/apiKeys.ts b/utils/apiKeys.ts index d25bcf7..d61bf3d 100644 --- a/utils/apiKeys.ts +++ b/utils/apiKeys.ts @@ -30,6 +30,13 @@ function hasEnvVar(name: string): boolean { return typeof value === "string" && value.length > 0; } +/** check if the user has a BYOK key for the given model's provider (does not throw) */ +export function hasProviderKey(model: string): boolean { + const requiredVars = getModelEnvVars(model); + if (requiredVars.length === 0) return true; + return requiredVars.some((v) => hasEnvVar(v)); +} + export function validateAgentApiKey(params: { agent: { name: string }; model: string | undefined; diff --git a/utils/docker.ts b/utils/docker.ts index fa2bd7e..9e608ea 100644 --- a/utils/docker.ts +++ b/utils/docker.ts @@ -118,7 +118,6 @@ const testEnvAllowList = new Set([ "GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY", "PULLFROG_MODEL", - "OPENCODE_MODEL", "LOG_LEVEL", "DEBUG", "NODE_ENV", diff --git a/utils/payload.ts b/utils/payload.ts index a0964b6..47cafdf 100644 --- a/utils/payload.ts +++ b/utils/payload.ts @@ -161,6 +161,9 @@ export function resolvePayload( // permissions: inputs > repoSettings > fallbacks push: inputs.push ?? repoSettings.push ?? "restricted", shell: resolvedShell, + + // set by proxy logic in main.ts when routing through OpenRouter + proxyModel: undefined as string | undefined, }; } diff --git a/utils/runContext.ts b/utils/runContext.ts index 8827d2a..7664b37 100644 --- a/utils/runContext.ts +++ b/utils/runContext.ts @@ -23,6 +23,8 @@ export interface RepoSettings { export interface RunContext { settings: RepoSettings; apiToken: string; + oss: boolean; + proxyModel?: string | undefined; } const defaultSettings: RepoSettings = { @@ -39,6 +41,7 @@ const defaultSettings: RepoSettings = { const defaultRunContext: RunContext = { settings: defaultSettings, apiToken: "", + oss: false, }; /** @@ -73,6 +76,8 @@ export async function fetchRunContext(params: { const data = (await response.json()) as { settings: RepoSettings | null; apiToken: string; + oss?: boolean; + proxyModel?: string; } | null; if (data === null) { @@ -88,6 +93,8 @@ export async function fetchRunContext(params: { postCheckoutScript: data.settings?.postCheckoutScript ?? null, }, apiToken: data.apiToken, + oss: data.oss ?? false, + proxyModel: data.proxyModel, }; } catch { clearTimeout(timeoutId); diff --git a/utils/runContextData.ts b/utils/runContextData.ts index 68d84cf..0d7a7d6 100644 --- a/utils/runContextData.ts +++ b/utils/runContextData.ts @@ -12,6 +12,8 @@ export interface RunContextData { }; repoSettings: RepoSettings; apiToken: string; + oss: boolean; + proxyModel?: string | undefined; } interface ResolveRunContextDataParams { @@ -42,5 +44,7 @@ export async function resolveRunContextData( }, repoSettings: runContext.settings, apiToken: runContext.apiToken, + oss: runContext.oss, + proxyModel: runContext.proxyModel, }; }