Compare commits

..
Author SHA1 Message Date
Mohamed Boudra e1c786e70b feat(app): store assistant image previews as assets 2026-04-22 22:23:10 +07:00
32 changed files with 230 additions and 1128 deletions
-11
View File
@@ -1,16 +1,5 @@
# Changelog
## 0.1.61 - 2026-04-23
### Added
- `additionalModels` option in provider config lets you add or relabel models without replacing the full list — entries merge with runtime-discovered models (ACP) or your static `models` list. See the [Providers docs](https://paseo.sh/docs/providers).
- New [Providers docs page](https://paseo.sh/docs/providers) covering first-class providers and every custom provider config pattern in one place.
### Improved
- Pi loads your installed extensions on startup so their models show up in the model picker.
- Resizing the explorer sidebar no longer rerenders the rest of the workspace.
- Images in assistant messages (both file paths and inline data URLs) persist as local attachments and open in the file pane.
## 0.1.60 - 2026-04-22
### Added
-43
View File
@@ -424,48 +424,6 @@ Models and modes are discovered dynamically at runtime from the agent process. I
Profile models (defined in config.json) completely replace runtime-discovered models when present.
If you want to keep runtime-discovered models and add or relabel a few entries, use `additionalModels` instead.
Example: add an experimental model while keeping every model the provider discovers at runtime:
```json
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "--acp"],
"additionalModels": [
{ "id": "experimental-model", "label": "Experimental", "isDefault": true }
]
}
}
}
}
```
Example: relabel a discovered model without replacing the full list:
```json
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "--acp"],
"additionalModels": [
{ "id": "provider/model-id", "label": "My Preferred Label" }
]
}
}
}
}
```
When an `additionalModels` entry has the same `id` as a discovered model, it updates that model in place.
---
## Provider override reference
@@ -480,7 +438,6 @@ Every entry under `agents.providers` accepts these fields:
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
| `order` | `number` | No | Sort order in the provider list |
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-20mGU+cFFjhyUr011d+hU0K+x/TvEXfK+HD6PQuOZ0M=";
npmDepsHash = "sha256-p7luf7/eeQR5seXerJd42x7gY73qqOecStNZN/3wIlk=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+19 -19
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.61",
"version": "0.1.60",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.61",
"version": "0.1.60",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -37385,16 +37385,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.61",
"version": "0.1.60",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.61",
"@getpaseo/highlight": "0.1.61",
"@getpaseo/server": "0.1.61",
"@getpaseo/expo-two-way-audio": "0.1.60",
"@getpaseo/highlight": "0.1.60",
"@getpaseo/server": "0.1.60",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -37539,11 +37539,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.61",
"version": "0.1.60",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.61",
"@getpaseo/server": "0.1.61",
"@getpaseo/relay": "0.1.60",
"@getpaseo/server": "0.1.60",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37584,11 +37584,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.61",
"version": "0.1.60",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.61",
"@getpaseo/server": "0.1.61",
"@getpaseo/cli": "0.1.60",
"@getpaseo/server": "0.1.60",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -37622,7 +37622,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.61",
"version": "0.1.60",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -37823,7 +37823,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.61",
"version": "0.1.60",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -37849,7 +37849,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.61",
"version": "0.1.60",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -37865,14 +37865,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.61",
"version": "0.1.60",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.61",
"@getpaseo/relay": "0.1.61",
"@getpaseo/highlight": "0.1.60",
"@getpaseo/relay": "0.1.60",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-coding-agent": "^0.67.68",
"@modelcontextprotocol/sdk": "^1.20.1",
@@ -38627,7 +38627,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.61",
"version": "0.1.60",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.61",
"version": "0.1.60",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.61",
"version": "0.1.60",
"private": true,
"scripts": {
"start": "APP_VARIANT=development expo start",
@@ -32,9 +32,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.61",
"@getpaseo/highlight": "0.1.61",
"@getpaseo/server": "0.1.61",
"@getpaseo/expo-two-way-audio": "0.1.60",
"@getpaseo/highlight": "0.1.60",
"@getpaseo/server": "0.1.60",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
+4 -10
View File
@@ -55,10 +55,9 @@ export function parseImageDataUrl(
if (!parsed.mimeType.toLowerCase().startsWith("image/")) {
return null;
}
const fingerprint = `${parsed.mimeType}\0${parsed.base64.length}\0${parsed.base64.slice(0, 64)}\0${parsed.base64.slice(-64)}`;
return {
...parsed,
cacheKey: `data-image:${parsed.mimeType}:${parsed.base64.length}:${hashString(fingerprint)}`,
cacheKey: `data-image:${parsed.mimeType}:${parsed.base64.length}:${hashString(parsed.base64)}`,
};
} catch {
return null;
@@ -81,18 +80,13 @@ export function getFileNameFromPath(path: string | null | undefined): string | n
}
export function createPreviewAttachmentId(input: {
base64: string;
mimeType: string;
path?: string | null;
size?: number | null;
modifiedAt?: string | null;
contentLength?: number | null;
}): string {
const path = input.path?.trim() ?? "";
const size = Number.isFinite(input.size) ? String(input.size) : "";
const modifiedAt = input.modifiedAt?.trim() ?? "";
const contentLength = Number.isFinite(input.contentLength) ? String(input.contentLength) : "";
const hash = hashString(`${input.mimeType}\0${path}\0${size}\0${modifiedAt}\0${contentLength}`);
return `preview_${size || contentLength || "unknown"}_${hash}`;
const hash = hashString(`${input.mimeType}\0${path}\0${input.base64}`);
return `preview_${input.base64.length}_${hash}`;
}
export async function blobToBase64(blob: Blob): Promise<string> {
+1 -3
View File
@@ -76,11 +76,9 @@ async function createFilePanePreview(file: ExplorerFile | null): Promise<{
const { content: _content, ...imageFile } = file;
const imageAttachment = await persistAttachmentFromBase64({
id: createPreviewAttachmentId({
base64: file.content,
mimeType: file.mimeType ?? "image/png",
path: file.path,
size: file.size,
modifiedAt: file.modifiedAt,
contentLength: file.content.length,
}),
base64: file.content,
mimeType: file.mimeType,
+2 -7
View File
@@ -635,11 +635,9 @@ function AssistantMarkdownImage({
return await persistAttachmentFromBase64({
id: createPreviewAttachmentId({
base64: file.content,
mimeType: file.mimeType ?? "image/png",
path: file.path || resolution.path,
size: file.size,
modifiedAt: file.modifiedAt,
contentLength: file.content.length,
}),
base64: file.content,
mimeType: file.mimeType,
@@ -657,10 +655,7 @@ function AssistantMarkdownImage({
}
return await persistAttachmentFromDataUrl({
id: createPreviewAttachmentId({
mimeType: dataImage.mimeType,
contentLength: dataImage.base64.length,
}),
id: createPreviewAttachmentId(dataImage),
dataUrl: source,
mimeType: dataImage.mimeType,
});
@@ -1,56 +0,0 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import type { LayoutChangeEvent } from "react-native";
import { describe, expect, it } from "vitest";
import { useContainerWidthBelow } from "./use-container-width";
function layoutEvent(width: number): LayoutChangeEvent {
return {
nativeEvent: {
layout: {
width,
height: 48,
x: 0,
y: 0,
},
},
} as LayoutChangeEvent;
}
describe("useContainerWidthBelow", () => {
it("does not re-render for width changes that stay in the same threshold bucket", () => {
let renderCount = 0;
const { result } = renderHook(() => {
renderCount += 1;
return useContainerWidthBelow(700);
});
expect(result.current.isBelow).toBe(true);
expect(renderCount).toBe(1);
act(() => {
result.current.onLayout(layoutEvent(650));
result.current.onLayout(layoutEvent(620));
result.current.onLayout(layoutEvent(699));
});
expect(result.current.isBelow).toBe(true);
expect(renderCount).toBe(1);
});
it("re-renders when the width crosses the threshold", () => {
let renderCount = 0;
const { result } = renderHook(() => {
renderCount += 1;
return useContainerWidthBelow(700);
});
act(() => {
result.current.onLayout(layoutEvent(760));
});
expect(result.current.isBelow).toBe(false);
expect(renderCount).toBe(2);
});
});
@@ -16,25 +16,3 @@ export function useContainerWidth(): {
width,
};
}
/**
* Tracks only whether a container is narrower than a threshold.
*/
export function useContainerWidthBelow(threshold: number): {
onLayout: (e: LayoutChangeEvent) => void;
isBelow: boolean;
} {
const [isBelow, setIsBelow] = useState(true);
return {
onLayout: useCallback(
(e: LayoutChangeEvent) => {
const nextIsBelow = e.nativeEvent.layout.width < threshold;
setIsBelow((currentIsBelow) =>
currentIsBelow === nextIsBelow ? currentIsBelow : nextIsBelow,
);
},
[threshold],
),
isBelow,
};
}
@@ -122,7 +122,7 @@ import { findAdjacentPane } from "@/utils/split-navigation";
import { isAbsolutePath } from "@/utils/path";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
import { isWeb, isNative } from "@/constants/platform";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import { useContainerWidth } from "@/hooks/use-container-width";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
@@ -1051,8 +1051,8 @@ function WorkspaceScreenContent({
);
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
const { closingTabIds, closeTab } = useCloseTabs();
const { onLayout: onHeaderLayout, isBelow: showCompactButtonLabels } =
useContainerWidthBelow(700);
const { onLayout: onHeaderLayout, width: headerWidth } = useContainerWidth();
const showCompactButtonLabels = headerWidth < 700;
const closeWorkspaceTabWithCleanup = useCallback(
function closeWorkspaceTabWithCleanup(input: {
tabId: string;
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.61",
"version": "0.1.60",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.61",
"@getpaseo/server": "0.1.61",
"@getpaseo/relay": "0.1.60",
"@getpaseo/server": "0.1.60",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.61",
"version": "0.1.60",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
@@ -13,8 +13,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.61",
"@getpaseo/server": "0.1.61",
"@getpaseo/cli": "0.1.60",
"@getpaseo/server": "0.1.60",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.61",
"version": "0.1.60",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.61",
"version": "0.1.60",
"type": "module",
"publishConfig": {
"access": "public"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.61",
"version": "0.1.60",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.61",
"version": "0.1.60",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -60,8 +60,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.61",
"@getpaseo/relay": "0.1.61",
"@getpaseo/highlight": "0.1.60",
"@getpaseo/relay": "0.1.60",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-coding-agent": "^0.67.68",
"@modelcontextprotocol/sdk": "^1.20.1",
@@ -67,7 +67,6 @@ export const ProviderOverrideSchema = z
command: z.array(z.string().min(1)).min(1).optional(),
env: z.record(z.string()).optional(),
models: z.array(ProviderProfileModelSchema).optional(),
additionalModels: z.array(ProviderProfileModelSchema).optional(),
disallowedTools: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
order: z.number().optional(),
@@ -666,193 +666,6 @@ describe("buildProviderRegistry", () => {
]);
});
test("additional models append to runtime models", async () => {
mockState.runtimeModels.set("claude", [
{
provider: "claude",
id: "runtime-pro",
label: "Runtime Pro",
},
]);
const registry = buildProviderRegistry(logger, {
providerOverrides: {
claude: {
additionalModels: [
{
id: "profile-fast",
label: "Profile Fast",
},
],
},
},
});
const models = await registry.claude.fetchModels({
cwd: "/tmp/registry-models",
force: false,
});
expect(models).toEqual([
{
provider: "claude",
id: "runtime-pro",
label: "Runtime Pro",
},
{
provider: "claude",
id: "profile-fast",
label: "Profile Fast",
},
]);
});
test("additional models merge onto profile replacement models", async () => {
mockState.runtimeModels.set("claude", [
{
provider: "claude",
id: "runtime-pro",
label: "Runtime Pro",
},
]);
const registry = buildProviderRegistry(logger, {
providerOverrides: {
claude: {
models: [
{
id: "profile-curated",
label: "Profile Curated",
},
],
additionalModels: [
{
id: "profile-extra",
label: "Profile Extra",
},
],
},
},
});
const models = await registry.claude.fetchModels({
cwd: "/tmp/registry-models",
force: false,
});
expect(models.map((model) => model.id)).toEqual(["profile-curated", "profile-extra"]);
});
test("additional models override matching runtime models in place", async () => {
mockState.runtimeModels.set("claude", [
{
provider: "claude",
id: "shared-model",
label: "Runtime Label",
description: "Runtime description",
metadata: {
source: "runtime",
},
},
{
provider: "claude",
id: "runtime-only",
label: "Runtime Only",
},
]);
const registry = buildProviderRegistry(logger, {
providerOverrides: {
claude: {
additionalModels: [
{
id: "shared-model",
label: "Profile Label",
},
],
},
},
});
const models = await registry.claude.fetchModels({
cwd: "/tmp/registry-models",
force: false,
});
expect(models).toEqual([
{
provider: "claude",
id: "shared-model",
label: "Profile Label",
description: "Runtime description",
metadata: {
source: "runtime",
},
},
{
provider: "claude",
id: "runtime-only",
label: "Runtime Only",
},
]);
});
test("additional model default overrides runtime default", async () => {
mockState.runtimeModels.set("claude", [
{
provider: "claude",
id: "runtime-default",
label: "Runtime Default",
isDefault: true,
},
{
provider: "claude",
id: "runtime-other",
label: "Runtime Other",
},
]);
const registry = buildProviderRegistry(logger, {
providerOverrides: {
claude: {
additionalModels: [
{
id: "profile-default",
label: "Profile Default",
isDefault: true,
},
],
},
},
});
const models = await registry.claude.fetchModels({
cwd: "/tmp/registry-models",
force: false,
});
expect(models).toEqual([
{
provider: "claude",
id: "runtime-default",
label: "Runtime Default",
isDefault: false,
},
{
provider: "claude",
id: "runtime-other",
label: "Runtime Other",
isDefault: false,
},
{
provider: "claude",
id: "profile-default",
label: "Profile Default",
isDefault: true,
},
]);
});
test("no profile models — runtime models returned as-is", async () => {
mockState.runtimeModels.set("claude", [
{
@@ -63,7 +63,6 @@ type ResolvedProvider = {
definition: AgentProviderDefinition;
runtimeSettings?: ProviderRuntimeSettings;
profileModels: ProviderProfileModel[];
additionalModels: ProviderProfileModel[];
enabled: boolean;
createBaseClient: (logger: Logger) => AgentClient;
};
@@ -225,59 +224,16 @@ function mapModel(provider: AgentProvider, model: AgentModelDefinition): AgentMo
function mergeModels(
provider: AgentProvider,
profileModels: ProviderProfileModel[],
additionalModels: ProviderProfileModel[],
runtimeModels: AgentModelDefinition[],
): AgentModelDefinition[] {
const baseModels =
profileModels.length === 0
? runtimeModels.map((model) => mapModel(provider, model))
: profileModels.map((model) => ({
...model,
provider,
}));
if (additionalModels.length === 0) {
return baseModels;
if (profileModels.length === 0) {
return runtimeModels.map((model) => mapModel(provider, model));
}
const mergedModels = [...baseModels];
let hasAdditionalDefault = false;
for (const model of additionalModels) {
const additionalModel = {
...model,
provider,
};
hasAdditionalDefault ||= additionalModel.isDefault === true;
const existingIndex = mergedModels.findIndex((candidate) => candidate.id === model.id);
if (existingIndex === -1) {
mergedModels.push(additionalModel);
continue;
}
mergedModels[existingIndex] = {
...mergedModels[existingIndex],
...additionalModel,
};
}
if (!hasAdditionalDefault) {
return mergedModels;
}
const additionalDefaultIds = new Set(
additionalModels.filter((model) => model.isDefault === true).map((model) => model.id),
);
return mergedModels.map((model) =>
additionalDefaultIds.has(model.id)
? model
: {
...model,
isDefault: false,
},
);
return profileModels.map((model) => ({
...model,
provider,
}));
}
function wrapSessionProvider(provider: AgentProvider, inner: AgentSession): AgentSession {
@@ -374,12 +330,7 @@ function createRegistryEntry(
return inner.provider === provider ? inner : wrapClientProvider(provider, inner);
},
fetchModels: async (options: ListModelsOptions) =>
mergeModels(
provider,
resolved.profileModels,
resolved.additionalModels,
await modelClient.listModels(options),
),
mergeModels(provider, resolved.profileModels, await modelClient.listModels(options)),
fetchModes: async (options: ListModesOptions) => {
const modes = modelClient.listModes
? await modelClient.listModes(options)
@@ -422,7 +373,6 @@ function buildResolvedBuiltinProviders(
definition: applyOverrideToDefinition(definition, override),
runtimeSettings: mergedRuntimeSettings,
profileModels: override?.models ?? [],
additionalModels: override?.additionalModels ?? [],
enabled: override?.enabled !== false,
createBaseClient: (logger) =>
factory(logger, mergedRuntimeSettings, {
@@ -466,7 +416,6 @@ function addDerivedProviders(
),
runtimeSettings: toRuntimeSettings(override),
profileModels: override.models ?? [],
additionalModels: override.additionalModels ?? [],
enabled: override.enabled !== false,
createBaseClient: (logger) =>
new GenericACPAgentClient({
@@ -496,7 +445,6 @@ function addDerivedProviders(
definition: createDerivedDefinition(providerId, baseDefinition, override),
runtimeSettings: mergedRuntimeSettings,
profileModels: override.models ?? [],
additionalModels: override.additionalModels ?? [],
enabled: override.enabled !== false,
createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings),
});
@@ -1,6 +1,3 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, test, vi } from "vitest";
import type { Api, Model } from "@mariozechner/pi-ai";
import pino from "pino";
@@ -96,126 +93,4 @@ describe("PiDirectAgentClient", () => {
expect(registry.getAll).not.toHaveBeenCalled();
expect(models.map((model) => model.id)).toEqual(["anthropic/claude-opus-4-5"]);
});
test("loads project extensions before listing available models", async () => {
const testRoot = await mkdtemp(join(tmpdir(), "paseo-pi-extension-"));
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
try {
const agentDir = join(testRoot, "agent");
const cwd = join(testRoot, "project");
const extensionDir = join(cwd, ".pi", "extensions");
process.env.PI_CODING_AGENT_DIR = agentDir;
await mkdir(extensionDir, { recursive: true });
await writeFile(
join(extensionDir, "dummy-provider.ts"),
`
export default function(pi) {
pi.registerProvider("paseo-dummy", {
baseUrl: "https://example.invalid/v1",
apiKey: "paseo-test-key",
api: "openai-responses",
models: [
{
id: "extension-model",
name: "Extension Model",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096
}
]
});
}
`,
"utf-8",
);
const client = new PiDirectAgentClient({
logger: pino({ level: "silent" }),
});
const models = await client.listModels({ cwd, force: false });
expect(models.map((model) => model.id)).toContain("paseo-dummy/extension-model");
} finally {
if (previousAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = previousAgentDir;
}
await rm(testRoot, { recursive: true, force: true });
}
});
test("creates sessions with project extension models and exposes extension commands", async () => {
const testRoot = await mkdtemp(join(tmpdir(), "paseo-pi-extension-session-"));
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
try {
const agentDir = join(testRoot, "agent");
const cwd = join(testRoot, "project");
const extensionDir = join(cwd, ".pi", "extensions");
process.env.PI_CODING_AGENT_DIR = agentDir;
await mkdir(extensionDir, { recursive: true });
await writeFile(
join(extensionDir, "dummy-command.ts"),
`
export default function(pi) {
pi.registerProvider("paseo-dummy", {
baseUrl: "https://example.invalid/v1",
apiKey: "paseo-test-key",
api: "openai-responses",
models: [
{
id: "extension-model",
name: "Extension Model",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096
}
]
});
pi.registerCommand("dummy-command", {
description: "Dummy extension command",
handler: async () => {}
});
}
`,
"utf-8",
);
const client = new PiDirectAgentClient({
logger: pino({ level: "silent" }),
});
const session = await client.createSession({
provider: "pi",
cwd,
model: "paseo-dummy/extension-model",
});
try {
await expect(session.listCommands()).resolves.toContainEqual({
name: "dummy-command",
description: "Dummy extension command",
argumentHint: "",
});
} finally {
await session.close();
}
} finally {
if (previousAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = previousAgentDir;
}
await rm(testRoot, { recursive: true, force: true });
}
});
});
@@ -4,13 +4,12 @@ import { join } from "node:path";
import { homedir } from "node:os";
import type { Logger } from "pino";
import {
AuthStorage,
ModelRegistry,
SessionManager,
createAgentSessionFromServices,
createAgentSessionServices,
createAgentSession,
type AgentSession as PiAgentSession,
type AgentSessionEvent,
type AgentSessionServices,
type BashToolInput,
type EditToolInput,
type FindToolInput,
@@ -102,11 +101,6 @@ interface StartTurnResult {
turnId: string;
}
interface PiDirectSessionResult {
session: PiAgentSession;
modelRegistry: ModelRegistry;
}
export type PiDirectSessionAdapter = Pick<
PiAgentSession,
| "abort"
@@ -1365,48 +1359,48 @@ export class PiDirectAgentClient implements AgentClient {
this.runtimeSettings = options.runtimeSettings;
}
private async getSessionServices(cwd: string): Promise<AgentSessionServices> {
return createAgentSessionServices({
cwd,
...(this.modelRegistry ? { modelRegistry: this.modelRegistry } : {}),
});
private getModelRegistry(): ModelRegistry {
if (!this.modelRegistry) {
this.modelRegistry = ModelRegistry.create(AuthStorage.create());
}
return this.modelRegistry;
}
private resolveConfiguredModel(
registry: PiDirectModelRegistry,
modelId: string | null | undefined,
): Model<Api> | undefined {
private resolveConfiguredModel(modelId: string | null | undefined): Model<Api> | undefined {
const parsedReference = parseModelReference(modelId ?? null);
if (!parsedReference) {
return undefined;
}
const registry = this.getModelRegistry();
return findModelInRegistry(registry, parsedReference);
}
private async createSdkSession(config: AgentSessionConfig): Promise<PiDirectSessionResult> {
private async createSdkSession(config: AgentSessionConfig): Promise<PiAgentSession> {
const thinkingLevel =
normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL;
const services = await this.getSessionServices(config.cwd);
const model = this.resolveConfiguredModel(services.modelRegistry, config.model);
const modelRegistry = this.getModelRegistry();
const model = this.resolveConfiguredModel(config.model);
const { session } = await createAgentSessionFromServices({
services,
const sessionOptions = {
cwd: config.cwd,
modelRegistry,
sessionManager: SessionManager.create(config.cwd),
thinkingLevel,
...(model ? { model } : {}),
});
};
const { session } = await createAgentSession(sessionOptions);
await session.bindExtensions({});
applySystemPrompt(session, config.systemPrompt);
return { session, modelRegistry: services.modelRegistry };
return session;
}
async createSession(
config: AgentSessionConfig,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const { session, modelRegistry } = await this.createSdkSession(config);
return new PiDirectAgentSession(session, modelRegistry, config);
const session = await this.createSdkSession(config);
return new PiDirectAgentSession(session, this.getModelRegistry(), config);
}
async resumeSession(
@@ -1440,34 +1434,37 @@ export class PiDirectAgentClient implements AgentClient {
modeId: overrides?.modeId,
};
const services = await this.getSessionServices(mergedConfig.cwd);
const model = this.resolveConfiguredModel(services.modelRegistry, mergedConfig.model);
const model = this.resolveConfiguredModel(mergedConfig.model);
const thinkingLevel = normalizePiThinkingOption(mergedConfig.thinkingOptionId);
const { session } = await createAgentSessionFromServices({
services,
const sessionOptions = {
cwd: mergedConfig.cwd,
modelRegistry: this.getModelRegistry(),
sessionManager: resumedManager,
...(model ? { model } : {}),
...(thinkingLevel ? { thinkingLevel } : {}),
});
};
const { session } = await createAgentSession(sessionOptions);
await session.bindExtensions({});
applySystemPrompt(session, mergedConfig.systemPrompt);
return new PiDirectAgentSession(session, services.modelRegistry, mergedConfig);
return new PiDirectAgentSession(session, this.getModelRegistry(), mergedConfig);
}
async listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]> {
const services = await this.getSessionServices(options.cwd);
const models = services.modelRegistry.getAvailable().map((model) => ({
provider: PI_PROVIDER,
id: `${model.provider}/${model.id}`,
label: `${model.provider}/${model.name}`,
description: `${model.provider}/${model.id}`,
metadata: {
provider: model.provider,
modelId: model.id,
} satisfies AgentMetadata,
thinkingOptions: model.reasoning ? PI_THINKING_OPTIONS.map(mapThinkingOption) : undefined,
defaultThinkingOptionId: model.reasoning ? DEFAULT_PI_THINKING_LEVEL : undefined,
}));
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
// Pi Direct uses an in-process global registry; cwd/force are intentionally irrelevant.
const models = this.getModelRegistry()
.getAvailable()
.map((model) => ({
provider: PI_PROVIDER,
id: `${model.provider}/${model.id}`,
label: `${model.provider}/${model.name}`,
description: `${model.provider}/${model.id}`,
metadata: {
provider: model.provider,
modelId: model.id,
} satisfies AgentMetadata,
thinkingOptions: model.reasoning ? PI_THINKING_OPTIONS.map(mapThinkingOption) : undefined,
defaultThinkingOptionId: model.reasoning ? DEFAULT_PI_THINKING_LEVEL : undefined,
}));
return transformPiModels(models);
}
@@ -165,34 +165,6 @@ describe("provider overrides (new format)", () => {
]);
});
test("additionalModels array accepted", () => {
const parsed = PersistedConfigSchema.parse({
agents: {
providers: {
zai: {
extends: "claude",
label: "ZAI",
additionalModels: [
{
id: "zai-fast",
label: "ZAI Fast",
isDefault: true,
},
],
},
},
},
});
expect(parsed.agents?.providers?.zai?.additionalModels).toEqual([
{
id: "zai-fast",
label: "ZAI Fast",
isDefault: true,
},
]);
});
test("order field accepted", () => {
const parsed = PersistedConfigSchema.parse({
agents: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.61",
"version": "0.1.60",
"private": true,
"type": "module",
"scripts": {
@@ -29,6 +29,7 @@
]
},
"allowedHosts": {
"description": "Deprecated: use hostnames instead",
"anyOf": [
{
"type": "boolean",
@@ -47,12 +48,9 @@
"properties": {
"enabled": {
"type": "boolean"
},
"injectIntoAgents": {
"type": "boolean"
}
},
"additionalProperties": true
"additionalProperties": false
},
"cors": {
"type": "object",
@@ -127,95 +125,68 @@
"additionalProperties": {
"type": "object",
"properties": {
"extends": {
"type": "string"
},
"label": {
"type": "string"
},
"description": {
"type": "string"
},
"command": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"minItems": 1
"anyOf": [
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"const": "default"
}
},
"required": ["mode"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"const": "append"
},
"args": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["mode"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"const": "replace"
},
"argv": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"minItems": 1
}
},
"required": ["mode", "argv"],
"additionalProperties": false
}
]
},
"env": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"models": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"label": {
"type": "string",
"minLength": 1
},
"description": {
"type": "string"
},
"isDefault": {
"type": "boolean"
},
"thinkingOptions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"label": {
"type": "string"
},
"description": {
"type": "string"
},
"isDefault": {
"type": "boolean"
}
},
"required": ["id", "label"],
"additionalProperties": false
}
}
},
"required": ["id", "label"],
"additionalProperties": false
}
},
"additionalModels": {
"type": "array",
"items": {
"$ref": "#/definitions/PaseoConfigV1/properties/agents/properties/providers/additionalProperties/properties/models/items"
}
},
"disallowedTools": {
"type": "array",
"items": {
"type": "string"
}
},
"enabled": {
"type": "boolean"
},
"order": {
"type": "number"
}
},
"additionalProperties": false
},
"propertyNames": {
"type": "string"
}
}
},
@@ -289,15 +260,6 @@
},
"additionalProperties": false
},
"turnDetection": {
"type": "object",
"properties": {
"provider": {
"$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider"
}
},
"additionalProperties": false
},
"tts": {
"type": "object",
"properties": {
-21
View File
@@ -25,7 +25,6 @@ import { Route as DocsVoiceRouteImport } from "./routes/docs/voice";
import { Route as DocsUpdatesRouteImport } from "./routes/docs/updates";
import { Route as DocsSkillsRouteImport } from "./routes/docs/skills";
import { Route as DocsSecurityRouteImport } from "./routes/docs/security";
import { Route as DocsProvidersRouteImport } from "./routes/docs/providers";
import { Route as DocsConfigurationRouteImport } from "./routes/docs/configuration";
import { Route as DocsCliRouteImport } from "./routes/docs/cli";
import { Route as DocsBestPracticesRouteImport } from "./routes/docs/best-practices";
@@ -111,11 +110,6 @@ const DocsSecurityRoute = DocsSecurityRouteImport.update({
path: "/security",
getParentRoute: () => DocsRoute,
} as any);
const DocsProvidersRoute = DocsProvidersRouteImport.update({
id: "/providers",
path: "/providers",
getParentRoute: () => DocsRoute,
} as any);
const DocsConfigurationRoute = DocsConfigurationRouteImport.update({
id: "/configuration",
path: "/configuration",
@@ -151,7 +145,6 @@ export interface FileRoutesByFullPath {
"/docs/best-practices": typeof DocsBestPracticesRoute;
"/docs/cli": typeof DocsCliRoute;
"/docs/configuration": typeof DocsConfigurationRoute;
"/docs/providers": typeof DocsProvidersRoute;
"/docs/security": typeof DocsSecurityRoute;
"/docs/skills": typeof DocsSkillsRoute;
"/docs/updates": typeof DocsUpdatesRoute;
@@ -172,7 +165,6 @@ export interface FileRoutesByTo {
"/docs/best-practices": typeof DocsBestPracticesRoute;
"/docs/cli": typeof DocsCliRoute;
"/docs/configuration": typeof DocsConfigurationRoute;
"/docs/providers": typeof DocsProvidersRoute;
"/docs/security": typeof DocsSecurityRoute;
"/docs/skills": typeof DocsSkillsRoute;
"/docs/updates": typeof DocsUpdatesRoute;
@@ -196,7 +188,6 @@ export interface FileRoutesById {
"/docs/best-practices": typeof DocsBestPracticesRoute;
"/docs/cli": typeof DocsCliRoute;
"/docs/configuration": typeof DocsConfigurationRoute;
"/docs/providers": typeof DocsProvidersRoute;
"/docs/security": typeof DocsSecurityRoute;
"/docs/skills": typeof DocsSkillsRoute;
"/docs/updates": typeof DocsUpdatesRoute;
@@ -221,7 +212,6 @@ export interface FileRouteTypes {
| "/docs/best-practices"
| "/docs/cli"
| "/docs/configuration"
| "/docs/providers"
| "/docs/security"
| "/docs/skills"
| "/docs/updates"
@@ -242,7 +232,6 @@ export interface FileRouteTypes {
| "/docs/best-practices"
| "/docs/cli"
| "/docs/configuration"
| "/docs/providers"
| "/docs/security"
| "/docs/skills"
| "/docs/updates"
@@ -265,7 +254,6 @@ export interface FileRouteTypes {
| "/docs/best-practices"
| "/docs/cli"
| "/docs/configuration"
| "/docs/providers"
| "/docs/security"
| "/docs/skills"
| "/docs/updates"
@@ -401,13 +389,6 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof DocsSecurityRouteImport;
parentRoute: typeof DocsRoute;
};
"/docs/providers": {
id: "/docs/providers";
path: "/providers";
fullPath: "/docs/providers";
preLoaderRoute: typeof DocsProvidersRouteImport;
parentRoute: typeof DocsRoute;
};
"/docs/configuration": {
id: "/docs/configuration";
path: "/configuration";
@@ -455,7 +436,6 @@ interface DocsRouteChildren {
DocsBestPracticesRoute: typeof DocsBestPracticesRoute;
DocsCliRoute: typeof DocsCliRoute;
DocsConfigurationRoute: typeof DocsConfigurationRoute;
DocsProvidersRoute: typeof DocsProvidersRoute;
DocsSecurityRoute: typeof DocsSecurityRoute;
DocsSkillsRoute: typeof DocsSkillsRoute;
DocsUpdatesRoute: typeof DocsUpdatesRoute;
@@ -468,7 +448,6 @@ const DocsRouteChildren: DocsRouteChildren = {
DocsBestPracticesRoute: DocsBestPracticesRoute,
DocsCliRoute: DocsCliRoute,
DocsConfigurationRoute: DocsConfigurationRoute,
DocsProvidersRoute: DocsProvidersRoute,
DocsSecurityRoute: DocsSecurityRoute,
DocsSkillsRoute: DocsSkillsRoute,
DocsUpdatesRoute: DocsUpdatesRoute,
-1
View File
@@ -12,7 +12,6 @@ const navigation = [
{ name: "Git worktrees", href: "/docs/worktrees" },
{ name: "CLI", href: "/docs/cli" },
{ name: "Skills", href: "/docs/skills" },
{ name: "Providers", href: "/docs/providers" },
{ name: "Configuration", href: "/docs/configuration" },
{ name: "Security", href: "/docs/security" },
{ name: "Best practices", href: "/docs/best-practices" },
@@ -11,8 +11,6 @@ export const Route = createFileRoute("/docs/configuration")({
component: Configuration,
});
const CUSTOM_PROVIDERS_URL = "https://github.com/getpaseo/paseo/blob/main/docs/CUSTOM-PROVIDERS.md";
function Configuration() {
return (
<div className="space-y-8">
@@ -61,12 +59,15 @@ function Configuration() {
<section className="space-y-4">
<h2 className="text-xl font-medium">Example</h2>
<p className="text-white/60 leading-relaxed">
Minimal example that configures listening address, hostnames, and MCP:
Minimal example that configures listening address, hostnames, provider keys, and MCP:
</p>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"$schema": "https://paseo.sh/schemas/paseo.config.v1.json",
"version": 1,
"providers": {
"openai": { "apiKey": "..." }
},
"daemon": {
"listen": "127.0.0.1:6767",
"hostnames": ["localhost", ".localhost"],
@@ -82,31 +83,84 @@ function Configuration() {
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Agent providers</h2>
<h2 className="text-xl font-medium">Agent provider runtime settings</h2>
<p className="text-white/60 leading-relaxed">
Agent providers — both the first-class ones Paseo ships with and custom entries you add
under <code className="font-mono">agents.providers</code> — are documented on their own
page.
Use <code className="font-mono">agents.providers</code> to customize how Paseo launches
agent provider CLIs. This works for <code className="font-mono">claude</code>,{" "}
<code className="font-mono">codex</code>, and
<code className="font-mono"> opencode</code>.
</p>
<p className="text-white/60 leading-relaxed">
See{" "}
<a href="/docs/providers" className="underline hover:text-white/80">
Providers
</a>{" "}
for first-class providers, how to point Claude at Anthropic-compatible endpoints (Z.AI,
Alibaba/Qwen), multiple profiles, custom binaries, ACP agents, and the{" "}
<code className="font-mono">additionalModels</code> merge behavior. The full field
reference lives on GitHub at{" "}
<a
href={CUSTOM_PROVIDERS_URL}
target="_blank"
rel="noreferrer"
className="underline hover:text-white/80"
>
docs/CUSTOM-PROVIDERS.md
</a>
.
<code className="font-mono">command.mode</code> can be{" "}
<code className="font-mono">default</code>, <code className="font-mono">append</code>, or{" "}
<code className="font-mono">replace</code>. Use <code className="font-mono">env</code> to
inject provider-specific environment variables.
</p>
<h3 className="text-lg font-medium">Enable Claude Code Chrome MCP</h3>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"claude": {
"command": {
"mode": "append",
"args": ["--chrome"]
}
}
}
}
}`}
</pre>
<h3 className="text-lg font-medium">
Point Claude to Anthropic-compatible endpoints (z.ai example)
</h3>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"claude": {
"env": {
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
"ANTHROPIC_AUTH_TOKEN": "auth token",
"ANTHROPIC_API_KEY": ""
}
}
}
}
}`}
</pre>
<h3 className="text-lg font-medium">Run Claude through Docker</h3>
<p className="text-white/60 leading-relaxed">
Create a wrapper script that runs Claude in Docker, then tell Paseo to replace the Claude
launch command with that script.
</p>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"claude": {
"command": {
"mode": "replace",
"argv": ["/Users/you/bin/claude-docker"]
}
}
}
}
}`}
</pre>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`#!/usr/bin/env bash
set -euo pipefail
docker run --rm -i \\
-v "$PWD":"$PWD" \\
-w "$PWD" \\
-v "$HOME/.claude":"$HOME/.claude" \\
ghcr.io/anthropics/claude-code:latest \\
claude "$@"`}
</pre>
</section>
<section className="space-y-4">
@@ -148,11 +148,6 @@ function GettingStarted() {
Voice
</a>
</li>
<li>
<a href="/docs/providers" className="underline hover:text-white/80">
Providers
</a>
</li>
<li>
<a href="/docs/configuration" className="underline hover:text-white/80">
Configuration
@@ -1,345 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { pageMeta } from "~/meta";
export const Route = createFileRoute("/docs/providers")({
head: () => ({
meta: pageMeta(
"Providers - Paseo Docs",
"First-class agent providers in Paseo, and how to configure custom providers, ACP agents, and profiles.",
),
}),
component: Providers,
});
const CUSTOM_PROVIDERS_URL = "https://github.com/getpaseo/paseo/blob/main/docs/CUSTOM-PROVIDERS.md";
function Providers() {
return (
<div className="space-y-10">
<div>
<h1 className="text-3xl font-medium font-title mb-4">Providers</h1>
<p className="text-white/60 leading-relaxed">
A provider is an agent CLI that Paseo knows how to launch, stream, and control. Paseo
ships with first-class providers for the major coding agents, and lets you add your own
through <code className="font-mono">config.json</code> — either by pointing an existing
provider at a different API, adding extra profiles, or plugging in any{" "}
<a
href="https://agentclientprotocol.com"
target="_blank"
rel="noreferrer"
className="underline hover:text-white/80"
>
ACP
</a>
-compatible agent.
</p>
</div>
<section className="space-y-4">
<h2 className="text-xl font-medium">First-class providers</h2>
<p className="text-white/60 leading-relaxed">
These work out of the box once the underlying CLI is installed and authenticated. Paseo
discovers them automatically, wires up modes, and exposes them in the app and CLI.
</p>
<ul className="text-white/60 space-y-3 list-disc list-inside">
<li>
<code className="font-mono">claude</code> — Anthropic's Claude Code. Multi-tool
assistant with MCP support, streaming, and deep reasoning.
</li>
<li>
<code className="font-mono">codex</code> — OpenAI's Codex workspace agent with sandbox
controls and optional network access.
</li>
<li>
<code className="font-mono">opencode</code> — Open-source coding assistant with
multi-provider model support.
</li>
<li>
<code className="font-mono">copilot</code> — GitHub Copilot via ACP, with dynamic modes
and session support.
</li>
<li>
<code className="font-mono">pi</code> — Minimal terminal-based coding agent with
multi-provider LLM support.
</li>
</ul>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Custom providers</h2>
<p className="text-white/60 leading-relaxed">
Everything beyond the defaults lives under{" "}
<code className="font-mono">agents.providers</code> in{" "}
<code className="font-mono">~/.paseo/config.json</code>. You can:
</p>
<ul className="text-white/60 space-y-2 list-disc list-inside">
<li>
<strong>Extend</strong> a first-class provider to point at a different API (Z.AI,
Alibaba/Qwen, a proxy, a self-hosted endpoint).
</li>
<li>
<strong>Add profiles</strong> — multiple entries against the same underlying provider
with different credentials or curated model lists.
</li>
<li>
<strong>Override the binary</strong> — run a nightly build, a wrapper script, or a
Docker image instead of the installed CLI.
</li>
<li>
<strong>Add ACP agents</strong> — Gemini CLI, Hermes, or any agent speaking the Agent
Client Protocol over stdio.
</li>
<li>
<strong>Disable</strong> a provider you don't use.
</li>
</ul>
<p className="text-white/60 leading-relaxed">
Provider IDs must be lowercase alphanumeric with hyphens (
<code className="font-mono">/^[a-z][a-z0-9-]*$/</code>). Every custom entry needs{" "}
<code className="font-mono">extends</code> (a first-class provider ID or{" "}
<code className="font-mono">"acp"</code>) and a <code className="font-mono">label</code>.
</p>
<p className="text-white/60 leading-relaxed">
The examples below are a quick tour. The full, up-to-date reference is on GitHub:{" "}
<a
href={CUSTOM_PROVIDERS_URL}
target="_blank"
rel="noreferrer"
className="underline hover:text-white/80"
>
docs/CUSTOM-PROVIDERS.md
</a>
.
</p>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Extending a first-class provider</h2>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"my-claude": {
"extends": "claude",
"label": "My Claude",
"env": {
"ANTHROPIC_API_KEY": "sk-ant-...",
"ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1"
}
}
}
}
}`}
</pre>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Z.AI (GLM) coding plan</h2>
<p className="text-white/60 leading-relaxed">
Z.AI exposes GLM models through an Anthropic-compatible endpoint. Point{" "}
<code className="font-mono">ANTHROPIC_BASE_URL</code> at their API and use{" "}
<code className="font-mono">ANTHROPIC_AUTH_TOKEN</code> for the key. Third-party endpoints
don't support Anthropic's server-side tools, so disable{" "}
<code className="font-mono">WebSearch</code>.
</p>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"zai": {
"extends": "claude",
"label": "ZAI",
"env": {
"ANTHROPIC_AUTH_TOKEN": "<your-zai-api-key>",
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
"API_TIMEOUT_MS": "3000000"
},
"disallowedTools": ["WebSearch"],
"models": [
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
{ "id": "glm-5.1", "label": "GLM 5.1" }
]
}
}
}
}`}
</pre>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Alibaba Cloud (Qwen) coding plan</h2>
<p className="text-white/60 leading-relaxed">
Alibaba's coding plan routes Claude Code to Qwen models via an Anthropic-compatible API.
Subscription keys look like <code className="font-mono">sk-sp-...</code> and must be
created in the Singapore region.
</p>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"qwen": {
"extends": "claude",
"label": "Qwen (Alibaba)",
"env": {
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<coding-plan-key>",
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
},
"disallowedTools": ["WebSearch"],
"models": [
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" }
]
}
}
}
}`}
</pre>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Multiple profiles</h2>
<p className="text-white/60 leading-relaxed">
Create as many entries as you want against the same first-class provider. Each one shows
up as a separate option in the app with its own credentials and models.
</p>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"claude-work": {
"extends": "claude",
"label": "Claude (Work)",
"env": { "ANTHROPIC_API_KEY": "sk-ant-work-..." }
},
"claude-personal": {
"extends": "claude",
"label": "Claude (Personal)",
"env": { "ANTHROPIC_API_KEY": "sk-ant-personal-..." }
}
}
}
}`}
</pre>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Custom binary</h2>
<p className="text-white/60 leading-relaxed">
<code className="font-mono">command</code> is an array — first element is the binary, the
rest are arguments. It fully replaces the default launch command for that provider.
</p>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"claude": {
"command": ["/opt/claude-nightly/claude"]
}
}
}
}`}
</pre>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">ACP providers</h2>
<p className="text-white/60 leading-relaxed">
Any agent that speaks{" "}
<a
href="https://agentclientprotocol.com"
target="_blank"
rel="noreferrer"
className="underline hover:text-white/80"
>
ACP
</a>{" "}
over stdio can be added with <code className="font-mono">extends: "acp"</code> and a{" "}
<code className="font-mono">command</code>. Paseo spawns the process, sends an{" "}
<code className="font-mono">initialize</code> JSON-RPC request, and the agent reports its
capabilities, modes, and models at runtime.
</p>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"gemini": {
"extends": "acp",
"label": "Google Gemini",
"command": ["gemini", "--acp"]
},
"hermes": {
"extends": "acp",
"label": "Hermes",
"command": ["hermes", "acp"]
}
}
}
}`}
</pre>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Adding or relabeling models</h2>
<p className="text-white/60 leading-relaxed">
<code className="font-mono">models</code> replaces the model list entirely.{" "}
<code className="font-mono">additionalModels</code> merges with runtime-discovered models
(ACP) or with <code className="font-mono">models</code> — use it to add an extra entry or
relabel a discovered one without redeclaring the full list. An entry with the same{" "}
<code className="font-mono">id</code> as a discovered model updates it in place.
</p>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"gemini": {
"extends": "acp",
"label": "Google Gemini",
"command": ["gemini", "--acp"],
"additionalModels": [
{ "id": "experimental-model", "label": "Experimental", "isDefault": true },
{ "id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro (preferred)" }
]
}
}
}
}`}
</pre>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Disabling a provider</h2>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"copilot": { "enabled": false }
}
}
}`}
</pre>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Full reference</h2>
<p className="text-white/60 leading-relaxed">
For the complete field reference (<code className="font-mono">extends</code>,{" "}
<code className="font-mono">label</code>, <code className="font-mono">command</code>,{" "}
<code className="font-mono">env</code>, <code className="font-mono">models</code>,{" "}
<code className="font-mono">additionalModels</code>,{" "}
<code className="font-mono">disallowedTools</code>,{" "}
<code className="font-mono">enabled</code>, <code className="font-mono">order</code>),
model and thinking-option schemas, and deeper examples for each plan, see{" "}
<a
href={CUSTOM_PROVIDERS_URL}
target="_blank"
rel="noreferrer"
className="underline hover:text-white/80"
>
docs/CUSTOM-PROVIDERS.md
</a>{" "}
on GitHub.
</p>
</section>
</div>
);
}
-1
View File
@@ -20,7 +20,6 @@ const sitemapPages = [
"/docs/best-practices",
"/docs/cli",
"/docs/configuration",
"/docs/providers",
"/docs/skills",
"/docs/security",
"/docs/updates",