feat: model + thinking control — the stats bar's picker, on pi-ai's catalog
The model-control wave (user-decided design, 2026-09-12): ONE controller
for the conversation agents (prober/teacher are functionally one agent);
background sub-agents stay config-pinned. pi-ai owns the registry — the
shipped 366-model OpenRouter catalog (cost, context, modality,
thinkingLevelMap), getSupportedThinkingLevels/clampThinkingLevel for the
per-model thinking truth, and streamSimple's automatic clamp. No second
registry to drift out of sync.
Seams:
- config.ts: ReasoningEffort retired for pi's ThinkingLevel
(off|minimal|low|medium|high|xhigh|max). GLM roles go honest at "low" —
pi's catalog says GLM-5.3-flash accepts exactly low|high|max, and the old
"minimal" was silently clamped to low by streamSimple since the swap;
nothing changes behaviorally. visualMaker → qwen/qwen3.8-flash (fixes
the dead qwen/qwen3.8-max slug — the catalog only ships -0902; also the
user's cost pick, 0.15/0.47 per-1M).
- src/agent/models.ts: piEngineFor(role, settings?) — provider+id resolved
from the catalog, thinking level from settings ?? config, CLAMPED to the
model's supported list (a settings change between models can never send
an invalid level). PiEngine gains `level` (observability/tests).
- ChatRecord.settings {model?: {provider, id}, thinkingLevel?} — written
by PUT /api/chats/[id]/settings (replace-whole, no updatedAt churn),
read by chat-handler at turn composition: agents are stateless per turn,
so the change is next-turn-effective by construction. The turns log
still records the ACTUAL billed model (the honest witness).
- GET /api/models: the configured-provider catalog as client-safe DTOs
(core/contracts/models.ts) + config defaults; a second provider joins
the picker by registering it in piModels() — nothing else changes.
- PUT settings validation gate (src/server/model-catalog.ts): unknown
provider/model or unsupported level → 400 carrying the supported list,
never a silent clamp.
- scripts/sync-pricing.ts: regenerated PRICES/CONTEXT_LIMITS from pi-ai's
shipped openrouter.json (offline; same source as the engine + picker).
All 366 models priced; glm's stale 0.15/0.5 → pi's 0.075/0.25, limit
1310720 → 1048576 — cost is inferred at read time, so history re-prices.
- UI (components/model-picker.tsx): stats-bar right side becomes model +
thinking buttons; menus open ABOVE; fuzzy search over "provider id
name" (core/fuzzy.ts — pi's /model pattern); model rows show name/id/
price/context; the thinking menu lists exactly the chosen model's
supported levels (GLM: low|high|max — no dead options, no "off").
Model switch drops an incompatible pinned level (falls back to config,
which clamps honestly) rather than carrying a 400.
Tests (350/350, +15): catalog projection + defaults, the settings gate,
pi thinking truth pinned against upgrades (glm low|high|max; minimal→low;
off→low — GLM cannot disable thinking), the engine factory's
override/fallback/clamp/throw matrix, fuzzy ranking, store round-trip,
and the re-priced folds (usage, stats bar, activity dock).
Live smoke (disposable driver, deleted): catalog route 366 + defaults;
glm+minimal → 400 + supported; turn 1 billed qwen/qwen3.8-flash under
the override; mid-session switch flipped turn 2 to glm; clear-overrides
restored the config pin. ALL CHECKS PASS on real OpenRouter.
This commit is contained in:
+23
-1
@@ -21,6 +21,7 @@ import { SubagentSteerCard } from "@/components/artifacts/steer-card";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { JobsContext } from "@/components/jobs-context";
|
||||
import { StatsBar } from "@/components/stats-bar";
|
||||
import type { ChatSettings } from "@/src/core";
|
||||
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
|
||||
import { PlanWindow } from "@/components/plan-window";
|
||||
import type { PlanBlockData } from "@/src/core";
|
||||
@@ -85,6 +86,7 @@ export function Chat({
|
||||
initialMessages,
|
||||
initialPrompt,
|
||||
initiallyInFlight,
|
||||
initialSettings,
|
||||
}: {
|
||||
chatId: string;
|
||||
title: string;
|
||||
@@ -93,6 +95,8 @@ export function Chat({
|
||||
/** A teacher turn is running server-side but attached to no client
|
||||
* (navigated away mid-turn, returned while in flight). */
|
||||
initiallyInFlight?: boolean;
|
||||
/** The record's per-chat model/thinking settings (the picker's seed). */
|
||||
initialSettings?: ChatSettings;
|
||||
}) {
|
||||
const { messages, sendMessage, addToolOutput, setMessages, status, error, stop } =
|
||||
useChat<TeacherUIMessage>({
|
||||
@@ -250,6 +254,9 @@ export function Chat({
|
||||
// server-side), and kept fresh by syncFromServer whenever it already
|
||||
// fetches the record. No turns yet → the bar renders "—" everywhere.
|
||||
const [turns, setTurns] = useState<TurnUsage[]>([]);
|
||||
// The per-chat model control state (model-picker writes via PUT; the
|
||||
// record is the source of truth, lifted here so the stats bar re-renders).
|
||||
const [settings, setSettings] = useState<ChatSettings | undefined>(initialSettings);
|
||||
const syncTurns = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/chats/${chatId}`);
|
||||
@@ -582,6 +589,9 @@ export function Chat({
|
||||
|
||||
<DockOverlay
|
||||
pending={pending}
|
||||
chatId={chatId}
|
||||
settings={settings}
|
||||
onSettingsSaved={setSettings}
|
||||
requiresReveal={requiresReveal}
|
||||
phase={phases.at(-1) ?? "probe"}
|
||||
turns={turns}
|
||||
@@ -645,6 +655,9 @@ function DockOverlay({
|
||||
turns,
|
||||
composer,
|
||||
onSubmitAnswer,
|
||||
chatId,
|
||||
settings,
|
||||
onSettingsSaved,
|
||||
}: {
|
||||
pending: ReturnType<typeof findPendingToolCall>;
|
||||
/** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */
|
||||
@@ -655,6 +668,10 @@ function DockOverlay({
|
||||
turns: TurnUsage[];
|
||||
composer: React.ReactNode;
|
||||
onSubmitAnswer: (output: QuestionAnswerData) => void;
|
||||
/** The model control's pass-through (the stats bar hosts it). */
|
||||
chatId: string;
|
||||
settings?: ChatSettings;
|
||||
onSettingsSaved: (settings: ChatSettings) => void;
|
||||
}) {
|
||||
const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext();
|
||||
// Teach-phase mount (session reload with a pending check): the dock starts
|
||||
@@ -818,7 +835,12 @@ function DockOverlay({
|
||||
{/* The stats row: beneath the composer/question shell, not
|
||||
flush to the screen bottom — static height, outside the
|
||||
morphing shell so it never joins a height animation. */}
|
||||
<StatsBar turns={turns} />
|
||||
<StatsBar
|
||||
turns={turns}
|
||||
chatId={chatId}
|
||||
settings={settings}
|
||||
onSettingsSaved={onSettingsSaved}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user