fix: skip API key validation for free opencode models

free models (big-pickle, gpt-5-nano, etc.) define envVars: [] and
isFree: true but validateAgentApiKey always required at least one
provider key. now the validation is model-aware: free models bypass
the check, keyed models validate their specific vars, and auto-select
still requires at least one known key.

closes #483

Made-with: Cursor
This commit is contained in:
Colin McDonnell
2026-03-16 20:48:59 +00:00
committed by pullfrog[bot]
parent 8a734c32f4
commit 30d68e53a7
6 changed files with 128 additions and 10 deletions
+32 -4
View File
@@ -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
});
+1
View File
@@ -151,6 +151,7 @@ export async function main(): Promise<MainResult> {
validateAgentApiKey({
agent,
model: payload.model,
owner: runContext.repo.owner,
name: runContext.repo.name,
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@pullfrog/pullfrog",
"version": "0.0.180",
"version": "0.0.181",
"type": "module",
"files": [
"index.js",
+1 -1
View File
@@ -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",
+75
View File
@@ -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");
});
});
});
+18 -4
View File
@@ -1,4 +1,4 @@
import { providers } from "../models.ts";
import { getModelEnvVars, providers } from "../models.ts";
import { getApiUrl } from "./apiUrl.ts";
const knownApiKeys: Set<string> = 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 }));
}