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 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
@@ -0,0 +1,6 @@
1
+ export class PizzaRuntimeError extends Error {
2
+ constructor(message: string) {
3
+ super(message);
4
+ this.name = "PizzaRuntimeError";
5
+ }
6
+ }
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
+ }
@@ -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
+ }