Skip to content
Merged
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
28 changes: 15 additions & 13 deletions .github/workflows/dev.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions .github/workflows/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ description: Daily status report for gh-aw project
timeout-minutes: 30
strict: false
engine:
id: pi
model: copilot/claude-sonnet-4-20250514
runtime:
id: pi
provider:
id: github
model: claude-sonnet-4-20250514

permissions:
contents: read
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/smoke-pi.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

120 changes: 108 additions & 12 deletions actions/setup/js/pi_provider.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
/**
* Pi Provider Extension for gh-aw
*
* Calls the AWF API proxy /reflect endpoint at session start to dynamically
* discover the open LLM inference paths configured for this run. This gives
* operators runtime visibility into which provider/model combination is active
* and verifies that the expected gateway port is reachable before the agent
* starts working.
* Registers Pi providers from the AWF-injected environment and calls the AWF
* API proxy /reflect endpoint at session start to dynamically discover the
* open LLM inference paths configured for this run. This gives operators
* runtime visibility into which provider/model combination is active and
* verifies that the expected gateway port is reachable before the agent starts
* working.
*
* When the model uses provider/model format (e.g. "copilot/claude-sonnet-4"),
* the extension logs the matched endpoint so failures can be diagnosed without
Expand All @@ -18,7 +19,10 @@
* configuration is required.
*
* Configuration (read from environment variables):
* PI_MODEL The engine.model value; may be "provider/model" or bare "model".
* GH_AW_PI_MODEL The original engine.model value; may be "provider/model"
* or bare "model". Preferred over PI_MODEL so gh-aw can pass
* model context to extensions without changing Pi CLI behavior.
* PI_MODEL Legacy fallback used when GH_AW_PI_MODEL is not set.
*/

"use strict";
Expand All @@ -29,6 +33,18 @@ const { fetchAWFReflect, AWF_API_PROXY_REFLECT_URL, AWF_REFLECT_OUTPUT_PATH, AWF
// prettier-ignore
const DEFAULT_LOGGER = /** @type {(msg: string) => void} */ (msg => process.stderr.write(`[gh-aw/pi-provider] ${new Date().toISOString()} ${msg}\n`));

/**
* Return the workflow-configured model string exposed to Pi extensions.
* GH_AW_PI_MODEL takes precedence because gh-aw sets it explicitly for extensions
* while continuing to pass the CLI model via --model. PI_MODEL remains a legacy
* fallback for older callers.
*
* @returns {string}
*/
function getConfiguredModel() {
return process.env.GH_AW_PI_MODEL || process.env.PI_MODEL || "";
}

/**
* Extract the provider prefix from a "provider/model" string.
* Returns an empty string when no slash is present (bare model name).
Expand Down Expand Up @@ -66,14 +82,91 @@ function resolveGatewayUrl(provider) {
return `http://api-proxy:${port}`;
}

/**
* Register a Pi provider and any aliases.
*
* @param {any} pi
* @param {string[]} names
* @param {Record<string, any>} config
* @param {(msg: string) => void} logger
*/
function registerProviderAliases(pi, names, config, logger) {
for (const name of names) {
pi.registerProvider(name, config);
logger(`registered provider=${name}`);
}
}

/**
* Register all supported Pi providers discovered from the environment.
*
* @param {any} pi
* @param {(msg: string) => void} logger
* @returns {number}
*/
function registerConfiguredProviders(pi, logger) {
let registeredCount = 0;

const copilotToken = process.env.COPILOT_GITHUB_TOKEN || process.env.GITHUB_TOKEN;
if (copilotToken) {
registerProviderAliases(
pi,
["github-copilot", "copilot"],
{
apiKey: copilotToken,
api: "openai-completions",
...(process.env.GITHUB_COPILOT_BASE_URL ? { baseUrl: process.env.GITHUB_COPILOT_BASE_URL } : {}),
},
logger
);
registeredCount += 2;
}

if (process.env.ANTHROPIC_API_KEY) {
registerProviderAliases(
pi,
["anthropic"],
{
apiKey: process.env.ANTHROPIC_API_KEY,
api: "anthropic",
...(process.env.ANTHROPIC_BASE_URL ? { baseUrl: process.env.ANTHROPIC_BASE_URL } : {}),
},
logger
);
registeredCount += 1;
}

const openAIKey = process.env.CODEX_API_KEY || process.env.OPENAI_API_KEY;
if (openAIKey) {
registerProviderAliases(
pi,
["openai", "codex"],
{
apiKey: openAIKey,
api: "openai-completions",
...(process.env.OPENAI_BASE_URL ? { baseUrl: process.env.OPENAI_BASE_URL } : {}),
},
logger
);
registeredCount += 2;
}

if (registeredCount === 0) {
logger("no provider credentials detected for Pi provider registration");
}

return registeredCount;
}

/**
* Pi provider extension for gh-aw.
*
* Subscribes to the `agent_start` and `agent_end` Pi SDK events and calls the AWF /reflect
* endpoint to discover and log the open LLM inference paths before the agent begins its
* first turn and again after it finishes. The post-run fetch is the authoritative snapshot
* used by the step summary; the pre-run fetch captures the initial proxy state for diagnostics
* in case the session exits unexpectedly before reaching `agent_end`.
* Registers providers immediately, then subscribes to the `agent_start` and `agent_end`
* Pi SDK events and calls the AWF /reflect endpoint to discover and log the open LLM
* inference paths before the agent begins its first turn and again after it finishes.
* The post-run fetch is the authoritative snapshot used by the step summary; the pre-run
* fetch captures the initial proxy state for diagnostics in case the session exits
* unexpectedly before reaching `agent_end`.
* Both calls are best-effort: any network or parse error is logged but does not abort the
* agent session.
*
Expand All @@ -82,9 +175,10 @@ function resolveGatewayUrl(provider) {
*/
function piProviderExtension(pi) {
const log = DEFAULT_LOGGER;
registerConfiguredProviders(pi, log);

pi.on("agent_start", async () => {
const model = process.env.PI_MODEL || "";
const model = getConfiguredModel();
const provider = extractProviderFromModel(model);

if (provider) {
Expand Down Expand Up @@ -123,5 +217,7 @@ function piProviderExtension(pi) {
}

module.exports = piProviderExtension;
module.exports.getConfiguredModel = getConfiguredModel;
module.exports.extractProviderFromModel = extractProviderFromModel;
module.exports.resolveGatewayUrl = resolveGatewayUrl;
module.exports.registerConfiguredProviders = registerConfiguredProviders;
78 changes: 78 additions & 0 deletions actions/setup/js/pi_provider.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

describe("pi_provider.cjs", () => {
let module;
let originalEnv;
let originalFetch;
let stderrOutput;

beforeEach(async () => {
originalEnv = { ...process.env };
originalFetch = global.fetch;
stderrOutput = [];
vi.spyOn(process.stderr, "write").mockImplementation(msg => {
stderrOutput.push(String(msg));
return true;
});
module = await import("./pi_provider.cjs?" + Date.now());
});

afterEach(() => {
process.env = originalEnv;
global.fetch = originalFetch;
vi.restoreAllMocks();
});

it("prefers GH_AW_PI_MODEL over PI_MODEL", () => {
process.env.GH_AW_PI_MODEL = "copilot/claude-sonnet-4";
process.env.PI_MODEL = "anthropic/claude-opus-4";

expect(module.getConfiguredModel()).toBe("copilot/claude-sonnet-4");
});

it("registers configured providers and aliases from the environment", () => {
process.env.COPILOT_GITHUB_TOKEN = "copilot-token";
process.env.GITHUB_COPILOT_BASE_URL = "https://copilot.example.test";
process.env.ANTHROPIC_API_KEY = "anthropic-token";
process.env.ANTHROPIC_BASE_URL = "https://anthropic.example.test";
process.env.CODEX_API_KEY = "codex-token";
process.env.OPENAI_BASE_URL = "https://openai.example.test";

const calls = [];
const pi = {
registerProvider: vi.fn((name, config) => {
calls.push([name, config]);
}),
on: vi.fn(),
};

const count = module.registerConfiguredProviders(pi, () => {});

expect(count).toBe(5);
expect(calls).toEqual([
["github-copilot", { apiKey: "copilot-token", api: "openai-completions", baseUrl: "https://copilot.example.test" }],
["copilot", { apiKey: "copilot-token", api: "openai-completions", baseUrl: "https://copilot.example.test" }],
["anthropic", { apiKey: "anthropic-token", api: "anthropic", baseUrl: "https://anthropic.example.test" }],
["openai", { apiKey: "codex-token", api: "openai-completions", baseUrl: "https://openai.example.test" }],
["codex", { apiKey: "codex-token", api: "openai-completions", baseUrl: "https://openai.example.test" }],
]);
});

it("logs the configured provider using GH_AW_PI_MODEL during agent_start", async () => {
process.env.GH_AW_PI_MODEL = "copilot/claude-sonnet-4";
global.fetch = vi.fn().mockRejectedValue(new Error("network disabled"));

const handlers = {};
const pi = {
registerProvider: vi.fn(),
on: vi.fn((event, handler) => {
handlers[event] = handler;
}),
};

module.default(pi);
await handlers.agent_start();

expect(stderrOutput.some(line => line.includes("provider=copilot model=copilot/claude-sonnet-4"))).toBe(true);
});
});
Loading
Loading