diff --git a/apps/web/src/app/api/v1/chat/completions/route.ts b/apps/web/src/app/api/v1/chat/completions/route.ts index 501532a2..2de6e487 100644 --- a/apps/web/src/app/api/v1/chat/completions/route.ts +++ b/apps/web/src/app/api/v1/chat/completions/route.ts @@ -3,7 +3,8 @@ import { hasEnabledPlugins } from "@/lib/chat/plugins"; import { pluginChatResponse } from "@/lib/chat/plugin-http"; import { runManager } from "@/lib/chat/run-manager"; import { RunLimitError, SSE_HEADERS } from "@/lib/chat/runs"; -import { currentUser, infernetFor, json, rateLimit, sameOrigin, unauthorized } from "@/lib/chat/server"; +import { routeRequest, routedChat, routerEnabled } from "@/lib/chat/routed"; +import { currentUser, env, infernetFor, json, modelRouter, rateLimit, sameOrigin, unauthorized } from "@/lib/chat/server"; export const dynamic = "force-dynamic"; export const maxDuration = 300; @@ -15,6 +16,12 @@ const err = (status: number, message: string, headers: Record = * OpenAI-compatible chat completions on the c0mpute network. * Authorization: Bearer c0k_... (or the c0mpute.com session cookie) * POST { model?, messages, stream?, temperature?, max_tokens?, plugins?: false, approvals?: string[], plugin_events?: true } + * + * `model`: a model id goes straight to that model. "auto" (or absent), + * "auto:fast" | "auto:standard" | "auto:high" | "auto:max" goes through the + * model router; `router: { effort?, max_cost_usd?, max_latency_ms? }` refines + * it. Routed answers carry `x-c0mpute-route` and (JSON / last SSE chunk) the + * full `x_c0mpute_route` receipt. POST /api/v1/route explains a plan for free. * With chat plugins on, the stream also carries `{"object":"c0mpute.plugin_event","event":{...}}` * chunks (tool calls, results, approvals) when the request sets plugin_events: true. * @@ -55,6 +62,27 @@ export async function POST(req: Request) { return pluginChatResponse({ userId: user.id, upstream: infernetFor(user.coinpay_sub), req: parsed, approvals, events: raw?.plugin_events === true, signal: req.signal }); } + // Model router (DIP 0035): `model` "auto", "auto:" or absent picks + // the model(s) and pattern per request. Any failure here falls back to the + // direct path below, so routing can never take chat down. + const route = routerEnabled(env) ? routeRequest(raw) : null; + if (route) { + try { + const rf = (raw?.response_format ?? null) as { type?: string } | null; + return await routedChat({ + router: modelRouter(), + upstream: infernetFor(user.coinpay_sub), + req: parsed, + route, + responseFormatJson: rf?.type === "json_object" || rf?.type === "json_schema", + signal: req.signal, + }); + } catch (e) { + if (req.signal.aborted) throw e; + console.warn("router: falling back to direct", e instanceof Error ? e.message : e); + } + } + let upstream: Response; try { // Billed to this person's compute balance when they have one; otherwise diff --git a/apps/web/src/app/api/v1/models/route.ts b/apps/web/src/app/api/v1/models/route.ts index a8827a4b..5caf4216 100644 --- a/apps/web/src/app/api/v1/models/route.ts +++ b/apps/web/src/app/api/v1/models/route.ts @@ -8,7 +8,9 @@ export async function GET() { try { const ids = await listModels(infernet); const created = Math.floor(Date.now() / 1000); - return json({ object: "list", data: ids.map((id) => ({ id, object: "model", created, owned_by: "c0mpute" })) }); + // The model router (DIP 0035) first: it picks among the live models below. + const auto = ["auto", "auto:fast", "auto:standard", "auto:high", "auto:max"].map((id) => ({ id, object: "model", created, owned_by: "c0mpute-router" })); + return json({ object: "list", data: [...auto, ...ids.map((id) => ({ id, object: "model", created, owned_by: "c0mpute" }))] }); } catch (e) { return json({ error: { message: e instanceof Error ? e.message : "models unavailable", code: 502 } }, 502); } diff --git a/apps/web/src/app/api/v1/route/route.ts b/apps/web/src/app/api/v1/route/route.ts new file mode 100644 index 00000000..c3abc6aa --- /dev/null +++ b/apps/web/src/app/api/v1/route/route.ts @@ -0,0 +1,28 @@ +import { parseChatRequest } from "@/lib/chat/completions"; +import { explainRoute, routeRequest } from "@/lib/chat/routed"; +import { clientIp, json, modelRouter, rateLimit } from "@/lib/chat/server"; + +export const dynamic = "force-dynamic"; + +/** + * Explain a routing decision without running it (free, public): + * POST { messages, model?: "auto" | "auto:", router?: { effort, max_cost_usd, max_latency_ms }, response_format? } + * Returns how the request was classified, the plan the router would run + * (pattern, models, judge, fallbacks, expected quality/cost/latency, and + * why), and the next-best alternatives. + */ +export async function POST(req: Request) { + const rl = rateLimit(`route:${clientIp(req)}`, 120); + if (!rl.ok) return json({ error: { message: "rate limit", code: 429 } }, 429, { "retry-after": String(rl.retryAfter) }); + const raw = (await req.json().catch(() => null)) as Record | null; + const parsed = parseChatRequest(raw); + if (typeof parsed === "string") return json({ error: { message: parsed, code: 400 } }, 400); + const route = routeRequest({ ...(raw ?? {}), model: raw?.model ?? "auto" }); + if (!route) return json({ error: { message: "model names a specific model; use auto or auto: to route", code: 400 } }, 400); + const rf = (raw?.response_format ?? null) as { type?: string } | null; + try { + return json(await explainRoute(modelRouter(), parsed.messages, { ...route, maxTokens: parsed.max_tokens }, rf?.type === "json_object" || rf?.type === "json_schema")); + } catch (e) { + return json({ error: { message: e instanceof Error ? e.message : String(e), code: 503 } }, 503); + } +} diff --git a/apps/web/src/app/chat/chat-app.tsx b/apps/web/src/app/chat/chat-app.tsx index c6d26239..b8d6ce98 100644 --- a/apps/web/src/app/chat/chat-app.tsx +++ b/apps/web/src/app/chat/chat-app.tsx @@ -388,7 +388,8 @@ export function ChatApp({ error }: { error: string | null }) { .catch(() => setMe(null)); fetch("/api/v1/models") .then((r) => (r.ok ? r.json() : { data: [] })) - .then((j) => setModels((j.data ?? []).map((m: { id: string }) => m.id))) + // Router entries ("auto", "auto:high", ...) have their own options above. + .then((j) => setModels((j.data ?? []).map((m: { id: string }) => m.id).filter((id: string) => !/^auto(:|$)/.test(id)))) .catch(() => {}); const c = load(); setConvos(c); @@ -1616,7 +1617,10 @@ export function ChatApp({ error }: { error: string | null }) { if (active) update(active.id, (c) => ({ ...c, model: e.target.value })); }} > - + + + + {models.map((m) => (