pi-pizza 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -0
- package/extensions/index.ts +53 -0
- package/package.json +35 -0
- package/src/config.ts +143 -0
- package/src/errors.ts +6 -0
- package/src/mascot.ts +49 -0
- package/src/router.ts +125 -0
- package/src/runtime.ts +161 -0
- package/src/settings.ts +38 -0
- package/src/types.ts +44 -0
- package/tsconfig.json +19 -0
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# pi-pizza
|
|
2
|
+
|
|
3
|
+
`pi-pizza` is a stock `pi` package that adds the Pizza startup mascot and a `pizza/auto` model provider.
|
|
4
|
+
|
|
5
|
+
The package keeps `pi` itself unmodified. Install it as an extension package, then use the `pizza/auto` model to route each turn to a task-appropriate provider/model.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Registers a `pizza/auto` model provider.
|
|
10
|
+
- Sets `pizza/auto` as the global default model after the extension first loads.
|
|
11
|
+
- Keeps explicit CLI choices intact: `pi --model ...` or `pi --provider ...` are not overwritten.
|
|
12
|
+
- Shows the Pizza mascot in the startup header while keeping the stock `pi` help text.
|
|
13
|
+
- Creates `~/.pi/agent/pizza.json` on first load.
|
|
14
|
+
- Routes turns across six roles: planner, reader, easy builder, hard backend builder, hard frontend builder, and executor.
|
|
15
|
+
- Prints routing decisions to stderr, for example:
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
[pizza] 🍕 Routing to CodeBuilder [Easy / GENERAL]: opencode-go/deepseek-v4-pro
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
From npm:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pi install npm:pi-pizza
|
|
27
|
+
pi
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
From a local checkout:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
git clone https://github.com/parkjangwon/pi-pizza.git
|
|
34
|
+
cd pi-pizza
|
|
35
|
+
npm install --ignore-scripts
|
|
36
|
+
pi install .
|
|
37
|
+
pi
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
For one-off local testing without installing:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pi -e .
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Default Model Behavior
|
|
47
|
+
|
|
48
|
+
After the extension first loads, it writes these global defaults to `~/.pi/agent/settings.json`:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"defaultProvider": "pizza",
|
|
53
|
+
"defaultModel": "auto"
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
That means plain `pi` will use `pizza/auto` on later runs.
|
|
58
|
+
|
|
59
|
+
To bypass Pizza for a single run, pass a model explicitly:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pi --model anthropic/claude-sonnet-4-5
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Configuration
|
|
66
|
+
|
|
67
|
+
On first load, `pi-pizza` creates `~/.pi/agent/pizza.json`:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"plannerModel": "",
|
|
72
|
+
"readerModel": "",
|
|
73
|
+
"builderEasyModel": "",
|
|
74
|
+
"builderHardBackendModel": "",
|
|
75
|
+
"builderHardFrontendModel": "",
|
|
76
|
+
"executorModel": ""
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Leave a field blank for auto-detection, or set a concrete `provider/model-id`.
|
|
81
|
+
|
|
82
|
+
Example:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"plannerModel": "deepseek/deepseek-chat",
|
|
87
|
+
"readerModel": "google/gemini-2.0-flash",
|
|
88
|
+
"builderEasyModel": "opencode-go/deepseek-v4-pro",
|
|
89
|
+
"builderHardBackendModel": "anthropic/claude-sonnet-4-5",
|
|
90
|
+
"builderHardFrontendModel": "anthropic/claude-sonnet-4-5",
|
|
91
|
+
"executorModel": "groq/llama-3.3-70b-versatile"
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Only models already known to `pi` and configured with auth can be selected automatically.
|
|
96
|
+
|
|
97
|
+
## Commands
|
|
98
|
+
|
|
99
|
+
- `/pizza` shows whether the extension is active and where the config lives.
|
|
100
|
+
- `/pizza-models` shows the resolved concrete model for each Pizza role.
|
|
101
|
+
- `/pizza-reset` asks for confirmation, then deletes `~/.pi/agent/pizza.json`.
|
|
102
|
+
- `/pizza-header-reset` restores the built-in `pi` startup header for the current session.
|
|
103
|
+
|
|
104
|
+
## Development
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npm install --ignore-scripts
|
|
108
|
+
npm run check
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The package is loaded by the `pi` manifest in `package.json`:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"pi": {
|
|
116
|
+
"extensions": ["./extensions/index.ts"]
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { deleteConfigFile, getConfigPath } from "../src/config.ts";
|
|
3
|
+
import { registerMascot } from "../src/mascot.ts";
|
|
4
|
+
import { PizzaRuntime } from "../src/runtime.ts";
|
|
5
|
+
import { ensurePizzaDefaultSettings, hasExplicitModelSelection } from "../src/settings.ts";
|
|
6
|
+
|
|
7
|
+
export default function (pi: ExtensionAPI): void {
|
|
8
|
+
const runtime = new PizzaRuntime();
|
|
9
|
+
|
|
10
|
+
pi.registerProvider("pizza", runtime.createProviderConfig());
|
|
11
|
+
registerMascot(pi);
|
|
12
|
+
|
|
13
|
+
pi.on("session_start", (_event, ctx) => {
|
|
14
|
+
runtime.bind(ctx.modelRegistry);
|
|
15
|
+
ensurePizzaDefaultSettings();
|
|
16
|
+
if (!hasExplicitModelSelection() && ctx.model?.provider !== "pizza") {
|
|
17
|
+
const pizzaAuto = ctx.modelRegistry.find("pizza", "auto");
|
|
18
|
+
if (pizzaAuto) {
|
|
19
|
+
void pi.setModel(pizzaAuto);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
pi.registerCommand("pizza", {
|
|
25
|
+
description: "Show pi-pizza routing configuration",
|
|
26
|
+
handler: async (_args, ctx) => {
|
|
27
|
+
runtime.bind(ctx.modelRegistry);
|
|
28
|
+
ctx.ui.notify(runtime.describe(), "info");
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
pi.registerCommand("pizza-models", {
|
|
33
|
+
description: "Show resolved pi-pizza role models",
|
|
34
|
+
handler: async (_args, ctx) => {
|
|
35
|
+
runtime.bind(ctx.modelRegistry);
|
|
36
|
+
ctx.ui.notify(runtime.describeModels(), "info");
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
pi.registerCommand("pizza-reset", {
|
|
41
|
+
description: "Delete ~/.pi/agent/pizza.json after confirmation",
|
|
42
|
+
handler: async (_args, ctx) => {
|
|
43
|
+
const configPath = getConfigPath();
|
|
44
|
+
const confirmed = await ctx.ui.confirm("Delete pi-pizza config?", `Delete ${configPath}?`);
|
|
45
|
+
if (!confirmed) {
|
|
46
|
+
ctx.ui.notify("pi-pizza config deletion cancelled", "info");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const deleted = deleteConfigFile();
|
|
50
|
+
ctx.ui.notify(deleted ? `Deleted ${configPath}` : `No config file found at ${configPath}`, "info");
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-pizza",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pizza mascot and multi-provider auto-router for pi.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"pizza",
|
|
10
|
+
"router"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"scripts": {
|
|
14
|
+
"check": "tsc --noEmit"
|
|
15
|
+
},
|
|
16
|
+
"pi": {
|
|
17
|
+
"extensions": [
|
|
18
|
+
"./extensions/index.ts"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"chalk": "5.6.2"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@earendil-works/pi-ai": "*",
|
|
26
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
27
|
+
"typebox": "*"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@earendil-works/pi-ai": "0.78.0",
|
|
31
|
+
"@earendil-works/pi-coding-agent": "0.78.0",
|
|
32
|
+
"typescript": "5.9.3",
|
|
33
|
+
"typebox": "1.0.63"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { Compile } from "typebox/compile";
|
|
6
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
7
|
+
import type { ModelLookup, PizzaConfigFile, PizzaResolvedConfig } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
const ConfigSchema = Type.Object({
|
|
10
|
+
plannerModel: Type.String(),
|
|
11
|
+
readerModel: Type.String(),
|
|
12
|
+
builderEasyModel: Type.String(),
|
|
13
|
+
builderHardBackendModel: Type.String(),
|
|
14
|
+
builderHardFrontendModel: Type.String(),
|
|
15
|
+
executorModel: Type.String(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const validateConfig = Compile(ConfigSchema);
|
|
19
|
+
|
|
20
|
+
const emptyConfig: PizzaConfigFile = {
|
|
21
|
+
plannerModel: "",
|
|
22
|
+
readerModel: "",
|
|
23
|
+
builderEasyModel: "",
|
|
24
|
+
builderHardBackendModel: "",
|
|
25
|
+
builderHardFrontendModel: "",
|
|
26
|
+
executorModel: "",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function getConfigPath(): string {
|
|
30
|
+
return join(homedir(), ".pi", "agent", "pizza.json");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function loadConfigFile(): PizzaConfigFile {
|
|
34
|
+
const configPath = getConfigPath();
|
|
35
|
+
if (!existsSync(configPath)) {
|
|
36
|
+
mkdirSync(join(homedir(), ".pi", "agent"), { recursive: true });
|
|
37
|
+
writeFileSync(configPath, `${JSON.stringify(emptyConfig, null, 2)}\n`, "utf8");
|
|
38
|
+
return emptyConfig;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const parsed: unknown = JSON.parse(readFileSync(configPath, "utf8"));
|
|
42
|
+
if (!validateConfig.Check(parsed)) {
|
|
43
|
+
return emptyConfig;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return parsed;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function deleteConfigFile(): boolean {
|
|
50
|
+
const configPath = getConfigPath();
|
|
51
|
+
if (!existsSync(configPath)) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
unlinkSync(configPath);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function resolveConfig(modelLookup: ModelLookup): PizzaResolvedConfig {
|
|
59
|
+
const available = modelLookup.getAvailable().filter((model) => model.provider !== "pizza");
|
|
60
|
+
const fallback = available[0] ?? createUnavailableModel();
|
|
61
|
+
const autoPlanner = findModel(
|
|
62
|
+
available,
|
|
63
|
+
["deepseek", "openai", "openrouter", "groq", "minimax", "minimax-cn", "zai", "google"],
|
|
64
|
+
["gpt-4o-mini", "llama", "flash", "deepseek-chat", "chat", "spark", "mini"],
|
|
65
|
+
fallback,
|
|
66
|
+
);
|
|
67
|
+
const autoReader = findModel(
|
|
68
|
+
available,
|
|
69
|
+
["deepseek", "opencode-go", "opencode", "zai", "minimax", "minimax-cn", "groq", "openai", "openrouter", "google"],
|
|
70
|
+
["gpt-4o-mini", "llama", "flash", "deepseek-chat", "chat", "lite", "spark", "mini", "gemini-2.0-flash"],
|
|
71
|
+
autoPlanner,
|
|
72
|
+
);
|
|
73
|
+
const autoHardBackend = findModel(
|
|
74
|
+
available,
|
|
75
|
+
["deepseek", "anthropic", "openai", "openrouter", "groq", "zai", "minimax", "minimax-cn", "google"],
|
|
76
|
+
["deepseek-coder", "coder", "r1", "reasoning", "sonnet", "claude-3-5", "grok-4", "grok-2", "gpt-4o"],
|
|
77
|
+
autoPlanner,
|
|
78
|
+
);
|
|
79
|
+
const autoHardFrontend = findModel(
|
|
80
|
+
available,
|
|
81
|
+
["anthropic", "openai", "openrouter", "deepseek", "google"],
|
|
82
|
+
["sonnet", "claude-3-5", "gpt-4o", "deepseek-coder", "coder", "pro", "gemini-2.0-pro"],
|
|
83
|
+
autoHardBackend,
|
|
84
|
+
);
|
|
85
|
+
const autoExecutor = findModel(
|
|
86
|
+
available,
|
|
87
|
+
["groq", "deepseek", "openai", "openrouter", "zai", "minimax", "minimax-cn", "google"],
|
|
88
|
+
["fast", "gpt-4o-mini", "llama", "deepseek-chat", "chat", "flash"],
|
|
89
|
+
autoPlanner,
|
|
90
|
+
);
|
|
91
|
+
const file = loadConfigFile();
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
plannerModel: parseModelSpec(modelLookup, file.plannerModel, autoPlanner),
|
|
95
|
+
readerModel: parseModelSpec(modelLookup, file.readerModel, autoReader),
|
|
96
|
+
builderEasyModel: parseModelSpec(modelLookup, file.builderEasyModel, autoReader),
|
|
97
|
+
builderHardBackendModel: parseModelSpec(modelLookup, file.builderHardBackendModel, autoHardBackend),
|
|
98
|
+
builderHardFrontendModel: parseModelSpec(modelLookup, file.builderHardFrontendModel, autoHardFrontend),
|
|
99
|
+
executorModel: parseModelSpec(modelLookup, file.executorModel, autoExecutor),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function findModel(
|
|
104
|
+
available: readonly Model<Api>[],
|
|
105
|
+
providerPreference: readonly string[],
|
|
106
|
+
idKeywords: readonly string[],
|
|
107
|
+
fallback: Model<Api>,
|
|
108
|
+
): Model<Api> {
|
|
109
|
+
for (const provider of providerPreference) {
|
|
110
|
+
const match = available.find(
|
|
111
|
+
(model) => model.provider === provider && idKeywords.some((keyword) => model.id.toLowerCase().includes(keyword)),
|
|
112
|
+
);
|
|
113
|
+
if (match) return match;
|
|
114
|
+
}
|
|
115
|
+
for (const provider of providerPreference) {
|
|
116
|
+
const match = available.find((model) => model.provider === provider);
|
|
117
|
+
if (match) return match;
|
|
118
|
+
}
|
|
119
|
+
return fallback;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseModelSpec(modelLookup: ModelLookup, modelSpec: string, fallback: Model<Api>): Model<Api> {
|
|
123
|
+
const slashIndex = modelSpec.indexOf("/");
|
|
124
|
+
if (slashIndex <= 0 || slashIndex === modelSpec.length - 1) {
|
|
125
|
+
return fallback;
|
|
126
|
+
}
|
|
127
|
+
return modelLookup.find(modelSpec.slice(0, slashIndex), modelSpec.slice(slashIndex + 1)) ?? fallback;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function createUnavailableModel(): Model<Api> {
|
|
131
|
+
return {
|
|
132
|
+
id: "unavailable",
|
|
133
|
+
name: "Unavailable",
|
|
134
|
+
api: "openai-completions",
|
|
135
|
+
provider: "pizza",
|
|
136
|
+
baseUrl: "https://invalid.local",
|
|
137
|
+
reasoning: false,
|
|
138
|
+
input: ["text"],
|
|
139
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
140
|
+
contextWindow: 128000,
|
|
141
|
+
maxTokens: 4096,
|
|
142
|
+
};
|
|
143
|
+
}
|
package/src/errors.ts
ADDED
package/src/mascot.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { VERSION, type ExtensionAPI, type Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
export function registerMascot(pi: ExtensionAPI): void {
|
|
5
|
+
pi.on("session_start", (_event, ctx) => {
|
|
6
|
+
if (!ctx.hasUI) return;
|
|
7
|
+
ctx.ui.setHeader((_tui, theme) => ({
|
|
8
|
+
render: () => renderPizzaStartupLogo(VERSION, theme).split("\n"),
|
|
9
|
+
invalidate: () => {},
|
|
10
|
+
}));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
pi.registerCommand("pizza-header-reset", {
|
|
14
|
+
description: "Restore the built-in pi startup header",
|
|
15
|
+
handler: async (_args, ctx) => {
|
|
16
|
+
ctx.ui.setHeader(undefined);
|
|
17
|
+
ctx.ui.notify("Built-in header restored", "info");
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function renderPizzaStartupLogo(version: string, theme: Theme): string {
|
|
23
|
+
const mascotLines = [
|
|
24
|
+
chalk.white(" ▄███▄ "),
|
|
25
|
+
chalk.white(" ███ "),
|
|
26
|
+
chalk.hex("#D2B48C")(" ▐▛███▜▌ "),
|
|
27
|
+
chalk.hex("#D2B48C")("▝▜█████▛▘"),
|
|
28
|
+
chalk.hex("#D2B48C")(" ▘▘ ▝▝ "),
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const titleLine = `${theme.bold(theme.fg("accent", "pi"))}${theme.fg("dim", ` v${version}`)}`;
|
|
32
|
+
const compactInstructions = theme.fg(
|
|
33
|
+
"muted",
|
|
34
|
+
"escape interrupt · ctrl+c/ctrl+d clear/exit · / commands · ! bash · ctrl+o more",
|
|
35
|
+
);
|
|
36
|
+
const compactOnboarding = theme.fg("dim", "Press ctrl+o to show full startup help and loaded resources.");
|
|
37
|
+
const onboarding = theme.fg(
|
|
38
|
+
"dim",
|
|
39
|
+
"Pi can explain its own features and look up its docs. Ask it how to use or extend Pi.",
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return [
|
|
43
|
+
`${mascotLines[0]} ${titleLine}`,
|
|
44
|
+
`${mascotLines[1]} ${compactInstructions}`,
|
|
45
|
+
`${mascotLines[2]} ${compactOnboarding}`,
|
|
46
|
+
`${mascotLines[3]}`,
|
|
47
|
+
`${mascotLines[4]} ${onboarding}`,
|
|
48
|
+
].join("\n");
|
|
49
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { completeSimple, type Api, type Context, type Message, type Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ModelLookup, PizzaDecision, PizzaResolvedConfig } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
let lastClassifiedPrompt = "";
|
|
5
|
+
let lastDecision: PizzaDecision | undefined;
|
|
6
|
+
|
|
7
|
+
export async function selectModel(
|
|
8
|
+
config: PizzaResolvedConfig,
|
|
9
|
+
modelLookup: ModelLookup,
|
|
10
|
+
context: Context,
|
|
11
|
+
): Promise<{ readonly model: Model<Api>; readonly label: string }> {
|
|
12
|
+
const lastMessage = context.messages[context.messages.length - 1];
|
|
13
|
+
const userPrompt = getLastUserMessageText(context.messages);
|
|
14
|
+
const isExecuting =
|
|
15
|
+
lastMessage?.role === "toolResult" ||
|
|
16
|
+
(lastMessage?.role === "assistant" && lastMessage.content.some((content) => content.type === "toolCall"));
|
|
17
|
+
|
|
18
|
+
if (isExecuting) {
|
|
19
|
+
return { model: config.executorModel, label: "CommandExecutor" };
|
|
20
|
+
}
|
|
21
|
+
if (!userPrompt) {
|
|
22
|
+
return { model: config.builderEasyModel, label: "CodeBuilder [Easy / GENERAL]" };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const decision = await classifyIntent(config.readerModel, modelLookup, userPrompt);
|
|
26
|
+
if (decision.difficulty === "EASY") {
|
|
27
|
+
return { model: config.builderEasyModel, label: `CodeBuilder [Easy / ${decision.domain}]` };
|
|
28
|
+
}
|
|
29
|
+
if (decision.domain === "FRONTEND") {
|
|
30
|
+
return { model: config.builderHardFrontendModel, label: "CodeBuilder [Hard / Frontend]" };
|
|
31
|
+
}
|
|
32
|
+
return { model: config.builderHardBackendModel, label: `CodeBuilder [Hard / ${decision.domain}]` };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getLastUserMessageText(messages: readonly Message[]): string {
|
|
36
|
+
const lastUser = [...messages].reverse().find((message) => message.role === "user");
|
|
37
|
+
if (!lastUser) return "";
|
|
38
|
+
if (typeof lastUser.content === "string") return lastUser.content;
|
|
39
|
+
return lastUser.content
|
|
40
|
+
.filter((content) => content.type === "text")
|
|
41
|
+
.map((content) => content.text)
|
|
42
|
+
.join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function classifyIntent(model: Model<Api>, modelLookup: ModelLookup, userPrompt: string): Promise<PizzaDecision> {
|
|
46
|
+
if (lastClassifiedPrompt === userPrompt && lastDecision) {
|
|
47
|
+
return lastDecision;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const auth = await modelLookup.getApiKeyAndHeaders(model);
|
|
51
|
+
const message = await completeSimple(
|
|
52
|
+
model,
|
|
53
|
+
{
|
|
54
|
+
messages: [{ role: "user", content: buildClassificationPrompt(userPrompt), timestamp: Date.now() }],
|
|
55
|
+
},
|
|
56
|
+
auth.ok ? buildAuthOptions(auth) : undefined,
|
|
57
|
+
);
|
|
58
|
+
const text = message.content
|
|
59
|
+
.filter((content) => content.type === "text")
|
|
60
|
+
.map((content) => content.text)
|
|
61
|
+
.join("\n");
|
|
62
|
+
const decision = parseDecision(text);
|
|
63
|
+
|
|
64
|
+
lastClassifiedPrompt = userPrompt;
|
|
65
|
+
lastDecision = decision;
|
|
66
|
+
return decision;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseDecision(text: string): PizzaDecision {
|
|
70
|
+
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
71
|
+
if (!jsonMatch) return { difficulty: "EASY", domain: "GENERAL" };
|
|
72
|
+
let parsed: unknown;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(jsonMatch[0]);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error instanceof SyntaxError) {
|
|
77
|
+
return { difficulty: "EASY", domain: "GENERAL" };
|
|
78
|
+
}
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
if (!isDecisionPayload(parsed)) return { difficulty: "EASY", domain: "GENERAL" };
|
|
82
|
+
return {
|
|
83
|
+
difficulty: parsed.difficulty,
|
|
84
|
+
domain: parsed.domain,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildAuthOptions(auth: { readonly apiKey?: string; readonly headers?: Record<string, string> }): {
|
|
89
|
+
readonly apiKey?: string;
|
|
90
|
+
readonly headers?: Record<string, string>;
|
|
91
|
+
} {
|
|
92
|
+
return {
|
|
93
|
+
...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
|
|
94
|
+
...(auth.headers ? { headers: auth.headers } : {}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isDecisionPayload(value: unknown): value is PizzaDecision {
|
|
99
|
+
if (!value || typeof value !== "object") return false;
|
|
100
|
+
if (!("difficulty" in value) || !("domain" in value)) return false;
|
|
101
|
+
return (
|
|
102
|
+
(value.difficulty === "EASY" || value.difficulty === "HARD") &&
|
|
103
|
+
(value.domain === "FRONTEND" || value.domain === "BACKEND" || value.domain === "GENERAL")
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildClassificationPrompt(userPrompt: string): string {
|
|
108
|
+
return `You are the TaskPlanner for pi-pizza, a multi-provider coding-agent router.
|
|
109
|
+
Classify the user's request.
|
|
110
|
+
|
|
111
|
+
Difficulty:
|
|
112
|
+
- EASY: simple reads, comments, renames, small boilerplate, basic CRUD, simple commands.
|
|
113
|
+
- HARD: complex reasoning, large refactors, algorithms, deep type errors, delicate UI/CSS.
|
|
114
|
+
|
|
115
|
+
Domain:
|
|
116
|
+
- FRONTEND: React, HTML, CSS, visual layouts, styling, component design.
|
|
117
|
+
- BACKEND: databases, servers, API logic, algorithms, TypeScript config, scripts.
|
|
118
|
+
- GENERAL: git, questions, docs, and non-code tasks.
|
|
119
|
+
|
|
120
|
+
Return strict JSON only:
|
|
121
|
+
{"difficulty":"EASY"|"HARD","domain":"FRONTEND"|"BACKEND"|"GENERAL"}
|
|
122
|
+
|
|
123
|
+
User request:
|
|
124
|
+
${userPrompt}`;
|
|
125
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAssistantMessageEventStream,
|
|
3
|
+
type Api,
|
|
4
|
+
type AssistantMessage,
|
|
5
|
+
type AssistantMessageEventStream,
|
|
6
|
+
type Context,
|
|
7
|
+
type Model,
|
|
8
|
+
type SimpleStreamOptions,
|
|
9
|
+
streamSimple,
|
|
10
|
+
} from "@earendil-works/pi-ai";
|
|
11
|
+
import type { ProviderConfig } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { resolveConfig } from "./config.ts";
|
|
13
|
+
import { PizzaRuntimeError } from "./errors.ts";
|
|
14
|
+
import { selectModel } from "./router.ts";
|
|
15
|
+
import type { ModelLookup, PizzaResolvedConfig } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
export class PizzaRuntime {
|
|
18
|
+
private modelLookup: ModelLookup | undefined;
|
|
19
|
+
private config: PizzaResolvedConfig | undefined;
|
|
20
|
+
|
|
21
|
+
bind(modelLookup: ModelLookup): void {
|
|
22
|
+
this.modelLookup = modelLookup;
|
|
23
|
+
this.config = resolveConfig(modelLookup);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
createProviderConfig(): ProviderConfig {
|
|
27
|
+
return {
|
|
28
|
+
name: "Pizza",
|
|
29
|
+
baseUrl: "https://pizza.local",
|
|
30
|
+
apiKey: "pizza-router",
|
|
31
|
+
api: "pizza-router",
|
|
32
|
+
streamSimple: (_model, context, options) => this.route(context, options),
|
|
33
|
+
models: [
|
|
34
|
+
{
|
|
35
|
+
id: "auto",
|
|
36
|
+
name: "Pizza Auto Router",
|
|
37
|
+
api: "pizza-router",
|
|
38
|
+
reasoning: true,
|
|
39
|
+
input: ["text", "image"],
|
|
40
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
41
|
+
contextWindow: 1000000,
|
|
42
|
+
maxTokens: 64000,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe(): string {
|
|
49
|
+
return `pi-pizza is active. Config: ~/.pi/agent/pizza.json. Select pizza/auto to route turns automatically.`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describeModels(): string {
|
|
53
|
+
const config = this.requireConfig();
|
|
54
|
+
return [
|
|
55
|
+
formatRole("plannerModel", config.plannerModel),
|
|
56
|
+
formatRole("readerModel", config.readerModel),
|
|
57
|
+
formatRole("builderEasyModel", config.builderEasyModel),
|
|
58
|
+
formatRole("builderHardBackendModel", config.builderHardBackendModel),
|
|
59
|
+
formatRole("builderHardFrontendModel", config.builderHardFrontendModel),
|
|
60
|
+
formatRole("executorModel", config.executorModel),
|
|
61
|
+
].join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private route(context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
|
|
65
|
+
const stream = createAssistantMessageEventStream();
|
|
66
|
+
void this.pipeRoutedStream(stream, context, options);
|
|
67
|
+
return stream;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private async pipeRoutedStream(
|
|
71
|
+
output: AssistantMessageEventStream,
|
|
72
|
+
context: Context,
|
|
73
|
+
options: SimpleStreamOptions | undefined,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
try {
|
|
76
|
+
const modelLookup = this.requireModelLookup();
|
|
77
|
+
const config = this.requireConfig();
|
|
78
|
+
if (!hasConcreteModels(config)) {
|
|
79
|
+
throw new PizzaRuntimeError("No authenticated non-pizza models are available for routing.");
|
|
80
|
+
}
|
|
81
|
+
const routed = await selectModel(config, modelLookup, context);
|
|
82
|
+
process.stderr.write(`[pizza] 🍕 Routing to ${routed.label}: ${routed.model.provider}/${routed.model.id}\n`);
|
|
83
|
+
|
|
84
|
+
const auth = await modelLookup.getApiKeyAndHeaders(routed.model);
|
|
85
|
+
if (!auth.ok) {
|
|
86
|
+
throw new PizzaRuntimeError(auth.error);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const input = streamSimple(routed.model, context, mergeOptions(options, auth));
|
|
90
|
+
for await (const event of input) {
|
|
91
|
+
output.push(event);
|
|
92
|
+
}
|
|
93
|
+
output.end();
|
|
94
|
+
} catch (error) {
|
|
95
|
+
output.push({ type: "error", reason: "error", error: createErrorMessage(error) });
|
|
96
|
+
output.end();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private requireModelLookup(): ModelLookup {
|
|
101
|
+
if (!this.modelLookup) {
|
|
102
|
+
throw new PizzaRuntimeError("pi-pizza has not been bound to a pi session yet.");
|
|
103
|
+
}
|
|
104
|
+
return this.modelLookup;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private requireConfig(): PizzaResolvedConfig {
|
|
108
|
+
if (!this.config) {
|
|
109
|
+
throw new PizzaRuntimeError("pi-pizza config is not loaded yet.");
|
|
110
|
+
}
|
|
111
|
+
return this.config;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function mergeOptions(
|
|
116
|
+
options: SimpleStreamOptions | undefined,
|
|
117
|
+
auth: { readonly apiKey?: string; readonly headers?: Record<string, string> },
|
|
118
|
+
): SimpleStreamOptions {
|
|
119
|
+
return {
|
|
120
|
+
...options,
|
|
121
|
+
...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
|
|
122
|
+
...(auth.headers ? { headers: { ...options?.headers, ...auth.headers } } : {}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function formatRole(role: string, model: Model<Api>): string {
|
|
127
|
+
return `${role}: ${model.provider}/${model.id}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function hasConcreteModels(config: PizzaResolvedConfig): boolean {
|
|
131
|
+
return [
|
|
132
|
+
config.plannerModel,
|
|
133
|
+
config.readerModel,
|
|
134
|
+
config.builderEasyModel,
|
|
135
|
+
config.builderHardBackendModel,
|
|
136
|
+
config.builderHardFrontendModel,
|
|
137
|
+
config.executorModel,
|
|
138
|
+
].some((model) => model.provider !== "pizza");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function createErrorMessage(error: unknown): AssistantMessage {
|
|
142
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
143
|
+
return {
|
|
144
|
+
role: "assistant",
|
|
145
|
+
content: [],
|
|
146
|
+
api: "pizza-router",
|
|
147
|
+
provider: "pizza",
|
|
148
|
+
model: "auto",
|
|
149
|
+
usage: {
|
|
150
|
+
input: 0,
|
|
151
|
+
output: 0,
|
|
152
|
+
cacheRead: 0,
|
|
153
|
+
cacheWrite: 0,
|
|
154
|
+
totalTokens: 0,
|
|
155
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
156
|
+
},
|
|
157
|
+
stopReason: "error",
|
|
158
|
+
errorMessage: message,
|
|
159
|
+
timestamp: Date.now(),
|
|
160
|
+
};
|
|
161
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const PIZZA_PROVIDER = "pizza";
|
|
6
|
+
const PIZZA_MODEL = "auto";
|
|
7
|
+
|
|
8
|
+
export function hasExplicitModelSelection(argv: readonly string[] = process.argv): boolean {
|
|
9
|
+
return argv.includes("--model") || argv.includes("--provider");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ensurePizzaDefaultSettings(): boolean {
|
|
13
|
+
const settingsPath = join(homedir(), ".pi", "agent", "settings.json");
|
|
14
|
+
const current = readSettings(settingsPath);
|
|
15
|
+
if (current["defaultProvider"] === PIZZA_PROVIDER && current["defaultModel"] === PIZZA_MODEL) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const next = {
|
|
20
|
+
...current,
|
|
21
|
+
defaultProvider: PIZZA_PROVIDER,
|
|
22
|
+
defaultModel: PIZZA_MODEL,
|
|
23
|
+
};
|
|
24
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
25
|
+
writeFileSync(settingsPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readSettings(settingsPath: string): Record<string, unknown> {
|
|
30
|
+
if (!existsSync(settingsPath)) {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
const parsed: unknown = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
34
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
return Object.fromEntries(Object.entries(parsed));
|
|
38
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
export type PizzaRole =
|
|
4
|
+
| "plannerModel"
|
|
5
|
+
| "readerModel"
|
|
6
|
+
| "builderEasyModel"
|
|
7
|
+
| "builderHardBackendModel"
|
|
8
|
+
| "builderHardFrontendModel"
|
|
9
|
+
| "executorModel";
|
|
10
|
+
|
|
11
|
+
export interface PizzaConfigFile {
|
|
12
|
+
readonly plannerModel: string;
|
|
13
|
+
readonly readerModel: string;
|
|
14
|
+
readonly builderEasyModel: string;
|
|
15
|
+
readonly builderHardBackendModel: string;
|
|
16
|
+
readonly builderHardFrontendModel: string;
|
|
17
|
+
readonly executorModel: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PizzaResolvedConfig {
|
|
21
|
+
readonly plannerModel: Model<Api>;
|
|
22
|
+
readonly readerModel: Model<Api>;
|
|
23
|
+
readonly builderEasyModel: Model<Api>;
|
|
24
|
+
readonly builderHardBackendModel: Model<Api>;
|
|
25
|
+
readonly builderHardFrontendModel: Model<Api>;
|
|
26
|
+
readonly executorModel: Model<Api>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type PizzaDifficulty = "EASY" | "HARD";
|
|
30
|
+
export type PizzaDomain = "FRONTEND" | "BACKEND" | "GENERAL";
|
|
31
|
+
|
|
32
|
+
export interface PizzaDecision {
|
|
33
|
+
readonly difficulty: PizzaDifficulty;
|
|
34
|
+
readonly domain: PizzaDomain;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ModelLookup {
|
|
38
|
+
getAvailable(): Model<Api>[];
|
|
39
|
+
find(provider: string, modelId: string): Model<Api> | undefined;
|
|
40
|
+
getApiKeyAndHeaders(model: Model<Api>): Promise<
|
|
41
|
+
| { readonly ok: true; readonly apiKey?: string; readonly headers?: Record<string, string> }
|
|
42
|
+
| { readonly ok: false; readonly error: string }
|
|
43
|
+
>;
|
|
44
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022", "DOM"],
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noUncheckedIndexedAccess": true,
|
|
9
|
+
"exactOptionalPropertyTypes": true,
|
|
10
|
+
"noImplicitOverride": true,
|
|
11
|
+
"noPropertyAccessFromIndexSignature": true,
|
|
12
|
+
"noFallthroughCasesInSwitch": true,
|
|
13
|
+
"verbatimModuleSyntax": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"allowImportingTsExtensions": true,
|
|
16
|
+
"noEmit": true
|
|
17
|
+
},
|
|
18
|
+
"include": ["extensions/**/*.ts", "src/**/*.ts"]
|
|
19
|
+
}
|