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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,11 @@ The report includes status, reason, count and mean latency for each provider;
`/alerts` shows current incidents even when their notification is suppressed.
The existing 15-minute alert check warns when at least three attempts fail and
failures exceed 5% for either provider. Counts account for Analytics Engine
sampling. No input text, labels, caller identifiers or upstream messages are stored.
sampling. No input text, caller identifiers or upstream messages are stored.
Successful simple and multi-label classifier names are retained for 90 days in
a separate aggregate KV record with no caller or source-text fields. Its keyed
fingerprint also appears in per-request analytics, allowing operators to resolve
label names in those pseudonymous records.

`AI_GATEWAY_DISABLED = "true"` in `wrangler.example.toml` keeps production on
TypeSafe directly after the gateway repeatedly returned 429 on September 19.
Expand Down
34 changes: 33 additions & 1 deletion e2e/spending.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ let barrier = Promise.resolve();
const modules = (await readdir('dist/server', { recursive: true })).filter(p => p.endsWith('.js')).sort((a, b) => a === 'index.js' ? -1 : b === 'index.js' ? 1 : a.localeCompare(b)).map(p => ({ type: 'ESModule', path: `dist/server/${p}` }));
const mf = new Miniflare(convertV4MiniflareOptions({ workers: [{ name: "spending", modules, modulesRoot: 'dist/server', compatibilityDate: '2026-08-01', compatibilityFlags: ['nodejs_compat'],
durableObjects: { FREE_BUDGET: { className: 'FreeBudget', useSQLite: true }, LIMITER: { className: 'RateLimiter', useSQLite: true } }, kvNamespaces: ['STATS'],
bindings: { SPENDING_ENABLED: 'true', PRIVACY_SALT: 'private-e2e-fixture', SPUR_API_KEY: 'fixture', TYPESAFE_API_KEY: 'fixture', OPENROUTER_API_KEY: 'fixture', INTERNAL_API_KEY: 'private-fixture', AGENT_API_KEY: 'operator-fixture', ENTERPRISE_API_KEY: 'enterprise-fixture', OPERATOR_DAILY_USD: '0.010001' },
bindings: { SPENDING_ENABLED: 'true', PRIVACY_SALT: 'private-e2e-fixture', SPUR_API_KEY: 'fixture', TYPESAFE_API_KEY: 'fixture', OPENROUTER_API_KEY: 'fixture', INTERNAL_API_KEY: 'private-fixture', FREE_LABEL_RPM: '200', FREE_LABEL_DAILY: '200', AGENT_API_KEY: 'operator-fixture', ENTERPRISE_API_KEY: 'enterprise-fixture', OPERATOR_DAILY_USD: '0.010001' },
outboundService: async request => {
if (request.url.startsWith('https://api.spur.us/')) { lookups++; return WorkerResponse.json({}); }
calls++; await barrier;
Expand Down Expand Up @@ -46,6 +46,38 @@ try {
const duplicate = await request('203.0.113.4', undefined, undefined, { 'idempotency-key': 'one' });
assert.equal(duplicate.status, 409);
report.results.push({ name: 'durable idempotency', first: first.status, duplicate: duplicate.status });
const beforeLabels = calls;
const labels = ['allow', 'deny'];
const labelResponses = await Promise.all(Array.from({ length: 3 }, (_, index) => request(`203.0.113.${20 + index}`, {
inputs: Array(100).fill('short text'), labels: index === 1 ? ['DENY', 'ALLOW'] : labels,
})));
const labelStatuses = labelResponses.map(response => response.status).sort();
assert.deepEqual(labelStatuses, [200, 200, 429]);
const labelDenied = labelResponses.find(response => response.status === 429);
assert.equal((await labelDenied.json()).code, 'label_set_limit');
assert.equal(labelDenied.headers.get('ratelimit-remaining'), '0');
const afterLabels = calls;
const dimensionDenied = await request('203.0.113.23', { items: ['text'], dimensions: { renamed: labels } });
assert.equal(dimensionDenied.status, 429);
const sdkDenied = await request('203.0.113.24', { state: 'text', questions: { renamed: { type: 'choice', criteria: { allow: null, deny: null } } } }, '/v1/systemone');
assert.equal(sdkDenied.status, 429);
assert.equal(calls, afterLabels);
assert.equal((await request('203.0.113.25', { input: 'text', labels: ['independent', 'other'] })).status, 200);
const registry = await mf.getKVNamespace('STATS');
let storedLabels;
for (let attempt = 0; attempt < 50 && !storedLabels; attempt++) {
const entries = await registry.list({ prefix: 'cls:' });
for (const key of entries.keys) {
const record = await registry.get(key.name, 'json');
if (record?.labels?.join(',') === 'allow,deny') storedLabels = record;
}
if (!storedLabels) await new Promise(resolve => setTimeout(resolve, 20));
}
assert.deepEqual(storedLabels?.labels, labels);
assert.equal(JSON.stringify(storedLabels).includes('short text'), false);
assert.equal(JSON.stringify(storedLabels).includes('203.0.113'), false);
report.results.push({ name: 'global label allowance across concurrent IPs, dimensions and SDK', statuses: labelStatuses,
decisionsAccepted: 200, providerCalls: afterLabels - beforeLabels, dimensionStatus: dimensionDenied.status, sdkStatus: sdkDenied.status, registry: storedLabels });
const budgets = await mf.getDurableObjectNamespace('FREE_BUDGET', 'spending');
const publicBudget = budgets.get(budgets.idFromName('free-spending'));
for (let i = 0; i < 50; i++) {
Expand Down
30 changes: 23 additions & 7 deletions src/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,18 @@
* does not answer at all, because a page that says what it is is a page worth
* attacking.
*
* None of these figures is anybody's data. The caller column is a day-scoped
* hash and the label column a keyed fingerprint, both made in src/privacy.ts
* before anything is written, so there is nothing here to leak and nothing to
* hand over if somebody asks.
* The caller column is a day-scoped hash and the label column a keyed
* fingerprint, both made in src/privacy.ts before per-request analytics are
* written. A separate 90-day registry resolves successful classifier
* fingerprints to aggregate label names; it contains no caller or source text.
*/

import type { Env } from "./index";
import { sql } from "./report";
import { reasonText } from "./features/admin/reasons";
import { esc, BASE_CSS } from "./ui";
import { secretEquals, deriveSigningKey, hmacHex } from "./secrets";
import { recordedClassifierLabels } from "./privacy";

const DATASET = "classifier_events";
// The __Secure- prefix is enforced by the browser, not by us: it refuses to
Expand Down Expand Up @@ -392,6 +393,21 @@ async function load(env: Env, range: RangeKey) {
),
]);

const classifierNames = new Map<string, string>();
const fingerprints = [...new Set(
[...topLabels, ...failLabels]
.map((row) => String(row.labels ?? ""))
.filter(Boolean),
)];
await Promise.all(fingerprints.map(async (fingerprint) => {
const labels = await recordedClassifierLabels(env.STATS, fingerprint);
if (labels.length) classifierNames.set(fingerprint, labels.join(" · "));
}));
const nameClassifiers = (rows: Row[]) => rows.map((row) => ({
...row,
label_names: classifierNames.get(String(row.labels ?? "")) ?? "—",
}));

return {
totals,
series,
Expand All @@ -400,12 +416,12 @@ async function load(env: Env, range: RangeKey) {
byCountry,
byStatus,
byClient,
topLabels,
topLabels: nameClassifiers(topLabels),
visitors,
labelSets,
byReason,
byAgent,
failLabels,
failLabels: nameClassifiers(failLabels),
dimensionTraffic,
dimensionCallers,
dimensionSeries,
Expand Down Expand Up @@ -541,7 +557,7 @@ ${table("Failure causes", d.byReason, ["reason", "status", "agent", "requests"],
${table("Dimension outcomes", d.dimensionTraffic, ["status", "reason", "requests", "uncertain", "fallback"], "dimensionTraffic")}
${table("Clients", d.byAgent, ["agent", "requests", "classifications"], "byAgent")}
${table("Countries", d.byCountry, ["country", "requests"], "byCountry")}
${table("Busiest classifiers", d.topLabels, ["labels", "requests", "classifications", "usd"], "topLabels")}
${table("Busiest classifiers", d.topLabels, ["label_names", "labels", "requests", "classifications", "usd"], "topLabels")}
</article></div>`;
return shell(
"admin · classifier.dev",
Expand Down
30 changes: 21 additions & 9 deletions src/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,8 +475,15 @@ LIMITS
3,000 per minute and 20,000 per day; the smart tier 200 per minute and 2,000
per day. A batch must fit the remaining quota in full. Public smart
requests accept at most 200 inputs; larger batches return 400 so callers
can split them. Pro workspaces allow 30,000/minute and 200,000/day on
fast, 2,000/minute and 20,000/day on smart, shared across keys and agents.
can split them. Anonymous traffic also shares a 5,000/minute and 50,000/day
allowance across every caller using the same label set. Rotating IPs does
not reset it; workspace, operator and partner keys bypass it.
REST, MCP, dimensions and TypeSafe choice questions share these counters.
Each dimension debits its labels by the item count; each SDK choice question
debits its labels once. These count attempts admitted by this gate, including
attempts subsequently refused by another quota or a provider.
Pro workspaces allow 30,000/minute and 200,000/day on fast, 2,000/minute and
20,000/day on smart, shared across keys and agents.
Pro, operator and partner keys have a 1,000-input ceiling.
Workspace keys use the workspace credit balance and share workspace quotas.
Free workspaces have the same ceilings as public access. Current plans are at
Expand Down Expand Up @@ -509,12 +516,14 @@ ERRORS
402 insufficient workspace balance for inference
403 the key is inactive or the workspace cannot authorize usage
404 not_found
429 rate_limit_minute, rate_limit_day, chunklaya_busy, with Retry-After; on the free
tier the body also carries upgrade, the URL of the plan that lifts
the limit (https://classifier.dev/pricing)
429 rate_limit_minute, rate_limit_day, label_set_limit, chunklaya_busy,
with Retry-After; on the free tier the body also carries upgrade, the
URL of the plan that lifts the limit (https://classifier.dev/pricing)
502 typesafe or typesafe_<status> when the decision model failed;
openrouter_<status>, chain_exhausted or timeout when the fallback
chain did; upstream_other. Retry with backoff.
503 label_set_unavailable when label admission cannot be checked;
no inference starts. Respect Retry-After and retry with backoff.
402 request_spending_limit: send fewer or shorter inputs, or use a funded
workspace key. Do not repeatedly retry an unchanged over-budget request.

Expand All @@ -541,10 +550,13 @@ ${roadmapDoc()}
PRIVACY

The text you send is never stored or logged. It goes to the model provider
for the classification and nowhere else. What is recorded: a keyed
fingerprint of the label set, never the labels, plus the tier, the model,
the latency, the status and a coarse country. The usage counts are built
from those.
for the classification and nowhere else. Per-request analytics record a
keyed fingerprint of the label set, plus the tier, model, latency, status and
coarse country. Successful simple and multi-label classifier names are also
kept for 90 days in a separate aggregate registry with no caller identity or
source text. Its shared fingerprint lets operators associate label names
with pseudonymous usage records. The same collection applies to TypeSafe
choice requests with one distinct label set.


Built by @michael_chomsky — https://x.com/michael_chomsky
Expand Down
6 changes: 4 additions & 2 deletions src/features/admin/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -699,13 +699,14 @@ function Dashboard({ data: d, range }: { data: AdminData; range: RangeKey }) {
</Panel>
<Panel
title="Classifiers with failures"
subtitle="Top 12 fingerprints and failure causes; original labels are never stored"
subtitle="Top 12 fingerprints and failure causes · label names expire after 90 days"
wide
unavailable={missing("failLabels")}
>
<Table
rows={d.failLabels}
columns={[
{ key: "label_names", label: "Labels" },
{ key: "labels", label: "Classifier fingerprint" },
{ key: "reason", label: "Cause" },
{ key: "requests", label: "Requests", format: count },
Expand Down Expand Up @@ -824,13 +825,14 @@ function Dashboard({ data: d, range }: { data: AdminData; range: RangeKey }) {
)}
<Panel
title="Busiest classifiers"
subtitle="Top 12 label-set fingerprints · names and source text are never recorded"
subtitle="Aggregate label names · caller identity and source text are not retained here"
wide
unavailable={missing("topLabels")}
>
<Table
rows={d.topLabels}
columns={[
{ key: "label_names", label: "Labels" },
{ key: "labels", label: "Classifier fingerprint" },
...numeric.slice(0, 3),
]}
Expand Down
2 changes: 2 additions & 0 deletions src/features/admin/reasons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const REASON_TEXT: Record<string, string> = {
input_too_long: "an input was over 32,000 characters",
rate_limit_minute: "per-minute rate limit",
rate_limit_day: "daily rate limit",
label_set_limit: "anonymous label-set limit",
label_set_unavailable: "label-set admission unavailable",
bad_dimensions: "invalid dimension definitions or conflicting options",
too_many_decisions: "too many item × dimension decisions",
dimension_context_too_large: "input and dimension exceed the model context",
Expand Down
Loading
Loading