Skip to content

Commit ac59b7c

Browse files
viloforgeviloforge
authored andcommitted
feat(api,spa): model price reference page
Add a read-only reference of every priced model and its list rate (USD per 1M tokens), backed by the existing model_prices table — no schema change. GET /api/prices returns the rows + max(updated_at) ("last fetched"). New SPA /prices page: searchable table (Input/Output/Cache read/Cache write per 1M tokens), last-fetched timestamp, and a nav link from /me. $0 rates render as "—" (free / flat-rate subscription). - db.ts fetchModelPrices(); prices/routes.ts mounted at /api - 2 new integration tests (seeded rows + updatedAt; auth required)
1 parent cdc6f86 commit ac59b7c

10 files changed

Lines changed: 315 additions & 2 deletions

File tree

packages/api/src/server/app.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ function makeFakeDb(): Db {
3434
async fetchSessions() {
3535
return [];
3636
},
37+
async fetchModelPrices() {
38+
return { updatedAt: null, items: [] };
39+
},
3740
async close() {},
3841
};
3942
}

packages/api/src/server/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import type { Verifier } from "./auth/idp.js";
2626
import type { Db } from "./db.js";
2727
import { ingestRoutes } from "./ingest/routes.js";
2828
import { meRoutes } from "./me/routes.js";
29+
import { pricesRoutes } from "./prices/routes.js";
2930

3031
export interface AppDeps {
3132
readonly publicUrl: string;
@@ -68,6 +69,7 @@ export function createApp(deps: AppDeps): Express {
6869

6970
app.use(ingestRoutes({ db: deps.db, verifier: deps.verifier }));
7071
app.use("/api", meRoutes({ db: deps.db, verifier: deps.verifier }));
72+
app.use("/api", pricesRoutes({ db: deps.db, verifier: deps.verifier }));
7173

7274
if (existsSync(SPA_DIR)) {
7375
// SPA bundle is present — serve it. Static assets first; any

packages/api/src/server/auth/middleware.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ function makeDb(opts: { failUpsert?: boolean } = {}): RecordingDb {
4545
async fetchSessions() {
4646
return [];
4747
},
48+
async fetchModelPrices() {
49+
return { updatedAt: null, items: [] };
50+
},
4851
async close() {},
4952
};
5053
}

packages/api/src/server/db.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,30 @@ export interface Db {
4848
* `?cursor=` query param (or null for the first page).
4949
*/
5050
fetchSessions(input: FetchSessionsInput): Promise<SessionRow[]>;
51+
/**
52+
* The model_prices reference table: every priced model with its list
53+
* rate (USD per 1,000,000 tokens), plus the most recent `updated_at`
54+
* across the table (when the prices were last synced). Ordered by model.
55+
*/
56+
fetchModelPrices(): Promise<ModelPrices>;
5157
close(): Promise<void>;
5258
}
5359

60+
export interface ModelPrice {
61+
readonly model: string;
62+
readonly inputPerMtok: number;
63+
readonly outputPerMtok: number;
64+
readonly cacheReadPerMtok: number;
65+
readonly cacheWritePerMtok: number;
66+
readonly source: string;
67+
}
68+
69+
export interface ModelPrices {
70+
/** Max(updated_at) across the table as ISO 8601, or null if empty. */
71+
readonly updatedAt: string | null;
72+
readonly items: readonly ModelPrice[];
73+
}
74+
5475
export interface UsageTotals {
5576
/** Actually-billed cost (SUM cost_total_usd). $0 for subscription usage. */
5677
readonly costUsd: number;
@@ -382,6 +403,37 @@ export function createDb(databaseUrl: string): Db {
382403
await pool.query(sql, values);
383404
},
384405

406+
async fetchModelPrices() {
407+
const { rows } = await pool.query<{
408+
model: string;
409+
input_per_mtok: string;
410+
output_per_mtok: string;
411+
cache_read_per_mtok: string;
412+
cache_write_per_mtok: string;
413+
source: string;
414+
updated_at: Date;
415+
}>(
416+
`SELECT model, input_per_mtok, output_per_mtok,
417+
cache_read_per_mtok, cache_write_per_mtok, source, updated_at
418+
FROM model_prices
419+
ORDER BY model`,
420+
);
421+
const updatedAt = rows.reduce<Date | null>((max, r) => {
422+
return max === null || r.updated_at > max ? r.updated_at : max;
423+
}, null);
424+
return {
425+
updatedAt: updatedAt ? updatedAt.toISOString() : null,
426+
items: rows.map((r) => ({
427+
model: r.model,
428+
inputPerMtok: Number.parseFloat(r.input_per_mtok),
429+
outputPerMtok: Number.parseFloat(r.output_per_mtok),
430+
cacheReadPerMtok: Number.parseFloat(r.cache_read_per_mtok),
431+
cacheWritePerMtok: Number.parseFloat(r.cache_write_per_mtok),
432+
source: r.source,
433+
})),
434+
};
435+
},
436+
385437
async close() {
386438
await pool.end();
387439
},

packages/api/src/server/me/me.integration.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,34 @@ describe("GET /api/me/sessions — pagination + scoping", () => {
332332
});
333333
});
334334

335+
describe("GET /api/prices — model price reference", () => {
336+
it("returns the seeded prices + updatedAt", async () => {
337+
if (!env) throw new Error("env failed");
338+
const r = await fetch(`${env.baseUrl}/api/prices`, { headers: { authorization: `Bearer ${env.aliceToken}` } });
339+
expect(r.status).toBe(200);
340+
const body = (await r.json()) as {
341+
updatedAt: string | null;
342+
count: number;
343+
items: { model: string; inputPerMtok: number; outputPerMtok: number }[];
344+
};
345+
expect(body.count).toBeGreaterThan(0);
346+
expect(body.count).toBe(body.items.length);
347+
expect(typeof body.updatedAt).toBe("string"); // table is seeded → has a timestamp
348+
const opus = body.items.find((m) => m.model === "claude-opus-4-7");
349+
expect(opus?.inputPerMtok).toBe(5);
350+
expect(opus?.outputPerMtok).toBe(25);
351+
// sorted by model
352+
const models = body.items.map((m) => m.model);
353+
expect([...models].sort()).toEqual(models);
354+
});
355+
356+
it("requires auth", async () => {
357+
if (!env) throw new Error("env failed");
358+
const r = await fetch(`${env.baseUrl}/api/prices`);
359+
expect(r.status).toBe(401);
360+
});
361+
});
362+
335363
// ---------------------------------------------------------------------------
336364
// env setup + IdP fixture + seed helpers
337365
// ---------------------------------------------------------------------------
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* `/api/prices` — the model price reference table.
3+
*
4+
* GET /api/prices list every priced model + its list rate (USD per
5+
* 1,000,000 tokens) + when prices were last synced.
6+
*
7+
* Reference data, not own-data — there's no rowScope here. It still
8+
* requires a valid TokenTracker.* token (requireAuth) so the surface
9+
* matches the rest of /api and the SPA can reuse its bearer.
10+
*/
11+
12+
import express, { type Request, type Response, type Router } from "express";
13+
import type { Verifier } from "../auth/idp.js";
14+
import { requireAuth } from "../auth/middleware.js";
15+
import type { Db } from "../db.js";
16+
17+
export interface PricesRouteDeps {
18+
readonly db: Db;
19+
readonly verifier: Verifier;
20+
}
21+
22+
export function pricesRoutes(deps: PricesRouteDeps): Router {
23+
const router = express.Router();
24+
const auth = requireAuth({ verifier: deps.verifier, db: deps.db });
25+
26+
router.get("/prices", auth, (_req: Request, res: Response) => handlePrices(res, deps));
27+
28+
return router;
29+
}
30+
31+
async function handlePrices(res: Response, deps: PricesRouteDeps): Promise<void> {
32+
const prices = await deps.db.fetchModelPrices();
33+
res.json({
34+
updatedAt: prices.updatedAt,
35+
count: prices.items.length,
36+
items: prices.items,
37+
});
38+
}

packages/spa/src/App.svelte

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
<script lang="ts">
22
import Home from "./pages/Home.svelte";
33
import Me from "./pages/Me.svelte";
4+
import Prices from "./pages/Prices.svelte";
45
56
const path = window.location.pathname;
6-
const page = path === "/me" || path.startsWith("/me/") ? "me" : "home";
7+
const page =
8+
path === "/prices" || path.startsWith("/prices/")
9+
? "prices"
10+
: path === "/me" || path.startsWith("/me/")
11+
? "me"
12+
: "home";
713
</script>
814

9-
{#if page === "me"}
15+
{#if page === "prices"}
16+
<Prices />
17+
{:else if page === "me"}
1018
<Me />
1119
{:else}
1220
<Home />

packages/spa/src/lib/api.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,22 @@ export interface SessionItem {
6868
readonly models: readonly string[];
6969
}
7070

71+
export interface ModelPrice {
72+
readonly model: string;
73+
readonly inputPerMtok: number;
74+
readonly outputPerMtok: number;
75+
readonly cacheReadPerMtok: number;
76+
readonly cacheWritePerMtok: number;
77+
readonly source: string;
78+
}
79+
80+
export interface PricesResponse {
81+
/** When prices were last synced (ISO 8601), or null if empty. */
82+
readonly updatedAt: string | null;
83+
readonly count: number;
84+
readonly items: readonly ModelPrice[];
85+
}
86+
7187
export interface SessionsResponse {
7288
readonly from: string;
7389
readonly to: string;
@@ -128,6 +144,10 @@ export function fetchSessions(opts?: {
128144
return getJson<SessionsResponse>(`/api/me/sessions${qs ? `?${qs}` : ""}`);
129145
}
130146

147+
export function fetchPrices(): Promise<PricesResponse> {
148+
return getJson<PricesResponse>("/api/prices");
149+
}
150+
131151
/** Begin the IdP login redirect (handled entirely client-side by MSAL). */
132152
export function redirectToLogin(): void {
133153
login();
@@ -143,6 +163,12 @@ export function fmtInt(n: number): string {
143163
return new Intl.NumberFormat("en-US").format(n);
144164
}
145165

166+
/** Price per 1M tokens, e.g. `$5.00`. `$0` is shown as `—` (free/subscription). */
167+
export function fmtRate(n: number): string {
168+
if (n === 0) return "—";
169+
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 4 }).format(n);
170+
}
171+
146172
export function fmtDateTime(iso: string): string {
147173
return new Date(iso).toLocaleString();
148174
}

packages/spa/src/pages/Me.svelte

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ $effect(() => {
110110
<option value="90">Last 90 days</option>
111111
</select>
112112
</label>
113+
<a href="/prices" class="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:hover:bg-slate-800">
114+
Prices
115+
</a>
113116
<button type="button" onclick={logout} class="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:hover:bg-slate-800">
114117
Log out
115118
</button>

0 commit comments

Comments
 (0)