6e4aca9c67
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The `pi-local` adapter runs the Pi coding agent, including inside remote/sandboxed execution targets; Pi resolves `--provider P --model M` by an exact (provider, id) match against its model registry, and it has no base-url CLI flag or env var: a `models.json` in its agent config dir (`$PI_CODING_AGENT_DIR`, falling back to `$HOME/.pi/agent`) is its only mechanism for custom or OpenAI/Anthropic-compatible endpoints > - Deployments increasingly put an LLM gateway between the harness and the model for cost, governance, or data-residency reasons: LiteLLM, OpenRouter, Portkey, Kong, a corporate proxy, self-hosted models (vLLM/Ollama), or region-pinned/sovereign endpoints. Today there is no supported way to get such provider config into Pi's registry for orchestrated runs > - The opencode adapter gained the equivalent capability in #7837 and codex in #7919; this pull request is the Pi analogue, so the harness layer stays gateway-agnostic regardless of which CLI an agent uses; nothing here is specific to one hosting setup > - This pull request reads `PAPERCLIP_PI_PROVIDERS` (Pi's `models.json` `providers` shape), materialises a managed `models.json` in a temp agent-config dir, points `PI_CODING_AGENT_DIR` at it, and ships it to remote execution targets with the run > - The benefit is Pi works behind any compatible gateway with config only; with no env set, behavior is unchanged ## Linked Issues or Issue Description No existing issue; describing in-PR (feature / adapter enhancement). - **Gap:** there is no supported way to register custom/gateway providers + models for `pi-local`. Pi's only custom-endpoint mechanism is a `models.json` in its agent config dir, and orchestrated (especially sandboxed) runs have no way to provision one declaratively. - Related: #7837 (the opencode-local analogue, same env-driven gateway-routing pattern) and #7919 (the codex-local analogue). Searched for duplicate or related PRs: no existing pi-local gateway/provider-routing PR found. > Note on ROADMAP: this is adapter-level, opt-in config (defaults unchanged) that *enables* gateway routing for one harness; it is not the core "Cloud / Sandbox agents" platform work itself. ## What Changed - New `packages/adapters/pi-local/src/server/runtime-config.ts`: `preparePiRuntimeConfig()` reads `PAPERCLIP_PI_PROVIDERS` (a JSON object in pi's `models.json` `providers` shape) from the run env, then `process.env`. When set, it expands `{env:VAR}` placeholders (run env first, then process env; unresolvable placeholders left intact), writes `{"providers": ...}` to a managed temp dir as `models.json`, and returns env with `PI_CODING_AGENT_DIR` pointing at it plus a cleanup handle. - `execute.ts`: the prepared dir ships to remote execution targets as the managed-runtime asset `agentConfig` (same mechanism as opencode's `xdgConfig`), and `PI_CODING_AGENT_DIR` is repointed to the in-target path; cleanup runs in `finally`. - Misconfiguration is visible, not silent: a set-but-unusable `PAPERCLIP_PI_PROVIDERS` (invalid JSON, not an object, no provider objects) surfaces an explanatory note instead of proceeding unconfigured into an opaque model-not-found failure later, and provider entries with non-object values are skipped with a note naming them. Unset/empty stays a silent no-op (feature off). - Defaults unchanged: with `PAPERCLIP_PI_PROVIDERS` unset, the adapter behaves byte-for-byte as before, for local runs and for every existing sandbox provider. ## Verification - All pi-local tests green against this base (new: providers written verbatim, `{env:VAR}` expansion from run env/process env/unresolvable, no-op when unset, `PI_CODING_AGENT_DIR` set and shipped, the misconfiguration notes incl. skipped non-object entries, remote asset sync + env repoint). Typecheck and build clean. - Production end-to-end evidence (our deployment, used as verification, not as the scope of the change): a pi agent in a Kubernetes gVisor sandbox resolved a custom provider from the shipped `models.json`, completed an assigned issue through an Anthropic-compatible gateway, and landed a billed usage row. ## Risks Low. The entire feature is opt-in behind one env var; the only behavior change when it is set is the intended one. The managed dir replaces the host agent dir for the run by design (credentials travel inside the provider config or via env-key indirection), which is the correct posture for orchestrated runs. No migration/UI impact. ## Model Used Claude Opus 4.8 (`claude-opus-4-8`, 1M context), extended thinking + tool use, via Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md (adapter-level opt-in config enabling gateway routing; not the core sandbox-platform work, noted above) - [x] I have searched GitHub for duplicate or related PRs and linked them above (#7837 and #7919 are the opencode/codex analogues; no pi-local duplicate found) - [x] I have either (a) linked existing issues OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots (n/a, no UI) - [ ] I have updated relevant documentation to reflect my changes (env var documented inline; no central doc references the adapter env yet) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green (green on the previous head; re-running on the final note-copy polish commit) - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups (both prior review findings are fixed at head: the indirect notes-based guard is now an explicit `agentConfigDir` handle, and a failed `models.json` write no longer leaks the temp dir; a re-review is requested for the note-copy polish) - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
153 lines
6.0 KiB
TypeScript
153 lines
6.0 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
type PreparedPiRuntimeConfig = {
|
|
env: Record<string, string>;
|
|
notes: string[];
|
|
/** The managed agent-config dir, or null when no provider config was written. */
|
|
agentConfigDir: string | null;
|
|
cleanup: () => Promise<void>;
|
|
};
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
// Recursively replace {env:VAR} placeholders with the resolved value. Used to bake
|
|
// gateway provider secrets (e.g. an LLM-gateway virtual key) into models.json
|
|
// SERVER-SIDE, where the value is reliably present. Pi resolves a provider apiKey
|
|
// by trying it as an env var name first, then as a literal -- but the (possibly
|
|
// sandboxed) run process env plumbing is not guaranteed to carry the key, so we
|
|
// resolve it here. Unresolvable placeholders are left intact; an env var set to
|
|
// an empty string counts as unresolvable (the placeholder stays for Pi to try).
|
|
function expandEnvPlaceholders<T>(value: T, resolve: (name: string) => string | undefined): T {
|
|
if (typeof value === "string") {
|
|
return value.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, name: string) => {
|
|
const resolved = resolve(name);
|
|
return resolved !== undefined && resolved.length > 0 ? resolved : match;
|
|
}) as unknown as T;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.map((entry) => expandEnvPlaceholders(entry, resolve)) as unknown as T;
|
|
}
|
|
if (isPlainObject(value)) {
|
|
const out: Record<string, unknown> = {};
|
|
for (const [key, entry] of Object.entries(value)) {
|
|
out[key] = expandEnvPlaceholders(entry, resolve);
|
|
}
|
|
return out as unknown as T;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
type ParsedProviderConfig = {
|
|
providers: Record<string, unknown> | null;
|
|
warning: string | null;
|
|
};
|
|
|
|
function parseProviderConfig(
|
|
raw: unknown,
|
|
resolveEnv: (name: string) => string | undefined,
|
|
): ParsedProviderConfig {
|
|
// Unset/empty (or an empty JSON object) is the normal "feature off" case: no warning.
|
|
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
return { providers: null, warning: null };
|
|
}
|
|
// A SET but unusable value is a misconfiguration; surface it so the run does
|
|
// not proceed silently unconfigured and fail later with an opaque
|
|
// model-not-found error pointing nowhere near the env var.
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (!isPlainObject(parsed)) {
|
|
return {
|
|
providers: null,
|
|
warning: "PAPERCLIP_PI_PROVIDERS is set but is not a JSON object; custom providers ignored.",
|
|
};
|
|
}
|
|
// Only keep provider entries that are themselves objects; surface the ones
|
|
// we drop so a malformed entry is just as diagnosable as malformed JSON.
|
|
const providers: Record<string, unknown> = {};
|
|
const skipped: string[] = [];
|
|
for (const [key, value] of Object.entries(parsed)) {
|
|
if (isPlainObject(value)) providers[key] = expandEnvPlaceholders(value, resolveEnv);
|
|
else skipped.push(key);
|
|
}
|
|
return {
|
|
providers: Object.keys(providers).length > 0 ? providers : null,
|
|
warning: skipped.length > 0
|
|
? `PAPERCLIP_PI_PROVIDERS: skipped provider(s) with non-object values: ${skipped.join(", ")}.`
|
|
: null,
|
|
};
|
|
} catch {
|
|
return {
|
|
providers: null,
|
|
warning: "PAPERCLIP_PI_PROVIDERS contains invalid JSON; custom providers ignored.",
|
|
};
|
|
}
|
|
}
|
|
|
|
// Materialize custom Pi providers supplied via PAPERCLIP_PI_PROVIDERS (a JSON
|
|
// object in Pi's models.json "providers" shape) into a managed agent-config dir.
|
|
//
|
|
// Pi has no base-url CLI flag or env var: the only mechanism for pointing it at a
|
|
// custom/OpenAI- or Anthropic-compatible endpoint is a models.json file in its
|
|
// agent config dir, which Pi resolves from $PI_CODING_AGENT_DIR (falling back to
|
|
// $HOME/.pi/agent). Pi resolves `--provider P --model M` by an exact (provider,
|
|
// model id) match against that registry, so routing a gateway model requires the
|
|
// provider entry to enumerate its models explicitly. We accept the providers as
|
|
// config (not hard-coded) so the gateway URL, key, and model list stay declarative.
|
|
//
|
|
// When PAPERCLIP_PI_PROVIDERS is set we write {"providers": ...} to a fresh temp
|
|
// dir and point PI_CODING_AGENT_DIR at it; the managed dir intentionally replaces
|
|
// the host agent dir (credentials travel inside the providers config itself, via
|
|
// a literal apiKey or a server-side-expanded {env:VAR} placeholder). For remote
|
|
// execution targets, execute.ts ships the dir to the sandbox as a runtime asset
|
|
// and repoints PI_CODING_AGENT_DIR at the in-sandbox copy.
|
|
export async function preparePiRuntimeConfig(input: {
|
|
env: Record<string, string>;
|
|
}): Promise<PreparedPiRuntimeConfig> {
|
|
const resolveEnv = (name: string): string | undefined => input.env[name] ?? process.env[name];
|
|
const { providers, warning } = parseProviderConfig(
|
|
input.env.PAPERCLIP_PI_PROVIDERS ?? process.env.PAPERCLIP_PI_PROVIDERS,
|
|
resolveEnv,
|
|
);
|
|
if (!providers) {
|
|
return {
|
|
env: input.env,
|
|
notes: warning ? [warning] : [],
|
|
agentConfigDir: null,
|
|
cleanup: async () => {},
|
|
};
|
|
}
|
|
|
|
const agentConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pi-agent-config-"));
|
|
try {
|
|
await fs.writeFile(
|
|
path.join(agentConfigDir, "models.json"),
|
|
`${JSON.stringify({ providers }, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
} catch (err) {
|
|
// Never leak the temp dir when the write fails (e.g. disk-full): the
|
|
// caller only receives the cleanup handle on success.
|
|
await fs.rm(agentConfigDir, { recursive: true, force: true }).catch(() => undefined);
|
|
throw err;
|
|
}
|
|
|
|
return {
|
|
env: {
|
|
...input.env,
|
|
PI_CODING_AGENT_DIR: agentConfigDir,
|
|
},
|
|
notes: [
|
|
...(warning ? [warning] : []),
|
|
`Injected ${Object.keys(providers).length} custom Pi provider(s) from PAPERCLIP_PI_PROVIDERS into a managed models.json: ${Object.keys(providers).join(", ")}.`,
|
|
],
|
|
agentConfigDir,
|
|
cleanup: async () => {
|
|
await fs.rm(agentConfigDir, { recursive: true, force: true });
|
|
},
|
|
};
|
|
}
|