mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
Add Paseo Agent provider
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -505,6 +505,7 @@ export function loadConfig(
|
||||
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
|
||||
metadataGeneration: persisted.agents?.metadataGeneration,
|
||||
providerOverrides,
|
||||
paseoAgentConfig: persisted.agents?.paseo,
|
||||
log: resolveLogConfigFromEnv(env, persisted),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user