Add Paseo Agent provider

This commit is contained in:
Mohamed Boudra
2026-07-02 08:38:27 +02:00
parent 2263469342
commit c9cd414571
37 changed files with 3864 additions and 5 deletions
+1 -1
View File
@@ -36,8 +36,8 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/paseo-agent.md](docs/paseo-agent.md) | Paseo Agent provider (id `paseo`): in-process Pi harness, `agents.paseo` typed inference providers, auth, OAuth limitation |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
+186
View File
@@ -0,0 +1,186 @@
# Paseo Agent provider
Paseo Agent is a built-in provider that runs Pi's coding-agent harness **in process** (no `pi` CLI, no `~/.pi` discovery). It is configured entirely by Paseo-owned config under `agents.paseo` in `$PASEO_HOME/config.json`. The model backends are "inference providers": one or more typed entries, each pointing at an API with its own key and models.
The provider id is **`paseo`** (the display name is "Paseo Agent"). Use it like any other provider, e.g. `paseo run --provider paseo --model <inferenceProviderName>/<modelId> ...`.
This is a prototype. There is no UI yet — config-file only.
> Smoke note: the daemon supervisor runs from `packages/server/dist`. After changing
> provider/config code, run `npm run build:server` (or run a source/dev daemon) before
> smoking, otherwise a stale `dist` may reject the `agents.paseo` config. Always pass
> `--host <addr>` to CLI smoke commands so they hit your isolated daemon, not the real
> daemon on `:6767`.
## MCP tools
Paseo Agent bridges `AgentSessionConfig.mcpServers` into Pi custom tools, so the
daemon-injected `paseo` MCP server (and any other configured MCP server) is available to
the model. On session start the provider connects to each server, lists its tools, and
registers them as Pi tools named `<serverName>__<toolName>`; tool input schemas (JSON
Schema) are converted to TypeBox, calls are proxied to the MCP server, and results map
back to the model. Connections are torn down on session close. Servers that fail to
connect or list are logged and skipped rather than failing the session.
Transports: HTTP (streamable) is the primary path (the injected `paseo` server is HTTP);
SSE and stdio transports are also wired via the MCP SDK. No extra config is needed — MCP
servers come from Paseo's normal injection/config, not from `agents.paseo`.
## Config shape
```jsonc
{
"agents": {
"paseo": {
// Optional. "<inferenceProviderName>/<modelId>". Used when an agent is
// started without an explicit model.
"defaultModel": "openrouter-main/anthropic/claude-3.7-sonnet",
// Inference providers, keyed by instance name. Names are free-form; you may
// run several entries of the same type against different APIs/keys/models.
"providers": {
"openrouter-main": {
"type": "openrouter",
"options": {
// apiKey may be omitted to fall back to the type's env var
// (here OPENROUTER_API_KEY). It may also be a literal, an env
// reference like "$OPENROUTER_API_KEY" / "${OPENROUTER_API_KEY}",
// or a "!command" that prints the key.
"models": [
{ "id": "anthropic/claude-3.7-sonnet", "label": "Claude 3.7", "reasoning": true },
{ "id": "openai/gpt-4o", "label": "GPT-4o" },
],
},
},
},
},
},
}
```
## Supported types
Each type supplies sensible defaults so you usually only provide an API key (or its
env var) and model ids.
| type | wire `api` | default base URL | default key env var |
| ------------------- | --------------------------- | --------------------------------- | --------------------- |
| `openrouter` | `openai-completions` | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` |
| `openai` | `openai-responses` | `https://api.openai.com/v1` | `OPENAI_API_KEY` |
| `anthropic` | `anthropic-messages` | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` |
| `opencode` | `openai-completions` | `https://opencode.ai/zen/v1` | `OPENCODE_API_KEY` |
| `openai-compatible` | `openai-completions` | _(required: `options.baseUrl`)_ | _(none — set apiKey)_ |
| `openai-codex` | `openai-codex-responses` | `https://chatgpt.com/backend-api` | _(OAuth — see below)_ |
| `custom` | _(required: `options.api`)_ | _(required: `options.baseUrl`)_ | _(none — set apiKey)_ |
Per-entry overrides live in `options`: `baseUrl`, `api`, `apiKey`, `headers`,
`authHeader` (send `Authorization: Bearer <apiKey>`), and `models[]`. Each model may
override `api` (e.g. an `anthropic-messages` model behind an otherwise
`openai-completions` provider), plus `label`, `reasoning`, `contextWindow`, `maxTokens`.
### OpenCode Zen / Go (OpenAI-compatible)
OpenCode Zen models speak either `openai-completions` or `anthropic-messages`. Use the
`opencode` type for Zen, or `openai-compatible` with an explicit base URL for Go, and
override the per-model `api` where a model is Anthropic-family:
```jsonc
{
"type": "opencode",
"options": {
"models": [
{ "id": "big-pickle" },
{ "id": "claude-sonnet", "api": "anthropic-messages" }
]
}
}
// OpenCode Go:
{
"type": "openai-compatible",
"options": {
"baseUrl": "https://opencode.ai/zen/go/v1",
"apiKey": "$OPENCODE_API_KEY",
"models": [{ "id": "glm-5" }]
}
}
```
Pi attaches its own `x-opencode-*` attribution headers automatically when the base URL
is on `opencode.ai`, so you do not set those yourself.
### `custom` escape hatch
When a backend needs a wire protocol the named types don't cover, use `custom` and set
`options.api` to a Pi wire protocol (e.g. `google-generative-ai`, `mistral-conversations`,
`openai-codex-responses`) plus `options.baseUrl`. This is a thin pass-through, not a place
to embed raw Pi internals.
## Authentication
- **API key / env var / command** — works for every type. Omit `apiKey` to use the
type's default env var; or set a literal, a `$ENV` reference, or a `!command`.
- A provider only counts as "available" (and its models listable for use) when its
resolved key is actually present — a literal value, a set env var, or a command.
### ChatGPT / OpenAI subscription (OAuth) — `openai-codex`
The `openai-codex` type uses a ChatGPT/OpenAI **subscription** via OAuth instead of an
API key, against `https://chatgpt.com/backend-api` with the `openai-codex-responses` wire
API. Paseo **owns** the auth: `paseo login chatgpt` runs Pi's browser PKCE/callback
OAuth flow by default, stores the credential in a Paseo-controlled file, and lets Pi
refresh/rotate it there. Paseo does **not** read ChatGPT/Codex/OpenCode/Pi or any other
tool's auth files.
Config — just declare the provider and its models (no credential field):
```jsonc
{
"agents": {
"paseo": {
"providers": {
"chatgpt": {
"type": "openai-codex",
"options": { "models": [{ "id": "gpt-5.3-codex", "reasoning": true }] },
},
},
},
},
}
```
> Use a model id that ChatGPT-account Codex supports — e.g. `gpt-5.3-codex` (live-verified),
> or another Pi codex id like `gpt-5.2`, `gpt-5.4`, `gpt-5.4-mini`. The non-subscription id
> `gpt-5-codex` is **not** accepted on a ChatGPT account (the backend returns a 400
> "model is not supported when using Codex with a ChatGPT account").
Then log in once (the credential is stored under the `chatgpt` provider instance):
```bash
paseo login chatgpt
# Opens your browser to approve (OAuth PKCE + local callback on 127.0.0.1:1455).
# If the browser can't open, the URL is printed to copy; you can also paste the code.
```
Headless machines (no browser) can use the device-code fallback:
```bash
paseo login chatgpt --device-code # prints a URL + code to enter on another device
```
Both store the credential at `$PASEO_HOME/paseo-agent/auth.json` (mode `0600`, created by
Pi's AuthStorage; pass `--home` to target a specific Paseo home). On each session the
provider loads that credential, and Pi refreshes expired access tokens — persisting any
rotated refresh token **back into Paseo's own file**. A codex provider counts as
"available" once a credential is stored. Token values are never logged or printed.
> **Rotation is handled within Paseo.** Because Paseo owns the store and Pi writes
> refreshed tokens back to it, rotation does not break Paseo. Run `paseo login chatgpt`
> again any time to re-authorize (e.g. after a long idle period or an explicit logout).
**Advanced / manual override (not the normal path).** If you already hold your own refresh
token you may set `options.refreshToken` to a literal, `"$ENV"`/`"${ENV}"`, or `"!command"`.
It is seeded into the Paseo store at session start. This is for power users/automation; the
normal path is `paseo login chatgpt`. Paseo still never reads another tool's auth files.
Other OAuth providers (Anthropic Pro/Max, Copilot) remain unwired; for those you can pass a
pre-obtained bearer token via `apiKey`/env where accepted (e.g. `ANTHROPIC_OAUTH_TOKEN`).
@@ -13,6 +13,7 @@ describe("resolveProviderIconName", () => {
expect(resolveProviderIconName("claude")).toEqual({ kind: "builtin", id: "claude" });
expect(resolveProviderIconName("omp")).toEqual({ kind: "builtin", id: "omp" });
expect(resolveProviderIconName("minimax")).toEqual({ kind: "builtin", id: "minimax" });
expect(resolveProviderIconName("paseo")).toEqual({ kind: "builtin", id: "paseo" });
});
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
@@ -7,6 +7,7 @@ import { CopilotIcon } from "@/components/icons/copilot-icon";
import { MiniMaxIcon } from "@/components/icons/minimax-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
import { OmpIcon } from "@/components/icons/omp-icon";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { PiIcon } from "@/components/icons/pi-icon";
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
import { resolveProviderIconName } from "@/components/provider-icon-name";
@@ -26,6 +27,7 @@ const BUILTIN_PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
minimax: MiniMaxIcon as unknown as ProviderIconComponent,
omp: OmpIcon as unknown as ProviderIconComponent,
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
paseo: PaseoLogo as unknown as ProviderIconComponent,
pi: PiIcon as unknown as ProviderIconComponent,
};
+4
View File
@@ -2,6 +2,7 @@ import { Command, Option } from "commander";
import { createAgentCommand } from "./commands/agent/index.js";
import { createDaemonCommand } from "./commands/daemon/index.js";
import { createChatCommand } from "./commands/chat/index.js";
import { createLoginCommand } from "./commands/login/index.js";
import { createLoopCommand } from "./commands/loop/index.js";
import { createPermitCommand } from "./commands/permit/index.js";
import { createProviderCommand } from "./commands/provider/index.js";
@@ -161,6 +162,9 @@ export function createCli(): Command {
// Permission commands
program.addCommand(createPermitCommand());
// Auth / login commands
program.addCommand(createLoginCommand());
// Provider commands
program.addCommand(createProviderCommand());
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { createCli } from "../../cli.js";
import { createLoginCommand } from "./index.js";
interface RecordedLogin {
providerInstance: string;
envHome: string | undefined;
mode: "browser" | "device";
}
describe("paseo login command", () => {
it("is registered on the top-level CLI", () => {
const program = createCli();
const login = program.commands.find((command) => command.name() === "login");
expect(login).toBeDefined();
});
it("exposes a `chatgpt` subcommand with browser default + headless flag", () => {
const login = createLoginCommand();
const chatgpt = login.commands.find((command) => command.name() === "chatgpt");
expect(chatgpt).toBeDefined();
const flags = chatgpt?.options.map((option) => option.long);
// Default flow is browser; device-code is an opt-in fallback.
expect(flags).toContain("--device-code");
expect(flags).toContain("--home");
// It must not require a copy/paste device flow by default.
expect(chatgpt?.description().toLowerCase()).toContain("chatgpt");
});
it("runs browser login by default and opens the Pi auth URL", async () => {
const recorded: RecordedLogin[] = [];
const openedUrls: string[] = [];
const output: string[] = [];
const login = createLoginCommand({
write: (message) => output.push(message),
writeError: (message) => output.push(message),
openBrowser: (url) => {
openedUrls.push(url);
return true;
},
promptForCode: async () => {
throw new Error("manual code prompt should not be used for successful browser login");
},
loginBrowser: async (options) => {
recorded.push({
providerInstance: options.providerInstance,
envHome: options.env?.PASEO_HOME,
mode: "browser",
});
options.onAuthUrl("https://auth.openai.com/oauth/authorize?client_id=paseo");
options.onProgress?.("callback complete");
return { path: "/tmp/paseo-home/paseo-agent/auth.json" };
},
loginDeviceCode: async () => {
throw new Error("device-code login should not be used by default");
},
});
await login.parseAsync(["node", "login", "chatgpt", "--home", "/tmp/paseo-home"]);
expect(recorded).toEqual([
{ providerInstance: "chatgpt", envHome: "/tmp/paseo-home", mode: "browser" },
]);
expect(openedUrls).toEqual(["https://auth.openai.com/oauth/authorize?client_id=paseo"]);
expect(output.join("\n")).toContain("browser flow");
expect(output.join("\n")).toContain("/tmp/paseo-home/paseo-agent/auth.json");
});
it("uses device-code login only when explicitly requested", async () => {
const recorded: RecordedLogin[] = [];
const output: string[] = [];
const login = createLoginCommand({
write: (message) => output.push(message),
writeError: (message) => output.push(message),
openBrowser: () => {
throw new Error("browser opener should not run for --device-code");
},
promptForCode: async () => {
throw new Error("manual browser prompt should not run for --device-code");
},
loginBrowser: async () => {
throw new Error("browser login should not run for --device-code");
},
loginDeviceCode: async (options) => {
recorded.push({
providerInstance: options.providerInstance,
envHome: options.env?.PASEO_HOME,
mode: "device",
});
options.onDeviceCode({
userCode: "ABCD-EFGH",
verificationUri: "https://auth.openai.com/codex/device",
intervalSeconds: 5,
expiresInSeconds: 900,
});
return { path: "/tmp/paseo-home/paseo-agent/auth.json" };
},
});
await login.parseAsync([
"node",
"login",
"chatgpt",
"--device-code",
"--home",
"/tmp/paseo-home",
]);
expect(recorded).toEqual([
{ providerInstance: "chatgpt", envHome: "/tmp/paseo-home", mode: "device" },
]);
expect(output.join("\n")).toContain("headless device-code flow");
expect(output.join("\n")).toContain("ABCD-EFGH");
});
});
+122
View File
@@ -0,0 +1,122 @@
import { createInterface } from "node:readline/promises";
import { Command } from "commander";
import {
loginAndStoreCodex,
loginAndStoreCodexBrowser,
type CodexDeviceCodeInfo,
} from "@getpaseo/server";
import { openBrowserUrl } from "../../utils/open-browser.js";
// First-class auth UX: `paseo login chatgpt`.
// Default flow is browser OAuth (PKCE + local callback on 127.0.0.1:1455) via Pi's
// helper; `--device-code` is a headless fallback. Credentials are stored in the
// Paseo-owned store ($PASEO_HOME/paseo-agent/auth.json). No foreign auth files are read.
const PROVIDER_INSTANCE = "chatgpt";
interface LoginChatgptOptions {
deviceCode?: boolean;
home?: string;
}
interface LoginResult {
path: string;
}
interface LoginCommandDependencies {
loginDeviceCode: typeof loginAndStoreCodex;
loginBrowser: typeof loginAndStoreCodexBrowser;
openBrowser: (url: string) => boolean;
promptForCode: (message: string) => Promise<string>;
write: (message: string) => void;
writeError: (message: string) => void;
}
const defaultDependencies: LoginCommandDependencies = {
loginDeviceCode: loginAndStoreCodex,
loginBrowser: loginAndStoreCodexBrowser,
openBrowser: openBrowserUrl,
promptForCode,
write: (message) => console.log(message),
writeError: (message) => console.error(message),
};
function resolveEnv(home: string | undefined): NodeJS.ProcessEnv {
return home ? { ...process.env, PASEO_HOME: home } : process.env;
}
async function promptForCode(message: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
return (await rl.question(`${message} `)).trim();
} finally {
rl.close();
}
}
function printDeviceCode(write: (message: string) => void, info: CodexDeviceCodeInfo): void {
write("To authorize Paseo:");
write(` 1. Open: ${info.verificationUri}`);
write(` 2. Enter code: ${info.userCode}`);
write(` (expires in ~${Math.round(info.expiresInSeconds / 60)} min — waiting...)\n`);
}
async function runChatgptLogin(
options: LoginChatgptOptions,
dependencies: LoginCommandDependencies,
): Promise<LoginResult> {
const env = resolveEnv(options.home);
const { write } = dependencies;
if (options.deviceCode) {
write("Paseo login — ChatGPT/Codex subscription (headless device-code flow)\n");
const { path } = await dependencies.loginDeviceCode({
providerInstance: PROVIDER_INSTANCE,
env,
onDeviceCode: (info) => printDeviceCode(write, info),
});
write(`\n✓ Logged in. Credential stored at ${path} (Paseo-owned, mode 0600).`);
return { path };
}
write("Paseo login — ChatGPT/Codex subscription (browser flow)\n");
const { path } = await dependencies.loginBrowser({
providerInstance: PROVIDER_INSTANCE,
env,
onAuthUrl: (url) => {
const opened = dependencies.openBrowser(url);
write(
opened ? "Opening your browser to authorize Paseo…" : "Open this URL to authorize Paseo:",
);
write(` ${url}\n`);
write("Waiting for you to approve in the browser…");
write("(If the browser didn't open, copy the URL above. You can also paste the code here.)");
},
onProgress: (message) => write(message),
promptForCode: dependencies.promptForCode,
});
write(`\n✓ Logged in. Credential stored at ${path} (Paseo-owned, mode 0600).`);
return { path };
}
export function createLoginCommand(dependencies: Partial<LoginCommandDependencies> = {}): Command {
const deps = { ...defaultDependencies, ...dependencies };
const login = new Command("login").description("Authenticate Paseo providers");
login
.command("chatgpt")
.description("Log in to ChatGPT/OpenAI (Codex subscription) for the Paseo Agent provider")
.option("--device-code", "Use the headless device-code flow instead of the browser flow")
.option("--home <path>", "Paseo home directory (default: ~/.paseo or $PASEO_HOME)")
.action(async (options: LoginChatgptOptions) => {
try {
await runChatgptLogin(options, deps);
} catch (error) {
deps.writeError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
}
});
return login;
}
+30
View File
@@ -0,0 +1,30 @@
import { spawn } from "node:child_process";
/**
* Best-effort cross-platform browser opener for CLI OAuth flows. Returns true if the
* opener process was spawned, false otherwise. Callers must always print the URL too,
* so a failed/headless open still lets the user copy it.
*/
function browserOpenCommand(url: string): { command: string; args: string[] } {
switch (process.platform) {
case "darwin":
return { command: "open", args: [url] };
case "win32":
return { command: "cmd", args: ["/c", "start", "", url] };
default:
return { command: "xdg-open", args: [url] };
}
}
export function openBrowserUrl(url: string): boolean {
const { command, args } = browserOpenCommand(url);
try {
const child = spawn(command, args, { stdio: "ignore", detached: true });
child.on("error", () => {});
child.unref();
return true;
} catch {
return false;
}
}
+9 -1
View File
@@ -57,7 +57,15 @@ export const ProviderOverrideSchema = z.object({
order: z.number().optional(),
});
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi", "omp"] as const;
const BUILTIN_PROVIDER_IDS = [
"claude",
"codex",
"copilot",
"opencode",
"pi",
"omp",
"paseo",
] as const;
const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
export const ProviderOverridesSchema = z
@@ -6,6 +6,7 @@ export const BUILTIN_PROVIDER_ICON_NAMES = [
"minimax",
"omp",
"opencode",
"paseo",
"pi",
];
@@ -220,6 +220,13 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
defaultModeId: null,
modes: [],
},
{
id: "paseo",
label: "Paseo Agent",
description: "Paseo's in-process agent harness with configurable inference providers",
defaultModeId: null,
modes: [],
},
];
export const DEV_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
+3
View File
@@ -65,6 +65,9 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@earendil-works/pi-agent-core": "0.77.0",
"@earendil-works/pi-ai": "0.77.0",
"@earendil-works/pi-coding-agent": "0.77.0",
"@getpaseo/client": "0.1.103",
"@getpaseo/highlight": "0.1.103",
"@getpaseo/protocol": "0.1.103",
@@ -36,6 +36,8 @@ import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js";
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
import { PiRpcAgentClient } from "./providers/pi/agent.js";
import { PaseoAgentClient } from "./providers/paseo-agent/agent.js";
import type { PaseoAgentConfig } from "./providers/paseo-agent/config.js";
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
import { MockSlowProviderClient } from "./providers/mock-slow-provider.js";
import {
@@ -78,11 +80,16 @@ export interface BuildProviderRegistryOptions {
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
managedProcesses?: ManagedProcessRegistry;
isDev?: boolean;
/**
* Opaque Paseo Agent config blob. The registry only forwards it to the
* paseo-agent client factory; it never reads the nested inference providers.
*/
paseoAgentConfig?: PaseoAgentConfig;
}
interface ProviderClientFactoryOptions extends Pick<
BuildProviderRegistryOptions,
"workspaceGitService" | "managedProcesses"
"workspaceGitService" | "managedProcesses" | "paseoAgentConfig"
> {
providerParams?: unknown;
customProvider?: {
@@ -159,6 +166,11 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
},
commandsRpcType: "get_available_commands",
}),
paseo: (logger, _runtimeSettings, options) =>
new PaseoAgentClient({
logger,
config: options?.paseoAgentConfig ?? {},
}),
mock: (logger) => new MockLoadTestAgentClient(logger),
"mock-slow": () => new MockSlowProviderClient(),
};
@@ -567,7 +579,10 @@ function createResolvedProviderClient(
function buildResolvedBuiltinProviders(
providerOverrides: Record<string, ProviderOverride>,
runtimeSettings: AgentProviderRuntimeSettingsMap | undefined,
options: Pick<BuildProviderRegistryOptions, "workspaceGitService" | "managedProcesses">,
options: Pick<
BuildProviderRegistryOptions,
"workspaceGitService" | "managedProcesses" | "paseoAgentConfig"
>,
isDev: boolean,
): Map<string, ResolvedProvider> {
const resolvedProviders = new Map<string, ResolvedProvider>();
@@ -598,6 +613,7 @@ function buildResolvedBuiltinProviders(
workspaceGitService: options.workspaceGitService,
managedProcesses: options.managedProcesses,
providerParams: override?.params,
paseoAgentConfig: options.paseoAgentConfig,
}),
});
}
@@ -717,6 +733,7 @@ export function buildProviderRegistry(
{
workspaceGitService: options?.workspaceGitService,
managedProcesses: options?.managedProcesses,
paseoAgentConfig: options?.paseoAgentConfig,
},
options?.isDev === true,
);
@@ -363,12 +363,13 @@ describe("ProviderSnapshotManager public surface", () => {
copilot: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
paseo: { enabled: false },
},
});
try {
const entries = await manager.listProviders({ cwd: "/tmp/project", wait: true });
const providers = entries.map((entry) => entry.provider).sort();
expect(providers).toEqual(["claude", "codex", "copilot", "omp", "opencode", "pi"]);
expect(providers).toEqual(["claude", "codex", "copilot", "omp", "opencode", "paseo", "pi"]);
for (const entry of entries) {
expect(entry.enabled).toBe(false);
expect(entry.status).toBe("unavailable");
@@ -27,6 +27,7 @@ import {
shutdownAgentClients,
type ProviderDefinition,
} from "./provider-registry.js";
import type { PaseoAgentConfig } from "./providers/paseo-agent/config.js";
import { applyMutableProviderConfigToOverrides } from "../daemon-config-store.js";
import {
formatProviderDiagnostic,
@@ -76,6 +77,7 @@ export interface ProviderSnapshotManagerOptions {
extraClients?: Partial<Record<AgentProvider, AgentClient>>;
refreshTimeoutMs?: number;
diagnosticTimeoutMs?: number;
paseoAgentConfig?: PaseoAgentConfig;
}
interface ProviderSnapshotRefreshOptions {
@@ -164,6 +166,7 @@ export class ProviderSnapshotManager {
private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined;
private providerOverrides: Record<string, ProviderOverride> | undefined;
private readonly baseProviderOverrides: Record<string, ProviderOverride> | undefined;
private readonly paseoAgentConfig: PaseoAgentConfig | undefined;
private providerRegistry: Record<AgentProvider, ProviderDefinition>;
private providerClients: Record<AgentProvider, AgentClient>;
@@ -176,6 +179,7 @@ export class ProviderSnapshotManager {
this.runtimeSettings = options.runtimeSettings;
this.providerOverrides = options.providerOverrides;
this.baseProviderOverrides = options.providerOverrides;
this.paseoAgentConfig = options.paseoAgentConfig;
this.refreshTimeoutMs = resolveRefreshTimeoutMs(options.refreshTimeoutMs);
this.diagnosticTimeoutMs = resolveDiagnosticTimeoutMs(
options.diagnosticTimeoutMs,
@@ -421,6 +425,7 @@ export class ProviderSnapshotManager {
workspaceGitService: this.workspaceGitService,
managedProcesses: this.managedProcesses,
isDev: this.isDev,
paseoAgentConfig: this.paseoAgentConfig,
});
for (const [provider, client] of Object.entries(this.extraClients) as Array<
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { AgentSessionConfig } from "../../agent-sdk-types.js";
import { PaseoAgentClient } from "./agent.js";
import { PaseoAgentConfigSchema, type PaseoAgentConfig } from "./config.js";
function makeConfig(): PaseoAgentConfig {
return PaseoAgentConfigSchema.parse({
defaultModel: "openrouter-main/test-model",
providers: {
"openrouter-main": {
type: "openrouter",
options: {
baseUrl: "https://openrouter.ai/api/v1",
apiKey: "sk-test",
api: "openai-completions",
models: [{ id: "test-model", label: "Test Model" }],
},
},
},
});
}
function sessionConfig(overrides?: Partial<AgentSessionConfig>): AgentSessionConfig {
return { provider: "paseo", cwd: process.cwd(), ...overrides };
}
describe("PaseoAgentClient", () => {
it("is available only when config has a usable inference provider", async () => {
const withConfig = new PaseoAgentClient({ logger: createTestLogger(), config: makeConfig() });
expect(await withConfig.isAvailable()).toBe(true);
const empty = new PaseoAgentClient({
logger: createTestLogger(),
config: PaseoAgentConfigSchema.parse({}),
});
expect(await empty.isAvailable()).toBe(false);
});
it("lists only configured models, never Pi disk/default models", async () => {
const client = new PaseoAgentClient({ logger: createTestLogger(), config: makeConfig() });
const models = await client.listModels({ cwd: process.cwd(), force: false });
expect(models.map((m) => m.id)).toEqual(["openrouter-main/test-model"]);
expect(models[0]?.isDefault).toBe(true);
});
it("throws when creating a session with no configured providers", async () => {
const client = new PaseoAgentClient({
logger: createTestLogger(),
config: PaseoAgentConfigSchema.parse({}),
});
await expect(client.createSession(sessionConfig())).rejects.toThrow(/no configured/i);
});
it("creates an in-process session bound to the configured model", async () => {
const client = new PaseoAgentClient({ logger: createTestLogger(), config: makeConfig() });
const session = await client.createSession(sessionConfig());
try {
expect(session.provider).toBe("paseo");
const info = await session.getRuntimeInfo();
expect(info.model).toBe("openrouter-main/test-model");
// In-memory prototype: no durable persistence handle.
expect(session.describePersistence()).toBeNull();
} finally {
await session.close();
}
});
it("honors an explicitly requested model over the default", async () => {
const config = PaseoAgentConfigSchema.parse({
defaultModel: "openrouter-main/a",
providers: {
"openrouter-main": {
type: "openrouter",
options: {
baseUrl: "https://openrouter.ai/api/v1",
apiKey: "sk-test",
api: "openai-completions",
models: [{ id: "a" }, { id: "b" }],
},
},
},
});
const client = new PaseoAgentClient({ logger: createTestLogger(), config });
const session = await client.createSession(sessionConfig({ model: "openrouter-main/b" }));
try {
const info = await session.getRuntimeInfo();
expect(info.model).toBe("openrouter-main/b");
} finally {
await session.close();
}
});
});
@@ -0,0 +1,560 @@
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Logger } from "pino";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
import {
getAgentStreamEventTurnId,
type AgentCapabilityFlags,
type AgentClient,
type AgentLaunchContext,
type AgentMode,
type AgentModelDefinition,
type AgentPermissionRequest,
type AgentPermissionResponse,
type AgentPersistenceHandle,
type AgentPromptInput,
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
type AgentSession,
type AgentSessionConfig,
type AgentSlashCommand,
type AgentStreamEvent,
type AgentTimelineItem,
type AgentUsage,
type ListModelsOptions,
} from "../../agent-sdk-types.js";
import {
PASEO_AGENT_PROVIDER,
type PaseoAgentConfig,
listPaseoAgentModels,
paseoAgentHasUsableModel,
paseoAgentInferenceProviders,
parsePaseoAgentModelId,
resolvePaseoAgentModel,
} from "./config.js";
import {
convertPromptInput,
getUserMessageText,
mapToolDetail,
parseToolArgs,
parseToolResult,
toAgentUsage,
type PiTrackedToolCall,
} from "./event-mapping.js";
import { createMcpToolBridge, type McpToolBridge } from "./mcp-bridge.js";
import { createPaseoAgentAuthStorage, hasStoredOAuthCredential } from "./oauth-store.js";
import { createPaseoAgentSession, type PaseoAgentSessionHandle } from "./pi-services.js";
const DEFAULT_THINKING_LEVEL: ThinkingLevel = "medium";
const PASEO_AGENT_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
// Phase 4 uses in-memory sessions; resume/persistence is out of scope.
supportsSessionPersistence: false,
supportsDynamicModes: false,
// MCP servers from AgentSessionConfig.mcpServers are bridged to Pi custom tools.
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
const THINKING_LEVELS: ReadonlySet<ThinkingLevel> = new Set<ThinkingLevel>([
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
]);
function normalizeThinkingLevel(value: string | null | undefined): ThinkingLevel | null {
return value && THINKING_LEVELS.has(value as ThinkingLevel) ? (value as ThinkingLevel) : null;
}
function resolveIsolatedAgentDir(): string {
// Paseo-owned, never ~/.pi. Inert because all Pi services are in-memory, but we
// still keep it off the project tree and outside Pi's default global config.
const base = process.env.PASEO_HOME ?? join(tmpdir(), "paseo-agent");
return join(base, "pi-harness");
}
interface PaseoAgentClientOptions {
logger: Logger;
config: PaseoAgentConfig;
}
export class PaseoAgentSession implements AgentSession {
readonly provider = PASEO_AGENT_PROVIDER;
readonly capabilities = PASEO_AGENT_CAPABILITIES;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private readonly activeToolCalls = new Map<string, PiTrackedToolCall>();
private activeTurnId: string | null = null;
private lastThinkingOptionId: string | null;
constructor(
private readonly handle: PaseoAgentSessionHandle,
private readonly config: AgentSessionConfig,
private readonly mcpBridge: McpToolBridge,
) {
this.lastThinkingOptionId =
normalizeThinkingLevel(config.thinkingOptionId) ?? this.piSession.thinkingLevel ?? null;
this.piSession.subscribe((event) => this.handleSessionEvent(event));
}
private get piSession() {
return this.handle.session;
}
get id(): string | null {
return this.piSession.sessionId;
}
private emit(event: AgentStreamEvent): void {
for (const subscriber of this.subscribers) {
subscriber(event);
}
}
private emitToolCall(
toolCallId: string,
toolCall: PiTrackedToolCall,
status: "running" | "completed" | "failed",
result: ReturnType<typeof parseToolResult>,
error: unknown,
): void {
const turnId = this.activeTurnId ?? undefined;
const baseItem = {
type: "tool_call" as const,
callId: toolCallId,
name: toolCall.toolName,
detail: mapToolDetail(toolCall, result),
};
const item =
status === "failed" ? { ...baseItem, status, error } : { ...baseItem, status, error: null };
this.emit({ type: "timeline", provider: PASEO_AGENT_PROVIDER, turnId, item });
}
private handleSessionEvent(event: AgentSessionEvent): void {
const turnId = this.activeTurnId ?? undefined;
switch (event.type) {
case "agent_start":
this.emit({
type: "thread_started",
provider: PASEO_AGENT_PROVIDER,
sessionId: this.piSession.sessionId,
});
return;
case "turn_start":
this.emit({ type: "turn_started", provider: PASEO_AGENT_PROVIDER, turnId });
return;
case "message_update": {
if (event.message.role !== "assistant") {
return;
}
if (event.assistantMessageEvent.type === "text_delta") {
this.emit({
type: "timeline",
provider: PASEO_AGENT_PROVIDER,
turnId,
item: { type: "assistant_message", text: event.assistantMessageEvent.delta ?? "" },
});
return;
}
if (event.assistantMessageEvent.type === "thinking_delta") {
this.emit({
type: "timeline",
provider: PASEO_AGENT_PROVIDER,
turnId,
item: { type: "reasoning", text: event.assistantMessageEvent.delta ?? "" },
});
}
return;
}
case "tool_execution_start": {
const toolCall = parseToolArgs(event.toolName, event.args);
this.activeToolCalls.set(event.toolCallId, toolCall);
this.emitToolCall(event.toolCallId, toolCall, "running", null, null);
return;
}
case "tool_execution_update": {
const toolCall = this.activeToolCalls.get(event.toolCallId);
if (!toolCall) {
return;
}
this.emitToolCall(
event.toolCallId,
toolCall,
"running",
parseToolResult(event.partialResult),
null,
);
return;
}
case "tool_execution_end": {
const toolCall =
this.activeToolCalls.get(event.toolCallId) ?? parseToolArgs(event.toolName, null);
this.activeToolCalls.delete(event.toolCallId);
const result = parseToolResult(event.result);
const status = event.isError ? "failed" : "completed";
this.emitToolCall(
event.toolCallId,
toolCall,
status,
result,
event.isError ? event.result : null,
);
return;
}
case "agent_end": {
const usage = toAgentUsage(this.piSession.getSessionStats());
const currentTurnId = turnId;
this.activeTurnId = null;
const errorMessage = this.piSession.agent.state.errorMessage;
if (errorMessage) {
this.emit({
type: "turn_failed",
provider: PASEO_AGENT_PROVIDER,
turnId: currentTurnId,
error: errorMessage,
});
return;
}
this.emit({
type: "turn_completed",
provider: PASEO_AGENT_PROVIDER,
turnId: currentTurnId,
usage,
});
return;
}
default:
return;
}
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
const timeline: AgentTimelineItem[] = [];
let finalText = "";
let usage: AgentUsage | undefined;
let turnId: string | null = null;
const bufferedEvents: AgentStreamEvent[] = [];
let settled = false;
let resolveCompletion!: () => void;
let rejectCompletion!: (error: Error) => void;
function processEvent(event: AgentStreamEvent): void {
if (settled) {
return;
}
const eventTurnId = getAgentStreamEventTurnId(event);
if (turnId && eventTurnId && eventTurnId !== turnId) {
return;
}
if (event.type === "timeline") {
timeline.push(event.item);
if (event.item.type === "assistant_message") {
finalText += event.item.text;
}
return;
}
if (event.type === "turn_completed") {
usage = event.usage;
settled = true;
resolveCompletion();
return;
}
if (event.type === "turn_failed") {
settled = true;
rejectCompletion(new Error(event.error));
}
}
const completion = new Promise<void>((resolve, reject) => {
resolveCompletion = resolve;
rejectCompletion = reject;
});
const unsubscribe = this.subscribe((event) => {
if (!turnId) {
bufferedEvents.push(event);
return;
}
processEvent(event);
});
try {
const result = await this.startTurn(prompt, options);
turnId = result.turnId;
for (const event of bufferedEvents) {
processEvent(event);
}
if (!settled) {
await completion;
}
} finally {
unsubscribe();
}
return { sessionId: this.piSession.sessionId, finalText, usage, timeline };
}
async startTurn(
prompt: AgentPromptInput,
_options?: AgentRunOptions,
): Promise<{ turnId: string }> {
if (this.activeTurnId) {
throw new Error("A Paseo Agent turn is already active");
}
const payload = convertPromptInput(prompt);
const turnId = randomUUID();
this.activeTurnId = turnId;
void this.piSession
.prompt(payload.text, payload.images ? { images: payload.images } : undefined)
.catch((error: unknown) => {
const failedTurnId = this.activeTurnId ?? turnId;
this.activeTurnId = null;
this.emit({
type: "turn_failed",
provider: PASEO_AGENT_PROVIDER,
turnId: failedTurnId,
error: error instanceof Error ? error.message : String(error),
});
});
return { turnId };
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
const pendingToolCalls = new Map<string, PiTrackedToolCall>();
let userIndex = 0;
for (const message of this.piSession.messages) {
if (message.role === "user") {
const text = getUserMessageText(message.content);
if (text) {
yield {
type: "timeline",
provider: PASEO_AGENT_PROVIDER,
item: { type: "user_message", text, messageId: `paseo-agent-user-${userIndex}` },
};
}
userIndex += 1;
continue;
}
if (message.role === "assistant") {
for (const content of message.content) {
if (content.type === "text" && content.text) {
yield {
type: "timeline",
provider: PASEO_AGENT_PROVIDER,
item: { type: "assistant_message", text: content.text },
};
continue;
}
if (content.type === "thinking" && content.thinking) {
yield {
type: "timeline",
provider: PASEO_AGENT_PROVIDER,
item: { type: "reasoning", text: content.thinking },
};
continue;
}
if (content.type === "toolCall") {
const tracked = parseToolArgs(content.name, content.arguments);
pendingToolCalls.set(content.id, tracked);
yield {
type: "timeline",
provider: PASEO_AGENT_PROVIDER,
item: {
type: "tool_call",
callId: content.id,
name: tracked.toolName,
status: "running",
detail: mapToolDetail(tracked, null),
error: null,
},
};
}
}
continue;
}
if (message.role === "toolResult") {
const tracked =
pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null);
pendingToolCalls.delete(message.toolCallId);
const detail = mapToolDetail(tracked, parseToolResult({ content: message.content }));
const base = {
type: "tool_call" as const,
callId: message.toolCallId,
name: tracked.toolName,
detail,
};
yield {
type: "timeline",
provider: PASEO_AGENT_PROVIDER,
item: message.isError
? { ...base, status: "failed", error: "Tool call failed" }
: { ...base, status: "completed", error: null },
};
}
}
}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
const model = this.piSession.model;
return {
provider: PASEO_AGENT_PROVIDER,
sessionId: this.piSession.sessionId,
model: model ? `${model.provider}/${model.id}` : null,
thinkingOptionId:
normalizeThinkingLevel(this.lastThinkingOptionId) ?? this.piSession.thinkingLevel ?? null,
modeId: null,
};
}
async getAvailableModes(): Promise<AgentMode[]> {
return [];
}
async getCurrentMode(): Promise<string | null> {
return null;
}
async setMode(_modeId: string): Promise<void> {
throw new Error("Paseo Agent does not expose selectable modes");
}
getPendingPermissions(): AgentPermissionRequest[] {
return [];
}
async respondToPermission(
_requestId: string,
_response: AgentPermissionResponse,
): Promise<void> {}
describePersistence(): AgentPersistenceHandle | null {
return null;
}
async interrupt(): Promise<void> {
await this.piSession.abort();
}
async close(): Promise<void> {
this.piSession.dispose();
await this.mcpBridge.close();
}
async listCommands(): Promise<AgentSlashCommand[]> {
return [];
}
async setModel(modelId: string | null): Promise<void> {
if (!modelId) {
return;
}
const reference = parsePaseoAgentModelId(modelId);
if (!reference) {
throw new Error(`Invalid Paseo Agent model: ${modelId}`);
}
const model = this.handle.modelRegistry.find(reference.provider, reference.id);
if (!model) {
throw new Error(`Unknown Paseo Agent model: ${modelId}`);
}
await this.piSession.setModel(model);
this.config.model = modelId;
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
const level = normalizeThinkingLevel(thinkingOptionId) ?? DEFAULT_THINKING_LEVEL;
this.piSession.setThinkingLevel(level);
this.lastThinkingOptionId = level;
this.config.thinkingOptionId = level;
}
}
export class PaseoAgentClient implements AgentClient {
readonly provider = PASEO_AGENT_PROVIDER;
readonly capabilities = PASEO_AGENT_CAPABILITIES;
private readonly logger: Logger;
private readonly config: PaseoAgentConfig;
constructor(options: PaseoAgentClientOptions) {
this.logger = options.logger;
this.config = options.config;
}
async createSession(
config: AgentSessionConfig,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const inferenceProviders = paseoAgentInferenceProviders(this.config);
if (inferenceProviders.length === 0) {
throw new Error(
"Paseo Agent has no configured inference providers. Add agents.paseo.providers to your Paseo config.",
);
}
const model = resolvePaseoAgentModel(this.config, config.model, inferenceProviders);
const thinkingLevel = normalizeThinkingLevel(config.thinkingOptionId) ?? undefined;
this.logger.debug(
{ provider: PASEO_AGENT_PROVIDER, model: model ? `${model.provider}/${model.id}` : null },
"Creating Paseo Agent session",
);
// OAuth providers (ChatGPT/Codex) use a Paseo-owned, file-backed AuthStorage so Pi
// reads the stored credential and persists refreshed tokens (rotation) back to it.
const usesOAuth = inferenceProviders.some((provider) => provider.oauth);
const authStorage = usesOAuth ? createPaseoAgentAuthStorage() : undefined;
// Bridge Paseo-injected MCP servers (e.g. the `paseo` HTTP server) into Pi tools.
const mcpBridge = await createMcpToolBridge({
mcpServers: config.mcpServers,
logger: this.logger,
});
try {
const handle = await createPaseoAgentSession({
cwd: config.cwd,
agentDir: resolveIsolatedAgentDir(),
inferenceProviders,
...(model ? { model } : {}),
...(thinkingLevel ? { thinkingLevel } : {}),
...(authStorage ? { authStorage } : {}),
...(mcpBridge.tools.length > 0 ? { customTools: mcpBridge.tools } : {}),
});
return new PaseoAgentSession(handle, config, mcpBridge);
} catch (error) {
await mcpBridge.close();
throw error;
}
}
async resumeSession(): Promise<AgentSession> {
throw new Error("Paseo Agent does not support session resume in this prototype");
}
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
return listPaseoAgentModels(this.config);
}
async isAvailable(): Promise<boolean> {
return paseoAgentHasUsableModel(this.config, process.env, (providerInstance) =>
hasStoredOAuthCredential(providerInstance),
);
}
}
@@ -0,0 +1,318 @@
import { describe, expect, it } from "vitest";
import {
PaseoAgentConfigSchema,
encodePaseoAgentModelId,
listPaseoAgentModels,
paseoAgentHasUsableModel,
paseoAgentInferenceProviders,
parsePaseoAgentModelId,
resolvePaseoAgentModel,
type PaseoAgentConfig,
} from "./config.js";
function configWith(overrides?: Partial<PaseoAgentConfig>): PaseoAgentConfig {
return PaseoAgentConfigSchema.parse({
providers: {
"openrouter-main": {
type: "openrouter",
options: {
apiKey: "sk-test",
models: [
{ id: "anthropic/claude", label: "Claude", reasoning: true },
{ id: "openai/gpt", reasoning: false },
],
},
},
},
...overrides,
});
}
describe("PaseoAgentConfigSchema", () => {
it("rejects unknown keys (strict)", () => {
expect(() => PaseoAgentConfigSchema.parse({ providers: {}, unexpected: true })).toThrow();
});
it("rejects an unknown inference provider type", () => {
expect(() =>
PaseoAgentConfigSchema.parse({
providers: { p: { type: "mystery", options: { models: [{ id: "m" }] } } },
}),
).toThrow();
});
it("requires at least one model per inference provider", () => {
expect(() =>
PaseoAgentConfigSchema.parse({
providers: { p: { type: "openrouter", options: { models: [] } } },
}),
).toThrow();
});
it("requires baseUrl for openai-compatible and api for custom", () => {
expect(() =>
PaseoAgentConfigSchema.parse({
providers: { p: { type: "openai-compatible", options: { models: [{ id: "m" }] } } },
}),
).toThrow(/baseUrl/);
expect(() =>
PaseoAgentConfigSchema.parse({
providers: {
p: { type: "custom", options: { baseUrl: "https://x.test", models: [{ id: "m" }] } },
},
}),
).toThrow(/api/);
});
it("accepts an empty config", () => {
expect(PaseoAgentConfigSchema.parse({})).toEqual({});
});
it("accepts multiple entries of the same type with distinct names", () => {
const config = PaseoAgentConfigSchema.parse({
providers: {
"openai-a": { type: "openai", options: { apiKey: "sk-a", models: [{ id: "gpt-a" }] } },
"openai-b": {
type: "openai",
options: { baseUrl: "https://proxy.test/v1", apiKey: "sk-b", models: [{ id: "gpt-b" }] },
},
},
});
expect(Object.keys(config.providers ?? {})).toEqual(["openai-a", "openai-b"]);
});
});
describe("model id encoding", () => {
it("round-trips provider + model id", () => {
const id = encodePaseoAgentModelId("openrouter-main", "anthropic/claude");
expect(parsePaseoAgentModelId(id)).toEqual({
provider: "openrouter-main",
id: "anthropic/claude",
});
});
it("returns null for an unprefixed id", () => {
expect(parsePaseoAgentModelId("noslash")).toBeNull();
});
});
describe("listPaseoAgentModels", () => {
it("exposes every configured model with provider-prefixed ids", () => {
const models = listPaseoAgentModels(configWith());
expect(models.map((m) => m.id)).toEqual([
"openrouter-main/anthropic/claude",
"openrouter-main/openai/gpt",
]);
expect(models.every((m) => m.provider === "paseo")).toBe(true);
});
it("marks the configured default model", () => {
const models = listPaseoAgentModels(configWith({ defaultModel: "openrouter-main/openai/gpt" }));
const defaults = models.filter((m) => m.isDefault).map((m) => m.id);
expect(defaults).toEqual(["openrouter-main/openai/gpt"]);
});
});
describe("paseoAgentInferenceProviders (per-type defaults)", () => {
it("applies openrouter defaults (base url, api, model fields)", () => {
const [provider] = paseoAgentInferenceProviders(configWith());
expect(provider.name).toBe("openrouter-main");
expect(provider.config.baseUrl).toBe("https://openrouter.ai/api/v1");
expect(provider.config.apiKey).toBe("sk-test");
expect(provider.config.models?.[0]).toMatchObject({
id: "anthropic/claude",
name: "Claude",
api: "openai-completions",
reasoning: true,
contextWindow: 128_000,
maxTokens: 16_384,
});
});
it("falls back to the type's env var when no apiKey is given", () => {
const config = PaseoAgentConfigSchema.parse({
providers: {
anthropic: { type: "anthropic", options: { models: [{ id: "claude-x" }] } },
},
});
const [provider] = paseoAgentInferenceProviders(config);
expect(provider.config.baseUrl).toBe("https://api.anthropic.com");
expect(provider.config.apiKey).toBe("$ANTHROPIC_API_KEY");
expect(provider.config.models?.[0]?.api).toBe("anthropic-messages");
});
it("supports an OpenCode Zen / openai-compatible endpoint with per-model api override", () => {
const config = PaseoAgentConfigSchema.parse({
providers: {
zen: {
type: "openai-compatible",
options: {
baseUrl: "https://opencode.ai/zen/v1",
apiKey: "$OPENCODE_API_KEY",
models: [{ id: "big-pickle" }, { id: "claude-sonnet", api: "anthropic-messages" }],
},
},
},
});
const [provider] = paseoAgentInferenceProviders(config);
expect(provider.config.baseUrl).toBe("https://opencode.ai/zen/v1");
expect(provider.config.models?.[0]?.api).toBe("openai-completions");
expect(provider.config.models?.[1]?.api).toBe("anthropic-messages");
});
it("passes through the custom escape hatch (explicit api + authHeader)", () => {
const config = PaseoAgentConfigSchema.parse({
providers: {
vertex: {
type: "custom",
options: {
baseUrl: "https://my-gateway.test/v1",
api: "google-generative-ai",
apiKey: "sk-custom",
authHeader: true,
headers: { "x-extra": "1" },
models: [{ id: "gemini" }],
},
},
},
});
const [provider] = paseoAgentInferenceProviders(config);
expect(provider.config.api).toBe("google-generative-ai");
expect(provider.config.authHeader).toBe(true);
expect(provider.config.headers).toEqual({ "x-extra": "1" });
expect(provider.config.models?.[0]?.api).toBe("google-generative-ai");
});
});
describe("paseoAgentHasUsableModel (env-aware auth)", () => {
it("is true for a literal api key", () => {
expect(paseoAgentHasUsableModel(configWith(), {})).toBe(true);
});
it("is false when no providers are configured", () => {
expect(paseoAgentHasUsableModel(PaseoAgentConfigSchema.parse({}), {})).toBe(false);
});
it("is false for an openai-compatible provider without any key", () => {
const config = PaseoAgentConfigSchema.parse({
providers: {
local: {
type: "openai-compatible",
options: { baseUrl: "https://local.test/v1", models: [{ id: "m" }] },
},
},
});
expect(paseoAgentHasUsableModel(config, {})).toBe(false);
});
it("follows the env var for an env-backed key", () => {
const config = PaseoAgentConfigSchema.parse({
providers: { openrouter: { type: "openrouter", options: { models: [{ id: "m" }] } } },
});
expect(paseoAgentHasUsableModel(config, {})).toBe(false);
expect(paseoAgentHasUsableModel(config, { OPENROUTER_API_KEY: "sk-env" })).toBe(true);
});
});
describe("resolvePaseoAgentModel", () => {
it("prefers the explicit request, then default, then first configured", () => {
const config = configWith({ defaultModel: "openrouter-main/openai/gpt" });
expect(resolvePaseoAgentModel(config, "openrouter-main/anthropic/claude")).toEqual({
provider: "openrouter-main",
id: "anthropic/claude",
});
expect(resolvePaseoAgentModel(config, null)).toEqual({
provider: "openrouter-main",
id: "openai/gpt",
});
expect(resolvePaseoAgentModel(configWith(), null)).toEqual({
provider: "openrouter-main",
id: "anthropic/claude",
});
});
it("returns undefined when no providers are configured", () => {
expect(resolvePaseoAgentModel(PaseoAgentConfigSchema.parse({}), null)).toBeUndefined();
});
it("ignores an implicit default whose provider is not registered", () => {
const config = configWith({ defaultModel: "ghost/model" });
expect(resolvePaseoAgentModel(config, null)).toEqual({
provider: "openrouter-main",
id: "anthropic/claude",
});
});
it("honors an explicit request even if its provider is not registered", () => {
expect(resolvePaseoAgentModel(configWith(), "ghost/model")).toEqual({
provider: "ghost",
id: "model",
});
});
});
describe("openai-codex (ChatGPT subscription) provider", () => {
function codexConfig(options: Record<string, unknown>): PaseoAgentConfig {
return PaseoAgentConfigSchema.parse({
providers: {
chatgpt: {
type: "openai-codex",
options: { models: [{ id: "gpt-5.3-codex" }], ...options },
},
},
});
}
it("accepts a codex provider with no credential field (login provides it)", () => {
const config = codexConfig({});
expect(config.providers?.chatgpt?.type).toBe("openai-codex");
});
it("rejects an unknown option like a foreign credentials file", () => {
expect(() =>
PaseoAgentConfigSchema.parse({
providers: {
chatgpt: {
type: "openai-codex",
options: { credentialsFile: "/Users/me/.codex/auth.json", models: [{ id: "x" }] },
},
},
}),
).toThrow();
});
it("maps to a codex inference provider with an oauth marker and no api key", () => {
const [provider] = paseoAgentInferenceProviders(codexConfig({}), {});
expect(provider.name).toBe("chatgpt");
expect(provider.oauth).toEqual({ kind: "openai-codex" });
expect(provider.config.apiKey).toBeUndefined();
expect(provider.config.api).toBe("openai-codex-responses");
expect(provider.config.baseUrl).toBe("https://chatgpt.com/backend-api");
expect(provider.config.models?.[0]?.api).toBe("openai-codex-responses");
});
it("carries an advanced self-supplied refresh token resolved from an env var", () => {
const config = codexConfig({ refreshToken: "$CODEX_REFRESH_TOKEN" });
const [provider] = paseoAgentInferenceProviders(config, { CODEX_REFRESH_TOKEN: "rt-env" });
expect(provider.oauth).toEqual({ kind: "openai-codex", refreshToken: "rt-env" });
});
it("availability uses the OAuth store predicate, or an advanced refresh token", () => {
// No stored credential and no advanced token → not available.
expect(paseoAgentHasUsableModel(codexConfig({}), {})).toBe(false);
// Stored credential (predicate true) → available.
expect(paseoAgentHasUsableModel(codexConfig({}), {}, () => true)).toBe(true);
// Advanced env-backed token → available without the store.
expect(
paseoAgentHasUsableModel(codexConfig({ refreshToken: "$CODEX_REFRESH_TOKEN" }), {
CODEX_REFRESH_TOKEN: "rt-env",
}),
).toBe(true);
});
it("lists codex models regardless of auth state", () => {
const models = listPaseoAgentModels(codexConfig({}));
expect(models.map((m) => m.id)).toEqual(["chatgpt/gpt-5.3-codex"]);
});
});
@@ -0,0 +1,407 @@
import { z } from "zod";
import type { AgentModelDefinition } from "../../agent-sdk-types.js";
import {
isRefreshTokenExpressionConfigured,
resolveRefreshTokenExpression,
} from "./oauth-credentials.js";
import type { PaseoAgentInferenceProvider, PaseoAgentModelReference } from "./pi-services.js";
export const PASEO_AGENT_PROVIDER = "paseo";
// Dedicated Paseo-owned config for the Paseo Agent provider. This is the single
// schema for `agents.paseo`; inference-provider fields are intentionally NOT
// merged into the shared strict ProviderOverrideSchema, and only this provider
// (via the helpers below) consumes it. This module imports no Pi runtime code so
// it stays cheap to load from persisted-config parsing.
//
// Inference providers are typed by `type`. Each known type carries sensible
// defaults (base URL, Pi wire `api`, and the env var its API key is read from),
// so a user only needs to give an `apiKey` (or the env var) and one or more model
// ids. `openai-compatible` covers any OpenAI Chat Completions endpoint (incl.
// OpenCode Zen/Go behind a custom base URL); `custom` is a thin escape hatch for
// directly choosing Pi's wire `api`.
//
// AUTH: API-key and env-var auth work for every type. ChatGPT/OpenAI subscription
// OAuth is supported via the `openai-codex` type. The product path is `paseo login
// chatgpt`, which runs Pi's browser PKCE/callback login by default and stores the
// credential in a Paseo-controlled file (see oauth-store.ts); the session loads it and
// Pi refreshes/persists rotation there. `options.refreshToken` is an advanced, manual
// escape hatch for users supplying their OWN token (literal/`$ENV`/`!cmd`) — it is not
// the normal path. Paseo never reads another tool's auth files. Other OAuth providers
// (e.g. Anthropic Pro/Max) remain unwired.
const PROVIDER_TYPES = [
"openrouter",
"openai",
"anthropic",
"opencode",
"openai-compatible",
"openai-codex",
"custom",
] as const;
export type PaseoAgentProviderType = (typeof PROVIDER_TYPES)[number];
interface ProviderTypeDefault {
/** Pi wire protocol. `undefined` for `custom`, where the user must pick one. */
api?: string;
/** Default base URL. `undefined` means the user must supply `options.baseUrl`. */
baseUrl?: string;
/** Env var the API key is read from when `options.apiKey` is omitted. */
envVar?: string;
}
// Defaults mirror Pi's built-in provider definitions (packages/ai models). Pi adds
// its own attribution headers for openrouter/opencode based on the base URL, so we
// deliberately do not inject provider headers here.
const PROVIDER_TYPE_DEFAULTS: Record<PaseoAgentProviderType, ProviderTypeDefault> = {
openrouter: {
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
envVar: "OPENROUTER_API_KEY",
},
openai: {
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
envVar: "OPENAI_API_KEY",
},
anthropic: {
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
envVar: "ANTHROPIC_API_KEY",
},
opencode: {
// OpenCode Zen. Some Zen models speak anthropic-messages; override per model
// with `api` when needed. Go models live behind a custom base URL via
// `openai-compatible` (or set `options.baseUrl` to .../zen/go/v1 here).
api: "openai-completions",
baseUrl: "https://opencode.ai/zen/v1",
envVar: "OPENCODE_API_KEY",
},
"openai-compatible": {
api: "openai-completions",
baseUrl: undefined,
envVar: undefined,
},
"openai-codex": {
// ChatGPT/OpenAI subscription via OAuth. Auth is a refresh token, not an API
// key, so there is no default env var here.
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
envVar: undefined,
},
custom: {
api: undefined,
baseUrl: undefined,
envVar: undefined,
},
};
const PaseoAgentModelSchema = z
.object({
id: z.string().min(1),
label: z.string().min(1).optional(),
// Override the provider's wire api for this model (e.g. an anthropic-messages
// model served by an otherwise openai-completions provider like OpenCode Zen).
api: z.string().min(1).optional(),
reasoning: z.boolean().optional(),
contextWindow: z.number().int().positive().optional(),
maxTokens: z.number().int().positive().optional(),
})
.strict();
const PaseoAgentProviderOptionsSchema = z
.object({
// API key. Literal value, an env-var reference like `$OPENROUTER_API_KEY` /
// `${OPENROUTER_API_KEY}`, or a `!command` (resolved by Pi at request time).
// When omitted, known types fall back to their default env var.
apiKey: z.string().min(1).optional(),
baseUrl: z.string().url().optional(),
// Override the wire api. Required for `custom`; optional elsewhere.
api: z.string().min(1).optional(),
headers: z.record(z.string()).optional(),
// Advanced: send `Authorization: Bearer <apiKey>` as a header. Only needed for
// endpoints whose wire api doesn't already attach the key.
authHeader: z.boolean().optional(),
// Advanced/manual ONLY: a self-supplied OAuth refresh token for `openai-codex`
// (literal, `$ENV`/`${ENV}`, or `!command`). The normal path is the Paseo-owned
// login (`paseo login chatgpt`), which stores the credential for you.
refreshToken: z.string().min(1).optional(),
models: z.array(PaseoAgentModelSchema).min(1),
})
.strict();
const PaseoAgentInferenceProviderSchema = z
.object({
type: z.enum(PROVIDER_TYPES),
options: PaseoAgentProviderOptionsSchema,
})
.strict()
.superRefine((entry, ctx) => {
const defaults = PROVIDER_TYPE_DEFAULTS[entry.type];
if (!defaults.baseUrl && !entry.options.baseUrl) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["options", "baseUrl"],
message: `Inference provider type "${entry.type}" requires options.baseUrl.`,
});
}
if (!defaults.api && !entry.options.api) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["options", "api"],
message: `Inference provider type "${entry.type}" requires options.api.`,
});
}
// `openai-codex` needs no credential field here: auth comes from the Paseo-owned
// store populated by `paseo login chatgpt`. `options.refreshToken` is an
// optional advanced override.
});
export const PaseoAgentConfigSchema = z
.object({
// Optional default model as "<inferenceProviderName>/<modelId>".
defaultModel: z.string().min(1).optional(),
// Inference providers keyed by instance name. Multiple entries may share a
// type while pointing at different APIs/base URLs/models.
providers: z.record(PaseoAgentInferenceProviderSchema).optional(),
})
.strict();
export type PaseoAgentConfig = z.infer<typeof PaseoAgentConfigSchema>;
type PaseoAgentInferenceProviderEntry = z.infer<typeof PaseoAgentInferenceProviderSchema>;
const DEFAULT_CONTEXT_WINDOW = 128_000;
const DEFAULT_MAX_TOKENS = 16_384;
interface ResolvedProviderSettings {
baseUrl?: string;
api?: string;
apiKey?: string;
headers?: Record<string, string>;
authHeader?: boolean;
}
function entries(config: PaseoAgentConfig): [string, PaseoAgentInferenceProviderEntry][] {
return Object.entries(config.providers ?? {});
}
/** Apply per-type defaults to a raw inference provider entry. */
function resolveProviderSettings(
entry: PaseoAgentInferenceProviderEntry,
): ResolvedProviderSettings {
const defaults = PROVIDER_TYPE_DEFAULTS[entry.type];
const apiKey = entry.options.apiKey ?? (defaults.envVar ? `$${defaults.envVar}` : undefined);
return {
baseUrl: entry.options.baseUrl ?? defaults.baseUrl,
api: entry.options.api ?? defaults.api,
apiKey,
headers: entry.options.headers,
authHeader: entry.options.authHeader,
};
}
const ENV_REFERENCE_PATTERN = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g;
/**
* Whether a resolved API-key value is actually configured. Mirrors Pi's config-value
* semantics without importing Pi: literals and `!command` values count as present;
* `$ENV` / `${ENV}` references count only when every referenced var is set.
*/
function isAuthConfigured(value: string | undefined, env: NodeJS.ProcessEnv): boolean {
if (!value) {
return false;
}
if (value.startsWith("!")) {
return true;
}
const referencedVars = Array.from(value.matchAll(ENV_REFERENCE_PATTERN), (match) => match[1]);
if (referencedVars.length === 0) {
return true;
}
return referencedVars.every((name) => Boolean(env[name]));
}
/** Encode the Paseo-facing model id from an inference provider + Pi model id. */
export function encodePaseoAgentModelId(providerName: string, modelId: string): string {
return `${providerName}/${modelId}`;
}
/** Parse a Paseo-facing model id back into its inference provider + Pi model id. */
export function parsePaseoAgentModelId(modelId: string): PaseoAgentModelReference | null {
const slash = modelId.indexOf("/");
if (slash <= 0 || slash === modelId.length - 1) {
return null;
}
return { provider: modelId.slice(0, slash), id: modelId.slice(slash + 1) };
}
function toPiModels(entry: PaseoAgentInferenceProviderEntry, settings: ResolvedProviderSettings) {
return entry.options.models.map((model) => {
const api = model.api ?? settings.api;
return {
id: model.id,
name: model.label ?? model.id,
...(api ? { api } : {}),
reasoning: model.reasoning ?? false,
input: ["text"] as ("text" | "image")[],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: model.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
maxTokens: model.maxTokens ?? DEFAULT_MAX_TOKENS,
};
});
}
/**
* Map Paseo-owned config into the in-memory inference providers the Pi seam expects.
* `openai-codex` entries carry an oauth marker instead of an API key. Their credential
* normally lives in the Paseo-owned store (populated by `paseo login chatgpt`); an
* advanced `options.refreshToken` may supply the user's own token instead.
*/
export function paseoAgentInferenceProviders(
config: PaseoAgentConfig,
env: NodeJS.ProcessEnv = process.env,
): PaseoAgentInferenceProvider[] {
return entries(config).flatMap(([name, entry]): PaseoAgentInferenceProvider[] => {
const settings = resolveProviderSettings(entry);
const models = toPiModels(entry, settings);
if (entry.type === "openai-codex") {
const refreshToken = entry.options.refreshToken
? resolveRefreshTokenExpression(entry.options.refreshToken, env)
: undefined;
return [
{
name,
config: {
...(settings.baseUrl ? { baseUrl: settings.baseUrl } : {}),
...(settings.api ? { api: settings.api } : {}),
models,
},
oauth: { kind: "openai-codex" as const, ...(refreshToken ? { refreshToken } : {}) },
},
];
}
return [
{
name,
config: {
...(settings.baseUrl ? { baseUrl: settings.baseUrl } : {}),
...(settings.apiKey ? { apiKey: settings.apiKey } : {}),
...(settings.api ? { api: settings.api } : {}),
...(settings.headers ? { headers: settings.headers } : {}),
...(settings.authHeader ? { authHeader: settings.authHeader } : {}),
models,
},
},
];
});
}
/** Enumerate configured models as Paseo model definitions (no Pi disk/auth reads). */
export function listPaseoAgentModels(config: PaseoAgentConfig): AgentModelDefinition[] {
const models: AgentModelDefinition[] = [];
for (const [name, entry] of entries(config)) {
for (const model of entry.options.models) {
const id = encodePaseoAgentModelId(name, model.id);
models.push({
provider: PASEO_AGENT_PROVIDER,
id,
label: model.label ?? model.id,
description: `${name} · ${model.id}`,
isDefault: config.defaultModel === id,
});
}
}
return models;
}
/**
* An inference provider is usable when it has at least one model and auth is configured.
* For API-key types that means a resolvable key (literal, set env var, or command). For
* `openai-codex`, auth comes from the Paseo-owned store (checked via `isOAuthAuthed`,
* keyed by provider instance name) or, as an advanced override, a resolvable
* `options.refreshToken`.
*/
export function paseoAgentHasUsableModel(
config: PaseoAgentConfig,
env: NodeJS.ProcessEnv = process.env,
isOAuthAuthed: (providerInstance: string) => boolean = () => false,
): boolean {
return entries(config).some(([name, entry]) => {
if (entry.options.models.length === 0) {
return false;
}
if (entry.type === "openai-codex") {
if (
entry.options.refreshToken &&
isRefreshTokenExpressionConfigured(entry.options.refreshToken, env)
) {
return true;
}
return isOAuthAuthed(name);
}
return isAuthConfigured(resolveProviderSettings(entry).apiKey, env);
});
}
/**
* Resolve which Pi model to launch: the explicit request is honored as-is; implicit
* default selection only chooses models from the providers actually registered with Pi.
*/
export function resolvePaseoAgentModel(
config: PaseoAgentConfig,
requestedModelId: string | null | undefined,
registeredProviders: PaseoAgentInferenceProvider[] = paseoAgentInferenceProviders(config),
): PaseoAgentModelReference | undefined {
if (requestedModelId) {
return parsePaseoAgentModelId(requestedModelId) ?? undefined;
}
for (const candidate of [config.defaultModel, firstModelId(config)]) {
if (!candidate) {
continue;
}
const parsed = parsePaseoAgentModelId(candidate);
if (parsed && hasRegisteredModel(registeredProviders, parsed)) {
return parsed;
}
}
return firstRegisteredModel(registeredProviders);
}
function firstModelId(config: PaseoAgentConfig): string | undefined {
for (const [name, entry] of entries(config)) {
const first = entry.options.models[0];
if (first) {
return encodePaseoAgentModelId(name, first.id);
}
}
return undefined;
}
function hasRegisteredModel(
providers: PaseoAgentInferenceProvider[],
model: PaseoAgentModelReference,
): boolean {
return providers.some(
(provider) =>
provider.name === model.provider &&
provider.config.models?.some((registered) => registered.id === model.id),
);
}
function firstRegisteredModel(
providers: PaseoAgentInferenceProvider[],
): PaseoAgentModelReference | undefined {
for (const provider of providers) {
const first = provider.config.models?.[0];
if (first) {
return { provider: provider.name, id: first.id };
}
}
return undefined;
}
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import {
convertPromptInput,
mapToolDetail,
parseToolArgs,
parseToolResult,
} from "./event-mapping.js";
describe("parseToolArgs", () => {
it("classifies known built-in tools", () => {
expect(parseToolArgs("bash", { command: "ls" }).kind).toBe("bash");
expect(parseToolArgs("read", { path: "/tmp/a" }).kind).toBe("read");
expect(parseToolArgs("grep", { pattern: "foo" }).kind).toBe("grep");
});
it("falls back to unknown for unrecognized tools or bad args", () => {
expect(parseToolArgs("mcp__paseo__do", { anything: 1 }).kind).toBe("unknown");
expect(parseToolArgs("bash", { notACommand: true }).kind).toBe("unknown");
});
it("accepts legacy edit args (old_string/new_string)", () => {
const call = parseToolArgs("edit", {
path: "/tmp/a",
old_string: "x",
new_string: "y",
});
expect(call.kind).toBe("edit");
if (call.kind === "edit") {
expect(call.args.edits[0]).toEqual({ oldText: "x", newText: "y" });
}
});
});
describe("mapToolDetail", () => {
it("maps a bash call with exit code and output", () => {
const call = parseToolArgs("bash", { command: "echo hi" });
const detail = mapToolDetail(call, parseToolResult({ output: "hi", exitCode: 0 }));
expect(detail).toMatchObject({ type: "shell", command: "echo hi", output: "hi", exitCode: 0 });
});
it("maps an edit call to a diff detail", () => {
const call = parseToolArgs("edit", {
path: "/tmp/a",
edits: [{ oldText: "a", newText: "b" }],
});
const detail = mapToolDetail(call, parseToolResult({ details: { diff: "--- diff ---" } }));
expect(detail).toMatchObject({
type: "edit",
filePath: "/tmp/a",
oldString: "a",
newString: "b",
unifiedDiff: "--- diff ---",
});
});
it("maps unknown tools to a passthrough detail", () => {
const call = parseToolArgs("mystery", { foo: 1 });
const detail = mapToolDetail(call, parseToolResult("done"));
expect(detail.type).toBe("unknown");
});
});
describe("convertPromptInput", () => {
it("passes a plain string through", () => {
expect(convertPromptInput("hello")).toEqual({ text: "hello" });
});
it("joins text blocks and collects images", () => {
const payload = convertPromptInput([
{ type: "text", text: "one" },
{ type: "image", data: "base64", mimeType: "image/png" },
{ type: "text", text: "two" },
]);
expect(payload.text).toBe("one\n\ntwo");
expect(payload.images).toEqual([{ type: "image", data: "base64", mimeType: "image/png" }]);
});
});
@@ -0,0 +1,349 @@
import type {
BashToolInput,
EditToolInput,
FindToolInput,
GrepToolInput,
LsToolInput,
ReadToolInput,
SessionStats,
WriteToolInput,
} from "@earendil-works/pi-coding-agent";
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import { z } from "zod";
import type { AgentPromptInput, AgentUsage, ToolCallDetail } from "../../agent-sdk-types.js";
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
// Pure event/tool/model mapping ported from the old direct Pi provider. These
// translate Pi's harness shapes into Paseo's provider-agnostic timeline types.
// Kept free of any session/runtime state so they can be unit-tested directly.
export interface PiPromptPayload {
text: string;
images?: ImageContent[];
}
interface ToolCallOutputSummary {
output?: string;
exitCode?: number | null;
}
interface PiBashToolCall {
kind: "bash";
toolName: "bash";
args: BashToolInput;
}
interface PiReadToolCall {
kind: "read";
toolName: "read";
args: ReadToolInput;
}
interface PiEditToolCall {
kind: "edit";
toolName: "edit";
args: EditToolInput;
}
interface PiWriteToolCall {
kind: "write";
toolName: "write";
args: WriteToolInput;
}
interface PiFindToolCall {
kind: "find";
toolName: "find";
args: FindToolInput;
}
interface PiGrepToolCall {
kind: "grep";
toolName: "grep";
args: GrepToolInput;
}
interface PiLsToolCall {
kind: "ls";
toolName: "ls";
args: LsToolInput;
}
interface PiUnknownToolCall {
kind: "unknown";
toolName: string;
args: unknown;
}
export type PiTrackedToolCall =
| PiBashToolCall
| PiReadToolCall
| PiEditToolCall
| PiWriteToolCall
| PiFindToolCall
| PiGrepToolCall
| PiLsToolCall
| PiUnknownToolCall;
const PiToolResultTextContentSchema = z.object({ type: z.literal("text"), text: z.string() });
const PiToolResultUnknownContentSchema = z.object({ type: z.string() }).passthrough();
const PiToolResultContentSchema = z.union([
PiToolResultTextContentSchema,
PiToolResultUnknownContentSchema,
]);
const PiToolResultObjectSchema = z
.object({
output: z.string().optional(),
stdout: z.string().optional(),
text: z.string().optional(),
content: z.array(PiToolResultContentSchema).optional(),
exitCode: z.number().optional(),
code: z.number().optional(),
details: z.object({ diff: z.string().optional() }).passthrough().optional(),
})
.passthrough();
const PiToolResultSchema = z.union([z.string(), PiToolResultObjectSchema, z.null()]);
type PiToolResult = z.infer<typeof PiToolResultSchema>;
const BashToolInputSchema: z.ZodType<BashToolInput> = z.object({
command: z.string(),
timeout: z.number().optional(),
});
const ReadToolInputSchema: z.ZodType<ReadToolInput> = z.object({
path: z.string(),
offset: z.number().optional(),
limit: z.number().optional(),
});
const EditToolInputSchema: z.ZodType<EditToolInput> = z.object({
path: z.string(),
edits: z.array(z.object({ oldText: z.string(), newText: z.string() })),
});
const LegacyEditToolInputSchema = z.object({
path: z.string(),
old_string: z.string().optional(),
oldString: z.string().optional(),
new_string: z.string().optional(),
newString: z.string().optional(),
});
const WriteToolInputSchema: z.ZodType<WriteToolInput> = z.object({
path: z.string(),
content: z.string(),
});
const FindToolInputSchema: z.ZodType<FindToolInput> = z.object({
pattern: z.string(),
path: z.string().optional(),
limit: z.number().optional(),
});
const GrepToolInputSchema: z.ZodType<GrepToolInput> = z.object({
pattern: z.string(),
path: z.string().optional(),
glob: z.string().optional(),
ignoreCase: z.boolean().optional(),
literal: z.boolean().optional(),
context: z.number().optional(),
limit: z.number().optional(),
});
const LsToolInputSchema: z.ZodType<LsToolInput> = z.object({
path: z.string().optional(),
limit: z.number().optional(),
});
type SimpleToolKind = "bash" | "read" | "write" | "find" | "grep" | "ls";
const SIMPLE_TOOL_SCHEMAS: {
[K in SimpleToolKind]: { safeParse: (data: unknown) => { success: boolean; data?: unknown } };
} = {
bash: BashToolInputSchema,
read: ReadToolInputSchema,
write: WriteToolInputSchema,
find: FindToolInputSchema,
grep: GrepToolInputSchema,
ls: LsToolInputSchema,
};
export function parseToolResult(rawResult: unknown): PiToolResult {
const parsed = PiToolResultSchema.safeParse(rawResult);
return parsed.success ? parsed.data : null;
}
function normalizeLegacyEditArgs(rawArgs: unknown): EditToolInput | null {
const parsed = LegacyEditToolInputSchema.safeParse(rawArgs);
if (!parsed.success) {
return null;
}
const oldText = parsed.data.old_string ?? parsed.data.oldString;
const newText = parsed.data.new_string ?? parsed.data.newString;
if (!oldText || newText === undefined) {
return null;
}
return { path: parsed.data.path, edits: [{ oldText, newText }] };
}
function parseEditToolArgs(rawArgs: unknown): PiTrackedToolCall {
const parsed = EditToolInputSchema.safeParse(rawArgs);
if (parsed.success) {
return { kind: "edit", toolName: "edit", args: parsed.data };
}
const legacyArgs = normalizeLegacyEditArgs(rawArgs);
if (legacyArgs) {
return { kind: "edit", toolName: "edit", args: legacyArgs };
}
return { kind: "unknown", toolName: "edit", args: rawArgs ?? null };
}
export function parseToolArgs(toolName: string, rawArgs: unknown): PiTrackedToolCall {
if (toolName === "edit") {
return parseEditToolArgs(rawArgs);
}
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
if (schema) {
const parsed = schema.safeParse(rawArgs);
if (parsed.success) {
return { kind: toolName as SimpleToolKind, toolName, args: parsed.data } as PiTrackedToolCall;
}
}
return { kind: "unknown", toolName, args: rawArgs ?? null };
}
export function extractTextFromToolResult(result: PiToolResult): string | undefined {
if (typeof result === "string") {
return result;
}
if (!result) {
return undefined;
}
const directText = result.output ?? result.stdout ?? result.text;
if (directText) {
return directText;
}
if (!result.content) {
return undefined;
}
const textParts: string[] = [];
for (const block of result.content) {
if (block.type === "text" && "text" in block) {
textParts.push(block.text as string);
}
}
return textParts.length > 0 ? textParts.join("\n") : undefined;
}
function resolveToolCallOutput(result: PiToolResult): ToolCallOutputSummary {
if (typeof result === "string") {
return { output: result };
}
if (!result) {
return {};
}
const summary: ToolCallOutputSummary = { output: extractTextFromToolResult(result) };
if (typeof result.exitCode === "number") {
return { ...summary, exitCode: result.exitCode };
}
if (typeof result.code === "number") {
return { ...summary, exitCode: result.code };
}
return { ...summary, exitCode: null };
}
export function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult): ToolCallDetail {
const parsedResult = result ?? null;
switch (toolCall.kind) {
case "bash": {
const summary = resolveToolCallOutput(parsedResult);
return {
type: "shell",
command: toolCall.args.command,
output: summary.output,
exitCode: summary.exitCode,
};
}
case "read":
return {
type: "read",
filePath: toolCall.args.path,
content: extractTextFromToolResult(parsedResult),
offset: toolCall.args.offset,
limit: toolCall.args.limit,
};
case "edit": {
const firstEdit = toolCall.args.edits[0];
const unifiedDiff =
parsedResult && typeof parsedResult !== "string" ? parsedResult.details?.diff : undefined;
return {
type: "edit",
filePath: toolCall.args.path,
oldString: firstEdit?.oldText,
newString: firstEdit?.newText,
unifiedDiff,
};
}
case "write":
return { type: "write", filePath: toolCall.args.path, content: toolCall.args.content };
case "find":
return {
type: "search",
query: toolCall.args.pattern,
toolName: "search",
content: typeof parsedResult === "string" ? parsedResult : undefined,
};
case "grep":
return {
type: "search",
query: toolCall.args.pattern,
toolName: "grep",
content: typeof parsedResult === "string" ? parsedResult : undefined,
};
case "ls":
return {
type: "search",
query: toolCall.args.path ?? "ls",
content: typeof parsedResult === "string" ? parsedResult : undefined,
};
default:
return { type: "unknown", input: toolCall.args, output: parsedResult };
}
}
export function convertPromptInput(prompt: AgentPromptInput): PiPromptPayload {
if (typeof prompt === "string") {
return { text: prompt };
}
const textParts: string[] = [];
const images: ImageContent[] = [];
for (const block of prompt) {
if (block.type === "text") {
textParts.push(block.text);
continue;
}
if (block.type === "image") {
images.push({ type: "image", data: block.data, mimeType: block.mimeType });
continue;
}
textParts.push(renderPromptAttachmentAsText(block));
}
const payload: PiPromptPayload = { text: textParts.join("\n\n") };
if (images.length > 0) {
payload.images = images;
}
return payload;
}
export function toAgentUsage(stats: SessionStats): AgentUsage | undefined {
const inputTokens = stats.tokens.input;
const cachedInputTokens = stats.tokens.cacheRead;
const outputTokens = stats.tokens.output;
const totalCostUsd = stats.cost;
if (inputTokens === 0 && cachedInputTokens === 0 && outputTokens === 0 && totalCostUsd === 0) {
return undefined;
}
return { inputTokens, cachedInputTokens, outputTokens, totalCostUsd };
}
const PiTextContentSchema = z.object({ type: z.literal("text"), text: z.string() });
export function getUserMessageText(content: string | (TextContent | ImageContent)[]): string {
if (typeof content === "string") {
return content;
}
const textParts: string[] = [];
for (const block of content) {
if (PiTextContentSchema.safeParse(block).success) {
textParts.push((block as TextContent).text);
}
}
return textParts.join("\n\n");
}
@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { McpServerConfig } from "../../agent-sdk-types.js";
import {
type McpCallToolResult,
type McpConnection,
type McpConnectionFactory,
type McpToolInfo,
createMcpToolBridge,
mapMcpToolContent,
} from "./mcp-bridge.js";
interface FakeConnectionSpec {
tools?: McpToolInfo[];
listToolsError?: Error;
callResult?: McpCallToolResult;
}
function fakeConnection(spec: FakeConnectionSpec) {
const calls: { toolName: string; args: Record<string, unknown> }[] = [];
const closed = { value: false };
const connection: McpConnection = {
async listTools() {
if (spec.listToolsError) {
throw spec.listToolsError;
}
return spec.tools ?? [];
},
async callTool(toolName, args) {
calls.push({ toolName, args });
return spec.callResult ?? { content: [{ type: "text", text: "ok" }] };
},
async close() {
closed.value = true;
},
};
return { connection, calls, closed };
}
const HTTP_SERVER: McpServerConfig = { type: "http", url: "https://example.test/mcp" };
describe("mapMcpToolContent", () => {
it("maps text and image blocks, notes other kinds", () => {
const content = mapMcpToolContent({
content: [
{ type: "text", text: "hello" },
{ type: "image", data: "b64", mimeType: "image/png" },
{ type: "resource_link", uri: "file:///x" },
{ type: "audio", mimeType: "audio/wav" },
],
});
expect(content).toEqual([
{ type: "text", text: "hello" },
{ type: "image", data: "b64", mimeType: "image/png" },
{ type: "text", text: "[resource file:///x]" },
{ type: "text", text: "[audio audio/wav]" },
]);
});
});
describe("createMcpToolBridge", () => {
it("lists tools from each server as namespaced Pi tools", async () => {
const { connection } = fakeConnection({
tools: [
{ name: "do_thing", description: "does a thing", inputSchema: { type: "object" } },
{ name: "other", inputSchema: { type: "object" } },
],
});
const connect: McpConnectionFactory = async () => connection;
const bridge = await createMcpToolBridge({
mcpServers: { paseo: HTTP_SERVER },
logger: createTestLogger(),
connect,
});
expect(bridge.tools.map((t) => t.name)).toEqual(["paseo__do_thing", "paseo__other"]);
expect(bridge.tools[0]?.description).toBe("does a thing");
await bridge.close();
});
it("proxies execute to callTool and maps the result", async () => {
const { connection, calls } = fakeConnection({
tools: [{ name: "echo", inputSchema: { type: "object" } }],
callResult: { content: [{ type: "text", text: "pong" }] },
});
const bridge = await createMcpToolBridge({
mcpServers: { paseo: HTTP_SERVER },
logger: createTestLogger(),
connect: async () => connection,
});
const tool = bridge.tools[0];
const result = await tool.execute("call-1", { msg: "ping" }, undefined, undefined, {} as never);
expect(calls).toEqual([{ toolName: "echo", args: { msg: "ping" } }]);
expect(result.content).toEqual([{ type: "text", text: "pong" }]);
await bridge.close();
});
it("throws from execute when the MCP result is an error", async () => {
const { connection } = fakeConnection({
tools: [{ name: "boom", inputSchema: { type: "object" } }],
callResult: { isError: true, content: [{ type: "text", text: "kaboom" }] },
});
const bridge = await createMcpToolBridge({
mcpServers: { paseo: HTTP_SERVER },
logger: createTestLogger(),
connect: async () => connection,
});
await expect(
bridge.tools[0].execute("call-1", {}, undefined, undefined, {} as never),
).rejects.toThrow(/kaboom/);
await bridge.close();
});
it("skips a server whose listTools fails but still closes it on teardown", async () => {
const { connection, closed } = fakeConnection({ listToolsError: new Error("nope") });
const bridge = await createMcpToolBridge({
mcpServers: { paseo: HTTP_SERVER },
logger: createTestLogger(),
connect: async () => connection,
});
expect(bridge.tools).toHaveLength(0);
await bridge.close();
expect(closed.value).toBe(true);
});
it("skips a server that fails to connect without throwing", async () => {
const connect = vi.fn(async () => {
throw new Error("connect failed");
});
const bridge = await createMcpToolBridge({
mcpServers: { paseo: HTTP_SERVER },
logger: createTestLogger(),
connect,
});
expect(bridge.tools).toHaveLength(0);
expect(connect).toHaveBeenCalledTimes(1);
await bridge.close();
});
it("closes every connection on teardown", async () => {
const a = fakeConnection({ tools: [{ name: "t", inputSchema: { type: "object" } }] });
const b = fakeConnection({ tools: [{ name: "u", inputSchema: { type: "object" } }] });
const connect: McpConnectionFactory = async (serverName) =>
serverName === "a" ? a.connection : b.connection;
const bridge = await createMcpToolBridge({
mcpServers: { a: HTTP_SERVER, b: HTTP_SERVER },
logger: createTestLogger(),
connect,
});
await bridge.close();
expect(a.closed.value).toBe(true);
expect(b.closed.value).toBe(true);
});
it("produces no tools when there are no MCP servers", async () => {
const bridge = await createMcpToolBridge({ logger: createTestLogger() });
expect(bridge.tools).toHaveLength(0);
await bridge.close();
});
});
@@ -0,0 +1,245 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import type { Logger } from "pino";
import type { AgentToolResultLike, ToolDefinition } from "./pi-services.js";
import type { McpServerConfig } from "../../agent-sdk-types.js";
import { mcpInputSchemaToTypeBox } from "./mcp-schema.js";
// Provider-owned bridge that turns `AgentSessionConfig.mcpServers` into Pi
// `customTools`. It owns the MCP client lifecycle: connect on creation, expose tools,
// proxy execution, and tear down on session close. Network/transport construction is
// behind an injectable connection factory so the bridge is testable with a fake.
const MCP_CLIENT_NAME = "paseo-agent";
const MCP_CLIENT_VERSION = "0.1.0";
export interface McpToolInfo {
name: string;
description?: string;
inputSchema?: unknown;
}
interface McpContentBlock {
type: string;
text?: string;
data?: string;
mimeType?: string;
uri?: string;
resource?: { text?: string; uri?: string; mimeType?: string; blob?: string };
}
export interface McpCallToolResult {
content?: McpContentBlock[];
isError?: boolean;
structuredContent?: unknown;
}
/** A live connection to one MCP server. The default impl wraps the MCP SDK Client. */
export interface McpConnection {
listTools(): Promise<McpToolInfo[]>;
callTool(toolName: string, args: Record<string, unknown>): Promise<McpCallToolResult>;
close(): Promise<void>;
}
export type McpConnectionFactory = (
serverName: string,
config: McpServerConfig,
) => Promise<McpConnection>;
export interface McpToolBridge {
/** Pi custom tools for every successfully-listed MCP tool. */
tools: ToolDefinition[];
/** Close every MCP connection. Idempotent and best-effort. */
close(): Promise<void>;
}
function buildTransport(config: McpServerConfig): Transport {
switch (config.type) {
case "http":
return new StreamableHTTPClientTransport(
new URL(config.url),
config.headers ? { requestInit: { headers: config.headers } } : undefined,
);
case "sse":
return new SSEClientTransport(
new URL(config.url),
config.headers ? { requestInit: { headers: config.headers } } : undefined,
);
case "stdio":
return new StdioClientTransport({
command: config.command,
...(config.args ? { args: config.args } : {}),
...(config.env ? { env: config.env } : {}),
});
}
}
/** Default connection factory: a real MCP SDK client over the configured transport. */
async function connectWithSdk(
_serverName: string,
config: McpServerConfig,
): Promise<McpConnection> {
const client = new Client(
{ name: MCP_CLIENT_NAME, version: MCP_CLIENT_VERSION },
{ capabilities: {} },
);
await client.connect(buildTransport(config));
return {
async listTools() {
const result = await client.listTools();
return result.tools.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
}));
},
async callTool(toolName, args) {
return (await client.callTool({ name: toolName, arguments: args })) as McpCallToolResult;
},
async close() {
await client.close();
},
};
}
function mcpResultText(result: McpCallToolResult): string {
const parts: string[] = [];
for (const block of result.content ?? []) {
if (block.type === "text" && typeof block.text === "string") {
parts.push(block.text);
} else if (block.type === "resource" && typeof block.resource?.text === "string") {
parts.push(block.resource.text);
}
}
return parts.join("\n");
}
/**
* Map an MCP `CallToolResult` content array into Pi tool-result content. Text and image
* blocks map directly; other block kinds become a short text note so nothing is silently
* dropped. Pure and exported for testing.
*/
export function mapMcpToolContent(result: McpCallToolResult): (TextContent | ImageContent)[] {
const content: (TextContent | ImageContent)[] = [];
for (const block of result.content ?? []) {
switch (block.type) {
case "text":
content.push({ type: "text", text: block.text ?? "" });
break;
case "image":
if (block.data && block.mimeType) {
content.push({ type: "image", data: block.data, mimeType: block.mimeType });
}
break;
case "audio":
content.push({
type: "text",
text: `[audio${block.mimeType ? ` ${block.mimeType}` : ""}]`,
});
break;
case "resource":
if (typeof block.resource?.text === "string") {
content.push({ type: "text", text: block.resource.text });
} else if (block.resource?.uri) {
content.push({ type: "text", text: `[resource ${block.resource.uri}]` });
}
break;
case "resource_link":
if (block.uri) {
content.push({ type: "text", text: `[resource ${block.uri}]` });
}
break;
default:
content.push({ type: "text", text: JSON.stringify(block) });
}
}
return content;
}
function buildToolDefinition(
serverName: string,
info: McpToolInfo,
connection: McpConnection,
): ToolDefinition {
const toolName = `${serverName}__${info.name}`;
const description = info.description ?? info.name;
const definition: ToolDefinition = {
name: toolName,
label: info.name,
description,
promptSnippet: description,
parameters: mcpInputSchemaToTypeBox(info.inputSchema),
async execute(_toolCallId, params) {
const args = (params ?? {}) as Record<string, unknown>;
const result = await connection.callTool(info.name, args);
if (result.isError) {
// Throwing makes Pi mark the tool call failed and surface the message.
throw new Error(mcpResultText(result) || `MCP tool "${info.name}" reported an error`);
}
const toolResult: AgentToolResultLike = {
content: mapMcpToolContent(result),
details: result.structuredContent ?? null,
};
return toolResult;
},
};
return definition;
}
/**
* Connect to every configured MCP server, list its tools, and produce Pi custom tools.
* Servers that fail to connect or list are logged and skipped rather than failing the
* whole session. Call `close()` on session teardown.
*/
export async function createMcpToolBridge(options: {
mcpServers?: Record<string, McpServerConfig>;
logger: Logger;
connect?: McpConnectionFactory;
}): Promise<McpToolBridge> {
const connect = options.connect ?? connectWithSdk;
const connections: McpConnection[] = [];
const tools: ToolDefinition[] = [];
for (const [serverName, serverConfig] of Object.entries(options.mcpServers ?? {})) {
let connection: McpConnection;
try {
connection = await connect(serverName, serverConfig);
} catch (error) {
options.logger.warn({ err: error, mcpServer: serverName }, "Paseo Agent: MCP connect failed");
continue;
}
connections.push(connection);
try {
const toolInfos = await connection.listTools();
for (const info of toolInfos) {
tools.push(buildToolDefinition(serverName, info, connection));
}
} catch (error) {
options.logger.warn(
{ err: error, mcpServer: serverName },
"Paseo Agent: MCP listTools failed",
);
}
}
return {
tools,
async close() {
await Promise.all(
connections.map((connection) =>
connection
.close()
.catch((error) =>
options.logger.warn({ err: error }, "Paseo Agent: MCP connection close failed"),
),
),
);
},
};
}
@@ -0,0 +1,77 @@
import { Value } from "typebox/value";
import { describe, expect, it } from "vitest";
import { mcpInputSchemaToTypeBox } from "./mcp-schema.js";
describe("mcpInputSchemaToTypeBox", () => {
it("converts an object schema with required and optional properties", () => {
const schema = mcpInputSchemaToTypeBox({
type: "object",
properties: { a: { type: "string" }, b: { type: "number" } },
required: ["a"],
});
expect(Value.Check(schema, { a: "x" })).toBe(true);
expect(Value.Check(schema, { a: "x", b: 1 })).toBe(true);
expect(Value.Check(schema, { b: 1 })).toBe(false); // missing required "a"
expect(Value.Check(schema, { a: 1 })).toBe(false); // wrong type for "a"
});
it("converts enums to a closed set", () => {
const schema = mcpInputSchemaToTypeBox({
type: "object",
properties: { mode: { type: "string", enum: ["read", "write"] } },
required: ["mode"],
});
expect(Value.Check(schema, { mode: "read" })).toBe(true);
expect(Value.Check(schema, { mode: "delete" })).toBe(false);
});
it("converts arrays with typed items", () => {
const schema = mcpInputSchemaToTypeBox({
type: "object",
properties: { tags: { type: "array", items: { type: "number" } } },
required: ["tags"],
});
expect(Value.Check(schema, { tags: [1, 2] })).toBe(true);
expect(Value.Check(schema, { tags: ["x"] })).toBe(false);
});
it("converts nested objects", () => {
const schema = mcpInputSchemaToTypeBox({
type: "object",
properties: {
filter: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
},
},
required: ["filter"],
});
expect(Value.Check(schema, { filter: { name: "a" } })).toBe(true);
expect(Value.Check(schema, { filter: {} })).toBe(false);
});
it("accepts any object when the schema is missing or empty", () => {
expect(Value.Check(mcpInputSchemaToTypeBox(undefined), { anything: 1 })).toBe(true);
expect(Value.Check(mcpInputSchemaToTypeBox({}), { anything: 1 })).toBe(true);
});
it("treats a property-only schema (no declared type) as an object", () => {
const schema = mcpInputSchemaToTypeBox({
properties: { q: { type: "string" } },
required: ["q"],
});
expect(Value.Check(schema, { q: "hi" })).toBe(true);
expect(Value.Check(schema, {})).toBe(false);
});
it("preserves descriptions", () => {
const schema = mcpInputSchemaToTypeBox({
type: "object",
description: "the tool input",
properties: {},
}) as Record<string, unknown>;
expect(schema.description).toBe("the tool input");
});
});
@@ -0,0 +1,161 @@
import { Type, type TSchema } from "@earendil-works/pi-ai";
// JSON-Schema → TypeBox conversion for MCP tool input schemas. MCP advertises tool
// parameters as JSON Schema; Pi tool definitions expect a TypeBox `TSchema`. This
// covers the shapes common MCP servers emit (objects, primitives, arrays, enums,
// unions, required/optional, additionalProperties) and falls back to a permissive
// `Type.Unknown()` for anything it doesn't recognise, so unusual schemas degrade to
// "accept anything" rather than throwing.
type JsonSchemaRecord = Record<string, unknown>;
// Index signature lets these annotations satisfy TypeBox's option types directly.
interface SchemaAnnotations {
[key: PropertyKey]: unknown;
description?: string;
default?: unknown;
title?: string;
}
function isRecord(value: unknown): value is JsonSchemaRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function annotationsOf(schema: JsonSchemaRecord): SchemaAnnotations {
const annotations: SchemaAnnotations = {};
if (typeof schema.description === "string") {
annotations.description = schema.description;
}
if (typeof schema.title === "string") {
annotations.title = schema.title;
}
if ("default" in schema) {
annotations.default = schema.default;
}
return annotations;
}
function literalOf(value: unknown): TSchema {
if (value === null) {
return Type.Null();
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return Type.Literal(value);
}
// Non-literal enum members (objects/arrays) can't be TypeBox literals.
return Type.Unknown();
}
function unionOf(members: TSchema[], annotations: SchemaAnnotations): TSchema {
if (members.length === 0) {
return Type.Unknown(annotations);
}
if (members.length === 1) {
return members[0];
}
return Type.Union(members, annotations);
}
function convertByType(
type: string,
schema: JsonSchemaRecord,
annotations: SchemaAnnotations,
): TSchema {
switch (type) {
case "string":
return Type.String(annotations);
case "number":
return Type.Number(annotations);
case "integer":
return Type.Integer(annotations);
case "boolean":
return Type.Boolean(annotations);
case "null":
return Type.Null(annotations);
case "array": {
const items = isRecord(schema.items) ? convertSchema(schema.items) : Type.Unknown();
return Type.Array(items, annotations);
}
case "object":
return convertObject(schema, annotations);
default:
return Type.Unknown(annotations);
}
}
function convertObject(schema: JsonSchemaRecord, annotations: SchemaAnnotations): TSchema {
const properties = isRecord(schema.properties) ? schema.properties : {};
const required = new Set(
Array.isArray(schema.required)
? schema.required.filter((name): name is string => typeof name === "string")
: [],
);
const fields: Record<string, TSchema> = {};
for (const [key, propSchema] of Object.entries(properties)) {
const converted = isRecord(propSchema) ? convertSchema(propSchema) : Type.Unknown();
fields[key] = required.has(key) ? converted : Type.Optional(converted);
}
const objectOptions: Record<string, unknown> = { ...annotations };
const additional = schema.additionalProperties;
if (additional === false || additional === true) {
objectOptions.additionalProperties = additional;
} else if (isRecord(additional)) {
objectOptions.additionalProperties = convertSchema(additional);
}
return Type.Object(fields, objectOptions);
}
function convertSchema(schema: JsonSchemaRecord): TSchema {
const annotations = annotationsOf(schema);
if (Array.isArray(schema.enum)) {
return unionOf(schema.enum.map(literalOf), annotations);
}
const composite = schema.anyOf ?? schema.oneOf;
if (Array.isArray(composite)) {
const members = composite.filter(isRecord).map(convertSchema);
return unionOf(members, annotations);
}
if (Array.isArray(schema.type)) {
const members = schema.type
.filter((t): t is string => typeof t === "string")
.map((t) => convertByType(t, schema, annotations));
return unionOf(members, annotations);
}
if (typeof schema.type === "string") {
return convertByType(schema.type, schema, annotations);
}
// No declared type: treat as an object when properties are present, else accept anything.
if (isRecord(schema.properties)) {
return convertObject(schema, annotations);
}
return Type.Unknown(annotations);
}
/**
* Convert an MCP tool `inputSchema` (JSON Schema) into a TypeBox schema for a Pi tool
* definition. Always returns an object schema at the top level so tool parameters are
* a well-formed object, even when the server omits or malforms the schema.
*/
export function mcpInputSchemaToTypeBox(inputSchema: unknown): TSchema {
if (!isRecord(inputSchema)) {
return Type.Object({}, { additionalProperties: true });
}
const { type } = inputSchema;
// MCP tool parameters are objects. Convert object schemas faithfully; for a bare
// schema with neither a declared object type nor properties, accept any object.
if (type === "object" || isRecord(inputSchema.properties)) {
return convertObject(inputSchema, annotationsOf(inputSchema));
}
// No declared type, or a non-object top-level type (unusual for tool params):
// accept any object so the tool stays callable.
return Type.Object({}, { additionalProperties: true });
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
isRefreshTokenExpressionConfigured,
resolveRefreshTokenExpression,
} from "./oauth-credentials.js";
describe("resolveRefreshTokenExpression", () => {
it("returns a literal value", () => {
expect(resolveRefreshTokenExpression("rt-literal", {})).toBe("rt-literal");
});
it("resolves an env reference and returns undefined when unset", () => {
expect(resolveRefreshTokenExpression("$CODEX_RT", { CODEX_RT: "rt-env" })).toBe("rt-env");
expect(resolveRefreshTokenExpression("${CODEX_RT}", { CODEX_RT: "rt-env" })).toBe("rt-env");
expect(resolveRefreshTokenExpression("$CODEX_RT", {})).toBeUndefined();
});
it("runs a !command and returns its trimmed output", () => {
expect(resolveRefreshTokenExpression("!printf rt-cmd", {})).toBe("rt-cmd");
expect(resolveRefreshTokenExpression("!exit 1", {})).toBeUndefined();
});
});
describe("isRefreshTokenExpressionConfigured", () => {
it("is true for a literal and a set env var, false for an unset env var", () => {
expect(isRefreshTokenExpressionConfigured("rt", {})).toBe(true);
expect(isRefreshTokenExpressionConfigured("$RT", { RT: "x" })).toBe(true);
expect(isRefreshTokenExpressionConfigured("$RT", {})).toBe(false);
});
it("assumes a !command is runnable without executing it", () => {
expect(isRefreshTokenExpressionConfigured("!exit 1", {})).toBe(true);
});
});
@@ -0,0 +1,71 @@
import { execSync } from "node:child_process";
// Resolution of a *self-supplied* OAuth refresh token expression — a literal, an env
// reference (`$VAR` / `${VAR}`), or a `!command` that prints the token. This is an
// advanced/manual escape hatch for users who already hold their own ChatGPT/Codex
// refresh token; the product path is `paseo login chatgpt`, which performs browser
// OAuth by default and writes a Paseo-owned credential store (see oauth-store.ts).
//
// This module deliberately does NOT read any other tool's auth files (Codex CLI,
// OpenCode, Pi, etc.) and imports no Pi runtime code. Token values are never logged.
const ENV_REFERENCE_PATTERN = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g;
const ENV_REFERENCE_DETECT = /\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/;
/** Substitute `$VAR` / `${VAR}` references. Returns undefined if any var is unset. */
function substituteEnv(value: string, env: NodeJS.ProcessEnv): string | undefined {
let missing = false;
const result = value.replace(ENV_REFERENCE_PATTERN, (_match, name: string) => {
const resolved = env[name];
if (resolved === undefined) {
missing = true;
return "";
}
return resolved;
});
return missing ? undefined : result;
}
/**
* Resolve a refresh-token expression to its literal value (may run a `!command`).
* Returns undefined when it can't be resolved.
*/
export function resolveRefreshTokenExpression(
value: string,
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
if (value.startsWith("!")) {
const command = value.slice(1).trim();
if (!command) {
return undefined;
}
try {
const output = execSync(command, { encoding: "utf8", env }).trim();
return output.length > 0 ? output : undefined;
} catch {
return undefined;
}
}
if (ENV_REFERENCE_DETECT.test(value)) {
const substituted = substituteEnv(value, env);
return substituted && substituted.length > 0 ? substituted : undefined;
}
return value.length > 0 ? value : undefined;
}
/**
* Cheap check: could this refresh-token expression yield a value without running a
* command? `!command` is assumed runnable; env refs require their vars to be set.
*/
export function isRefreshTokenExpressionConfigured(
value: string,
env: NodeJS.ProcessEnv = process.env,
): boolean {
if (value.startsWith("!")) {
return true;
}
if (ENV_REFERENCE_DETECT.test(value)) {
return substituteEnv(value, env) !== undefined;
}
return value.length > 0;
}
@@ -0,0 +1,129 @@
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 {
hasStoredOAuthCredential,
loginAndStoreCodex,
loginAndStoreCodexBrowser,
paseoAgentAuthStoragePath,
} from "./oauth-store.js";
describe("oauth-store", () => {
let home: string;
let env: NodeJS.ProcessEnv;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "paseo-oauth-store-"));
env = { PASEO_HOME: home };
});
afterEach(() => {
rmSync(home, { recursive: true, force: true });
});
it("derives the store path from PASEO_HOME", () => {
expect(paseoAgentAuthStoragePath(env)).toBe(join(home, "paseo-agent", "auth.json"));
});
it("reports no stored credential before login", () => {
expect(hasStoredOAuthCredential("chatgpt", env)).toBe(false);
});
it("runs the Pi login helper and persists a Paseo-owned credential", async () => {
const loginCalls: string[] = [];
const deviceCodes: unknown[] = [];
const login = async (opts: { onDeviceCode: (info: unknown) => void }) => {
loginCalls.push("called");
opts.onDeviceCode({
userCode: "ABCD-EFGH",
verificationUri: "https://auth.openai.com/codex/device",
intervalSeconds: 5,
expiresInSeconds: 900,
});
return { refresh: "rt-from-login", access: "ac", expires: 123, accountId: "acct" };
};
const { path } = await loginAndStoreCodex({
providerInstance: "chatgpt",
env,
onDeviceCode: (info) => deviceCodes.push(info),
login,
});
expect(loginCalls).toEqual(["called"]);
expect(deviceCodes).toEqual([expect.objectContaining({ userCode: "ABCD-EFGH" })]);
expect(path).toBe(join(home, "paseo-agent", "auth.json"));
// Credential persisted to the Paseo-owned store (not any foreign file).
expect(hasStoredOAuthCredential("chatgpt", env)).toBe(true);
const stored = JSON.parse(readFileSync(path, "utf8"));
expect(stored.chatgpt).toMatchObject({ type: "oauth", refresh: "rt-from-login" });
// Stored private (0600).
expect(statSync(path).mode & 0o777).toBe(0o600);
});
it("keys the credential by provider instance name", async () => {
const login = async () => ({ refresh: "rt", access: "", expires: 0 });
await loginAndStoreCodex({
providerInstance: "work-chatgpt",
env,
onDeviceCode: () => {},
login,
});
expect(hasStoredOAuthCredential("work-chatgpt", env)).toBe(true);
expect(hasStoredOAuthCredential("chatgpt", env)).toBe(false);
});
it("browser login surfaces the auth URL and persists a Paseo-owned credential", async () => {
const authUrls: Array<[string, string | undefined]> = [];
const loginCalls: string[] = [];
// Fake Pi browser-login helper: emits an auth URL, returns credentials.
const login = async (opts: { onAuth: (info: { url: string }) => void }) => {
loginCalls.push("called");
opts.onAuth({ url: "https://auth.openai.com/oauth/authorize?x=1" });
return { refresh: "rt-browser", access: "ac", expires: 456, accountId: "acct" };
};
const { path } = await loginAndStoreCodexBrowser({
providerInstance: "chatgpt",
env,
onAuthUrl: (url, instructions) => authUrls.push([url, instructions]),
login,
});
expect(loginCalls).toEqual(["called"]);
expect(authUrls).toEqual([["https://auth.openai.com/oauth/authorize?x=1", undefined]]);
expect(path).toBe(join(home, "paseo-agent", "auth.json"));
expect(hasStoredOAuthCredential("chatgpt", env)).toBe(true);
const stored = JSON.parse(readFileSync(path, "utf8"));
expect(stored.chatgpt).toMatchObject({ type: "oauth", refresh: "rt-browser" });
expect(statSync(path).mode & 0o777).toBe(0o600);
});
it("browser login falls back to manual code entry only when the callback can't complete", async () => {
const prompts: string[] = [];
const promptForCode = async (message: string) => {
prompts.push(message);
return "pasted-code";
};
// Fake helper that cannot complete via callback and invokes onPrompt.
const login = async (opts: { onPrompt: (p: { message: string }) => Promise<string> }) => {
const code = await opts.onPrompt({ message: "Paste the code:" });
expect(code).toBe("pasted-code");
return { refresh: "rt-manual", access: "", expires: 0 };
};
await loginAndStoreCodexBrowser({
providerInstance: "chatgpt",
env,
onAuthUrl: () => {},
promptForCode,
login,
});
expect(prompts).toEqual(["Paste the code:"]);
expect(hasStoredOAuthCredential("chatgpt", env)).toBe(true);
});
});
@@ -0,0 +1,124 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { AuthStorage } from "@earendil-works/pi-coding-agent";
import type { OAuthCredentials } from "@earendil-works/pi-ai";
import { loginOpenAICodex, loginOpenAICodexDeviceCode } from "@earendil-works/pi-ai/oauth";
// Paseo-owned OAuth credential store for the Paseo Agent provider. Credentials live
// in a Paseo-controlled file (NOT ~/.pi, ~/.codex, OpenCode, or any other tool's
// store) and are managed through Pi's own AuthStorage, so Pi refreshes tokens and
// persists rotation back into Paseo's file. The login flows reuse Pi's OAuth helpers
// (browser PKCE/callback by default, device-code as a headless fallback) — Paseo does
// not reimplement the OAuth protocol.
export interface CodexDeviceCodeInfo {
userCode: string;
verificationUri: string;
intervalSeconds: number;
expiresInSeconds: number;
}
type DeviceCodeLogin = (options: {
onDeviceCode: (info: CodexDeviceCodeInfo) => void;
signal?: AbortSignal;
}) => Promise<OAuthCredentials>;
type BrowserLogin = (options: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: { message: string }) => Promise<string>;
onProgress?: (message: string) => void;
}) => Promise<OAuthCredentials>;
/** Path to the Paseo-owned auth store. Uses PASEO_HOME; falls back to ~/.paseo. */
export function paseoAgentAuthStoragePath(env: NodeJS.ProcessEnv = process.env): string {
const base = env.PASEO_HOME ?? join(homedir(), ".paseo");
return join(base, "paseo-agent", "auth.json");
}
/**
* Pi AuthStorage backed by the Paseo-owned file. Pi creates the parent dir (0700) and
* the file (0600) and re-chmods on every write, so refreshed tokens stay private.
*/
export function createPaseoAgentAuthStorage(env: NodeJS.ProcessEnv = process.env): AuthStorage {
return AuthStorage.create(paseoAgentAuthStoragePath(env));
}
/**
* Read-only check (no file creation) for whether a Paseo-owned OAuth credential exists
* for a provider instance. Used for availability without constructing AuthStorage.
*/
export function hasStoredOAuthCredential(
providerInstance: string,
env: NodeJS.ProcessEnv = process.env,
): boolean {
const path = paseoAgentAuthStoragePath(env);
if (!existsSync(path)) {
return false;
}
try {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (typeof parsed !== "object" || parsed === null) {
return false;
}
const entry = (parsed as Record<string, unknown>)[providerInstance];
return (
typeof entry === "object" && entry !== null && (entry as { type?: unknown }).type === "oauth"
);
} catch {
return false;
}
}
/**
* Run Pi's ChatGPT/Codex device-code OAuth login and persist the resulting credential
* into the Paseo-owned store under `providerInstance`. The `login` dependency defaults
* to Pi's helper and is injectable for tests (no network). Never reads foreign files.
*/
export async function loginAndStoreCodex(options: {
providerInstance: string;
onDeviceCode: (info: CodexDeviceCodeInfo) => void;
env?: NodeJS.ProcessEnv;
signal?: AbortSignal;
login?: DeviceCodeLogin;
}): Promise<{ path: string }> {
const login = options.login ?? (loginOpenAICodexDeviceCode as DeviceCodeLogin);
const credentials = await login({ onDeviceCode: options.onDeviceCode, signal: options.signal });
const path = paseoAgentAuthStoragePath(options.env);
const authStorage = AuthStorage.create(path);
authStorage.set(options.providerInstance, { type: "oauth", ...credentials });
return { path };
}
/**
* Run Pi's ChatGPT/Codex **browser** OAuth login (PKCE + local callback on
* 127.0.0.1:1455) and persist the resulting credential into the Paseo-owned store.
* This is the default, first-class login UX. `onAuthUrl` receives the authorization
* URL (the caller opens it / prints it); `promptForCode` is a fallback used only if
* the browser callback can't complete (manual code paste). The `login` dependency
* defaults to Pi's helper and is injectable for tests. Never reads foreign files.
*/
export async function loginAndStoreCodexBrowser(options: {
providerInstance: string;
onAuthUrl: (url: string, instructions?: string) => void;
promptForCode?: (message: string) => Promise<string>;
onProgress?: (message: string) => void;
env?: NodeJS.ProcessEnv;
login?: BrowserLogin;
}): Promise<{ path: string }> {
const login = options.login ?? (loginOpenAICodex as BrowserLogin);
const credentials = await login({
onAuth: (info) => options.onAuthUrl(info.url, info.instructions),
onProgress: options.onProgress,
onPrompt: async (prompt) => {
if (!options.promptForCode) {
throw new Error("Browser login did not complete and no manual code entry was available.");
}
return options.promptForCode(prompt.message);
},
});
const path = paseoAgentAuthStoragePath(options.env);
const authStorage = AuthStorage.create(path);
authStorage.set(options.providerInstance, { type: "oauth", ...credentials });
return { path };
}
@@ -0,0 +1,205 @@
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { AuthStorage } from "@earendil-works/pi-coding-agent";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
type CreatePaseoAgentSessionOptions,
type PaseoAgentInferenceProvider,
createPaseoAgentSession,
} from "./pi-services.js";
function codexInferenceProvider(): PaseoAgentInferenceProvider {
return {
name: "chatgpt",
config: {
baseUrl: "https://chatgpt.com/backend-api",
api: "openai-codex-responses",
models: [
{
id: "gpt-5.3-codex",
name: "gpt-5.3-codex",
api: "openai-codex-responses",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16384,
},
],
},
};
}
const FAKE_PROVIDER = "paseo-test-openrouter";
const FAKE_MODEL_ID = "test-model";
function fakeInferenceProvider(): PaseoAgentInferenceProvider {
return {
name: FAKE_PROVIDER,
config: {
baseUrl: "https://example.invalid/v1",
apiKey: "sk-in-memory-only",
api: "openai-completions",
models: [
{
id: FAKE_MODEL_ID,
name: "Paseo Test Model",
api: "openai-completions",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16384,
},
],
},
};
}
describe("createPaseoAgentSession (no-discovery spike)", () => {
let cwd: string;
let agentDir: string;
let fakeHome: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), "paseo-agent-cwd-"));
agentDir = join(mkdtempSync(join(tmpdir(), "paseo-agent-dir-")), "agent");
fakeHome = mkdtempSync(join(tmpdir(), "paseo-agent-home-"));
// Redirect HOME so any accidental ~/.pi discovery would land in fakeHome and be detectable.
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
process.env.HOME = fakeHome;
process.env.USERPROFILE = fakeHome;
});
afterEach(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = originalUserProfile;
for (const dir of [cwd, fakeHome, agentDir]) {
rmSync(dir, { recursive: true, force: true });
}
});
function baseOptions(): CreatePaseoAgentSessionOptions {
return {
cwd,
agentDir,
inferenceProviders: [fakeInferenceProvider()],
model: { provider: FAKE_PROVIDER, id: FAKE_MODEL_ID },
};
}
it("creates a session from an in-memory inference provider and selects its model", async () => {
const { session, modelRegistry } = await createPaseoAgentSession(baseOptions());
expect(session).toBeDefined();
expect(session.model?.provider).toBe(FAKE_PROVIDER);
expect(session.model?.id).toBe(FAKE_MODEL_ID);
// The in-memory model is the only one reachable with configured auth.
const available = modelRegistry.getAvailable();
expect(available.some((m) => m.provider === FAKE_PROVIDER && m.id === FAKE_MODEL_ID)).toBe(
true,
);
});
it("performs no Pi resource discovery", async () => {
const { resourceLoader } = await createPaseoAgentSession(baseOptions());
expect(resourceLoader.getSkills().skills).toHaveLength(0);
expect(resourceLoader.getExtensions().extensions).toHaveLength(0);
expect(resourceLoader.getPrompts().prompts).toHaveLength(0);
});
it("uses an in-memory session manager with no on-disk session file", async () => {
const { sessionManager } = await createPaseoAgentSession(baseOptions());
expect(sessionManager.getSessionFile()).toBeUndefined();
});
it("touches no ~/.pi config and writes nothing to the isolated agentDir", async () => {
await createPaseoAgentSession(baseOptions());
// No discovery against the redirected home directory: if Pi resolved its
// default agentDir (~/.pi/agent) it would create or read it under fakeHome.
expect(existsSync(join(fakeHome, ".pi"))).toBe(false);
// Nothing persisted to the Paseo-owned isolated agentDir.
const agentDirContents = existsSync(agentDir) ? readdirSync(agentDir) : [];
expect(agentDirContents).toHaveLength(0);
// No session/auth/model files leaked into the cwd either.
expect(existsSync(join(cwd, ".pi"))).toBe(false);
});
it("rejects a model that no inference provider registered", async () => {
await expect(
createPaseoAgentSession({
...baseOptions(),
inferenceProviders: [],
}),
).rejects.toThrow(/not registered/);
});
it("activates supplied custom tools alongside the built-in tools", async () => {
const { session } = await createPaseoAgentSession({
...baseOptions(),
customTools: [
{
name: "paseo__demo",
label: "demo",
description: "demo tool",
parameters: { type: "object" } as never,
async execute() {
return { content: [{ type: "text", text: "ok" }], details: null };
},
},
],
});
const active = session.getActiveToolNames();
expect(active).toContain("paseo__demo");
// Built-in tools remain active too.
expect(active).toContain("bash");
});
it("registers a codex provider and seeds the advanced refresh-token override", async () => {
const codex = codexInferenceProvider();
const { session, modelRegistry } = await createPaseoAgentSession({
cwd,
agentDir,
model: { provider: "chatgpt", id: "gpt-5.3-codex" },
inferenceProviders: [
{ ...codex, oauth: { kind: "openai-codex", refreshToken: "rt-test-only" } },
],
});
expect(session.model?.provider).toBe("chatgpt");
expect(modelRegistry.find("chatgpt", "gpt-5.3-codex")?.api).toBe("openai-codex-responses");
const available = modelRegistry.getAvailable();
expect(available.some((m) => m.provider === "chatgpt" && m.id === "gpt-5.3-codex")).toBe(true);
});
it("loads a codex credential from a Paseo-owned AuthStorage (product path)", async () => {
// Simulate the result of `paseo login chatgpt`: a credential already in the store.
const authPath = join(mkdtempSync(join(tmpdir(), "paseo-agent-auth-")), "auth.json");
const authStorage = AuthStorage.create(authPath);
authStorage.set("chatgpt", { type: "oauth", access: "", refresh: "rt-stored", expires: 0 });
const { modelRegistry } = await createPaseoAgentSession({
cwd,
agentDir,
authStorage,
model: { provider: "chatgpt", id: "gpt-5.3-codex" },
// No oauth.refreshToken marker — the credential comes from the Paseo store.
inferenceProviders: [{ ...codexInferenceProvider(), oauth: { kind: "openai-codex" } }],
});
const available = modelRegistry.getAvailable();
expect(available.some((m) => m.provider === "chatgpt" && m.id === "gpt-5.3-codex")).toBe(true);
rmSync(authPath, { force: true });
});
});
@@ -0,0 +1,195 @@
import {
AuthStorage,
type AgentSession as PiAgentSession,
DefaultResourceLoader,
ModelRegistry,
type ResourceLoader,
SessionManager,
SettingsManager,
type ToolDefinition,
createAgentSession,
} from "@earendil-works/pi-coding-agent";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import { openaiCodexOAuthProvider } from "@earendil-works/pi-ai/oauth";
// Re-export the Pi tool contract so the MCP bridge can build custom tools without
// importing the Pi SDK type names itself.
export type { ToolDefinition };
/** Shape a Pi custom tool's `execute` must return (subset of Pi's AgentToolResult). */
export interface AgentToolResultLike {
content: (TextContent | ImageContent)[];
details: unknown;
terminate?: boolean;
}
// The single seam between Paseo and Pi's in-process harness. Every `@earendil-works/*`
// import and all no-discovery service construction lives here so the rest of the
// Paseo Agent provider never touches Pi's disk-backed config, auth, or sessions.
// ProviderConfigInput is not re-exported from the package index, so derive it from
// the public `registerProvider` signature.
export type PiProviderConfig = Parameters<ModelRegistry["registerProvider"]>[1];
type PiAuthData = Parameters<typeof AuthStorage.inMemory>[0];
type PiSettings = Parameters<typeof SettingsManager.inMemory>[0];
/** OAuth wiring for an inference provider (currently only ChatGPT/Codex). */
export interface PaseoAgentOAuth {
kind: "openai-codex";
/**
* Advanced/manual override: an already-resolved refresh token to seed into the auth
* store. Omitted on the product path, where the credential already lives in the
* Paseo-owned store (populated by login).
*/
refreshToken?: string;
}
export interface PaseoAgentInferenceProvider {
/** Instance name, e.g. "openrouter-main". Used as the Pi provider key. */
name: string;
/** Typed Pi provider config: baseUrl, apiKey, models, api, etc. */
config: PiProviderConfig;
/** When present, register an OAuth provider and seed an in-memory credential. */
oauth?: PaseoAgentOAuth;
}
export interface PaseoAgentModelReference {
provider: string;
id: string;
}
export interface CreatePaseoAgentSessionOptions {
/** Working directory for the agent. */
cwd: string;
/**
* Isolated, Paseo-owned global config directory. Never `~/.pi`. Used only to
* satisfy Pi's path math; all services below are in-memory so nothing is read
* from or written to it during creation.
*/
agentDir: string;
/** Inference providers (model backends) registered entirely in memory. */
inferenceProviders: PaseoAgentInferenceProvider[];
/** Explicit model selection. When omitted, Pi falls back to its own resolution. */
model?: PaseoAgentModelReference;
thinkingLevel?: ThinkingLevel;
/** In-memory credential seed, if any provider auth is keyed by AuthStorage. */
auth?: PiAuthData;
/**
* Pi AuthStorage to use. Defaults to a fresh in-memory store. The Paseo Agent
* provider passes a file-backed, Paseo-owned store for OAuth providers so Pi can
* refresh tokens and persist rotation. Any oauth markers carrying a refresh token
* are still seeded into whichever store is used.
*/
authStorage?: AuthStorage;
/** In-memory settings overrides. Empty by default. */
settings?: PiSettings;
/** Paseo-bridged tools (e.g. MCP) to register alongside built-in tools. */
customTools?: ToolDefinition[];
}
export interface PaseoAgentSessionHandle {
session: PiAgentSession;
modelRegistry: ModelRegistry;
resourceLoader: ResourceLoader;
sessionManager: SessionManager;
}
/**
* Build a fully Paseo-controlled Pi `ResourceLoader` that performs no discovery.
*
* Discovery only happens inside `reload()`; the constructor initialises valid empty
* state. We never call `reload()`, and the `no*` flags ensure that even an accidental
* reload would not scan `~/.pi`, the project, or the cwd.
*/
function createNoDiscoveryResourceLoader(options: {
cwd: string;
agentDir: string;
settingsManager: SettingsManager;
}): ResourceLoader {
return new DefaultResourceLoader({
cwd: options.cwd,
agentDir: options.agentDir,
settingsManager: options.settingsManager,
noExtensions: true,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
});
}
/**
* Create a Pi agent session through the high-level `createAgentSession` API with
* every service supplied in-memory and no Pi config discovery.
*/
export async function createPaseoAgentSession(
options: CreatePaseoAgentSessionOptions,
): Promise<PaseoAgentSessionHandle> {
// Use the caller's Paseo-owned store when provided (so Pi refreshes + persists token
// rotation there), else a fresh in-memory store.
const authStorage = options.authStorage ?? AuthStorage.inMemory({ ...options.auth });
// Seed any oauth marker that carries a refresh token (the advanced/manual override).
// The product path leaves this empty — the credential is already in the Paseo store.
// Empty `access` + `expires: 0` forces a refresh on the first request.
for (const provider of options.inferenceProviders) {
if (provider.oauth?.kind === "openai-codex" && provider.oauth.refreshToken) {
authStorage.set(provider.name, {
type: "oauth",
access: "",
refresh: provider.oauth.refreshToken,
expires: 0,
});
}
}
const modelRegistry = ModelRegistry.inMemory(authStorage);
for (const provider of options.inferenceProviders) {
const config =
provider.oauth?.kind === "openai-codex"
? { ...provider.config, oauth: openaiCodexOAuthProvider }
: provider.config;
modelRegistry.registerProvider(provider.name, config);
}
const settingsManager = SettingsManager.inMemory(options.settings ?? {});
const sessionManager = SessionManager.inMemory(options.cwd);
const resourceLoader = createNoDiscoveryResourceLoader({
cwd: options.cwd,
agentDir: options.agentDir,
settingsManager,
});
const model = options.model
? modelRegistry.find(options.model.provider, options.model.id)
: undefined;
if (options.model && !model) {
throw new Error(
`Paseo Agent: model ${options.model.provider}/${options.model.id} is not registered by any inference provider`,
);
}
const { session } = await createAgentSession({
cwd: options.cwd,
agentDir: options.agentDir,
authStorage,
modelRegistry,
settingsManager,
sessionManager,
resourceLoader,
...(model ? { model } : {}),
...(options.thinkingLevel ? { thinkingLevel: options.thinkingLevel } : {}),
...(options.customTools ? { customTools: options.customTools } : {}),
});
// Custom (MCP) tools are registered but not active by default — only the built-in
// tool set is. Activate them so the model can actually call them.
if (options.customTools && options.customTools.length > 0) {
const customToolNames = options.customTools.map((tool) => tool.name);
session.setActiveToolsByName([...session.getActiveToolNames(), ...customToolNames]);
}
return { session, modelRegistry, resourceLoader, sessionManager };
}
+3
View File
@@ -144,6 +144,7 @@ import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
} from "./agent/provider-launch-config.js";
import type { PaseoAgentConfig } from "./agent/providers/paseo-agent/config.js";
import type { PersistedConfig } from "./persisted-config.js";
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js";
import { ScriptHealthMonitor } from "./script-health-monitor.js";
@@ -373,6 +374,7 @@ export interface PaseoDaemonConfig {
}>;
};
providerOverrides?: Record<string, ProviderOverride>;
paseoAgentConfig?: PaseoAgentConfig;
log?: PersistedConfig["log"];
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
pushNotificationSender?: PushNotificationSender;
@@ -739,6 +741,7 @@ export async function createPaseoDaemon(
logger: providerSnapshotLogger,
runtimeSettings: config.agentProviderSettings,
providerOverrides: config.providerOverrides,
paseoAgentConfig: config.paseoAgentConfig,
workspaceGitService,
managedProcesses,
isDev: config.isDev === true,
+1
View File
@@ -505,6 +505,7 @@ export function loadConfig(
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
metadataGeneration: persisted.agents?.metadataGeneration,
providerOverrides,
paseoAgentConfig: persisted.agents?.paseo,
log: resolveLogConfigFromEnv(env, persisted),
};
}
+8
View File
@@ -48,6 +48,14 @@ export {
type SherpaLoaderEnvResolution,
} from "./speech/providers/local/sherpa/sherpa-runtime-env.js";
// Paseo Agent (ChatGPT/Codex) OAuth login + Paseo-owned credential store
export {
loginAndStoreCodexBrowser,
loginAndStoreCodex,
paseoAgentAuthStoragePath,
type CodexDeviceCodeInfo,
} from "./agent/providers/paseo-agent/oauth-store.js";
// Provider binary resolution
export {
type ProviderOverride,
@@ -241,6 +241,30 @@ describe("PersistedConfigSchema agent provider runtime settings", () => {
],
});
});
test("accepts the dedicated agents.paseo inference provider config", () => {
const parsed = PersistedConfigSchema.parse({
agents: {
paseo: {
defaultModel: "openrouter-main/anthropic/claude",
providers: {
"openrouter-main": {
type: "openrouter",
options: {
baseUrl: "https://openrouter.ai/api/v1",
apiKey: "sk-test",
api: "openai-completions",
models: [{ id: "anthropic/claude", label: "Claude" }],
},
},
},
},
},
});
expect(parsed.agents?.paseo?.defaultModel).toBe("openrouter-main/anthropic/claude");
expect(parsed.agents?.paseo?.providers?.["openrouter-main"]?.type).toBe("openrouter");
});
});
describe("provider overrides (new format)", () => {
@@ -8,6 +8,7 @@ import {
ProviderOverridesSchema,
} from "./agent/provider-launch-config.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { PaseoAgentConfigSchema } from "./agent/providers/paseo-agent/config.js";
import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js";
import { TerminalProfileSchema } from "@getpaseo/protocol/messages";
@@ -299,6 +300,7 @@ export const PersistedConfigSchema = z
.object({
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),
metadataGeneration: AgentMetadataGenerationSchema.optional(),
paseo: PaseoAgentConfigSchema.optional(),
})
.strict()
.optional(),
@@ -321,6 +321,104 @@
},
"additionalProperties": false
}
},
"paseo": {
"type": "object",
"properties": {
"defaultModel": {
"type": "string",
"minLength": 1
},
"providers": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"openrouter",
"openai",
"anthropic",
"opencode",
"openai-compatible",
"openai-codex",
"custom"
]
},
"options": {
"type": "object",
"properties": {
"apiKey": {
"type": "string",
"minLength": 1
},
"baseUrl": {
"type": "string",
"format": "uri"
},
"api": {
"type": "string",
"minLength": 1
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"authHeader": {
"type": "boolean"
},
"refreshToken": {
"type": "string",
"minLength": 1
},
"models": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"label": {
"type": "string",
"minLength": 1
},
"api": {
"type": "string",
"minLength": 1
},
"reasoning": {
"type": "boolean"
},
"contextWindow": {
"type": "integer",
"exclusiveMinimum": 0
},
"maxTokens": {
"type": "integer",
"exclusiveMinimum": 0
}
},
"required": ["id"],
"additionalProperties": false
},
"minItems": 1
}
},
"required": ["models"],
"additionalProperties": false
}
},
"required": ["type", "options"],
"additionalProperties": false
}
}
},
"additionalProperties": false
}
},
"additionalProperties": false