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
48 changes: 25 additions & 23 deletions .github/workflows/dev.lock.yml

Large diffs are not rendered by default.

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

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

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .github/workflows/smoke-pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ permissions:
name: Smoke Pi
engine:
id: pi
model: claude-sonnet-4-20250514
model: copilot/claude-sonnet-4-20250514
strict: true
imports:
- shared/gh.md
Expand Down
109 changes: 109 additions & 0 deletions actions/setup/js/pi_provider.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// @ts-check

/**
* 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.
*
* 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
* inspecting container internals.
*
* The extension is automatically added to every Pi agent invocation by the
* gh-aw compiler alongside pi_steering_extension.cjs. No workflow frontmatter
* configuration is required.
*
* Configuration (read from environment variables):
* PI_MODEL The engine.model value; may be "provider/model" or bare "model".
*/

"use strict";

const { fetchAWFReflect, AWF_API_PROXY_REFLECT_URL, AWF_REFLECT_OUTPUT_PATH, AWF_REFLECT_TIMEOUT_MS, AWF_MODELS_URL_TIMEOUT_MS } = require("./awf_reflect.cjs");

// Default logger: prefixed with "[gh-aw/pi-provider]" for easy grepping.
// prettier-ignore
const DEFAULT_LOGGER = /** @type {(msg: string) => void} */ (msg => process.stderr.write(`[gh-aw/pi-provider] ${new Date().toISOString()} ${msg}\n`));

/**
* Extract the provider prefix from a "provider/model" string.
* Returns an empty string when no slash is present (bare model name).
*
* @param {string} model
* @returns {string}
*/
function extractProviderFromModel(model) {
if (!model) return "";
const slashIdx = model.indexOf("/");
if (slashIdx <= 0) return "";
return model.slice(0, slashIdx).toLowerCase();
}

/**
* Resolve the expected LLM gateway base URL for a given provider prefix.
* Returns null when the provider is not one of the well-known AWF sidecar providers.
*
* @param {string} provider - Lowercase provider prefix (e.g. "copilot", "anthropic").
* @returns {string|null}
*/
function resolveGatewayUrl(provider) {
const GATEWAY_PORTS = /** @type {Record<string, number>} */ {
copilot: 10002,
anthropic: 10000,
openai: 10001,
codex: 10001,
google: 10003,
};
const port = GATEWAY_PORTS[provider];
if (!port) return null;
return `http://host.docker.internal:${port}`;
}
Comment on lines +32 to +64

/**
* Pi provider extension for gh-aw.
*
* Subscribes to the `agent_start` Pi SDK event and calls the AWF /reflect
* endpoint to discover and log the open LLM inference paths before the agent
* begins its first turn. This is best-effort: any network or parse error is
* logged but does not abort the agent session.
*
* @param {any} pi - Pi ExtensionAPI instance
* @returns {void}
*/
function piProviderExtension(pi) {
const log = DEFAULT_LOGGER;

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

if (provider) {
const gatewayUrl = resolveGatewayUrl(provider);
if (gatewayUrl) {
log(`provider=${provider} model=${model} gateway=${gatewayUrl}`);
} else {
log(`provider=${provider} model=${model} (no known AWF gateway port for this provider)`);
}
} else {
log(`model=${model || "(not set)"} (no provider prefix — defaulting to Copilot gateway)`);
}

// Fetch AWF API proxy reflection data and persist to disk so the post-run
// step summary (awf_reflect_summary.cjs) can include provider and model info.
await fetchAWFReflect({
reflectUrl: AWF_API_PROXY_REFLECT_URL,
outputPath: AWF_REFLECT_OUTPUT_PATH,
timeoutMs: AWF_REFLECT_TIMEOUT_MS,
modelsTimeoutMs: AWF_MODELS_URL_TIMEOUT_MS,
logger: log,
});
});
}

module.exports = piProviderExtension;
module.exports.extractProviderFromModel = extractProviderFromModel;
module.exports.resolveGatewayUrl = resolveGatewayUrl;
2 changes: 1 addition & 1 deletion pkg/constants/version_constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const DefaultGeminiVersion Version = "0.39.1"
const DefaultCrushVersion Version = "0.59.0"

// DefaultPiVersion is the default version of the Pi CLI
const DefaultPiVersion Version = "0.0.1"
const DefaultPiVersion Version = "0.72.1"

// DefaultOpenCodeVersion is the default version of the OpenCode CLI
const DefaultOpenCodeVersion Version = "1.2.14"
Expand Down
95 changes: 84 additions & 11 deletions pkg/workflow/domains.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,36 @@ var GeminiDefaultDomains = []string{
"registry.npmjs.org",
}

// PiDefaultDomains are the default domains required for the Pi CLI to operate.
// Pi routes its API calls through the AWF LLM gateway (host.docker.internal) when
// the firewall is enabled. The api.pi.ai domain covers the Pi API endpoint.
// PiBaseDefaultDomains are the base domains required for the Pi CLI to operate,
// independent of the chosen LLM provider. When a model uses provider/model format,
// provider-specific API domains are added on top via GetPiDefaultDomains().
var PiBaseDefaultDomains = []string{
"api.pi.ai", // Pi CLI telemetry / update checks
"host.docker.internal", // MCP gateway / API proxy access
"github.com",
"raw.githubusercontent.com",
"registry.npmjs.org", // npm package downloads
}

// piProviderDomains maps provider prefixes to their API domains.
// Mirrors crushProviderDomains / openCodeProviderDomains for the same set of
// providers that Pi can route through via the AWF LLM gateway.
// Note: "google" is intentionally omitted — Pi backend resolution only supports
// copilot, anthropic, openai, and codex; adding google here without backend
// support would produce an inconsistent routing configuration.
var piProviderDomains = map[string]string{
"copilot": "api.githubcopilot.com",
"github-copilot": "api.githubcopilot.com",
"anthropic": "api.anthropic.com",
"openai": "api.openai.com",
"codex": "api.openai.com",
}

// PiDefaultDomains are the static default domains for backward compatibility when
// no model provider prefix is given. When a provider/model format is used, the
// dynamic path (GetPiDefaultDomains) resolves provider-specific domains instead.
var PiDefaultDomains = []string{
"api.githubcopilot.com", // Default provider (Copilot routing)
"api.pi.ai",
"host.docker.internal",
"github.com",
Expand Down Expand Up @@ -270,6 +296,40 @@ func GetCrushAllowedDomainsWithToolsAndRuntimes(model string, network *NetworkPe
return GetAllowedDomainsForEngineWithModel(constants.CrushEngine, model, network, tools, runtimes)
}

// GetPiDefaultDomains returns the default domains for Pi based on the model provider.
// It starts with PiBaseDefaultDomains and adds the provider-specific API domain when
// the model uses provider/model format (e.g. "copilot/claude-sonnet-4-20250514").
// When no provider prefix is present the default Copilot API domain is included for
// backward compatibility.
// Returns an error if the model string is malformed (e.g. a leading slash).
func GetPiDefaultDomains(model string) ([]string, error) {
provider, err := extractProviderFromModel(model)
if err != nil {
return nil, err
}
domains := make([]string, 0, len(PiBaseDefaultDomains)+1)
domains = append(domains, PiBaseDefaultDomains...)

if domain, ok := piProviderDomains[provider]; ok {
domains = append(domains, domain)
} else if provider == "" {
// No provider prefix → default to Copilot routing for backward compatibility.
domains = append(domains, piProviderDomains["copilot"])
}

return domains, nil
}

// GetPiAllowedDomainsWithModel merges Pi default domains with NetworkPermissions, HTTP MCP
// server domains, and runtime ecosystem domains.
// Pass the selected model (e.g. "copilot/claude-sonnet-4-20250514") so provider-specific
// API domains are included. Returns a deduplicated, sorted, comma-separated string suitable
// for AWF's --allow-domains flag.
// Returns an error if the model string is malformed (e.g. a leading slash).
func GetPiAllowedDomainsWithModel(model string, network *NetworkPermissions, tools map[string]any, runtimes map[string]any) (string, error) {
return GetAllowedDomainsForEngineWithModel(constants.PiEngine, model, network, tools, runtimes)
}

// PlaywrightDomains are the domains required for Playwright browser downloads
// These domains are needed when Playwright MCP server initializes in the Docker container
var PlaywrightDomains = []string{
Expand Down Expand Up @@ -695,20 +755,19 @@ func mergeDomainsWithNetworkToolsAndRuntimes(defaultDomains []string, network *N
}

// engineDefaultDomains maps each engine to its static default required domains.
// Engines with model-specific defaults (for example, Crush) are resolved in
// Engines with model-specific defaults (for example, Crush, OpenCode, Pi) are resolved in
// getDefaultDomainsForEngine instead of being stored directly in this map.
var engineDefaultDomains = map[constants.EngineName][]string{
constants.CopilotEngine: CopilotDefaultDomains,
constants.ClaudeEngine: ClaudeDefaultDomains,
constants.CodexEngine: CodexDefaultDomains,
constants.GeminiEngine: GeminiDefaultDomains,
constants.PiEngine: PiDefaultDomains,
}

// getDefaultDomainsForEngine returns the engine's default required domains.
// OpenCode and Crush domains are model/provider-specific, so they must be
// resolved via GetOpenCodeDefaultDomains(model) / GetCrushDefaultDomains(model)
// rather than the static engineDefaultDomains map.
// OpenCode, Crush, and Pi domains are model/provider-specific, so they must be
// resolved via their respective Get*DefaultDomains(model) functions rather than
// the static engineDefaultDomains map.
// Falls back to an empty default domain list for unknown engines.
// Returns an error if the model string is malformed (e.g. a leading slash).
func getDefaultDomainsForEngine(engine constants.EngineName, model string) ([]string, error) {
Expand All @@ -718,6 +777,9 @@ func getDefaultDomainsForEngine(engine constants.EngineName, model string) ([]st
if engine == constants.CrushEngine {
return GetCrushDefaultDomains(model)
}
if engine == constants.PiEngine {
return GetPiDefaultDomains(model)
}

return engineDefaultDomains[engine], nil
}
Expand Down Expand Up @@ -788,10 +850,13 @@ func GetGeminiAllowedDomainsWithToolsAndRuntimes(network *NetworkPermissions, to
}

// GetPiAllowedDomains merges Pi default domains with NetworkPermissions, HTTP MCP server domains,
// and runtime ecosystem domains.
// and runtime ecosystem domains. Uses backward-compatible Copilot routing when no model is given.
// For model-aware resolution, prefer GetPiAllowedDomainsWithModel.
// Returns a deduplicated, sorted, comma-separated string suitable for AWF's --allow-domains flag.
func GetPiAllowedDomains(network *NetworkPermissions, tools map[string]any, runtimes map[string]any) string {
return GetAllowedDomainsForEngine(constants.PiEngine, network, tools, runtimes)
// Empty model → backward-compatible Copilot routing; no malformed-model error possible.
result, _ := GetPiAllowedDomainsWithModel("", network, tools, runtimes)
return result
}

// GetBlockedDomains returns the blocked domains from network permissions
Expand Down Expand Up @@ -934,7 +999,15 @@ func (c *Compiler) computeAllowedDomainsForSanitization(data *WorkflowData) (str
case "gemini":
base = GetGeminiAllowedDomainsWithToolsAndRuntimes(data.NetworkPermissions, data.Tools, data.Runtimes)
case "pi":
base = GetPiAllowedDomains(data.NetworkPermissions, data.Tools, data.Runtimes)
model := ""
if data.EngineConfig != nil {
model = data.EngineConfig.Model
}
var err error
base, err = GetPiAllowedDomainsWithModel(model, data.NetworkPermissions, data.Tools, data.Runtimes)
if err != nil {
return "", err
}
case "opencode":
model := ""
if data.EngineConfig != nil {
Expand Down
Loading
Loading