Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6d34ee01b | |||
| 39525547b5 | |||
| 3ff11f97eb | |||
| b31800c213 | |||
| 3a1ffde545 | |||
| cccf1775d6 | |||
| 026cc7a276 | |||
| c6a3ee0e9a | |||
| 30d68e53a7 | |||
| 8a734c32f4 | |||
| 2e37fb3dfa | |||
| cbbcb64859 | |||
| df9598ea5f |
@@ -25,7 +25,7 @@ jobs:
|
|||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
@@ -41,11 +41,11 @@ jobs:
|
|||||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }}
|
PULLFROG_MODEL: ${{ vars.PULLFROG_MODEL }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
@@ -79,7 +79,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|||||||
+13
-11
@@ -75,9 +75,9 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
|
|||||||
// ── model resolution (see wiki/model-resolution.md) ─────────────────────────────
|
// ── model resolution (see wiki/model-resolution.md) ─────────────────────────────
|
||||||
//
|
//
|
||||||
// priority:
|
// priority:
|
||||||
// 1. OPENCODE_MODEL env var (explicit override)
|
// 1. PULLFROG_MODEL env var (explicit override)
|
||||||
// 2. explicit slug from repo config / payload
|
// 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
|
// 4. undefined → let OpenCode decide
|
||||||
|
|
||||||
function getOpenCodeModels(cliPath: string): string[] {
|
function getOpenCodeModels(cliPath: string): string[] {
|
||||||
@@ -107,9 +107,9 @@ function resolveOpenCodeModel(ctx: {
|
|||||||
modelSlug?: string | undefined;
|
modelSlug?: string | undefined;
|
||||||
}): string | undefined {
|
}): string | undefined {
|
||||||
// 1. explicit env var override
|
// 1. explicit env var override
|
||||||
const envModel = process.env.OPENCODE_MODEL?.trim();
|
const envModel = process.env.PULLFROG_MODEL?.trim();
|
||||||
if (envModel) {
|
if (envModel) {
|
||||||
log.info(`» model: ${envModel} (override via OPENCODE_MODEL)`);
|
log.info(`» model: ${envModel} (override via PULLFROG_MODEL)`);
|
||||||
return envModel;
|
return envModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,17 +125,17 @@ function resolveOpenCodeModel(ctx: {
|
|||||||
|
|
||||||
// 3. auto-select: ask OpenCode what's available, pick our best curated match.
|
// 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.
|
// `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 availableModels = getOpenCodeModels(ctx.cliPath);
|
||||||
const availableSet = new Set(availableModels);
|
const availableSet = new Set(availableModels);
|
||||||
if (availableSet.size > 0) {
|
if (availableSet.size > 0) {
|
||||||
log.debug(`» opencode models (${availableSet.size}): ${availableModels.join(", ")}`);
|
log.debug(`» opencode models (${availableSet.size}): ${availableModels.join(", ")}`);
|
||||||
const match =
|
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));
|
modelAliases.find((a) => availableSet.has(a.resolve));
|
||||||
if (match) {
|
if (match) {
|
||||||
log.info(
|
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}`);
|
log.warning(`» model auto-selected. ${AUTO_SELECT_WARNING}`);
|
||||||
return match.resolve;
|
return match.resolve;
|
||||||
@@ -621,10 +621,12 @@ export const opentoad = agent({
|
|||||||
run: async (ctx) => {
|
run: async (ctx) => {
|
||||||
const cliPath = await installOpencodeCli();
|
const cliPath = await installOpencodeCli();
|
||||||
|
|
||||||
const model = resolveOpenCodeModel({
|
const model =
|
||||||
cliPath,
|
ctx.payload.proxyModel ??
|
||||||
modelSlug: ctx.payload.model,
|
resolveOpenCodeModel({
|
||||||
});
|
cliPath,
|
||||||
|
modelSlug: ctx.payload.model,
|
||||||
|
});
|
||||||
|
|
||||||
const tempHome = ctx.tmpdir;
|
const tempHome = ctx.tmpdir;
|
||||||
mkdirSync(join(tempHome, ".config", "opencode"), { recursive: true });
|
mkdirSync(join(tempHome, ".config", "opencode"), { recursive: true });
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export const agent = (input: Agent): Agent => {
|
|||||||
return {
|
return {
|
||||||
...input,
|
...input,
|
||||||
run: async (ctx: AgentRunContext): Promise<AgentResult> => {
|
run: async (ctx: AgentRunContext): Promise<AgentResult> => {
|
||||||
log.info(`» agent: ${input.name}`);
|
|
||||||
if (ctx.payload.model) log.info(`» model: ${ctx.payload.model}`);
|
if (ctx.payload.model) log.info(`» model: ${ctx.payload.model}`);
|
||||||
if (ctx.payload.timeout) log.info(`» timeout: ${ctx.payload.timeout}`);
|
if (ctx.payload.timeout) log.info(`» timeout: ${ctx.payload.timeout}`);
|
||||||
log.info(`» push: ${ctx.payload.push}`);
|
log.info(`» push: ${ctx.payload.push}`);
|
||||||
|
|||||||
@@ -19718,10 +19718,10 @@ var require_core = __commonJS({
|
|||||||
(0, command_1.issueCommand)("set-env", { name }, convertedVal);
|
(0, command_1.issueCommand)("set-env", { name }, convertedVal);
|
||||||
}
|
}
|
||||||
exports.exportVariable = exportVariable;
|
exports.exportVariable = exportVariable;
|
||||||
function setSecret2(secret) {
|
function setSecret3(secret) {
|
||||||
(0, command_1.issueCommand)("add-mask", {}, secret);
|
(0, command_1.issueCommand)("add-mask", {}, secret);
|
||||||
}
|
}
|
||||||
exports.setSecret = setSecret2;
|
exports.setSecret = setSecret3;
|
||||||
function addPath(inputPath) {
|
function addPath(inputPath) {
|
||||||
const filePath = process.env["GITHUB_PATH"] || "";
|
const filePath = process.env["GITHUB_PATH"] || "";
|
||||||
if (filePath) {
|
if (filePath) {
|
||||||
@@ -19838,12 +19838,12 @@ Support boolean input list: \`true | True | TRUE | false | False | FALSE\``);
|
|||||||
return process.env[`STATE_${name}`] || "";
|
return process.env[`STATE_${name}`] || "";
|
||||||
}
|
}
|
||||||
exports.getState = getState;
|
exports.getState = getState;
|
||||||
function getIDToken2(aud) {
|
function getIDToken3(aud) {
|
||||||
return __awaiter(this, void 0, void 0, function* () {
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
return yield oidc_utils_1.OidcClient.getIDToken(aud);
|
return yield oidc_utils_1.OidcClient.getIDToken(aud);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
exports.getIDToken = getIDToken2;
|
exports.getIDToken = getIDToken3;
|
||||||
var summary_1 = require_summary();
|
var summary_1 = require_summary();
|
||||||
Object.defineProperty(exports, "summary", { enumerable: true, get: function() {
|
Object.defineProperty(exports, "summary", { enumerable: true, get: function() {
|
||||||
return summary_1.summary;
|
return summary_1.summary;
|
||||||
@@ -131925,19 +131925,40 @@ var providers = {
|
|||||||
"claude-opus": {
|
"claude-opus": {
|
||||||
displayName: "Claude Opus",
|
displayName: "Claude Opus",
|
||||||
resolve: "anthropic/claude-opus-4-6",
|
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-sonnet": {
|
||||||
"claude-haiku": { displayName: "Claude Haiku", resolve: "anthropic/claude-haiku-4-5" }
|
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({
|
openai: provider({
|
||||||
displayName: "OpenAI",
|
displayName: "OpenAI",
|
||||||
envVars: ["OPENAI_API_KEY"],
|
envVars: ["OPENAI_API_KEY"],
|
||||||
models: {
|
models: {
|
||||||
"gpt-codex": { displayName: "GPT Codex", resolve: "openai/gpt-5.3-codex", recommended: true },
|
"gpt-codex": {
|
||||||
"gpt-codex-mini": { displayName: "GPT Codex Mini", resolve: "openai/codex-mini-latest" },
|
displayName: "GPT Codex",
|
||||||
o3: { displayName: "O3", resolve: "openai/o3" }
|
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({
|
google: provider({
|
||||||
@@ -131947,18 +131968,36 @@ var providers = {
|
|||||||
"gemini-pro": {
|
"gemini-pro": {
|
||||||
displayName: "Gemini Pro",
|
displayName: "Gemini Pro",
|
||||||
resolve: "google/gemini-3.1-pro-preview",
|
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({
|
xai: provider({
|
||||||
displayName: "xAI",
|
displayName: "xAI",
|
||||||
envVars: ["XAI_API_KEY"],
|
envVars: ["XAI_API_KEY"],
|
||||||
models: {
|
models: {
|
||||||
grok: { displayName: "Grok", resolve: "xai/grok-4", recommended: true },
|
grok: {
|
||||||
"grok-fast": { displayName: "Grok Fast", resolve: "xai/grok-4-fast" },
|
displayName: "Grok",
|
||||||
"grok-code-fast": { displayName: "Grok Code Fast", resolve: "xai/grok-code-fast-1" }
|
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({
|
deepseek: provider({
|
||||||
@@ -131968,16 +132007,26 @@ var providers = {
|
|||||||
"deepseek-reasoner": {
|
"deepseek-reasoner": {
|
||||||
displayName: "DeepSeek Reasoner",
|
displayName: "DeepSeek Reasoner",
|
||||||
resolve: "deepseek/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({
|
moonshotai: provider({
|
||||||
displayName: "Moonshot AI",
|
displayName: "Moonshot AI",
|
||||||
envVars: ["MOONSHOT_API_KEY"],
|
envVars: ["MOONSHOT_API_KEY"],
|
||||||
models: {
|
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({
|
opencode: provider({
|
||||||
@@ -131987,21 +132036,74 @@ var providers = {
|
|||||||
"big-pickle": {
|
"big-pickle": {
|
||||||
displayName: "Big Pickle",
|
displayName: "Big Pickle",
|
||||||
resolve: "opencode/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-opus": {
|
||||||
"claude-sonnet": { displayName: "Claude Sonnet", resolve: "opencode/claude-sonnet-4-6" },
|
displayName: "Claude Opus",
|
||||||
"claude-haiku": { displayName: "Claude Haiku", resolve: "opencode/claude-haiku-4-5" },
|
resolve: "opencode/claude-opus-4-6",
|
||||||
"gpt-codex": { displayName: "GPT Codex", resolve: "opencode/gpt-5.3-codex" },
|
openRouterResolve: "openrouter/anthropic/claude-opus-4.6"
|
||||||
"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" },
|
|
||||||
"gpt-5-nano": { displayName: "GPT-5 Nano", resolve: "opencode/gpt-5-nano" },
|
|
||||||
"mimo-v2-flash-free": {
|
|
||||||
displayName: "MiMo V2 Flash",
|
|
||||||
resolve: "opencode/mimo-v2-flash-free"
|
|
||||||
},
|
},
|
||||||
"minimax-m2.5-free": { displayName: "MiniMax M2.5", resolve: "opencode/minimax-m2.5-free" }
|
"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",
|
||||||
|
envVars: [],
|
||||||
|
isFree: true
|
||||||
|
},
|
||||||
|
"mimo-v2-pro-free": {
|
||||||
|
displayName: "MiMo V2 Pro",
|
||||||
|
resolve: "opencode/mimo-v2-pro-free",
|
||||||
|
envVars: [],
|
||||||
|
isFree: true
|
||||||
|
},
|
||||||
|
"minimax-m2.5-free": {
|
||||||
|
displayName: "MiniMax M2.5",
|
||||||
|
resolve: "opencode/minimax-m2.5-free",
|
||||||
|
envVars: [],
|
||||||
|
isFree: true
|
||||||
|
},
|
||||||
|
"nemotron-3-super-free": {
|
||||||
|
displayName: "Nemotron 3 Super",
|
||||||
|
resolve: "opencode/nemotron-3-super-free",
|
||||||
|
envVars: [],
|
||||||
|
isFree: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
openrouter: provider({
|
openrouter: provider({
|
||||||
@@ -132011,45 +132113,90 @@ var providers = {
|
|||||||
"claude-opus": {
|
"claude-opus": {
|
||||||
displayName: "Claude Opus",
|
displayName: "Claude Opus",
|
||||||
resolve: "openrouter/anthropic/claude-opus-4.6",
|
resolve: "openrouter/anthropic/claude-opus-4.6",
|
||||||
recommended: true
|
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
|
||||||
|
preferred: true
|
||||||
},
|
},
|
||||||
"claude-sonnet": {
|
"claude-sonnet": {
|
||||||
displayName: "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": {
|
"claude-haiku": {
|
||||||
displayName: "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": {
|
"gpt-codex-mini": {
|
||||||
displayName: "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"
|
||||||
},
|
},
|
||||||
"gemini-pro": {
|
"gemini-pro": {
|
||||||
displayName: "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": {
|
"gemini-flash": {
|
||||||
displayName: "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": {
|
"deepseek-chat": {
|
||||||
displayName: "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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
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(
|
var modelAliases = Object.entries(providers).flatMap(
|
||||||
([providerKey, config3]) => Object.entries(config3.models).map(([modelId, def]) => ({
|
([providerKey, config3]) => Object.entries(config3.models).map(([modelId, def]) => ({
|
||||||
slug: `${providerKey}/${modelId}`,
|
slug: `${providerKey}/${modelId}`,
|
||||||
provider: providerKey,
|
provider: providerKey,
|
||||||
displayName: def.displayName,
|
displayName: def.displayName,
|
||||||
resolve: def.resolve,
|
resolve: def.resolve,
|
||||||
recommended: def.recommended ?? false
|
openRouterResolve: def.openRouterResolve,
|
||||||
|
preferred: def.preferred ?? false,
|
||||||
|
isFree: def.isFree ?? false
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
function resolveModelSlug(slug) {
|
function resolveModelSlug(slug) {
|
||||||
@@ -144973,6 +145120,11 @@ function GetCheckSuiteLogsTool(ctx) {
|
|||||||
// utils/buildPullfrogFooter.ts
|
// utils/buildPullfrogFooter.ts
|
||||||
var PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
|
var PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
|
||||||
var FROG_LOGO = `<a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-18px.png"><img src="https://pullfrog.com/logos/frog-green-full-18px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a>`;
|
var FROG_LOGO = `<a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-18px.png"><img src="https://pullfrog.com/logos/frog-green-full-18px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a>`;
|
||||||
|
function formatModelLabel(slug) {
|
||||||
|
const alias = modelAliases.find((a) => a.slug === slug);
|
||||||
|
if (!alias) return `\`${slug}\``;
|
||||||
|
return alias.isFree ? `\`${alias.displayName}\` (free)` : `\`${alias.displayName}\``;
|
||||||
|
}
|
||||||
function buildPullfrogFooter(params) {
|
function buildPullfrogFooter(params) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (params.customParts) {
|
if (params.customParts) {
|
||||||
@@ -144988,11 +145140,10 @@ function buildPullfrogFooter(params) {
|
|||||||
if (params.triggeredBy) {
|
if (params.triggeredBy) {
|
||||||
parts.push("Triggered by [Pullfrog](https://pullfrog.com)");
|
parts.push("Triggered by [Pullfrog](https://pullfrog.com)");
|
||||||
}
|
}
|
||||||
const allParts = [
|
if (params.model) {
|
||||||
...parts,
|
parts.push(`Using ${formatModelLabel(params.model)}`);
|
||||||
"[pullfrog.com](https://pullfrog.com)",
|
}
|
||||||
"[\u{1D54F}](https://x.com/pullfrogai)"
|
const allParts = [...parts, "[\u{1D54F}](https://x.com/pullfrogai)"];
|
||||||
];
|
|
||||||
return `
|
return `
|
||||||
${PULLFROG_DIVIDER}
|
${PULLFROG_DIVIDER}
|
||||||
<sup>${FROG_LOGO} \uFF5C ${allParts.join(" \uFF5C ")}</sup>`;
|
<sup>${FROG_LOGO} \uFF5C ${allParts.join(" \uFF5C ")}</sup>`;
|
||||||
@@ -145056,22 +145207,25 @@ async function buildCommentFooter(params) {
|
|||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const footerParams = {
|
return buildPullfrogFooter({
|
||||||
triggeredBy: true,
|
triggeredBy: true,
|
||||||
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId, jobId } : void 0
|
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId, jobId } : void 0,
|
||||||
};
|
customParts: params.customParts,
|
||||||
if (params.customParts && params.customParts.length > 0) {
|
model: params.model
|
||||||
return buildPullfrogFooter({ ...footerParams, customParts: params.customParts });
|
});
|
||||||
}
|
|
||||||
return buildPullfrogFooter(footerParams);
|
|
||||||
}
|
}
|
||||||
function buildImplementPlanLink(owner, repo, issueNumber, commentId) {
|
function buildImplementPlanLink(owner, repo, issueNumber, commentId) {
|
||||||
const apiUrl = getApiUrl();
|
const apiUrl = getApiUrl();
|
||||||
return `[Implement plan \u2794](${apiUrl}/trigger/${owner}/${repo}/${issueNumber}?action=implement&comment_id=${commentId})`;
|
return `[Implement plan \u2794](${apiUrl}/trigger/${owner}/${repo}/${issueNumber}?action=implement&comment_id=${commentId})`;
|
||||||
}
|
}
|
||||||
async function addFooter(ctx, body) {
|
async function addFooter(ctx, body) {
|
||||||
|
if (/<br\s*\/?>[ \t]*\n(?!\s*\n)/i.test(body)) {
|
||||||
|
throw new Error(
|
||||||
|
"body contains <br/> followed by a non-blank line, which breaks GitHub markdown rendering. always add a blank line after <br/> tags."
|
||||||
|
);
|
||||||
|
}
|
||||||
const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
|
const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
|
||||||
const footer = await buildCommentFooter({ octokit: ctx.octokit });
|
const footer = await buildCommentFooter({ octokit: ctx.octokit, model: ctx.toolState?.model });
|
||||||
return `${bodyWithoutFooter}${footer}`;
|
return `${bodyWithoutFooter}${footer}`;
|
||||||
}
|
}
|
||||||
var Comment = type({
|
var Comment = type({
|
||||||
@@ -145088,6 +145242,26 @@ function CreateCommentTool(ctx) {
|
|||||||
parameters: Comment,
|
parameters: Comment,
|
||||||
execute: execute(async ({ issueNumber, body, type: commentType }) => {
|
execute: execute(async ({ issueNumber, body, type: commentType }) => {
|
||||||
const bodyWithFooter = await addFooter(ctx, body);
|
const bodyWithFooter = await addFooter(ctx, body);
|
||||||
|
if (commentType === "Summary" && ctx.toolState.existingSummaryCommentId) {
|
||||||
|
log.info(
|
||||||
|
`\xBB redirecting create_issue_comment(Summary) to update existing comment ${ctx.toolState.existingSummaryCommentId}`
|
||||||
|
);
|
||||||
|
const result2 = await ctx.octokit.rest.issues.updateComment({
|
||||||
|
owner: ctx.repo.owner,
|
||||||
|
repo: ctx.repo.name,
|
||||||
|
comment_id: ctx.toolState.existingSummaryCommentId,
|
||||||
|
body: bodyWithFooter
|
||||||
|
});
|
||||||
|
if (result2.data.node_id) {
|
||||||
|
await updateCommentNodeId(ctx, "summaryCommentNodeId", result2.data.node_id);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
commentId: result2.data.id,
|
||||||
|
url: result2.data.html_url,
|
||||||
|
body: result2.data.body
|
||||||
|
};
|
||||||
|
}
|
||||||
const result = await ctx.octokit.rest.issues.createComment({
|
const result = await ctx.octokit.rest.issues.createComment({
|
||||||
owner: ctx.repo.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.repo.name,
|
repo: ctx.repo.name,
|
||||||
@@ -145159,7 +145333,8 @@ async function reportProgress(ctx, params) {
|
|||||||
const bodyWithoutFooter = stripExistingFooter(body);
|
const bodyWithoutFooter = stripExistingFooter(body);
|
||||||
const footer = await buildCommentFooter({
|
const footer = await buildCommentFooter({
|
||||||
octokit: ctx.octokit,
|
octokit: ctx.octokit,
|
||||||
customParts
|
customParts,
|
||||||
|
model: ctx.toolState.model
|
||||||
});
|
});
|
||||||
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
|
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
|
||||||
const result2 = await ctx.octokit.rest.issues.updateComment({
|
const result2 = await ctx.octokit.rest.issues.updateComment({
|
||||||
@@ -145185,7 +145360,8 @@ async function reportProgress(ctx, params) {
|
|||||||
const bodyWithoutFooter = stripExistingFooter(body);
|
const bodyWithoutFooter = stripExistingFooter(body);
|
||||||
const footer = await buildCommentFooter({
|
const footer = await buildCommentFooter({
|
||||||
octokit: ctx.octokit,
|
octokit: ctx.octokit,
|
||||||
customParts
|
customParts,
|
||||||
|
model: ctx.toolState.model
|
||||||
});
|
});
|
||||||
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
|
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
|
||||||
const result2 = await ctx.octokit.rest.issues.updateComment({
|
const result2 = await ctx.octokit.rest.issues.updateComment({
|
||||||
@@ -145227,7 +145403,8 @@ async function reportProgress(ctx, params) {
|
|||||||
const bodyWithoutFooter = stripExistingFooter(body);
|
const bodyWithoutFooter = stripExistingFooter(body);
|
||||||
const footer = await buildCommentFooter({
|
const footer = await buildCommentFooter({
|
||||||
octokit: ctx.octokit,
|
octokit: ctx.octokit,
|
||||||
customParts
|
customParts,
|
||||||
|
model: ctx.toolState.model
|
||||||
});
|
});
|
||||||
const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`;
|
const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`;
|
||||||
const updateResult = await ctx.octokit.rest.issues.updateComment({
|
const updateResult = await ctx.octokit.rest.issues.updateComment({
|
||||||
@@ -146660,7 +146837,8 @@ var PullRequest = type({
|
|||||||
function buildPrBodyWithFooter(ctx, body) {
|
function buildPrBodyWithFooter(ctx, body) {
|
||||||
const footer = buildPullfrogFooter({
|
const footer = buildPullfrogFooter({
|
||||||
triggeredBy: true,
|
triggeredBy: true,
|
||||||
workflowRun: ctx.runId ? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId } : void 0
|
workflowRun: ctx.runId ? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId } : void 0,
|
||||||
|
model: ctx.toolState.model
|
||||||
});
|
});
|
||||||
const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
|
const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
|
||||||
return `${bodyWithoutFooter}${footer}`;
|
return `${bodyWithoutFooter}${footer}`;
|
||||||
@@ -146793,8 +146971,10 @@ function PullRequestInfoTool(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mcp/review.ts
|
// mcp/review.ts
|
||||||
function isStatusError(err) {
|
function getHttpStatus(err) {
|
||||||
return typeof err === "object" && err !== null && "status" in err && typeof err.status === "number";
|
if (typeof err !== "object" || err === null) return void 0;
|
||||||
|
const status = err.status;
|
||||||
|
return typeof status === "number" ? status : void 0;
|
||||||
}
|
}
|
||||||
var CreatePullRequestReview = type({
|
var CreatePullRequestReview = type({
|
||||||
pull_number: type.number.describe("The pull request number to review"),
|
pull_number: type.number.describe("The pull request number to review"),
|
||||||
@@ -146810,7 +146990,7 @@ var CreatePullRequestReview = type({
|
|||||||
"The file path to comment on (relative to repo root). Must be a file that appears in the PR diff."
|
"The file path to comment on (relative to repo root). Must be a file that appears in the PR diff."
|
||||||
),
|
),
|
||||||
line: type.number.describe(
|
line: type.number.describe(
|
||||||
"End line of the comment range. For single-line comments, set equal to 'start_line'. Use NEW column from diff format."
|
"Line number to comment on. For multi-line ranges, this is the end line. Use NEW column from diff format."
|
||||||
),
|
),
|
||||||
side: type.enumerated("LEFT", "RIGHT").describe(
|
side: type.enumerated("LEFT", "RIGHT").describe(
|
||||||
"Side of the diff: LEFT (old code, lines starting with -) or RIGHT (new code, lines starting with + or unchanged). Defaults to RIGHT."
|
"Side of the diff: LEFT (old code, lines starting with -) or RIGHT (new code, lines starting with + or unchanged). Defaults to RIGHT."
|
||||||
@@ -146820,8 +147000,8 @@ var CreatePullRequestReview = type({
|
|||||||
"Full replacement code for the line range [start_line, line]. MUST preserve the exact indentation of the original code."
|
"Full replacement code for the line range [start_line, line]. MUST preserve the exact indentation of the original code."
|
||||||
).optional(),
|
).optional(),
|
||||||
start_line: type.number.describe(
|
start_line: type.number.describe(
|
||||||
"Start line of the comment range. For single-line comments, set equal to 'line'. The range [start_line, line] defines which lines a suggestion replaces."
|
"Start line for multi-line comment ranges. Omit for single-line comments. The range [start_line, line] defines which lines a suggestion replaces."
|
||||||
)
|
).optional()
|
||||||
}).array().describe(
|
}).array().describe(
|
||||||
"Inline comments on lines within diff hunks. Feedback about code outside the diff goes in 'body' instead."
|
"Inline comments on lines within diff hunks. Feedback about code outside the diff goes in 'body' instead."
|
||||||
).optional()
|
).optional()
|
||||||
@@ -146829,11 +147009,21 @@ var CreatePullRequestReview = type({
|
|||||||
function CreatePullRequestReviewTool(ctx) {
|
function CreatePullRequestReviewTool(ctx) {
|
||||||
return tool({
|
return tool({
|
||||||
name: "create_pull_request_review",
|
name: "create_pull_request_review",
|
||||||
description: `Submit a review for an existing pull request. IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. Only use 'body' for a 1-2 sentence summary with urgency and critical callouts. Use 'suggestion' to propose replacement code - MUST preserve exact indentation of original code. Example replacing lines 42-44 (3 lines) with 5 lines: { path: 'src/api.ts', start_line: 42, line: 44, suggestion: ' const result = await fetch(url);\\n if (!result.ok) {\\n log.error(result.status);\\n throw new Error("request failed");\\n }' } CONSTRAINT: Inline comments can ONLY target files and lines that appear in the PR diff. Commenting on files or lines outside the diff will cause GitHub API errors. Put feedback about code outside the diff in 'body' instead.`,
|
description: `Submit a review for an existing pull request. Each call creates a permanent, visible review on the PR \u2014 NEVER submit test or diagnostic reviews. IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. Only use 'body' for a 1-2 sentence summary with urgency and critical callouts. Use 'suggestion' to propose replacement code - MUST preserve exact indentation of original code. Example replacing lines 42-44 (3 lines) with 5 lines: { path: 'src/api.ts', start_line: 42, line: 44, suggestion: ' const result = await fetch(url);\\n if (!result.ok) {\\n log.error(result.status);\\n throw new Error("request failed");\\n }' } CONSTRAINT: Inline comments can ONLY target files and lines that appear in the PR diff. If GitHub rejects comments due to incorrect line numbers, re-read the diff and retry.`,
|
||||||
parameters: CreatePullRequestReview,
|
parameters: CreatePullRequestReview,
|
||||||
execute: execute(async ({ pull_number, body, approved, commit_id, comments = [] }) => {
|
execute: execute(async ({ pull_number, body, approved, commit_id, comments = [] }) => {
|
||||||
if (body) body = fixDoubleEscapedString(body);
|
if (body) body = fixDoubleEscapedString(body);
|
||||||
ctx.toolState.issueNumber = pull_number;
|
ctx.toolState.issueNumber = pull_number;
|
||||||
|
if (!body && comments.length === 0) {
|
||||||
|
log.info(
|
||||||
|
"review has no body and no inline comments \u2014 skipping submission (no issues found)"
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
skipped: true,
|
||||||
|
reason: "no issues found \u2014 nothing to post"
|
||||||
|
};
|
||||||
|
}
|
||||||
let event = approved ? "APPROVE" : "COMMENT";
|
let event = approved ? "APPROVE" : "COMMENT";
|
||||||
if (event === "APPROVE" && !ctx.prApproveEnabled) {
|
if (event === "APPROVE" && !ctx.prApproveEnabled) {
|
||||||
log.info("prApproveEnabled is disabled \u2014 downgrading APPROVE to COMMENT");
|
log.info("prApproveEnabled is disabled \u2014 downgrading APPROVE to COMMENT");
|
||||||
@@ -146845,6 +147035,7 @@ function CreatePullRequestReviewTool(ctx) {
|
|||||||
pull_number,
|
pull_number,
|
||||||
event
|
event
|
||||||
};
|
};
|
||||||
|
let latestHeadSha;
|
||||||
if (commit_id) {
|
if (commit_id) {
|
||||||
params.commit_id = commit_id;
|
params.commit_id = commit_id;
|
||||||
} else {
|
} else {
|
||||||
@@ -146853,42 +147044,54 @@ function CreatePullRequestReviewTool(ctx) {
|
|||||||
repo: ctx.repo.name,
|
repo: ctx.repo.name,
|
||||||
pull_number
|
pull_number
|
||||||
});
|
});
|
||||||
params.commit_id = pr.data.head.sha;
|
latestHeadSha = pr.data.head.sha;
|
||||||
|
params.commit_id = ctx.toolState.checkoutSha ?? latestHeadSha;
|
||||||
|
if (ctx.toolState.checkoutSha && latestHeadSha !== ctx.toolState.checkoutSha) {
|
||||||
|
log.info(
|
||||||
|
`anchoring review to checkout ${ctx.toolState.checkoutSha.slice(0, 7)} (HEAD is now ${latestHeadSha.slice(0, 7)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (comments.length > 0) {
|
const reviewComments = comments.map((comment) => {
|
||||||
params.comments = comments.map((comment) => {
|
let commentBody = fixDoubleEscapedString(comment.body || "");
|
||||||
let commentBody = fixDoubleEscapedString(comment.body || "");
|
if (comment.suggestion !== void 0) {
|
||||||
if (comment.suggestion !== void 0) {
|
const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```";
|
||||||
const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```";
|
commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock;
|
||||||
commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock;
|
}
|
||||||
}
|
const side = comment.side || "RIGHT";
|
||||||
const side = comment.side || "RIGHT";
|
const reviewComment = {
|
||||||
const reviewComment = {
|
path: comment.path,
|
||||||
path: comment.path,
|
line: comment.line,
|
||||||
line: comment.line,
|
body: commentBody,
|
||||||
body: commentBody,
|
side
|
||||||
side,
|
};
|
||||||
start_line: comment.start_line,
|
if (comment.start_line != null && comment.start_line !== comment.line) {
|
||||||
start_side: side
|
reviewComment.start_line = comment.start_line;
|
||||||
};
|
reviewComment.start_side = side;
|
||||||
return reviewComment;
|
}
|
||||||
});
|
return reviewComment;
|
||||||
|
});
|
||||||
|
if (reviewComments.length > 0) {
|
||||||
|
params.comments = reviewComments;
|
||||||
}
|
}
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = body ? await createAndSubmitWithFooter(ctx, params, {
|
result = body ? await createAndSubmitWithFooter(ctx, params, {
|
||||||
body,
|
body,
|
||||||
approved: approved ?? false,
|
approved: approved ?? false,
|
||||||
hasComments: comments.length > 0
|
hasComments: reviewComments.length > 0
|
||||||
}) : await ctx.octokit.rest.pulls.createReview(params);
|
}) : await ctx.octokit.rest.pulls.createReview(params);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isStatusError(err) && err.status === 422 && params.comments?.length) {
|
if (getHttpStatus(err) !== 422 || !params.comments?.length) throw err;
|
||||||
const paths = [...new Set(params.comments.map((comment) => comment.path))];
|
const details = params.comments.map((c) => {
|
||||||
throw new Error(
|
const line = c.line ?? 0;
|
||||||
`${err.message ?? "422 Unprocessable Entity"}. The review had ${params.comments.length} inline comment(s) targeting these paths: ${paths.join(", ")}. GitHub cannot resolve one or more of these paths in the PR diff (common when the PR has >100 changed files and some are truncated). Fix: remove the failing comment(s) and retry. Put their feedback in the review body instead.`
|
const startLine = c.start_line ?? line;
|
||||||
);
|
const range2 = startLine !== line ? `${startLine}-${line}` : `${line}`;
|
||||||
}
|
return `${c.path}:${range2} (${c.side ?? "RIGHT"})`;
|
||||||
throw err;
|
});
|
||||||
|
throw new Error(
|
||||||
|
`GitHub rejected inline comment(s) with "Line could not be resolved". This usually means the diff changed since you last read it (new commits pushed). Re-read the diff to get current line numbers, or move failing comments to the review body. Affected: ${details.join(", ")}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
|
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
|
||||||
if (!result.data.id) {
|
if (!result.data.id) {
|
||||||
@@ -146902,10 +147105,9 @@ function CreatePullRequestReviewTool(ctx) {
|
|||||||
nodeId: reviewNodeId,
|
nodeId: reviewNodeId,
|
||||||
reviewedSha: actuallyReviewedSha
|
reviewedSha: actuallyReviewedSha
|
||||||
};
|
};
|
||||||
const headMovedDuringReview = ctx.toolState.checkoutSha && params.commit_id !== ctx.toolState.checkoutSha;
|
if (ctx.toolState.checkoutSha && latestHeadSha && latestHeadSha !== ctx.toolState.checkoutSha) {
|
||||||
if (headMovedDuringReview) {
|
|
||||||
const fromSha = ctx.toolState.checkoutSha;
|
const fromSha = ctx.toolState.checkoutSha;
|
||||||
const toSha = params.commit_id;
|
const toSha = latestHeadSha;
|
||||||
ctx.toolState.checkoutSha = toSha;
|
ctx.toolState.checkoutSha = toSha;
|
||||||
log.info(
|
log.info(
|
||||||
`new commits detected during review: ${fromSha.slice(0, 7)}..${toSha.slice(0, 7)}`
|
`new commits detected during review: ${fromSha.slice(0, 7)}..${toSha.slice(0, 7)}`
|
||||||
@@ -146955,7 +147157,8 @@ async function createAndSubmitWithFooter(ctx, params, opts) {
|
|||||||
}
|
}
|
||||||
const footer = buildPullfrogFooter({
|
const footer = buildPullfrogFooter({
|
||||||
workflowRun: ctx.runId ? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId } : void 0,
|
workflowRun: ctx.runId ? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId } : void 0,
|
||||||
customParts
|
customParts,
|
||||||
|
model: ctx.toolState.model
|
||||||
});
|
});
|
||||||
return ctx.octokit.rest.pulls.submitReview({
|
return ctx.octokit.rest.pulls.submitReview({
|
||||||
owner: params.owner,
|
owner: params.owner,
|
||||||
@@ -147635,7 +147838,7 @@ An existing plan comment was found for this issue. Update that comment with the
|
|||||||
2. Delegate a subagent to analyze the diff and produce a structured summary. Include in its prompt:
|
2. Delegate a subagent to analyze the diff and produce a structured summary. Include in its prompt:
|
||||||
- the diff file path
|
- the diff file path
|
||||||
- PR metadata (title, file count, commit count, base/head branches)
|
- PR metadata (title, file count, commit count, base/head branches)
|
||||||
- format instructions from EVENT INSTRUCTIONS (if any); otherwise use default format: TL;DR, key changes list, per-change sections with before/after framing
|
- format instructions from EVENT INSTRUCTIONS (if any); otherwise use default format: TL;DR, key changes list, per-change sections with plain-language \`##\` titles and before/after framing
|
||||||
- instruct it to use the TOC to selectively read relevant diff sections, not the entire file
|
- instruct it to use the TOC to selectively read relevant diff sections, not the entire file
|
||||||
- instruct it to return the full summary markdown via \`${ghPullfrogMcpName}/set_output\`
|
- instruct it to return the full summary markdown via \`${ghPullfrogMcpName}/set_output\`
|
||||||
3. After the subagent completes, call \`${ghPullfrogMcpName}/create_issue_comment\` with \`type: "Summary"\` and the summary body.
|
3. After the subagent completes, call \`${ghPullfrogMcpName}/create_issue_comment\` with \`type: "Summary"\` and the summary body.
|
||||||
@@ -147676,12 +147879,12 @@ function buildOrchestratorGuidance(mode, opts = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
async function fetchExistingPlanComment(ctx, issueNumber) {
|
async function fetchExistingPlanComment(ctx, issueNumber) {
|
||||||
if (!ctx.apiToken) return null;
|
if (!ctx.githubInstallationToken) return null;
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch({
|
const response = await apiFetch({
|
||||||
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/issue/${issueNumber}/plan-comment`,
|
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/issue/${issueNumber}/plan-comment`,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { authorization: `Bearer ${ctx.apiToken}` },
|
headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
|
||||||
signal: AbortSignal.timeout(1e4)
|
signal: AbortSignal.timeout(1e4)
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -147691,17 +147894,27 @@ async function fetchExistingPlanComment(ctx, issueNumber) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function fetchExistingSummaryComment(ctx, prNumber) {
|
async function fetchExistingSummaryComment(ctx, prNumber) {
|
||||||
if (!ctx.apiToken) return null;
|
if (!ctx.githubInstallationToken) {
|
||||||
|
log.warning("fetchExistingSummaryComment: no token, skipping");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const path3 = `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/pr/${prNumber}/summary-comment`;
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch({
|
const response = await apiFetch({
|
||||||
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/pr/${prNumber}/summary-comment`,
|
path: path3,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { authorization: `Bearer ${ctx.apiToken}` },
|
headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
|
||||||
signal: AbortSignal.timeout(1e4)
|
signal: AbortSignal.timeout(1e4)
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return response.ok && "commentId" in data ? data : null;
|
if (response.ok && "commentId" in data) {
|
||||||
} catch {
|
return data;
|
||||||
|
}
|
||||||
|
const errMsg = "error" in data ? data.error : "(no error body)";
|
||||||
|
log.warning(`fetchExistingSummaryComment: ${response.status} ${path3} \u2014 ${errMsg}`);
|
||||||
|
return null;
|
||||||
|
} catch (error49) {
|
||||||
|
log.warning("fetchExistingSummaryComment failed:", error49);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148549,7 +148762,7 @@ Do NOT overwrite a good comment with links/details with a generic message like "
|
|||||||
|
|
||||||
2. **ANALYZE** - Read the diff file. Use the TOC to selectively read relevant sections \u2014 do not read the entire file unless the PR is small.
|
2. **ANALYZE** - Read the diff file. Use the TOC to selectively read relevant sections \u2014 do not read the entire file unless the PR is small.
|
||||||
|
|
||||||
3. **SUMMARIZE** - Write a structured summary following the format from EVENT INSTRUCTIONS. If no format instructions are provided, produce a concise summary with a TL;DR, key changes list, and per-change sections with before/after framing.
|
3. **SUMMARIZE** - Write a structured summary following the format from EVENT INSTRUCTIONS. If no format instructions are provided, produce a concise summary with a TL;DR, key changes list, and per-change sections with human-readable \`##\` titles and before/after framing.
|
||||||
|
|
||||||
4. **POST** - Call ${ghPullfrogMcpName}/create_issue_comment with type: 'Summary' and the summary body.
|
4. **POST** - Call ${ghPullfrogMcpName}/create_issue_comment with type: 'Summary' and the summary body.
|
||||||
|
|
||||||
@@ -148697,7 +148910,6 @@ var agent = (input) => {
|
|||||||
return {
|
return {
|
||||||
...input,
|
...input,
|
||||||
run: async (ctx) => {
|
run: async (ctx) => {
|
||||||
log.info(`\xBB agent: ${input.name}`);
|
|
||||||
if (ctx.payload.model) log.info(`\xBB model: ${ctx.payload.model}`);
|
if (ctx.payload.model) log.info(`\xBB model: ${ctx.payload.model}`);
|
||||||
if (ctx.payload.timeout) log.info(`\xBB timeout: ${ctx.payload.timeout}`);
|
if (ctx.payload.timeout) log.info(`\xBB timeout: ${ctx.payload.timeout}`);
|
||||||
log.info(`\xBB push: ${ctx.payload.push}`);
|
log.info(`\xBB push: ${ctx.payload.push}`);
|
||||||
@@ -148757,9 +148969,9 @@ function getOpenCodeModels(cliPath) {
|
|||||||
}
|
}
|
||||||
var AUTO_SELECT_WARNING = "select a model explicitly in the Pullfrog console (https://pullfrog.com/console) to avoid this.";
|
var AUTO_SELECT_WARNING = "select a model explicitly in the Pullfrog console (https://pullfrog.com/console) to avoid this.";
|
||||||
function resolveOpenCodeModel(ctx) {
|
function resolveOpenCodeModel(ctx) {
|
||||||
const envModel = process.env.OPENCODE_MODEL?.trim();
|
const envModel = process.env.PULLFROG_MODEL?.trim();
|
||||||
if (envModel) {
|
if (envModel) {
|
||||||
log.info(`\xBB model: ${envModel} (override via OPENCODE_MODEL)`);
|
log.info(`\xBB model: ${envModel} (override via PULLFROG_MODEL)`);
|
||||||
return envModel;
|
return envModel;
|
||||||
}
|
}
|
||||||
if (ctx.modelSlug) {
|
if (ctx.modelSlug) {
|
||||||
@@ -148774,10 +148986,10 @@ function resolveOpenCodeModel(ctx) {
|
|||||||
const availableSet = new Set(availableModels);
|
const availableSet = new Set(availableModels);
|
||||||
if (availableSet.size > 0) {
|
if (availableSet.size > 0) {
|
||||||
log.debug(`\xBB opencode models (${availableSet.size}): ${availableModels.join(", ")}`);
|
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) {
|
if (match3) {
|
||||||
log.info(
|
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}`);
|
log.warning(`\xBB model auto-selected. ${AUTO_SELECT_WARNING}`);
|
||||||
return match3.resolve;
|
return match3.resolve;
|
||||||
@@ -149090,7 +149302,7 @@ var opentoad = agent({
|
|||||||
install: installOpencodeCli,
|
install: installOpencodeCli,
|
||||||
run: async (ctx) => {
|
run: async (ctx) => {
|
||||||
const cliPath = await installOpencodeCli();
|
const cliPath = await installOpencodeCli();
|
||||||
const model = resolveOpenCodeModel({
|
const model = ctx.payload.proxyModel ?? resolveOpenCodeModel({
|
||||||
cliPath,
|
cliPath,
|
||||||
modelSlug: ctx.payload.model
|
modelSlug: ctx.payload.model
|
||||||
});
|
});
|
||||||
@@ -149142,12 +149354,22 @@ to fix this, add the required secret to your GitHub repository:
|
|||||||
4. set the value to your API key
|
4. set the value to your API key
|
||||||
5. click "Add secret"
|
5. click "Add secret"
|
||||||
|
|
||||||
configure your model at ${settingsUrl}`;
|
configure your model at ${settingsUrl}
|
||||||
|
|
||||||
|
for full setup instructions, see https://docs.pullfrog.com/keys`;
|
||||||
|
}
|
||||||
|
function hasEnvVar(name) {
|
||||||
|
const value2 = process.env[name];
|
||||||
|
return typeof value2 === "string" && value2.length > 0;
|
||||||
}
|
}
|
||||||
function validateAgentApiKey(params) {
|
function validateAgentApiKey(params) {
|
||||||
const hasAnyKey = Object.entries(process.env).some(
|
if (params.model) {
|
||||||
([key, value2]) => value2 && typeof value2 === "string" && knownApiKeys.has(key)
|
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) {
|
if (!hasAnyKey) {
|
||||||
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
|
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
|
||||||
}
|
}
|
||||||
@@ -149272,7 +149494,8 @@ ${ctx.error}` : ctx.error;
|
|||||||
const footer = buildPullfrogFooter({
|
const footer = buildPullfrogFooter({
|
||||||
triggeredBy: true,
|
triggeredBy: true,
|
||||||
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : void 0,
|
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : void 0,
|
||||||
customParts
|
customParts,
|
||||||
|
model: ctx.toolState.model
|
||||||
});
|
});
|
||||||
await octokit.rest.issues.updateComment({
|
await octokit.rest.issues.updateComment({
|
||||||
owner: repoContext.owner,
|
owner: repoContext.owner,
|
||||||
@@ -149737,7 +149960,7 @@ import { isAbsolute, resolve } from "node:path";
|
|||||||
// package.json
|
// package.json
|
||||||
var package_default = {
|
var package_default = {
|
||||||
name: "@pullfrog/pullfrog",
|
name: "@pullfrog/pullfrog",
|
||||||
version: "0.0.180",
|
version: "0.0.182",
|
||||||
type: "module",
|
type: "module",
|
||||||
files: [
|
files: [
|
||||||
"index.js",
|
"index.js",
|
||||||
@@ -149939,7 +150162,9 @@ function resolvePayload(resolvedPromptInput, repoSettings) {
|
|||||||
progressCommentId: jsonPayload?.progressCommentId,
|
progressCommentId: jsonPayload?.progressCommentId,
|
||||||
// permissions: inputs > repoSettings > fallbacks
|
// permissions: inputs > repoSettings > fallbacks
|
||||||
push: inputs.push ?? repoSettings.push ?? "restricted",
|
push: inputs.push ?? repoSettings.push ?? "restricted",
|
||||||
shell: resolvedShell
|
shell: resolvedShell,
|
||||||
|
// set by proxy logic in main.ts when routing through OpenRouter
|
||||||
|
proxyModel: void 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150054,7 +150279,8 @@ var defaultSettings = {
|
|||||||
};
|
};
|
||||||
var defaultRunContext = {
|
var defaultRunContext = {
|
||||||
settings: defaultSettings,
|
settings: defaultSettings,
|
||||||
apiToken: ""
|
apiToken: "",
|
||||||
|
oss: false
|
||||||
};
|
};
|
||||||
async function fetchRunContext(params) {
|
async function fetchRunContext(params) {
|
||||||
const timeoutMs = 3e4;
|
const timeoutMs = 3e4;
|
||||||
@@ -150085,7 +150311,9 @@ async function fetchRunContext(params) {
|
|||||||
setupScript: data.settings?.setupScript ?? null,
|
setupScript: data.settings?.setupScript ?? null,
|
||||||
postCheckoutScript: data.settings?.postCheckoutScript ?? null
|
postCheckoutScript: data.settings?.postCheckoutScript ?? null
|
||||||
},
|
},
|
||||||
apiToken: data.apiToken
|
apiToken: data.apiToken,
|
||||||
|
oss: data.oss ?? false,
|
||||||
|
proxyModel: data.proxyModel
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
@@ -150108,7 +150336,9 @@ async function resolveRunContextData(params) {
|
|||||||
data: repoResponse.data
|
data: repoResponse.data
|
||||||
},
|
},
|
||||||
repoSettings: runContext.settings,
|
repoSettings: runContext.settings,
|
||||||
apiToken: runContext.apiToken
|
apiToken: runContext.apiToken,
|
||||||
|
oss: runContext.oss,
|
||||||
|
proxyModel: runContext.proxyModel
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150247,6 +150477,48 @@ function resolveOutputSchema() {
|
|||||||
log.info("\xBB structured output schema provided \u2014 output will be required");
|
log.info("\xBB structured output schema provided \u2014 output will be required");
|
||||||
return parsed2;
|
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) {
|
async function writeJobSummary(toolState) {
|
||||||
const usageSummary = formatUsageSummary(toolState.usageEntries);
|
const usageSummary = formatUsageSummary(toolState.usageEntries);
|
||||||
const summaryParts = [toolState.lastProgressBody, usageSummary].filter(Boolean);
|
const summaryParts = [toolState.lastProgressBody, usageSummary].filter(Boolean);
|
||||||
@@ -150274,11 +150546,22 @@ async function main() {
|
|||||||
const runContext = await resolveRunContextData({ octokit: initialOctokit, token: jobToken });
|
const runContext = await resolveRunContextData({ octokit: initialOctokit, token: jobToken });
|
||||||
timer.checkpoint("runContextData");
|
timer.checkpoint("runContextData");
|
||||||
const payload = resolvePayload(resolvedPromptInput, runContext.repoSettings);
|
const payload = resolvePayload(resolvedPromptInput, runContext.repoSettings);
|
||||||
|
toolState.model = payload.model;
|
||||||
const tokenRef = __using(_stack2, await resolveTokens({ push: payload.push }), true);
|
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") {
|
if (payload.shell !== "enabled") {
|
||||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
||||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
||||||
}
|
}
|
||||||
|
await resolveProxyModel({
|
||||||
|
payload,
|
||||||
|
oss: runContext.oss,
|
||||||
|
proxyModel: runContext.proxyModel,
|
||||||
|
oidcCredentials
|
||||||
|
});
|
||||||
const octokit = createOctokit(tokenRef.mcpToken);
|
const octokit = createOctokit(tokenRef.mcpToken);
|
||||||
const runInfo = await resolveRun({ octokit });
|
const runInfo = await resolveRun({ octokit });
|
||||||
let toolContext;
|
let toolContext;
|
||||||
@@ -150306,6 +150589,7 @@ async function main() {
|
|||||||
const agent2 = resolveAgent();
|
const agent2 = resolveAgent();
|
||||||
validateAgentApiKey({
|
validateAgentApiKey({
|
||||||
agent: agent2,
|
agent: agent2,
|
||||||
|
model: payload.proxyModel ?? payload.model,
|
||||||
owner: runContext.repo.owner,
|
owner: runContext.repo.owner,
|
||||||
name: runContext.repo.name
|
name: runContext.repo.name
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type { ModelAlias, ModelProvider, ProviderConfig } from "./models.ts";
|
|||||||
export {
|
export {
|
||||||
getModelEnvVars,
|
getModelEnvVars,
|
||||||
getModelProvider,
|
getModelProvider,
|
||||||
|
getProviderDisplayName,
|
||||||
modelAliases,
|
modelAliases,
|
||||||
parseModel,
|
parseModel,
|
||||||
providers,
|
providers,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type {
|
|||||||
export {
|
export {
|
||||||
getModelEnvVars,
|
getModelEnvVars,
|
||||||
getModelProvider,
|
getModelProvider,
|
||||||
|
getProviderDisplayName,
|
||||||
ghPullfrogMcpName,
|
ghPullfrogMcpName,
|
||||||
modelAliases,
|
modelAliases,
|
||||||
parseModel,
|
parseModel,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
DEFAULT_ACTIVITY_TIMEOUT_MS,
|
DEFAULT_ACTIVITY_TIMEOUT_MS,
|
||||||
} from "./utils/activity.ts";
|
} from "./utils/activity.ts";
|
||||||
import { resolveAgent } from "./utils/agent.ts";
|
import { resolveAgent } from "./utils/agent.ts";
|
||||||
|
import { apiFetch } from "./utils/apiFetch.ts";
|
||||||
import { validateAgentApiKey } from "./utils/apiKeys.ts";
|
import { validateAgentApiKey } from "./utils/apiKeys.ts";
|
||||||
import { resolveBody } from "./utils/body.ts";
|
import { resolveBody } from "./utils/body.ts";
|
||||||
import { formatUsageSummary, log, writeSummary } from "./utils/cli.ts";
|
import { formatUsageSummary, log, writeSummary } from "./utils/cli.ts";
|
||||||
@@ -63,6 +64,71 @@ function resolveOutputSchema(): Record<string, unknown> | undefined {
|
|||||||
return parsed as Record<string, unknown>;
|
return parsed as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import type { ResolvedPayload } from "./utils/payload.ts";
|
||||||
|
|
||||||
|
interface OidcCredentials {
|
||||||
|
requestUrl: string;
|
||||||
|
requestToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise<string | null> {
|
||||||
|
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<void> {
|
||||||
|
// 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<void> {
|
async function writeJobSummary(toolState: ToolState): Promise<void> {
|
||||||
const usageSummary = formatUsageSummary(toolState.usageEntries);
|
const usageSummary = formatUsageSummary(toolState.usageEntries);
|
||||||
const summaryParts = [toolState.lastProgressBody, usageSummary].filter(Boolean);
|
const summaryParts = [toolState.lastProgressBody, usageSummary].filter(Boolean);
|
||||||
@@ -103,18 +169,35 @@ export async function main(): Promise<MainResult> {
|
|||||||
|
|
||||||
// resolve payload to determine shell permission
|
// resolve payload to determine shell permission
|
||||||
const payload = resolvePayload(resolvedPromptInput, runContext.repoSettings);
|
const payload = resolvePayload(resolvedPromptInput, runContext.repoSettings);
|
||||||
|
toolState.model = payload.model;
|
||||||
|
|
||||||
// resolve tokens:
|
// resolve tokens first — acquireNewToken needs OIDC env vars for token exchange
|
||||||
// - gitToken: contents permission based on push setting (assumed exfiltratable)
|
|
||||||
// - mcpToken: full installation token (not exfiltratable via MCP tools)
|
|
||||||
await using tokenRef = await resolveTokens({ push: payload.push });
|
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
|
// clear OIDC env vars in restricted mode to prevent agent from minting tokens
|
||||||
if (payload.shell !== "enabled") {
|
if (payload.shell !== "enabled") {
|
||||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
||||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
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
|
// create octokit with MCP token for GitHub API calls
|
||||||
const octokit = createOctokit(tokenRef.mcpToken);
|
const octokit = createOctokit(tokenRef.mcpToken);
|
||||||
|
|
||||||
@@ -151,6 +234,7 @@ export async function main(): Promise<MainResult> {
|
|||||||
|
|
||||||
validateAgentApiKey({
|
validateAgentApiKey({
|
||||||
agent,
|
agent,
|
||||||
|
model: payload.proxyModel ?? payload.model,
|
||||||
owner: runContext.repo.owner,
|
owner: runContext.repo.owner,
|
||||||
name: runContext.repo.name,
|
name: runContext.repo.name,
|
||||||
});
|
});
|
||||||
|
|||||||
+41
-9
@@ -11,7 +11,8 @@ import { execute, tool } from "./shared.ts";
|
|||||||
|
|
||||||
type CommentNodeIdField = "planCommentNodeId" | "summaryCommentNodeId";
|
type CommentNodeIdField = "planCommentNodeId" | "summaryCommentNodeId";
|
||||||
|
|
||||||
/** PATCH workflow-run with a comment node_id so future runs can update that comment in place. */
|
// IMPORTANT: this route authenticates via Pullfrog API JWT (verifyApiToken),
|
||||||
|
// NOT a GitHub token. use ctx.apiToken here. see wiki/api-auth.md.
|
||||||
export async function updateCommentNodeId(
|
export async function updateCommentNodeId(
|
||||||
ctx: ToolContext,
|
ctx: ToolContext,
|
||||||
field: CommentNodeIdField,
|
field: CommentNodeIdField,
|
||||||
@@ -54,6 +55,7 @@ export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
|
|||||||
interface BuildCommentFooterParams {
|
interface BuildCommentFooterParams {
|
||||||
octokit?: OctokitWithPlugins | undefined;
|
octokit?: OctokitWithPlugins | undefined;
|
||||||
customParts?: string[] | undefined;
|
customParts?: string[] | undefined;
|
||||||
|
model?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildCommentFooter(params: BuildCommentFooterParams): Promise<string> {
|
async function buildCommentFooter(params: BuildCommentFooterParams): Promise<string> {
|
||||||
@@ -76,17 +78,14 @@ async function buildCommentFooter(params: BuildCommentFooterParams): Promise<str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const footerParams = {
|
return buildPullfrogFooter({
|
||||||
triggeredBy: true,
|
triggeredBy: true,
|
||||||
workflowRun: runId
|
workflowRun: runId
|
||||||
? { owner: repoContext.owner, repo: repoContext.name, runId, jobId }
|
? { owner: repoContext.owner, repo: repoContext.name, runId, jobId }
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
customParts: params.customParts,
|
||||||
|
model: params.model,
|
||||||
if (params.customParts && params.customParts.length > 0) {
|
});
|
||||||
return buildPullfrogFooter({ ...footerParams, customParts: params.customParts });
|
|
||||||
}
|
|
||||||
return buildPullfrogFooter(footerParams);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildImplementPlanLink(
|
function buildImplementPlanLink(
|
||||||
@@ -101,11 +100,17 @@ function buildImplementPlanLink(
|
|||||||
|
|
||||||
export interface AddFooterCtx {
|
export interface AddFooterCtx {
|
||||||
octokit?: OctokitWithPlugins | undefined;
|
octokit?: OctokitWithPlugins | undefined;
|
||||||
|
toolState?: { model?: string | undefined } | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addFooter(ctx: AddFooterCtx, body: string): Promise<string> {
|
export async function addFooter(ctx: AddFooterCtx, body: string): Promise<string> {
|
||||||
|
if (/<br\s*\/?>[ \t]*\n(?!\s*\n)/i.test(body)) {
|
||||||
|
throw new Error(
|
||||||
|
"body contains <br/> followed by a non-blank line, which breaks GitHub markdown rendering. always add a blank line after <br/> tags."
|
||||||
|
);
|
||||||
|
}
|
||||||
const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
|
const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
|
||||||
const footer = await buildCommentFooter({ octokit: ctx.octokit });
|
const footer = await buildCommentFooter({ octokit: ctx.octokit, model: ctx.toolState?.model });
|
||||||
return `${bodyWithoutFooter}${footer}`;
|
return `${bodyWithoutFooter}${footer}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +134,30 @@ export function CreateCommentTool(ctx: ToolContext) {
|
|||||||
execute: execute(async ({ issueNumber, body, type: commentType }) => {
|
execute: execute(async ({ issueNumber, body, type: commentType }) => {
|
||||||
const bodyWithFooter = await addFooter(ctx, body);
|
const bodyWithFooter = await addFooter(ctx, body);
|
||||||
|
|
||||||
|
// if a summary comment already exists (found by select_mode), update instead of creating
|
||||||
|
if (commentType === "Summary" && ctx.toolState.existingSummaryCommentId) {
|
||||||
|
log.info(
|
||||||
|
`» redirecting create_issue_comment(Summary) to update existing comment ${ctx.toolState.existingSummaryCommentId}`
|
||||||
|
);
|
||||||
|
const result = await ctx.octokit.rest.issues.updateComment({
|
||||||
|
owner: ctx.repo.owner,
|
||||||
|
repo: ctx.repo.name,
|
||||||
|
comment_id: ctx.toolState.existingSummaryCommentId,
|
||||||
|
body: bodyWithFooter,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.data.node_id) {
|
||||||
|
await updateCommentNodeId(ctx, "summaryCommentNodeId", result.data.node_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
commentId: result.data.id,
|
||||||
|
url: result.data.html_url,
|
||||||
|
body: result.data.body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const result = await ctx.octokit.rest.issues.createComment({
|
const result = await ctx.octokit.rest.issues.createComment({
|
||||||
owner: ctx.repo.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.repo.name,
|
repo: ctx.repo.name,
|
||||||
@@ -237,6 +266,7 @@ export async function reportProgress(
|
|||||||
const footer = await buildCommentFooter({
|
const footer = await buildCommentFooter({
|
||||||
octokit: ctx.octokit,
|
octokit: ctx.octokit,
|
||||||
customParts,
|
customParts,
|
||||||
|
model: ctx.toolState.model,
|
||||||
});
|
});
|
||||||
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
|
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
|
||||||
|
|
||||||
@@ -274,6 +304,7 @@ export async function reportProgress(
|
|||||||
const footer = await buildCommentFooter({
|
const footer = await buildCommentFooter({
|
||||||
octokit: ctx.octokit,
|
octokit: ctx.octokit,
|
||||||
customParts,
|
customParts,
|
||||||
|
model: ctx.toolState.model,
|
||||||
});
|
});
|
||||||
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
|
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
|
||||||
|
|
||||||
@@ -334,6 +365,7 @@ export async function reportProgress(
|
|||||||
const footer = await buildCommentFooter({
|
const footer = await buildCommentFooter({
|
||||||
octokit: ctx.octokit,
|
octokit: ctx.octokit,
|
||||||
customParts,
|
customParts,
|
||||||
|
model: ctx.toolState.model,
|
||||||
});
|
});
|
||||||
const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`;
|
const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ function buildPrBodyWithFooter(ctx: ToolContext, body: string): string {
|
|||||||
workflowRun: ctx.runId
|
workflowRun: ctx.runId
|
||||||
? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId }
|
? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId }
|
||||||
: undefined,
|
: undefined,
|
||||||
|
model: ctx.toolState.model,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
|
const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
|
||||||
|
|||||||
+79
-47
@@ -8,10 +8,10 @@ import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
|
|||||||
import type { ToolContext } from "./server.ts";
|
import type { ToolContext } from "./server.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
function isStatusError(err: unknown): err is { status: number; message?: string } {
|
function getHttpStatus(err: unknown): number | undefined {
|
||||||
return (
|
if (typeof err !== "object" || err === null) return undefined;
|
||||||
typeof err === "object" && err !== null && "status" in err && typeof err.status === "number"
|
const status = (err as Record<string, unknown>).status;
|
||||||
);
|
return typeof status === "number" ? status : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// one-shot review tool
|
// one-shot review tool
|
||||||
@@ -35,7 +35,7 @@ export const CreatePullRequestReview = type({
|
|||||||
"The file path to comment on (relative to repo root). Must be a file that appears in the PR diff."
|
"The file path to comment on (relative to repo root). Must be a file that appears in the PR diff."
|
||||||
),
|
),
|
||||||
line: type.number.describe(
|
line: type.number.describe(
|
||||||
"End line of the comment range. For single-line comments, set equal to 'start_line'. Use NEW column from diff format."
|
"Line number to comment on. For multi-line ranges, this is the end line. Use NEW column from diff format."
|
||||||
),
|
),
|
||||||
side: type
|
side: type
|
||||||
.enumerated("LEFT", "RIGHT")
|
.enumerated("LEFT", "RIGHT")
|
||||||
@@ -51,9 +51,11 @@ export const CreatePullRequestReview = type({
|
|||||||
"Full replacement code for the line range [start_line, line]. MUST preserve the exact indentation of the original code."
|
"Full replacement code for the line range [start_line, line]. MUST preserve the exact indentation of the original code."
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
start_line: type.number.describe(
|
start_line: type.number
|
||||||
"Start line of the comment range. For single-line comments, set equal to 'line'. The range [start_line, line] defines which lines a suggestion replaces."
|
.describe(
|
||||||
),
|
"Start line for multi-line comment ranges. Omit for single-line comments. The range [start_line, line] defines which lines a suggestion replaces."
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.array()
|
.array()
|
||||||
.describe(
|
.describe(
|
||||||
@@ -67,14 +69,14 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
|||||||
name: "create_pull_request_review",
|
name: "create_pull_request_review",
|
||||||
description:
|
description:
|
||||||
"Submit a review for an existing pull request. " +
|
"Submit a review for an existing pull request. " +
|
||||||
|
"Each call creates a permanent, visible review on the PR — NEVER submit test or diagnostic reviews. " +
|
||||||
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
|
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
|
||||||
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts. " +
|
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts. " +
|
||||||
"Use 'suggestion' to propose replacement code - MUST preserve exact indentation of original code. " +
|
"Use 'suggestion' to propose replacement code - MUST preserve exact indentation of original code. " +
|
||||||
"Example replacing lines 42-44 (3 lines) with 5 lines: " +
|
"Example replacing lines 42-44 (3 lines) with 5 lines: " +
|
||||||
`{ path: 'src/api.ts', start_line: 42, line: 44, suggestion: ' const result = await fetch(url);\\n if (!result.ok) {\\n log.error(result.status);\\n throw new Error("request failed");\\n }' }` +
|
`{ path: 'src/api.ts', start_line: 42, line: 44, suggestion: ' const result = await fetch(url);\\n if (!result.ok) {\\n log.error(result.status);\\n throw new Error("request failed");\\n }' }` +
|
||||||
" CONSTRAINT: Inline comments can ONLY target files and lines that appear in the PR diff." +
|
" CONSTRAINT: Inline comments can ONLY target files and lines that appear in the PR diff." +
|
||||||
" Commenting on files or lines outside the diff will cause GitHub API errors." +
|
" If GitHub rejects comments due to incorrect line numbers, re-read the diff and retry.",
|
||||||
" Put feedback about code outside the diff in 'body' instead.",
|
|
||||||
parameters: CreatePullRequestReview,
|
parameters: CreatePullRequestReview,
|
||||||
execute: execute(async ({ pull_number, body, approved, commit_id, comments = [] }) => {
|
execute: execute(async ({ pull_number, body, approved, commit_id, comments = [] }) => {
|
||||||
if (body) body = fixDoubleEscapedString(body);
|
if (body) body = fixDoubleEscapedString(body);
|
||||||
@@ -82,6 +84,18 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
|||||||
// set issue context (PRs are issues)
|
// set issue context (PRs are issues)
|
||||||
ctx.toolState.issueNumber = pull_number;
|
ctx.toolState.issueNumber = pull_number;
|
||||||
|
|
||||||
|
// skip empty reviews (no body, no inline comments) — nothing to post
|
||||||
|
if (!body && comments.length === 0) {
|
||||||
|
log.info(
|
||||||
|
"review has no body and no inline comments — skipping submission (no issues found)"
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
skipped: true,
|
||||||
|
reason: "no issues found — nothing to post",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// enforce prApproveEnabled: downgrade APPROVE to COMMENT if disabled
|
// enforce prApproveEnabled: downgrade APPROVE to COMMENT if disabled
|
||||||
let event: "APPROVE" | "COMMENT" = approved ? "APPROVE" : "COMMENT";
|
let event: "APPROVE" | "COMMENT" = approved ? "APPROVE" : "COMMENT";
|
||||||
if (event === "APPROVE" && !ctx.prApproveEnabled) {
|
if (event === "APPROVE" && !ctx.prApproveEnabled) {
|
||||||
@@ -95,6 +109,7 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
|||||||
pull_number,
|
pull_number,
|
||||||
event,
|
event,
|
||||||
};
|
};
|
||||||
|
let latestHeadSha: string | undefined;
|
||||||
if (commit_id) {
|
if (commit_id) {
|
||||||
params.commit_id = commit_id;
|
params.commit_id = commit_id;
|
||||||
} else {
|
} else {
|
||||||
@@ -103,28 +118,39 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
|||||||
repo: ctx.repo.name,
|
repo: ctx.repo.name,
|
||||||
pull_number,
|
pull_number,
|
||||||
});
|
});
|
||||||
params.commit_id = pr.data.head.sha;
|
latestHeadSha = pr.data.head.sha;
|
||||||
|
// anchor to checkout sha so line numbers match the diff the agent analyzed
|
||||||
|
params.commit_id = ctx.toolState.checkoutSha ?? latestHeadSha;
|
||||||
|
if (ctx.toolState.checkoutSha && latestHeadSha !== ctx.toolState.checkoutSha) {
|
||||||
|
log.info(
|
||||||
|
`anchoring review to checkout ${ctx.toolState.checkoutSha.slice(0, 7)} ` +
|
||||||
|
`(HEAD is now ${latestHeadSha.slice(0, 7)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (comments.length > 0) {
|
type ReviewComment = NonNullable<typeof params.comments>[number];
|
||||||
type ReviewComment = (typeof params.comments & {})[number];
|
const reviewComments = comments.map((comment) => {
|
||||||
params.comments = comments.map((comment) => {
|
let commentBody = fixDoubleEscapedString(comment.body || "");
|
||||||
let commentBody = fixDoubleEscapedString(comment.body || "");
|
if (comment.suggestion !== undefined) {
|
||||||
if (comment.suggestion !== undefined) {
|
const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```";
|
||||||
const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```";
|
commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock;
|
||||||
commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock;
|
}
|
||||||
}
|
const side = comment.side || "RIGHT";
|
||||||
|
const reviewComment: ReviewComment = {
|
||||||
|
path: comment.path,
|
||||||
|
line: comment.line,
|
||||||
|
body: commentBody,
|
||||||
|
side,
|
||||||
|
};
|
||||||
|
if (comment.start_line != null && comment.start_line !== comment.line) {
|
||||||
|
reviewComment.start_line = comment.start_line;
|
||||||
|
reviewComment.start_side = side;
|
||||||
|
}
|
||||||
|
return reviewComment;
|
||||||
|
});
|
||||||
|
|
||||||
const side = comment.side || "RIGHT";
|
if (reviewComments.length > 0) {
|
||||||
const reviewComment: ReviewComment = {
|
params.comments = reviewComments;
|
||||||
path: comment.path,
|
|
||||||
line: comment.line,
|
|
||||||
body: commentBody,
|
|
||||||
side,
|
|
||||||
start_line: comment.start_line,
|
|
||||||
start_side: side,
|
|
||||||
};
|
|
||||||
return reviewComment;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// no body → single-step createReview (no footer needed)
|
// no body → single-step createReview (no footer needed)
|
||||||
@@ -135,20 +161,24 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
|||||||
? await createAndSubmitWithFooter(ctx, params, {
|
? await createAndSubmitWithFooter(ctx, params, {
|
||||||
body,
|
body,
|
||||||
approved: approved ?? false,
|
approved: approved ?? false,
|
||||||
hasComments: comments.length > 0,
|
hasComments: reviewComments.length > 0,
|
||||||
})
|
})
|
||||||
: await ctx.octokit.rest.pulls.createReview(params);
|
: await ctx.octokit.rest.pulls.createReview(params);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (isStatusError(err) && err.status === 422 && params.comments?.length) {
|
if (getHttpStatus(err) !== 422 || !params.comments?.length) throw err;
|
||||||
const paths = [...new Set(params.comments.map((comment) => comment.path))];
|
|
||||||
throw new Error(
|
const details = params.comments.map((c) => {
|
||||||
`${err.message ?? "422 Unprocessable Entity"}. ` +
|
const line = c.line ?? 0;
|
||||||
`The review had ${params.comments.length} inline comment(s) targeting these paths: ${paths.join(", ")}. ` +
|
const startLine = c.start_line ?? line;
|
||||||
`GitHub cannot resolve one or more of these paths in the PR diff (common when the PR has >100 changed files and some are truncated). ` +
|
const range = startLine !== line ? `${startLine}-${line}` : `${line}`;
|
||||||
`Fix: remove the failing comment(s) and retry. Put their feedback in the review body instead.`
|
return `${c.path}:${range} (${c.side ?? "RIGHT"})`;
|
||||||
);
|
});
|
||||||
}
|
throw new Error(
|
||||||
throw err;
|
`GitHub rejected inline comment(s) with "Line could not be resolved". ` +
|
||||||
|
`This usually means the diff changed since you last read it (new commits pushed). ` +
|
||||||
|
`Re-read the diff to get current line numbers, or move failing comments to the review body. ` +
|
||||||
|
`Affected: ${details.join(", ")}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
|
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
|
||||||
if (!result.data.id) {
|
if (!result.data.id) {
|
||||||
@@ -169,12 +199,13 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
|||||||
|
|
||||||
// detect commits pushed since checkout and guide the agent to review them
|
// detect commits pushed since checkout and guide the agent to review them
|
||||||
// inline instead of dispatching a separate workflow run
|
// inline instead of dispatching a separate workflow run
|
||||||
const headMovedDuringReview =
|
if (
|
||||||
ctx.toolState.checkoutSha && params.commit_id !== ctx.toolState.checkoutSha;
|
ctx.toolState.checkoutSha &&
|
||||||
|
latestHeadSha &&
|
||||||
if (headMovedDuringReview) {
|
latestHeadSha !== ctx.toolState.checkoutSha
|
||||||
const fromSha = ctx.toolState.checkoutSha!;
|
) {
|
||||||
const toSha = params.commit_id!;
|
const fromSha = ctx.toolState.checkoutSha;
|
||||||
|
const toSha = latestHeadSha;
|
||||||
// advance checkoutSha so the next review submission tracks correctly
|
// advance checkoutSha so the next review submission tracks correctly
|
||||||
ctx.toolState.checkoutSha = toSha;
|
ctx.toolState.checkoutSha = toSha;
|
||||||
|
|
||||||
@@ -245,6 +276,7 @@ async function createAndSubmitWithFooter(
|
|||||||
? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId }
|
? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId }
|
||||||
: undefined,
|
: undefined,
|
||||||
customParts,
|
customParts,
|
||||||
|
model: ctx.toolState.model,
|
||||||
});
|
});
|
||||||
|
|
||||||
return ctx.octokit.rest.pulls.submitReview({
|
return ctx.octokit.rest.pulls.submitReview({
|
||||||
|
|||||||
+22
-8
@@ -2,6 +2,7 @@ import { type } from "arktype";
|
|||||||
import { ghPullfrogMcpName } from "../external.ts";
|
import { ghPullfrogMcpName } from "../external.ts";
|
||||||
import type { Mode } from "../modes.ts";
|
import type { Mode } from "../modes.ts";
|
||||||
import { apiFetch } from "../utils/apiFetch.ts";
|
import { apiFetch } from "../utils/apiFetch.ts";
|
||||||
|
import { log } from "../utils/log.ts";
|
||||||
import type { ToolContext } from "./server.ts";
|
import type { ToolContext } from "./server.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
@@ -192,7 +193,7 @@ An existing plan comment was found for this issue. Update that comment with the
|
|||||||
2. Delegate a subagent to analyze the diff and produce a structured summary. Include in its prompt:
|
2. Delegate a subagent to analyze the diff and produce a structured summary. Include in its prompt:
|
||||||
- the diff file path
|
- the diff file path
|
||||||
- PR metadata (title, file count, commit count, base/head branches)
|
- PR metadata (title, file count, commit count, base/head branches)
|
||||||
- format instructions from EVENT INSTRUCTIONS (if any); otherwise use default format: TL;DR, key changes list, per-change sections with before/after framing
|
- format instructions from EVENT INSTRUCTIONS (if any); otherwise use default format: TL;DR, key changes list, per-change sections with plain-language \`##\` titles and before/after framing
|
||||||
- instruct it to use the TOC to selectively read relevant diff sections, not the entire file
|
- instruct it to use the TOC to selectively read relevant diff sections, not the entire file
|
||||||
- instruct it to return the full summary markdown via \`${ghPullfrogMcpName}/set_output\`
|
- instruct it to return the full summary markdown via \`${ghPullfrogMcpName}/set_output\`
|
||||||
3. After the subagent completes, call \`${ghPullfrogMcpName}/create_issue_comment\` with \`type: "Summary"\` and the summary body.
|
3. After the subagent completes, call \`${ghPullfrogMcpName}/create_issue_comment\` with \`type: "Summary"\` and the summary body.
|
||||||
@@ -253,16 +254,19 @@ export type PlanCommentResponsePayload = { error: string } | { commentId: number
|
|||||||
// matches the API response for /repo/[owner]/[repo]/pr/[prNumber]/summary-comment
|
// matches the API response for /repo/[owner]/[repo]/pr/[prNumber]/summary-comment
|
||||||
export type SummaryCommentResponsePayload = { error: string } | { commentId: number; body: string };
|
export type SummaryCommentResponsePayload = { error: string } | { commentId: number; body: string };
|
||||||
|
|
||||||
|
// IMPORTANT: these routes authenticate via GitHub installation token (getEnrichedRepo),
|
||||||
|
// NOT the Pullfrog API JWT (ctx.apiToken). use ctx.githubInstallationToken here.
|
||||||
|
// see wiki/api-auth.md for the two auth patterns.
|
||||||
async function fetchExistingPlanComment(
|
async function fetchExistingPlanComment(
|
||||||
ctx: ToolContext,
|
ctx: ToolContext,
|
||||||
issueNumber: number
|
issueNumber: number
|
||||||
): Promise<Extract<PlanCommentResponsePayload, { commentId: number }> | null> {
|
): Promise<Extract<PlanCommentResponsePayload, { commentId: number }> | null> {
|
||||||
if (!ctx.apiToken) return null;
|
if (!ctx.githubInstallationToken) return null;
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch({
|
const response = await apiFetch({
|
||||||
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/issue/${issueNumber}/plan-comment`,
|
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/issue/${issueNumber}/plan-comment`,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { authorization: `Bearer ${ctx.apiToken}` },
|
headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
const data = (await response.json()) as PlanCommentResponsePayload;
|
const data = (await response.json()) as PlanCommentResponsePayload;
|
||||||
@@ -276,17 +280,27 @@ async function fetchExistingSummaryComment(
|
|||||||
ctx: ToolContext,
|
ctx: ToolContext,
|
||||||
prNumber: number
|
prNumber: number
|
||||||
): Promise<Extract<SummaryCommentResponsePayload, { commentId: number }> | null> {
|
): Promise<Extract<SummaryCommentResponsePayload, { commentId: number }> | null> {
|
||||||
if (!ctx.apiToken) return null;
|
if (!ctx.githubInstallationToken) {
|
||||||
|
log.warning("fetchExistingSummaryComment: no token, skipping");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const path = `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/pr/${prNumber}/summary-comment`;
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch({
|
const response = await apiFetch({
|
||||||
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/pr/${prNumber}/summary-comment`,
|
path,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { authorization: `Bearer ${ctx.apiToken}` },
|
headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
const data = (await response.json()) as SummaryCommentResponsePayload;
|
const data = (await response.json()) as SummaryCommentResponsePayload;
|
||||||
return response.ok && "commentId" in data ? data : null;
|
if (response.ok && "commentId" in data) {
|
||||||
} catch {
|
return data;
|
||||||
|
}
|
||||||
|
const errMsg = "error" in data ? data.error : "(no error body)";
|
||||||
|
log.warning(`fetchExistingSummaryComment: ${response.status} ${path} — ${errMsg}`);
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
log.warning("fetchExistingSummaryComment failed:", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export interface ToolState {
|
|||||||
existingSummaryCommentId?: number;
|
existingSummaryCommentId?: number;
|
||||||
output?: string;
|
output?: string;
|
||||||
usageEntries: AgentUsage[];
|
usageEntries: AgentUsage[];
|
||||||
|
model?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InitToolStateParams {
|
interface InitToolStateParams {
|
||||||
|
|||||||
+15
-3
@@ -47,6 +47,18 @@ describe("getModelEnvVars", () => {
|
|||||||
it("returns empty array for unknown provider", () => {
|
it("returns empty array for unknown provider", () => {
|
||||||
expect(getModelEnvVars("unknown/model")).toEqual([]);
|
expect(getModelEnvVars("unknown/model")).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns empty env vars for free opencode models", () => {
|
||||||
|
expect(getModelEnvVars("opencode/big-pickle")).toEqual([]);
|
||||||
|
expect(getModelEnvVars("opencode/gpt-5-nano")).toEqual([]);
|
||||||
|
expect(getModelEnvVars("opencode/mimo-v2-pro-free")).toEqual([]);
|
||||||
|
expect(getModelEnvVars("opencode/minimax-m2.5-free")).toEqual([]);
|
||||||
|
expect(getModelEnvVars("opencode/nemotron-3-super-free")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still requires OPENCODE_API_KEY for non-free opencode models", () => {
|
||||||
|
expect(getModelEnvVars("opencode/claude-opus")).toEqual(["OPENCODE_API_KEY"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveModelSlug", () => {
|
describe("resolveModelSlug", () => {
|
||||||
@@ -84,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)) {
|
for (const providerKey of Object.keys(providers)) {
|
||||||
const recommended = modelAliases.filter((a) => a.provider === providerKey && a.recommended);
|
const preferred = modelAliases.filter((a) => a.provider === providerKey && a.preferred);
|
||||||
expect(recommended.length, `${providerKey} should have exactly 1 recommended model`).toBe(1);
|
expect(preferred.length, `${providerKey} should have exactly 1 preferred model`).toBe(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,15 +16,23 @@ export interface ModelAlias {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
/** concrete models.dev specifier, e.g. "anthropic/claude-opus-4-6" */
|
/** concrete models.dev specifier, e.g. "anthropic/claude-opus-4-6" */
|
||||||
resolve: string;
|
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 */
|
/** 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModelDef {
|
interface ModelDef {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
/** concrete models.dev specifier, e.g. "anthropic/claude-opus-4-6" */
|
/** concrete models.dev specifier, e.g. "anthropic/claude-opus-4-6" */
|
||||||
resolve: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderConfig {
|
export interface ProviderConfig {
|
||||||
@@ -47,19 +55,40 @@ export const providers = {
|
|||||||
"claude-opus": {
|
"claude-opus": {
|
||||||
displayName: "Claude Opus",
|
displayName: "Claude Opus",
|
||||||
resolve: "anthropic/claude-opus-4-6",
|
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({
|
openai: provider({
|
||||||
displayName: "OpenAI",
|
displayName: "OpenAI",
|
||||||
envVars: ["OPENAI_API_KEY"],
|
envVars: ["OPENAI_API_KEY"],
|
||||||
models: {
|
models: {
|
||||||
"gpt-codex": { displayName: "GPT Codex", resolve: "openai/gpt-5.3-codex", recommended: true },
|
"gpt-codex": {
|
||||||
"gpt-codex-mini": { displayName: "GPT Codex Mini", resolve: "openai/codex-mini-latest" },
|
displayName: "GPT Codex",
|
||||||
o3: { displayName: "O3", resolve: "openai/o3" },
|
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({
|
google: provider({
|
||||||
@@ -69,18 +98,36 @@ export const providers = {
|
|||||||
"gemini-pro": {
|
"gemini-pro": {
|
||||||
displayName: "Gemini Pro",
|
displayName: "Gemini Pro",
|
||||||
resolve: "google/gemini-3.1-pro-preview",
|
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({
|
xai: provider({
|
||||||
displayName: "xAI",
|
displayName: "xAI",
|
||||||
envVars: ["XAI_API_KEY"],
|
envVars: ["XAI_API_KEY"],
|
||||||
models: {
|
models: {
|
||||||
grok: { displayName: "Grok", resolve: "xai/grok-4", recommended: true },
|
grok: {
|
||||||
"grok-fast": { displayName: "Grok Fast", resolve: "xai/grok-4-fast" },
|
displayName: "Grok",
|
||||||
"grok-code-fast": { displayName: "Grok Code Fast", resolve: "xai/grok-code-fast-1" },
|
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({
|
deepseek: provider({
|
||||||
@@ -90,16 +137,26 @@ export const providers = {
|
|||||||
"deepseek-reasoner": {
|
"deepseek-reasoner": {
|
||||||
displayName: "DeepSeek Reasoner",
|
displayName: "DeepSeek Reasoner",
|
||||||
resolve: "deepseek/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({
|
moonshotai: provider({
|
||||||
displayName: "Moonshot AI",
|
displayName: "Moonshot AI",
|
||||||
envVars: ["MOONSHOT_API_KEY"],
|
envVars: ["MOONSHOT_API_KEY"],
|
||||||
models: {
|
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({
|
opencode: provider({
|
||||||
@@ -109,21 +166,74 @@ export const providers = {
|
|||||||
"big-pickle": {
|
"big-pickle": {
|
||||||
displayName: "Big Pickle",
|
displayName: "Big Pickle",
|
||||||
resolve: "opencode/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-opus": {
|
||||||
"claude-sonnet": { displayName: "Claude Sonnet", resolve: "opencode/claude-sonnet-4-6" },
|
displayName: "Claude Opus",
|
||||||
"claude-haiku": { displayName: "Claude Haiku", resolve: "opencode/claude-haiku-4-5" },
|
resolve: "opencode/claude-opus-4-6",
|
||||||
"gpt-codex": { displayName: "GPT Codex", resolve: "opencode/gpt-5.3-codex" },
|
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
|
||||||
"gemini-pro": { displayName: "Gemini Pro", resolve: "opencode/gemini-3.1-pro" },
|
},
|
||||||
"gemini-flash": { displayName: "Gemini Flash", resolve: "opencode/gemini-3-flash" },
|
"claude-sonnet": {
|
||||||
"kimi-k2": { displayName: "Kimi K2", resolve: "opencode/kimi-k2.5" },
|
displayName: "Claude Sonnet",
|
||||||
"gpt-5-nano": { displayName: "GPT-5 Nano", resolve: "opencode/gpt-5-nano" },
|
resolve: "opencode/claude-sonnet-4-6",
|
||||||
"mimo-v2-flash-free": {
|
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6",
|
||||||
displayName: "MiMo V2 Flash",
|
},
|
||||||
resolve: "opencode/mimo-v2-flash-free",
|
"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",
|
||||||
|
envVars: [],
|
||||||
|
isFree: true,
|
||||||
|
},
|
||||||
|
"mimo-v2-pro-free": {
|
||||||
|
displayName: "MiMo V2 Pro",
|
||||||
|
resolve: "opencode/mimo-v2-pro-free",
|
||||||
|
envVars: [],
|
||||||
|
isFree: true,
|
||||||
|
},
|
||||||
|
"minimax-m2.5-free": {
|
||||||
|
displayName: "MiniMax M2.5",
|
||||||
|
resolve: "opencode/minimax-m2.5-free",
|
||||||
|
envVars: [],
|
||||||
|
isFree: true,
|
||||||
|
},
|
||||||
|
"nemotron-3-super-free": {
|
||||||
|
displayName: "Nemotron 3 Super",
|
||||||
|
resolve: "opencode/nemotron-3-super-free",
|
||||||
|
envVars: [],
|
||||||
|
isFree: true,
|
||||||
},
|
},
|
||||||
"minimax-m2.5-free": { displayName: "MiniMax M2.5", resolve: "opencode/minimax-m2.5-free" },
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
openrouter: provider({
|
openrouter: provider({
|
||||||
@@ -133,35 +243,59 @@ export const providers = {
|
|||||||
"claude-opus": {
|
"claude-opus": {
|
||||||
displayName: "Claude Opus",
|
displayName: "Claude Opus",
|
||||||
resolve: "openrouter/anthropic/claude-opus-4.6",
|
resolve: "openrouter/anthropic/claude-opus-4.6",
|
||||||
recommended: true,
|
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
|
||||||
|
preferred: true,
|
||||||
},
|
},
|
||||||
"claude-sonnet": {
|
"claude-sonnet": {
|
||||||
displayName: "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": {
|
"claude-haiku": {
|
||||||
displayName: "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": {
|
"gpt-codex-mini": {
|
||||||
displayName: "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",
|
||||||
},
|
},
|
||||||
"gemini-pro": {
|
"gemini-pro": {
|
||||||
displayName: "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": {
|
"gemini-flash": {
|
||||||
displayName: "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": {
|
"deepseek-chat": {
|
||||||
displayName: "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<string, ProviderConfig>;
|
} satisfies Record<string, ProviderConfig>;
|
||||||
@@ -182,9 +316,24 @@ export function getModelProvider(slug: string): string {
|
|||||||
return parseModel(slug).provider;
|
return parseModel(slug).provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getProviderDisplayName(slug: string): string | undefined {
|
||||||
|
const parsed = parseModel(slug);
|
||||||
|
return (providers as Record<string, ProviderConfig>)[parsed.provider]?.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
export function getModelEnvVars(slug: string): string[] {
|
export function getModelEnvVars(slug: string): string[] {
|
||||||
const p = getModelProvider(slug);
|
const parsed = parseModel(slug);
|
||||||
return (providers as Record<string, ProviderConfig>)[p]?.envVars.slice() ?? [];
|
const providerConfig = (providers as Record<string, ProviderConfig>)[parsed.provider];
|
||||||
|
if (!providerConfig) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelConfig = providerConfig.models[parsed.model];
|
||||||
|
if (modelConfig?.envVars) {
|
||||||
|
return modelConfig.envVars.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
return providerConfig.envVars.slice();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── derived flat list ──────────────────────────────────────────────────────────
|
// ── derived flat list ──────────────────────────────────────────────────────────
|
||||||
@@ -196,7 +345,9 @@ export const modelAliases: ModelAlias[] = Object.entries(providers).flatMap(
|
|||||||
provider: providerKey,
|
provider: providerKey,
|
||||||
displayName: def.displayName,
|
displayName: def.displayName,
|
||||||
resolve: def.resolve,
|
resolve: def.resolve,
|
||||||
recommended: def.recommended ?? false,
|
openRouterResolve: def.openRouterResolve,
|
||||||
|
preferred: def.preferred ?? false,
|
||||||
|
isFree: def.isFree ?? false,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ Do NOT overwrite a good comment with links/details with a generic message like "
|
|||||||
|
|
||||||
2. **ANALYZE** - Read the diff file. Use the TOC to selectively read relevant sections — do not read the entire file unless the PR is small.
|
2. **ANALYZE** - Read the diff file. Use the TOC to selectively read relevant sections — do not read the entire file unless the PR is small.
|
||||||
|
|
||||||
3. **SUMMARIZE** - Write a structured summary following the format from EVENT INSTRUCTIONS. If no format instructions are provided, produce a concise summary with a TL;DR, key changes list, and per-change sections with before/after framing.
|
3. **SUMMARIZE** - Write a structured summary following the format from EVENT INSTRUCTIONS. If no format instructions are provided, produce a concise summary with a TL;DR, key changes list, and per-change sections with human-readable \`##\` titles and before/after framing.
|
||||||
|
|
||||||
4. **POST** - Call ${ghPullfrogMcpName}/create_issue_comment with type: 'Summary' and the summary body.
|
4. **POST** - Call ${ghPullfrogMcpName}/create_issue_comment with type: 'Summary' and the summary body.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@pullfrog/pullfrog",
|
"name": "@pullfrog/pullfrog",
|
||||||
"version": "0.0.180",
|
"version": "0.0.182",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"index.js",
|
"index.js",
|
||||||
|
|||||||
@@ -37509,9 +37509,282 @@ function getApiUrl() {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// models.ts
|
||||||
|
function provider(config) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
var providers = {
|
||||||
|
anthropic: provider({
|
||||||
|
displayName: "Anthropic",
|
||||||
|
envVars: ["ANTHROPIC_API_KEY"],
|
||||||
|
models: {
|
||||||
|
"claude-opus": {
|
||||||
|
displayName: "Claude Opus",
|
||||||
|
resolve: "anthropic/claude-opus-4-6",
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
openai: provider({
|
||||||
|
displayName: "OpenAI",
|
||||||
|
envVars: ["OPENAI_API_KEY"],
|
||||||
|
models: {
|
||||||
|
"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({
|
||||||
|
displayName: "Google",
|
||||||
|
envVars: ["GOOGLE_GENERATIVE_AI_API_KEY", "GEMINI_API_KEY"],
|
||||||
|
models: {
|
||||||
|
"gemini-pro": {
|
||||||
|
displayName: "Gemini Pro",
|
||||||
|
resolve: "google/gemini-3.1-pro-preview",
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
xai: provider({
|
||||||
|
displayName: "xAI",
|
||||||
|
envVars: ["XAI_API_KEY"],
|
||||||
|
models: {
|
||||||
|
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({
|
||||||
|
displayName: "DeepSeek",
|
||||||
|
envVars: ["DEEPSEEK_API_KEY"],
|
||||||
|
models: {
|
||||||
|
"deepseek-reasoner": {
|
||||||
|
displayName: "DeepSeek Reasoner",
|
||||||
|
resolve: "deepseek/deepseek-reasoner",
|
||||||
|
openRouterResolve: "openrouter/deepseek/deepseek-v3.2",
|
||||||
|
preferred: true
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
openRouterResolve: "openrouter/moonshotai/kimi-k2.5",
|
||||||
|
preferred: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
opencode: provider({
|
||||||
|
displayName: "OpenCode",
|
||||||
|
envVars: ["OPENCODE_API_KEY"],
|
||||||
|
models: {
|
||||||
|
"big-pickle": {
|
||||||
|
displayName: "Big Pickle",
|
||||||
|
resolve: "opencode/big-pickle",
|
||||||
|
preferred: true,
|
||||||
|
envVars: [],
|
||||||
|
isFree: true
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
envVars: [],
|
||||||
|
isFree: true
|
||||||
|
},
|
||||||
|
"mimo-v2-pro-free": {
|
||||||
|
displayName: "MiMo V2 Pro",
|
||||||
|
resolve: "opencode/mimo-v2-pro-free",
|
||||||
|
envVars: [],
|
||||||
|
isFree: true
|
||||||
|
},
|
||||||
|
"minimax-m2.5-free": {
|
||||||
|
displayName: "MiniMax M2.5",
|
||||||
|
resolve: "opencode/minimax-m2.5-free",
|
||||||
|
envVars: [],
|
||||||
|
isFree: true
|
||||||
|
},
|
||||||
|
"nemotron-3-super-free": {
|
||||||
|
displayName: "Nemotron 3 Super",
|
||||||
|
resolve: "opencode/nemotron-3-super-free",
|
||||||
|
envVars: [],
|
||||||
|
isFree: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
openrouter: provider({
|
||||||
|
displayName: "OpenRouter",
|
||||||
|
envVars: ["OPENROUTER_API_KEY"],
|
||||||
|
models: {
|
||||||
|
"claude-opus": {
|
||||||
|
displayName: "Claude Opus",
|
||||||
|
resolve: "openrouter/anthropic/claude-opus-4.6",
|
||||||
|
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-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"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"deepseek-chat": {
|
||||||
|
displayName: "DeepSeek Chat",
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
var modelAliases = Object.entries(providers).flatMap(
|
||||||
|
([providerKey, config]) => Object.entries(config.models).map(([modelId, def]) => ({
|
||||||
|
slug: `${providerKey}/${modelId}`,
|
||||||
|
provider: providerKey,
|
||||||
|
displayName: def.displayName,
|
||||||
|
resolve: def.resolve,
|
||||||
|
openRouterResolve: def.openRouterResolve,
|
||||||
|
preferred: def.preferred ?? false,
|
||||||
|
isFree: def.isFree ?? false
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
// utils/buildPullfrogFooter.ts
|
// utils/buildPullfrogFooter.ts
|
||||||
var PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
|
var PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
|
||||||
var FROG_LOGO = `<a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-18px.png"><img src="https://pullfrog.com/logos/frog-green-full-18px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a>`;
|
var FROG_LOGO = `<a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-18px.png"><img src="https://pullfrog.com/logos/frog-green-full-18px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a>`;
|
||||||
|
function formatModelLabel(slug) {
|
||||||
|
const alias = modelAliases.find((a) => a.slug === slug);
|
||||||
|
if (!alias) return `\`${slug}\``;
|
||||||
|
return alias.isFree ? `\`${alias.displayName}\` (free)` : `\`${alias.displayName}\``;
|
||||||
|
}
|
||||||
function buildPullfrogFooter(params) {
|
function buildPullfrogFooter(params) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (params.customParts) {
|
if (params.customParts) {
|
||||||
@@ -37527,11 +37800,10 @@ function buildPullfrogFooter(params) {
|
|||||||
if (params.triggeredBy) {
|
if (params.triggeredBy) {
|
||||||
parts.push("Triggered by [Pullfrog](https://pullfrog.com)");
|
parts.push("Triggered by [Pullfrog](https://pullfrog.com)");
|
||||||
}
|
}
|
||||||
const allParts = [
|
if (params.model) {
|
||||||
...parts,
|
parts.push(`Using ${formatModelLabel(params.model)}`);
|
||||||
"[pullfrog.com](https://pullfrog.com)",
|
}
|
||||||
"[\u{1D54F}](https://x.com/pullfrogai)"
|
const allParts = [...parts, "[\u{1D54F}](https://x.com/pullfrogai)"];
|
||||||
];
|
|
||||||
return `
|
return `
|
||||||
${PULLFROG_DIVIDER}
|
${PULLFROG_DIVIDER}
|
||||||
<sup>${FROG_LOGO} \uFF5C ${allParts.join(" \uFF5C ")}</sup>`;
|
<sup>${FROG_LOGO} \uFF5C ${allParts.join(" \uFF5C ")}</sup>`;
|
||||||
@@ -41284,7 +41556,7 @@ var core3 = __toESM(require_core(), 1);
|
|||||||
// package.json
|
// package.json
|
||||||
var package_default = {
|
var package_default = {
|
||||||
name: "@pullfrog/pullfrog",
|
name: "@pullfrog/pullfrog",
|
||||||
version: "0.0.180",
|
version: "0.0.182",
|
||||||
type: "module",
|
type: "module",
|
||||||
files: [
|
files: [
|
||||||
"index.js",
|
"index.js",
|
||||||
|
|||||||
@@ -19,20 +19,20 @@ exports[`latest model per provider snapshot > matches snapshot 1`] = `
|
|||||||
"releaseDate": "2026-01",
|
"releaseDate": "2026-01",
|
||||||
},
|
},
|
||||||
"openai": {
|
"openai": {
|
||||||
"modelId": "gpt-5.4",
|
"modelId": "gpt-5.4-nano",
|
||||||
"releaseDate": "2026-03-05",
|
"releaseDate": "2026-03-17",
|
||||||
},
|
},
|
||||||
"opencode": {
|
"opencode": {
|
||||||
"modelId": "nemotron-3-super-free",
|
"modelId": "mimo-v2-pro-free",
|
||||||
"releaseDate": "2026-03-11",
|
"releaseDate": "2026-03-18",
|
||||||
},
|
},
|
||||||
"openrouter": {
|
"openrouter": {
|
||||||
"modelId": "openrouter/hunter-alpha",
|
"modelId": "xiaomi/mimo-v2-pro",
|
||||||
"releaseDate": "2026-03-11",
|
"releaseDate": "2026-03-18",
|
||||||
},
|
},
|
||||||
"xai": {
|
"xai": {
|
||||||
"modelId": "grok-4.20-experimental-beta-0304-reasoning",
|
"modelId": "grok-4.20-multi-agent-0309",
|
||||||
"releaseDate": "2026-03-04",
|
"releaseDate": "2026-03-09",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
+1
-1
@@ -63,7 +63,7 @@ const dynamicAgentsExpression = "$" + "{{ fromJSON(needs.changes.outputs.agents)
|
|||||||
const expectedAgentEnvVars = [
|
const expectedAgentEnvVars = [
|
||||||
"GITHUB_TOKEN",
|
"GITHUB_TOKEN",
|
||||||
...new Set(Object.values(providers).flatMap((p) => [...p.envVars])),
|
...new Set(Object.values(providers).flatMap((p) => [...p.envVars])),
|
||||||
"OPENCODE_MODEL",
|
"PULLFROG_MODEL",
|
||||||
].sort();
|
].sort();
|
||||||
|
|
||||||
const expectedAgnosticEnvVars = ["ANTHROPIC_API_KEY", "GITHUB_TOKEN"].sort();
|
const expectedAgnosticEnvVars = ["ANTHROPIC_API_KEY", "GITHUB_TOKEN"].sort();
|
||||||
|
|||||||
+86
-2
@@ -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<string>();
|
||||||
|
|
||||||
|
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<OpenRouterModelsResponse>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("openRouterResolve OpenRouter API validity", async () => {
|
||||||
|
const orData = await openRouterApi;
|
||||||
|
const orModelIds = new Set(orData.data.map((m) => m.id));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
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 () => {
|
describe("latest model per provider snapshot", async () => {
|
||||||
const data = await api;
|
const data = await api;
|
||||||
const providerKeys = Object.keys(providers) as ModelProvider[];
|
const providerKeys = Object.keys(providers) as ModelProvider[];
|
||||||
@@ -58,10 +134,16 @@ describe("latest model per provider snapshot", async () => {
|
|||||||
|
|
||||||
let latest: { modelId: string; releaseDate: string } | undefined;
|
let latest: { modelId: string; releaseDate: string } | undefined;
|
||||||
for (const [modelId, model] of Object.entries(providerData.models)) {
|
for (const [modelId, model] of Object.entries(providerData.models)) {
|
||||||
if (model.status === "deprecated") continue;
|
// skip non-GA models so beta/nightly churn doesn't break the snapshot
|
||||||
|
if (model.status) continue;
|
||||||
const rd = model.release_date;
|
const rd = model.release_date;
|
||||||
if (!rd) continue;
|
if (!rd) continue;
|
||||||
if (!latest || rd > latest.releaseDate) {
|
// tiebreak by modelId for stable ordering when release dates match
|
||||||
|
if (
|
||||||
|
!latest ||
|
||||||
|
rd > latest.releaseDate ||
|
||||||
|
(rd === latest.releaseDate && modelId > latest.modelId)
|
||||||
|
) {
|
||||||
latest = { modelId, releaseDate: rd };
|
latest = { modelId, releaseDate: rd };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,6 +152,8 @@ describe("latest model per provider snapshot", async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// when this fails, a provider shipped a new model. check whether we need
|
||||||
|
// to add or update an alias in models.ts before updating the snapshot.
|
||||||
it("matches snapshot", () => {
|
it("matches snapshot", () => {
|
||||||
expect(latestByProvider).toMatchSnapshot();
|
expect(latestByProvider).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -307,7 +307,7 @@ async function runTestForAgent(ctx: RunContext): Promise<ValidationResult> {
|
|||||||
|
|
||||||
// use anthropic sonnet to avoid google quota issues and gemini doom-looping
|
// use anthropic sonnet to avoid google quota issues and gemini doom-looping
|
||||||
if (ctx.agent === "opentoad") {
|
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
|
// build file-based env vars for MCP servers that don't inherit parent env
|
||||||
|
|||||||
@@ -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-pro-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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+28
-5
@@ -1,4 +1,4 @@
|
|||||||
import { providers } from "../models.ts";
|
import { getModelEnvVars, providers } from "../models.ts";
|
||||||
import { getApiUrl } from "./apiUrl.ts";
|
import { getApiUrl } from "./apiUrl.ts";
|
||||||
|
|
||||||
const knownApiKeys: Set<string> = new Set(Object.values(providers).flatMap((p) => [...p.envVars]));
|
const knownApiKeys: Set<string> = new Set(Object.values(providers).flatMap((p) => [...p.envVars]));
|
||||||
@@ -20,18 +20,41 @@ to fix this, add the required secret to your GitHub repository:
|
|||||||
4. set the value to your API key
|
4. set the value to your API key
|
||||||
5. click "Add secret"
|
5. click "Add secret"
|
||||||
|
|
||||||
configure your model at ${settingsUrl}`;
|
configure your model at ${settingsUrl}
|
||||||
|
|
||||||
|
for full setup instructions, see https://docs.pullfrog.com/keys`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasEnvVar(name: string): boolean {
|
||||||
|
const value = process.env[name];
|
||||||
|
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: {
|
export function validateAgentApiKey(params: {
|
||||||
agent: { name: string };
|
agent: { name: string };
|
||||||
|
model: string | undefined;
|
||||||
owner: string;
|
owner: string;
|
||||||
name: string;
|
name: string;
|
||||||
}): void {
|
}): void {
|
||||||
const hasAnyKey = Object.entries(process.env).some(
|
// if a specific model is configured, only check that model's required env vars
|
||||||
([key, value]) => value && typeof value === "string" && knownApiKeys.has(key)
|
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) {
|
if (!hasAnyKey) {
|
||||||
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
|
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { modelAliases } from "../models.ts";
|
||||||
|
|
||||||
export const PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
|
export const PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
|
||||||
|
|
||||||
const FROG_LOGO = `<a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-18px.png"><img src="https://pullfrog.com/logos/frog-green-full-18px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a>`;
|
const FROG_LOGO = `<a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-18px.png"><img src="https://pullfrog.com/logos/frog-green-full-18px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a>`;
|
||||||
@@ -18,13 +20,21 @@ export interface BuildPullfrogFooterParams {
|
|||||||
/** alternative: just pass a pre-built URL directly (for shortlinks etc.) */
|
/** alternative: just pass a pre-built URL directly (for shortlinks etc.) */
|
||||||
workflowRunUrl?: string | undefined;
|
workflowRunUrl?: string | undefined;
|
||||||
/** arbitrary custom parts (e.g., action links) */
|
/** arbitrary custom parts (e.g., action links) */
|
||||||
customParts?: string[];
|
customParts?: string[] | undefined;
|
||||||
|
/** model slug from payload (e.g., "anthropic/claude-opus"). shown in footer as "Using `Model Name`" */
|
||||||
|
model?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatModelLabel(slug: string): string {
|
||||||
|
const alias = modelAliases.find((a) => a.slug === slug);
|
||||||
|
if (!alias) return `\`${slug}\``;
|
||||||
|
return alias.isFree ? `\`${alias.displayName}\` (free)` : `\`${alias.displayName}\``;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* build a pullfrog footer with configurable parts
|
* build a pullfrog footer with configurable parts
|
||||||
* always includes: frog logo at start, pullfrog.com link and X link at end
|
* always includes: frog logo at start and X link at end
|
||||||
* order: action links (customParts) > workflow run > attribution > reference links
|
* order: action links (customParts) > workflow run > model > attribution > reference links
|
||||||
*/
|
*/
|
||||||
export function buildPullfrogFooter(params: BuildPullfrogFooterParams): string {
|
export function buildPullfrogFooter(params: BuildPullfrogFooterParams): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
@@ -45,11 +55,11 @@ export function buildPullfrogFooter(params: BuildPullfrogFooterParams): string {
|
|||||||
parts.push("Triggered by [Pullfrog](https://pullfrog.com)");
|
parts.push("Triggered by [Pullfrog](https://pullfrog.com)");
|
||||||
}
|
}
|
||||||
|
|
||||||
const allParts = [
|
if (params.model) {
|
||||||
...parts,
|
parts.push(`Using ${formatModelLabel(params.model)}`);
|
||||||
"[pullfrog.com](https://pullfrog.com)",
|
}
|
||||||
"[𝕏](https://x.com/pullfrogai)",
|
|
||||||
];
|
const allParts = [...parts, "[𝕏](https://x.com/pullfrogai)"];
|
||||||
|
|
||||||
return `
|
return `
|
||||||
${PULLFROG_DIVIDER}
|
${PULLFROG_DIVIDER}
|
||||||
|
|||||||
+1
-1
@@ -117,7 +117,7 @@ const testEnvAllowList = new Set([
|
|||||||
"ANTHROPIC_API_KEY",
|
"ANTHROPIC_API_KEY",
|
||||||
"GEMINI_API_KEY",
|
"GEMINI_API_KEY",
|
||||||
"GOOGLE_GENERATIVE_AI_API_KEY",
|
"GOOGLE_GENERATIVE_AI_API_KEY",
|
||||||
"OPENCODE_MODEL",
|
"PULLFROG_MODEL",
|
||||||
"LOG_LEVEL",
|
"LOG_LEVEL",
|
||||||
"DEBUG",
|
"DEBUG",
|
||||||
"NODE_ENV",
|
"NODE_ENV",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export async function reportErrorToComment(ctx: ReportErrorParams): Promise<void
|
|||||||
triggeredBy: true,
|
triggeredBy: true,
|
||||||
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : undefined,
|
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : undefined,
|
||||||
customParts,
|
customParts,
|
||||||
|
model: ctx.toolState.model,
|
||||||
});
|
});
|
||||||
|
|
||||||
await octokit.rest.issues.updateComment({
|
await octokit.rest.issues.updateComment({
|
||||||
|
|||||||
@@ -161,6 +161,9 @@ export function resolvePayload(
|
|||||||
// permissions: inputs > repoSettings > fallbacks
|
// permissions: inputs > repoSettings > fallbacks
|
||||||
push: inputs.push ?? repoSettings.push ?? "restricted",
|
push: inputs.push ?? repoSettings.push ?? "restricted",
|
||||||
shell: resolvedShell,
|
shell: resolvedShell,
|
||||||
|
|
||||||
|
// set by proxy logic in main.ts when routing through OpenRouter
|
||||||
|
proxyModel: undefined as string | undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export interface RepoSettings {
|
|||||||
export interface RunContext {
|
export interface RunContext {
|
||||||
settings: RepoSettings;
|
settings: RepoSettings;
|
||||||
apiToken: string;
|
apiToken: string;
|
||||||
|
oss: boolean;
|
||||||
|
proxyModel?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSettings: RepoSettings = {
|
const defaultSettings: RepoSettings = {
|
||||||
@@ -39,6 +41,7 @@ const defaultSettings: RepoSettings = {
|
|||||||
const defaultRunContext: RunContext = {
|
const defaultRunContext: RunContext = {
|
||||||
settings: defaultSettings,
|
settings: defaultSettings,
|
||||||
apiToken: "",
|
apiToken: "",
|
||||||
|
oss: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,6 +76,8 @@ export async function fetchRunContext(params: {
|
|||||||
const data = (await response.json()) as {
|
const data = (await response.json()) as {
|
||||||
settings: RepoSettings | null;
|
settings: RepoSettings | null;
|
||||||
apiToken: string;
|
apiToken: string;
|
||||||
|
oss?: boolean;
|
||||||
|
proxyModel?: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
if (data === null) {
|
if (data === null) {
|
||||||
@@ -88,6 +93,8 @@ export async function fetchRunContext(params: {
|
|||||||
postCheckoutScript: data.settings?.postCheckoutScript ?? null,
|
postCheckoutScript: data.settings?.postCheckoutScript ?? null,
|
||||||
},
|
},
|
||||||
apiToken: data.apiToken,
|
apiToken: data.apiToken,
|
||||||
|
oss: data.oss ?? false,
|
||||||
|
proxyModel: data.proxyModel,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface RunContextData {
|
|||||||
};
|
};
|
||||||
repoSettings: RepoSettings;
|
repoSettings: RepoSettings;
|
||||||
apiToken: string;
|
apiToken: string;
|
||||||
|
oss: boolean;
|
||||||
|
proxyModel?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResolveRunContextDataParams {
|
interface ResolveRunContextDataParams {
|
||||||
@@ -42,5 +44,7 @@ export async function resolveRunContextData(
|
|||||||
},
|
},
|
||||||
repoSettings: runContext.settings,
|
repoSettings: runContext.settings,
|
||||||
apiToken: runContext.apiToken,
|
apiToken: runContext.apiToken,
|
||||||
|
oss: runContext.oss,
|
||||||
|
proxyModel: runContext.proxyModel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user