mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
This adds experimental support for virtual models. A virtual model is a catalog entry that an extension registers with pi.registerVirtualModel(). It does not talk to a provider itself; for every request it picks a physical model and thinking level. The motivation is to make routing policies pluggable: plan on a strong model and implement on a cheap one, pick a model with a classifier, or move to a larger window when the context grows. Today this needs model switching by hand or hacks in prepareRequest. The selection stays virtual: model_change records it, resume restores it, and each response records the physical model and thinking level that produced it. Router state can be stored on the session branch, so it follows /tree and forks and survives compaction. Context limits, compaction and image sizing use the physical model a request was routed to. examples/extensions/jev-router.ts shows a full router that uses the Jev classifier to pick Sol or Terra for planning and hands off to Luna after the first edit. docs/virtual-models.md describes the API.
113 lines
4.7 KiB
TypeScript
113 lines
4.7 KiB
TypeScript
/**
|
|
* Jev router - a virtual model that plans on a strong model and implements on a cheap one.
|
|
*
|
|
* Registers `jev/auto`, which routes between three OpenAI Codex models:
|
|
*
|
|
* - Planning: GPT-5.6 Sol for complex work, GPT-5.6 Terra otherwise. The Jev classifier rates the
|
|
* first user message; planning stays on the chosen model.
|
|
* - Implementation: GPT-5.6 Luna.
|
|
*
|
|
* The planning model explores, plans, and makes the first edit. After the first successful `edit`
|
|
* or `write` tool call, the next request of the same turn goes to Luna, and the session stays
|
|
* there. A session therefore switches models once and accepts a single prompt-cache miss.
|
|
*
|
|
* The phase is router state: Pi stores it on the session branch, so it follows the session tree
|
|
* and survives compaction. The selected thinking level passes through as the reasoning effort of
|
|
* the chosen model. Requests outside the agent loop, such as compaction summaries, go to Luna.
|
|
*
|
|
* Requires TypeSafe credentials (TYPESAFE_API_KEY) and an OpenAI Codex login.
|
|
* Usage: pi -e ./jev-router.ts --model jev/auto
|
|
*/
|
|
|
|
import type { Message } from "@earendil-works/pi-ai";
|
|
import type { ExtensionAPI, ExtensionContext, ModelRoute, ModelRouteRequest } from "@earendil-works/pi-coding-agent";
|
|
|
|
const PROVIDER = "openai-codex";
|
|
const SOL = "gpt-5.6-sol";
|
|
const TERRA = "gpt-5.6-terra";
|
|
const LUNA = "gpt-5.6-luna";
|
|
|
|
/** Tools whose successful result means implementation has started. */
|
|
const EDIT_TOOLS = new Set(["edit", "write"]);
|
|
|
|
interface JevState {
|
|
phase: "planning" | "implementation";
|
|
/** OpenAI Codex model for this phase. */
|
|
model: string;
|
|
}
|
|
|
|
type JevRequest = ModelRouteRequest<JevState>;
|
|
|
|
function routeTo(request: JevRequest, ctx: ExtensionContext, id: string, state?: JevState): ModelRoute<JevState> {
|
|
const model = ctx.modelRegistry.find(PROVIDER, id);
|
|
if (!model) throw new Error(`Model ${PROVIDER}/${id} is not in the catalog`);
|
|
return { model, thinkingLevel: request.thinkingLevel, state };
|
|
}
|
|
|
|
function lastUserText(messages: readonly Message[]): string {
|
|
const content = messages.filter((message) => message.role === "user").at(-1)?.content ?? "";
|
|
if (typeof content === "string") return content;
|
|
return content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("\n");
|
|
}
|
|
|
|
/** Whether a tool call since the last user message edited a file successfully. */
|
|
function editedThisTurn(messages: readonly Message[]): boolean {
|
|
const lastUser = messages.findLastIndex((message) => message.role === "user");
|
|
return messages
|
|
.slice(lastUser + 1)
|
|
.some((message) => message.role === "toolResult" && EDIT_TOOLS.has(message.toolName) && !message.isError);
|
|
}
|
|
|
|
/** Planning model for a new session: Sol for complex work, Terra otherwise or when Jev is unavailable. */
|
|
async function choosePlanningModel(request: JevRequest, ctx: ExtensionContext): Promise<string> {
|
|
// Keep a planning model the session already uses, so switching to jev/auto costs no cache miss.
|
|
const previous = request.previous?.model;
|
|
if (previous?.provider === PROVIDER && (previous.id === SOL || previous.id === TERRA)) return previous.id;
|
|
|
|
const jev = ctx.modelRegistry.findOfType("classifier", "typesafe", "jev-latest");
|
|
if (!jev) return TERRA;
|
|
const result = await ctx.modelRegistry.classify(
|
|
jev,
|
|
{
|
|
state: { prompt: lastUserText(request.messages).slice(0, 16_000) },
|
|
questions: {
|
|
complexity: {
|
|
type: "choice",
|
|
instructions: "How demanding is the software engineering work requested in `prompt`?",
|
|
criteria: {
|
|
standard: "Ordinary features, fixes, reviews, or questions",
|
|
complex: "Subtle design, cross-cutting changes, or hard debugging",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{ signal: request.signal },
|
|
);
|
|
const answer = result.stopReason === "stop" ? result.answers.complexity : undefined;
|
|
return answer?.type === "choice" && (answer.probabilities.complex ?? 0) >= 0.5 ? SOL : TERRA;
|
|
}
|
|
|
|
export default function (pi: ExtensionAPI) {
|
|
pi.registerVirtualModel<JevState>({
|
|
provider: "jev",
|
|
id: "auto",
|
|
name: "Auto (Jev)",
|
|
thinkingLevels: ["low", "medium", "high", "xhigh"],
|
|
// Shared by all three models; shown before the first response.
|
|
contextWindow: 272_000,
|
|
maxTokens: 128_000,
|
|
async route(request, ctx) {
|
|
if (request.reason === "direct") return routeTo(request, ctx, LUNA);
|
|
const state = request.state;
|
|
if (!state) {
|
|
const model = await choosePlanningModel(request, ctx);
|
|
return routeTo(request, ctx, model, { phase: "planning", model });
|
|
}
|
|
// The planning model made the first edit: hand the rest of the work to Luna.
|
|
if (state.phase === "planning" && editedThisTurn(request.messages)) {
|
|
return routeTo(request, ctx, LUNA, { phase: "implementation", model: LUNA });
|
|
}
|
|
return routeTo(request, ctx, state.model);
|
|
},
|
|
});
|
|
}
|