Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,26 @@ jobs:

```

#### OpenAI-compatible BYOK gateway

To use any OpenAI-compatible bring-your-own-key (BYOK) gateway, add these GitHub Actions secrets to your repository:

- `OPENAI_COMPATIBLE_API_KEY`
- `OPENAI_COMPATIBLE_BASE_URL` — must be an OpenAI-compatible `/v1` endpoint (for example, `https://gateway.example.com/v1`)
- `OPENAI_COMPATIBLE_MODEL_ID`

Then select the generic OpenAI-compatible BYOK model and pass the secrets to the action:

```yaml
env:
PULLFROG_MODEL: openai-compatible/byok
OPENAI_COMPATIBLE_API_KEY: ${{ secrets.OPENAI_COMPATIBLE_API_KEY }}
OPENAI_COMPATIBLE_BASE_URL: ${{ secrets.OPENAI_COMPATIBLE_BASE_URL }}
OPENAI_COMPATIBLE_MODEL_ID: ${{ secrets.OPENAI_COMPATIBLE_MODEL_ID }}
```

LiteLLM is a supported OpenAI-compatible gateway example, not a special provider.

To gate merges on Pullfrog with branch protection, add `status_checks: enabled` under `with:`. Each PR run then posts a `pullfrog` check (run completion — success when the run finishes, failure on error/timeout) and a `pullfrog-approval` check (whether Pullfrog would approve the PR), both requireable as status checks. See [PR reviews → Required status checks](https://docs.pullfrog.dev/pr-reviews#required-status-checks-branch-protection).

#### 2. Create `triggers.yml`
Expand Down
67 changes: 54 additions & 13 deletions agents/opencode_v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,12 @@ import {
} from "@opencode-ai/sdk/v2";
import { Agent, fetch as undiciFetch } from "undici";
import { pullfrogMcpName } from "../external.ts";
import { BEDROCK_MODEL_ID_ENV } from "../models.ts";
import {
BEDROCK_MODEL_ID_ENV,
OPENAI_COMPATIBLE_API_KEY_ENV,
OPENAI_COMPATIBLE_BASE_URL_ENV,
OPENAI_COMPATIBLE_MODEL_ID_ENV,
} from "../models.ts";
import type { ToolState } from "../toolState.ts";
import { AGENT_ACTIVITY_TIMEOUT_MS, markActivity } from "../utils/activity.ts";
import type { AgentDiagnostic } from "../utils/agentHangReport.ts";
Expand Down Expand Up @@ -98,7 +103,15 @@ const installCli = () => installOpencodeCli({ binPath: "bin/opencode.exe" });

// ── config ─────────────────────────────────────────────────────────────────────

function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): string {
export function buildOpenCodeConfig(params: {
mcpServerUrl: string;
model: string | undefined;
openaiCompatible: {
modelId: string | undefined;
baseURL: string | undefined;
apiKey: string | undefined;
};
}): OpenCodeConfig {
const config: OpenCodeConfig = {
permission: {
bash: "deny",
Expand All @@ -115,27 +128,55 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
// deleting live git locks (the corruption in #860/#864 — the dangerous
// `rm` guidance is gone, but the spurious aborts shouldn't happen either).
// server-side cap is 600s (`checkout_pr` `timeoutMs`).
[pullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl, timeout: 300_000 },
[pullfrogMcpName]: { type: "remote", url: params.mcpServerUrl, timeout: 300_000 },
},
agent: (() => {
const cfg = buildReviewerAgentConfig(model);
const reviewerModel = (cfg[REVIEWER_AGENT_NAME] as { model?: string })?.model ?? "(inherit)";
log.info(`» subagent models: reviewfrog=${reviewerModel}`);
return cfg;
})(),
agent: buildReviewerAgentConfig(params.model),
// gemini-3 thinking pinned to high for review depth; gpt and anthropic
// effort set elsewhere (gpt: upstream default, anthropic: --effort flag in claude.ts).
provider: { google: { models: geminiHighThinkingOverrides() } },
};

if (model) {
config.model = model;
const slashIndex = model.indexOf("/");
const openAICompatibleModelId = params.openaiCompatible.modelId?.trim();
if (openAICompatibleModelId && params.model === `openai-compatible/${openAICompatibleModelId}`) {
config.provider = {
...config.provider,
"openai-compatible": {
npm: "@ai-sdk/openai-compatible",
options: {
baseURL: params.openaiCompatible.baseURL?.trim(),
apiKey: params.openaiCompatible.apiKey?.trim(),
},
models: {
[openAICompatibleModelId]: { name: openAICompatibleModelId },
},
},
};
}

if (params.model) {
config.model = params.model;
const slashIndex = params.model.indexOf("/");
if (slashIndex > 0) {
config.enabled_providers = [model.slice(0, slashIndex).toLowerCase()];
config.enabled_providers = [params.model.slice(0, slashIndex).toLowerCase()];
}
}

return config;
}

function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): string {
const config = buildOpenCodeConfig({
mcpServerUrl: ctx.mcpServerUrl,
model,
openaiCompatible: {
modelId: process.env[OPENAI_COMPATIBLE_MODEL_ID_ENV],
baseURL: process.env[OPENAI_COMPATIBLE_BASE_URL_ENV],
apiKey: process.env[OPENAI_COMPATIBLE_API_KEY_ENV],
Comment thread
overbit marked this conversation as resolved.
},
});
const reviewerModel =
(config.agent?.[REVIEWER_AGENT_NAME] as { model?: string } | undefined)?.model ?? "(inherit)";
log.info(`» subagent models: reviewfrog=${reviewerModel}`);
return JSON.stringify(config);
}

Expand Down
2 changes: 1 addition & 1 deletion agents/subagentRegistration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe("subagent registration source asserts", () => {
expect(opencodeSharedSource).toMatch(/overrides\.reviewer/);
});
it("v2 runner passes orchestrator model to buildReviewerAgentConfig", () => {
expect(opencodeV2Source).toMatch(/buildReviewerAgentConfig\(model\)/);
expect(opencodeV2Source).toMatch(/buildReviewerAgentConfig\(params\.model\)/);
});
});
});
33 changes: 31 additions & 2 deletions models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@
* contracts. so the single `bedrock/byok` and `vertex/byok` entries are
* routing slugs, not model aliases: the harness reads the backend-specific
* env var and routes to claude-code for Anthropic IDs or opencode for
* everything else.
* everything else. `"openai-compatible"` means the actual model ID comes
* from `OPENAI_COMPATIBLE_MODEL_ID` and is served through the configured
* OpenAI-compatible endpoint.
*/
export type ModelRouting = "bedrock" | "vertex";
export type ModelRouting = "bedrock" | "vertex" | "openai-compatible";

export interface ModelAlias {
/** stable alias stored in DB, e.g. "anthropic/claude-opus" */
Expand Down Expand Up @@ -468,6 +470,21 @@ export const providers = {
},
},
}),
"openai-compatible": provider({
displayName: "OpenAI-compatible",
envVars: [
"OPENAI_COMPATIBLE_API_KEY",
"OPENAI_COMPATIBLE_BASE_URL",
"OPENAI_COMPATIBLE_MODEL_ID",
],
models: {
byok: {
displayName: "OpenAI-compatible",
resolve: "openai-compatible",
routing: "openai-compatible",
},
},
}),
openrouter: provider({
displayName: "OpenRouter",
envVars: ["OPENROUTER_API_KEY"],
Expand Down Expand Up @@ -806,6 +823,18 @@ export const BEDROCK_MODEL_ID_ENV = "BEDROCK_MODEL_ID";
/** env var that supplies the Vertex AI model ID for the `vertex/byok` slug. */
export const VERTEX_MODEL_ID_ENV = "VERTEX_MODEL_ID";

/** env vars required to serve the `openai-compatible/byok` routing slug. */
export const OPENAI_COMPATIBLE_API_KEY_ENV = "OPENAI_COMPATIBLE_API_KEY";
export const OPENAI_COMPATIBLE_BASE_URL_ENV = "OPENAI_COMPATIBLE_BASE_URL";
export const OPENAI_COMPATIBLE_MODEL_ID_ENV = "OPENAI_COMPATIBLE_MODEL_ID";

/** all three are required — setup validators check against this single list. */
export const OPENAI_COMPATIBLE_REQUIRED_ENV_VARS = [
OPENAI_COMPATIBLE_API_KEY_ENV,
OPENAI_COMPATIBLE_BASE_URL_ENV,
OPENAI_COMPATIBLE_MODEL_ID_ENV,
] as const;

/**
* the Bedrock model ID passed to claude-code or opencode is whatever the
* user set in `BEDROCK_MODEL_ID` — Pullfrog never resolves or upgrades it.
Expand Down
30 changes: 29 additions & 1 deletion test/models.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest";
import { getModelEnvVars, modelAliases, resolveCliModel, resolveDisplayAlias } from "../models.ts";
import {
getModelEnvVars,
OPENAI_COMPATIBLE_API_KEY_ENV,
OPENAI_COMPATIBLE_BASE_URL_ENV,
OPENAI_COMPATIBLE_MODEL_ID_ENV,
modelAliases,
providers,
resolveCliModel,
resolveDisplayAlias,
} from "../models.ts";

// ── pure alias-registry invariants ──────────────────────────────────────────────
//
Expand All @@ -13,6 +22,25 @@ import { getModelEnvVars, modelAliases, resolveCliModel, resolveDisplayAlias } f
// the models-bump cron flags entries that become fillable (see rule 9 in models-bump.yml).
const BYOK_ONLY_MODELS = new Set<string>([]);

describe("OpenAI-compatible registry", () => {
it("declares the required environment variables and dynamic routing alias", () => {
expect(providers["openai-compatible"].envVars).toEqual([
OPENAI_COMPATIBLE_API_KEY_ENV,
OPENAI_COMPATIBLE_BASE_URL_ENV,
OPENAI_COMPATIBLE_MODEL_ID_ENV,
]);
expect(getModelEnvVars("openai-compatible/byok")).toEqual(
providers["openai-compatible"].envVars
);
expect(modelAliases.find((alias) => alias.slug === "openai-compatible/byok")).toMatchObject({
provider: "openai-compatible",
resolve: "openai-compatible",
routing: "openai-compatible",
});
expect(modelAliases.find((alias) => alias.slug === "litellm/byok")).toBeUndefined();
});
});

describe("openRouterResolve completeness", () => {
for (const alias of modelAliases) {
if (alias.isFree) continue;
Expand Down
68 changes: 68 additions & 0 deletions utils/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildOpenCodeConfig } from "../agents/opencode_v2.ts";
import { resolveAgent, resolveModel } from "./agent.ts";
import { cleanupVertexCredentials, materializeVertexCredentials } from "./vertex.ts";

Expand All @@ -21,6 +22,8 @@ const STRIPPED = [
/^VERTEX_SERVICE_ACCOUNT_JSON$/,
/^VERTEX_LOCATION$/,
/^VERTEX_MODEL_ID$/,
/^OPENAI_COMPATIBLE_BASE_URL$/,
/^OPENAI_COMPATIBLE_MODEL_ID$/,
/^PULLFROG_SECRET_HOME$/,
/^PULLFROG_MODEL$/,
/^PULLFROG_AGENT$/,
Expand Down Expand Up @@ -162,13 +165,78 @@ describe("resolveModel", () => {
expect(() => resolveModel({ slug: "vertex/byok" })).toThrow("VERTEX_MODEL_ID");
});

it("resolves openai-compatible/byok to the configured model", () => {
process.env.OPENAI_COMPATIBLE_MODEL_ID = "azure/gpt-5.6-deployment";
expect(resolveModel({ slug: "openai-compatible/byok" })).toBe(
"openai-compatible/azure/gpt-5.6-deployment"
);
});

it("throws when openai-compatible/byok is selected without OPENAI_COMPATIBLE_MODEL_ID", () => {
expect(() => resolveModel({ slug: "openai-compatible/byok" })).toThrow(
"OPENAI_COMPATIBLE_MODEL_ID"
);
});

it("PULLFROG_MODEL=vertex/byok defers to VERTEX_MODEL_ID, not the sentinel", () => {
process.env.PULLFROG_MODEL = "vertex/byok";
process.env.VERTEX_MODEL_ID = "gemini-2.5-pro";
expect(resolveModel({ slug: "openai/gpt" })).toBe("gemini-2.5-pro");
});
});

describe("buildOpenCodeConfig", () => {
it("translates an OpenAI-compatible route into OpenCode's provider payload", () => {
const modelId = "azure/gpt-5.6-production";
const config = buildOpenCodeConfig({
mcpServerUrl: "http://127.0.0.1:3000/mcp",
model: `openai-compatible/${modelId}`,
openaiCompatible: {
modelId,
baseURL: "https://gateway.example.com/v1",
apiKey: "openai-compatible-test-key",
},
});

expect(config.provider).toMatchObject({
"openai-compatible": {
npm: "@ai-sdk/openai-compatible",
options: {
baseURL: "https://gateway.example.com/v1",
apiKey: "openai-compatible-test-key",
},
models: {
[modelId]: { name: modelId },
},
},
});
expect(config.model).toBe(`openai-compatible/${modelId}`);
expect(config.enabled_providers).toEqual(["openai-compatible"]);
});

it("trims baseURL and apiKey to avoid trailing-newline secret breakage", () => {
const modelId = "azure/gpt-5.6-production";
const config = buildOpenCodeConfig({
mcpServerUrl: "http://127.0.0.1:3000/mcp",
model: `openai-compatible/${modelId}`,
openaiCompatible: {
modelId,
baseURL: "https://gateway.example.com/v1\n",
apiKey: "openai-compatible-test-key\n",
},
});

expect(config.provider).toMatchObject({
"openai-compatible": {
options: {
baseURL: "https://gateway.example.com/v1",
apiKey: "openai-compatible-test-key",
},
},
});
});
});

describe("materializeVertexCredentials", () => {
it("writes service-account JSON outside tmpdir and defaults project from project_id", () => {
const dir = mkdtempSync(join(tmpdir(), "vertex-creds-test-"));
Expand Down
17 changes: 17 additions & 0 deletions utils/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
getModelProvider,
isBedrockAnthropicId,
isVertexAnthropicId,
OPENAI_COMPATIBLE_MODEL_ID_ENV,
OPENAI_COMPATIBLE_REQUIRED_ENV_VARS,
resolveCliModel,
resolveDisplayAlias,
VERTEX_MODEL_ID_ENV,
Expand Down Expand Up @@ -32,6 +34,10 @@ function hasVertexAuth(): boolean {
return hasEnvVar(VERTEX_SERVICE_ACCOUNT_JSON_ENV);
}

function getMissingOpenAICompatibleEnvVars(): string[] {
return OPENAI_COMPATIBLE_REQUIRED_ENV_VARS.filter((name) => !process.env[name]?.trim());
}

/**
* resolve a single slug to its CLI-ready model string. routing aliases
* (e.g. `bedrock/byok`) defer to their backing env var instead of the
Expand Down Expand Up @@ -64,6 +70,17 @@ function resolveSlug(slug: string): string | undefined {
}
return vertexId;
}
if (alias?.routing === "openai-compatible") {
const openAICompatibleModelId = process.env[OPENAI_COMPATIBLE_MODEL_ID_ENV]?.trim();
if (!openAICompatibleModelId) {
const missing = getMissingOpenAICompatibleEnvVars();
throw new Error(
`OpenAI-compatible model selected but required configuration is missing: ${missing.join(", ")}. ` +
"set the missing environment variables before running Pullfrog."
);
}
return `openai-compatible/${openAICompatibleModelId}`;
}
return resolveCliModel(slug);
}

Expand Down
Loading