diff --git a/entry b/entry index e1350ac..d05f8ff 100755 --- a/entry +++ b/entry @@ -132065,6 +132065,25 @@ var providers = { } }) }; +function parseModel(slug) { + const slashIdx = slug.indexOf("/"); + if (slashIdx === -1) { + throw new Error(`invalid model slug "${slug}" \u2014 expected "provider/model"`); + } + return { provider: slug.slice(0, slashIdx), model: slug.slice(slashIdx + 1) }; +} +function getModelEnvVars(slug) { + const parsed2 = parseModel(slug); + const providerConfig = providers[parsed2.provider]; + if (!providerConfig) { + return []; + } + const modelConfig = providerConfig.models[parsed2.model]; + if (modelConfig?.envVars) { + return modelConfig.envVars.slice(); + } + return providerConfig.envVars.slice(); +} var modelAliases = Object.entries(providers).flatMap( ([providerKey, config3]) => Object.entries(config3.models).map(([modelId, def]) => ({ slug: `${providerKey}/${modelId}`, @@ -149212,10 +149231,18 @@ to fix this, add the required secret to your GitHub repository: configure your model at ${settingsUrl}`; } +function hasEnvVar(name) { + const value2 = process.env[name]; + return typeof value2 === "string" && value2.length > 0; +} function validateAgentApiKey(params) { - const hasAnyKey = Object.entries(process.env).some( - ([key, value2]) => value2 && typeof value2 === "string" && knownApiKeys.has(key) - ); + if (params.model) { + const requiredVars = getModelEnvVars(params.model); + if (requiredVars.length === 0) return; + if (requiredVars.some((v) => hasEnvVar(v))) return; + throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name })); + } + const hasAnyKey = [...knownApiKeys].some((k) => hasEnvVar(k)); if (!hasAnyKey) { throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name })); } @@ -149805,7 +149832,7 @@ import { isAbsolute, resolve } from "node:path"; // package.json var package_default = { name: "@pullfrog/pullfrog", - version: "0.0.180", + version: "0.0.181", type: "module", files: [ "index.js", @@ -150374,6 +150401,7 @@ async function main() { const agent2 = resolveAgent(); validateAgentApiKey({ agent: agent2, + model: payload.model, owner: runContext.repo.owner, name: runContext.repo.name }); diff --git a/main.ts b/main.ts index 9d3ca98..d530628 100644 --- a/main.ts +++ b/main.ts @@ -151,6 +151,7 @@ export async function main(): Promise { validateAgentApiKey({ agent, + model: payload.model, owner: runContext.repo.owner, name: runContext.repo.name, }); diff --git a/package.json b/package.json index 9eefbab..b001fb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pullfrog/pullfrog", - "version": "0.0.180", + "version": "0.0.181", "type": "module", "files": [ "index.js", diff --git a/post b/post index 1643e46..a828513 100755 --- a/post +++ b/post @@ -41284,7 +41284,7 @@ var core3 = __toESM(require_core(), 1); // package.json var package_default = { name: "@pullfrog/pullfrog", - version: "0.0.180", + version: "0.0.181", type: "module", files: [ "index.js", diff --git a/utils/apiKeys.test.ts b/utils/apiKeys.test.ts new file mode 100644 index 0000000..b49a0bc --- /dev/null +++ b/utils/apiKeys.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { validateAgentApiKey } from "./apiKeys.ts"; + +const base = { + agent: { name: "opentoad" }, + owner: "test-owner", + name: "test-repo", +}; + +const savedEnv = { ...process.env }; + +beforeEach(() => { + // strip all known provider keys so tests start clean + for (const key of Object.keys(process.env)) { + if (key.endsWith("_API_KEY")) delete process.env[key]; + } +}); + +afterEach(() => { + process.env = { ...savedEnv }; +}); + +describe("validateAgentApiKey", () => { + describe("free model (no keys required)", () => { + it("passes with zero env keys", () => { + expect(() => validateAgentApiKey({ ...base, model: "opencode/big-pickle" })).not.toThrow(); + }); + + it("passes for other free opencode models", () => { + for (const slug of [ + "opencode/gpt-5-nano", + "opencode/mimo-v2-flash-free", + "opencode/minimax-m2.5-free", + "opencode/nemotron-3-super-free", + ]) { + expect(() => validateAgentApiKey({ ...base, model: slug })).not.toThrow(); + } + }); + }); + + describe("keyed model", () => { + it("passes when the required key is present", () => { + process.env.ANTHROPIC_API_KEY = "sk-test"; + expect(() => validateAgentApiKey({ ...base, model: "anthropic/claude-opus" })).not.toThrow(); + }); + + it("throws when the required key is missing", () => { + expect(() => validateAgentApiKey({ ...base, model: "anthropic/claude-opus" })).toThrow( + "no API key found" + ); + }); + + it("passes for opencode keyed model with OPENCODE_API_KEY", () => { + process.env.OPENCODE_API_KEY = "sk-test"; + expect(() => validateAgentApiKey({ ...base, model: "opencode/claude-opus" })).not.toThrow(); + }); + + it("throws for opencode keyed model without OPENCODE_API_KEY", () => { + expect(() => validateAgentApiKey({ ...base, model: "opencode/claude-opus" })).toThrow( + "no API key found" + ); + }); + }); + + describe("no model (auto-select)", () => { + it("passes when any known provider key is present", () => { + process.env.OPENAI_API_KEY = "sk-test"; + expect(() => validateAgentApiKey({ ...base, model: undefined })).not.toThrow(); + }); + + it("throws when no provider keys are present", () => { + expect(() => validateAgentApiKey({ ...base, model: undefined })).toThrow("no API key found"); + }); + }); +}); diff --git a/utils/apiKeys.ts b/utils/apiKeys.ts index 8b9002c..880dd74 100644 --- a/utils/apiKeys.ts +++ b/utils/apiKeys.ts @@ -1,4 +1,4 @@ -import { providers } from "../models.ts"; +import { getModelEnvVars, providers } from "../models.ts"; import { getApiUrl } from "./apiUrl.ts"; const knownApiKeys: Set = new Set(Object.values(providers).flatMap((p) => [...p.envVars])); @@ -23,15 +23,29 @@ to fix this, add the required secret to your GitHub repository: configure your model at ${settingsUrl}`; } +function hasEnvVar(name: string): boolean { + const value = process.env[name]; + return typeof value === "string" && value.length > 0; +} + export function validateAgentApiKey(params: { agent: { name: string }; + model: string | undefined; owner: string; name: string; }): void { - const hasAnyKey = Object.entries(process.env).some( - ([key, value]) => value && typeof value === "string" && knownApiKeys.has(key) - ); + // if a specific model is configured, only check that model's required env vars + if (params.model) { + const requiredVars = getModelEnvVars(params.model); + // free models have no required env vars — skip validation entirely + if (requiredVars.length === 0) return; + if (requiredVars.some((v) => hasEnvVar(v))) return; + throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name })); + } + + // no model configured — auto-select requires at least one known provider key + const hasAnyKey = [...knownApiKeys].some((k) => hasEnvVar(k)); if (!hasAnyKey) { throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name })); }