Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion apps/web/src/app/api/v1/chat/completions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,6 +16,12 @@ const err = (status: number, message: string, headers: Record<string, string> =
* 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.
*
Expand Down Expand Up @@ -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:<effort>" 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
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/app/api/v1/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/app/api/v1/route/route.ts
Original file line number Diff line number Diff line change
@@ -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:<effort>", 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<string, unknown> | 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:<effort> 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);
}
}
8 changes: 6 additions & 2 deletions apps/web/src/app/chat/chat-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -1616,7 +1617,10 @@ export function ChatApp({ error }: { error: string | null }) {
if (active) update(active.id, (c) => ({ ...c, model: e.target.value }));
}}
>
<option value="auto">auto (any live node)</option>
<option value="auto">auto: router picks (standard)</option>
<option value="auto:fast">auto:fast (quickest good model)</option>
<option value="auto:high">auto:high (checked, escalates)</option>
<option value="auto:max">auto:max (several models, fused)</option>
{models.map((m) => (
<option key={m} value={m}>
{m}
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/lib/chat/completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// provider node (p2p_only: never the paid hosted fallback) under c0mpute's
// trusted client key; c0mpute users are rate-limited per account here.

import { parseAutoModel } from "@/lib/router";

export type ChatMessage = { role: "system" | "user" | "assistant"; content: string };
export type ChatRequest = {
model?: string;
Expand Down Expand Up @@ -32,7 +34,8 @@ export function parseChatRequest(body: unknown): ChatRequest | string {
}
if (chars > MAX_CHARS) return `messages total more than ${MAX_CHARS} characters`;
const out: ChatRequest = { messages, stream: b.stream === true };
if (typeof b.model === "string" && b.model && b.model !== "auto") out.model = b.model.slice(0, 200);
// "auto" and the router aliases ("auto:high", ...) are not model ids.
if (typeof b.model === "string" && b.model && !parseAutoModel(b.model)) out.model = b.model.slice(0, 200);
if (typeof b.temperature === "number" && Number.isFinite(b.temperature)) {
out.temperature = Math.min(2, Math.max(0, b.temperature));
}
Expand Down
95 changes: 95 additions & 0 deletions apps/web/src/lib/chat/routed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Database } from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import { Router, buildProfiles, MemoryStore, type Snapshot } from "@/lib/router";
import { completionJson, routeRequest, routedChat, routedStream, sqlStatsStore, teeForLearning } from "./routed";
import type { Sql } from "./store";

const pool = buildProfiles(["qwen2.5:0.5b", "qwen2.5:7b", "qwen3:4b", "qwen2.5-coder:7b"]);

function sqliteSql(): Sql {
const db = new Database(":memory:");
return {
async all(q, p = []) {
return db.query(q).all(...(p as never[])) as Record<string, unknown>[];
},
async run(q, p = []) {
db.query(q).run(...(p as never[]));
},
};
}

const sseOf = (parts: string[]) =>
new ReadableStream<Uint8Array>({
start(c) {
const e = new TextEncoder();
for (const p of parts) c.enqueue(e.encode(p));
c.close();
},
});

describe("routeRequest", () => {
test("auto, aliases and absent model route; named models do not", () => {
expect(routeRequest({ model: "auto" })?.effort).toBe("standard");
expect(routeRequest({})?.effort).toBe("standard");
expect(routeRequest({ model: "auto:max" })?.effort).toBe("max");
expect(routeRequest({ model: "qwen2.5:7b" })).toBeNull();
const r = routeRequest({ model: "auto", router: { effort: "high", max_cost_usd: 0.01, max_latency_ms: 999_999 } });
expect(r).toEqual({ effort: "high", maxCostUsd: 0.01, maxLatencyMs: 290_000 });
});
});

describe("persistence", () => {
test("stats survive a round trip through SQL", async () => {
const store = sqlStatsStore(sqliteSql());
expect(await store.load()).toBeNull();
const snap: Snapshot = { v: 1, quality: { "m\u0001code": { ok: 3, fail: 1 } }, health: {} };
await store.save(snap);
await store.save({ ...snap, quality: { "m\u0001code": { ok: 4, fail: 1 } } });
expect((await store.load())?.quality["m\u0001code"].ok).toBe(4);
});
});

describe("responses", () => {
test("a streamed reply passes through untouched and still teaches the router", async () => {
const parts = [
'data: {"choices":[{"delta":{"content":"Hel"}}]}\n\n',
'data: {"choices":[{"delta":{"content":"lo there"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',
];
let seen = "";
const out = await new Response(teeForLearning(sseOf(parts), (t) => (seen = t))).text();
expect(out).toBe(parts.join(""));
expect(seen).toBe("Hello there");
});

test("a routed SSE stream ends with the receipt and [DONE]", async () => {
const r = new Router({ call: async () => ({ text: "Paris is the capital of France." }), models: async () => pool });
const work = r.run([{ role: "user", content: "Capital of France?" }], { effort: "standard" });
const body = await new Response(routedStream(work)).text();
expect(body).toContain("Paris");
expect(body).toContain("x_c0mpute_route");
expect(body.trim().endsWith("data: [DONE]")).toBe(true);
});

test("JSON completions carry usage and the receipt", async () => {
const r = new Router({ call: async () => ({ text: "hi", inTokens: 5, outTokens: 2 }), models: async () => pool });
const { text, model, receipt } = await r.run([{ role: "user", content: "hi" }], { effort: "fast" });
const j = completionJson(text, model, receipt);
expect(j.choices[0].message.content).toBe("hi");
expect(j.usage.total_tokens).toBe(7);
expect(j.x_c0mpute_route.final.model).toBe(model);
});

test("routedChat: standard + stream goes straight through on the planned model", async () => {
const r = new Router({ call: async () => ({ text: "x" }), models: async () => pool, store: new MemoryStore() });
const seen: string[] = [];
const upstream = async (_path: string, init?: RequestInit) => {
seen.push(JSON.parse(String(init?.body)).model);
return new Response(sseOf(['data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n']), { headers: { "content-type": "text/event-stream" } });
};
const res = await routedChat({ router: r, upstream, req: { messages: [{ role: "user", content: "hello" }], stream: true }, route: { effort: "standard" } });
expect(res.headers.get("x-c0mpute-route")).toContain("pattern=single");
expect(await res.text()).toContain("ok");
expect(seen.length).toBe(1);
expect(seen[0]).toBeTruthy();
});
});
Loading
Loading