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
2 changes: 2 additions & 0 deletions src/cost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

/** TypeSafe bills Jev per input token: $0.042 per million. */
export const JEV_USD_PER_MTOK = 0.042;
/** Versioned Jev model whose context window and retail rate are pinned for account billing. */
export const JEV_ACCOUNT_MODEL = "jev-1.13.0";

export type TokenCounts = {
/** Includes cached input; subtract cachedInputTokens only when both are known. */
Expand Down
21 changes: 16 additions & 5 deletions src/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ TYPESAFE SDK COMPATIBILITY
import { choice, TypeSafeClient } from "@typesafe-ai/sdk";

const client = new TypeSafeClient({
apiKey: "unused", // the SDK requires a non-empty value
apiKey: process.env.CLASSIFIER_API_KEY ?? "unused",
baseURL: "https://classifier.dev",
});
const result = await client.systemOne({
Expand All @@ -51,10 +51,21 @@ TYPESAFE SDK COMPATIBILITY
)},
)

The placeholder key is never forwarded. classifier.dev uses its own TypeSafe
credential and applies the public fast-tier quota, counted by questions:
3,000/minute and 20,000/day per IP. The corresponding HTTP resources are
POST /v1/systemone and GET /v1/models.
The apiKey selects how classifier.dev accounts for POST /v1/systemone:

"unused" or any non-workspace value
Free anonymous use. The value is ignored and never forwarded. Fast-tier
quota is counted by questions: 3,000/minute and 20,000/day per IP.

classifier_agent_...
A workspace key from https://classifier.dev/app/keys. Requests use the
workspace's shared quota and credit balance. Free workspaces keep the
same ceilings; Pro workspaces get 10x limits. Charges use TypeSafe's
returned token usage and appear in workspace usage history.

Do not put a real TypeSafe API key here: classifier.dev never forwards caller
credentials. GET /v1/models is public and does not spend quota or credits.
The corresponding HTTP resources are POST /v1/systemone and GET /v1/models.


LAYA TRIAL
Expand Down
36 changes: 23 additions & 13 deletions src/http/classification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { parseTokenRateCard, priceTokens } from "../server/token-pricing";
import { extendTokenReservation, providerCallBound } from "../server/token-reservation";
import { refundTokenReservation, settleTokenReservation } from "../server/token-ledger";
import { writeAccountAnalytics } from "../server/analytics/write";
import { typeSafeDecisionCount } from "../typesafe-compat";

const card = parseTokenRateCard(JSON.stringify(rates))!;
const background = { waitUntil(promise: Promise<unknown>) { void promise.catch(() => {}); } } as ExecutionContext;
Expand All @@ -16,22 +17,31 @@ const background = { waitUntil(promise: Promise<unknown>) { void promise.catch((
export async function accountClassification(request: Request, env: AppEnv & Partial<Env>, source: "API" | "MCP" = "API",
ctx: ExecutionContext = background): Promise<Response | null> {
if (!/^Bearer\s+classifier_agent_/i.test(request.headers.get("authorization") || "")) return null;
if (request.method !== "POST" || !["/", "/v1/classify", "/v1/classify/batch", "/sandbox/classify", "/v1/sandbox/classify"].includes(new URL(request.url).pathname)) return null;
const path = new URL(request.url).pathname;
const typeSafe = path === "/v1/systemone";
if (request.method !== "POST" || !["/", "/v1/classify", "/v1/classify/batch", "/sandbox/classify", "/v1/sandbox/classify", "/v1/systemone"].includes(path)) return null;
const accountId = await requireApiAccount(request, env);
if (Number(request.headers.get("content-length") || 0) > 1_000_000) throw new AppError(413, "Request is too large.");
const text = await request.text();
if (new TextEncoder().encode(text).length > 1_000_000) throw new AppError(413, "Request is too large.");
let body: Record<string, unknown>;
try { body = JSON.parse(text); } catch { throw new AppError(400, "Send valid JSON."); }
if (!body || typeof body !== "object" || Array.isArray(body)) throw new AppError(400, "Send a JSON object.");
const rawInputs = body.inputs ?? body.items ?? body.input;
const inputs = typeof rawInputs === "string" ? [rawInputs] : rawInputs;
if (!Array.isArray(inputs) || !inputs.length || inputs.length > 10_000 || inputs.some((input) => typeof input !== "string"))
throw new AppError(400, "Provide a nonempty list of text inputs.");
if (body.tier !== undefined && !["fast", "smart"].includes(String(body.tier))) throw new AppError(400, "Invalid classification tier.");
const tier = body.tier === "smart" ? "smart" : "fast";
const reservation = await authorizeAndReserve(request, env, 0, inputs.length, {
type: `${source} · ${body.dimensions ? "Dimensions" : body.multi ? "Multi-label" : "Single-label"}`, meteringMode: "tokens",
let body: Record<string, unknown> = {};
let itemCount: number;
if (typeSafe) {
itemCount = typeSafeDecisionCount(text);
} else {
try { body = JSON.parse(text); } catch { throw new AppError(400, "Send valid JSON."); }
if (!body || typeof body !== "object" || Array.isArray(body)) throw new AppError(400, "Send a JSON object.");
const rawInputs = body.inputs ?? body.items ?? body.input;
const inputs = typeof rawInputs === "string" ? [rawInputs] : rawInputs;
if (!Array.isArray(inputs) || !inputs.length || inputs.length > 10_000 || inputs.some((input) => typeof input !== "string"))
throw new AppError(400, "Provide a nonempty list of text inputs.");
itemCount = inputs.length;
if (body.tier !== undefined && !["fast", "smart"].includes(String(body.tier))) throw new AppError(400, "Invalid classification tier.");
}
const tier = !typeSafe && body.tier === "smart" ? "smart" : "fast";
const usageType = typeSafe ? "TypeSafe System One" : body.dimensions ? "Dimensions" : body.multi ? "Multi-label" : "Single-label";
const reservation = await authorizeAndReserve(request, env, 0, itemCount, {
type: `${source} · ${usageType}`, meteringMode: "tokens",
});
if (!reservation) throw new AppError(401, "Missing account credential.");
const meter = newMeter();
Expand All @@ -58,7 +68,7 @@ export async function accountClassification(request: Request, env: AppEnv & Part
};
const analytics = (success: boolean, retailCostUsd: number | null) => writeAccountAnalytics(env, {
accountId, keyId: reservation.agentId, requestId: reservation.id, source, tier,
status: success ? "success" : "error", items: inputs.length, ...tokens(),
status: success ? "success" : "error", items: itemCount, ...tokens(),
model: meter.tokens.map((row) => row.model).join(","), providerCostUsd: meter.tokens.length && !meter.tokens.some(row => row.provider === "modal") ? meter.usd : null,
retailCostUsd, latencyMs: Date.now() - started,
escalations: meter.tokens.filter((row) => row.provider === "openrouter").reduce((total, row) => total + row.calls, 0),
Expand Down
25 changes: 17 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ const agentView = (origin: string) => ({
classify: { method: "POST", url: `${origin}/v1/classify`, alias: `${origin}/`, body: { inputs: ["..."], labels: ["a", "b"], tier: "fast|smart", multi: false } },
classify_dimensions: { method: "POST", url: `${origin}/v1/classify`, body: { items: ["..."], dimensions: { team: ["billing", "platform"], kind: ["bug", "request"] } } },
classify_one: { method: "GET", url: `${origin}/{labels}/{text}`, query_form: `${origin}/?labels={a,b}&text={text}` },
typesafe_system_one: { method: "POST", url: `${origin}/v1/systemone`, compatibility: "TypeSafe System One wire contract; use the official SDK with this origin and any non-empty placeholder API key" },
typesafe_system_one: { method: "POST", url: `${origin}/v1/systemone`, compatibility: "TypeSafe System One wire contract; use the official SDK with a placeholder for anonymous use or a classifier_agent_ workspace key for workspace quota and billing" },
typesafe_models: { method: "GET", url: `${origin}/v1/models`, compatibility: "TypeSafe model-list response consumed by the official SDK" },
subscribe: {
method: "POST", url: `${origin}/${newsletter.SUBSCRIBE_PATH}`,
Expand Down Expand Up @@ -317,7 +317,7 @@ const agentView = (origin: string) => ({
},
mcp: { tools: `${origin}/mcp`, docs: `${origin}/mcp/docs`, card: `${origin}/.well-known/mcp/server-card.json`, setup: `${origin}/mcp-setup` },
cli: { install: "npm i -g classifier-dev", example: "classify bug,feature,praise < feedback.txt" },
sdks: { python: 'pip install "classifier-dev @ git+https://github.com/mrmps/classifier-dev.git@python-v0.1.0#subdirectory=sdk/python"', go: "go get github.com/mrmps/classifier-dev/sdk/go", javascript: "fetch(); no package needed", typesafe: { javascript: "@typesafe-ai/sdk", python: "typesafe-sdk", base_url: origin, api_key: "any non-empty placeholder; never forwarded" } },
sdks: { python: 'pip install "classifier-dev @ git+https://github.com/mrmps/classifier-dev.git@python-v0.1.0#subdirectory=sdk/python"', go: "go get github.com/mrmps/classifier-dev/sdk/go", javascript: "fetch(); no package needed", typesafe: { javascript: "@typesafe-ai/sdk", python: "typesafe-sdk", base_url: origin, api_key: { anonymous: "any non-empty placeholder", workspace: "classifier_agent_... from /app/keys", note: "caller credentials are never forwarded to TypeSafe" } } },
skill: { install: `npx skills add ${origin}`, url: `${origin}/skill.md` },
limits: { fast: "3,000 classifications/min, 20,000/day per IP", smart: "200/min, 2,000/day per IP", headers: ["RateLimit-Limit", "RateLimit-Remaining", "RateLimit-Policy", "Retry-After"] },
pricing: { price: 0, currency: "USD", url: `${origin}/pricing` },
Expand Down Expand Up @@ -1350,9 +1350,13 @@ const worker = {
const decisions = path === "v1/systemone" && req.method === "POST"
? typeSafeDecisionCount(body ?? "")
: 0;
const multiplier = account?.multiplier ?? 1;
const quotaOwner = account ? `account:${account.id}` : ip;
const rpm = TIERS.fast.rpm * multiplier;
const daily = TIERS.fast.daily * multiplier;
let remaining = -1;
if (decisions && !enterprise) {
const gate = await limited(env, "fast", ip, decisions);
const gate = await limited(env, "fast", quotaOwner, decisions, multiplier);
remaining = gate.remaining;
if (gate.limited) {
const retryAfter = gate.resetIn ?? 60;
Expand All @@ -1361,15 +1365,20 @@ const worker = {
429,
{
"retry-after": String(retryAfter),
"ratelimit-limit": String(TIERS.fast.rpm),
"ratelimit-limit": String(rpm),
"ratelimit-remaining": "0",
"ratelimit-policy": `${TIERS.fast.rpm};w=60, ${TIERS.fast.daily};w=86400`,
"ratelimit-policy": `${rpm};w=60, ${daily};w=86400`,
},
);
}
}

const response = await typeSafeCompatibleResponse(req, env.TYPESAFE_API_KEY, body);
const response = await typeSafeCompatibleResponse(
req,
env.TYPESAFE_API_KEY,
body,
decisions && meter.beforeCall ? meter : undefined,
);
const headers = new Headers(response.headers);
// Own the browser policy at this boundary. An upstream credentialed CORS
// header combined with our wildcard origin would make an otherwise valid
Expand All @@ -1384,8 +1393,8 @@ const worker = {
]) headers.delete(name);
for (const [name, value] of Object.entries({ ...CORS, ...SECURITY })) headers.set(name, value);
if (decisions) {
headers.set("ratelimit-limit", enterprise ? "unlimited" : String(TIERS.fast.rpm));
headers.set("ratelimit-policy", enterprise ? "unlimited" : `${TIERS.fast.rpm};w=60, ${TIERS.fast.daily};w=86400`);
headers.set("ratelimit-limit", enterprise ? "unlimited" : String(rpm));
headers.set("ratelimit-policy", enterprise ? "unlimited" : `${rpm};w=60, ${daily};w=86400`);
if (remaining >= 0) headers.set("ratelimit-remaining", String(remaining));
}
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
Expand Down
4 changes: 2 additions & 2 deletions src/jev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
*/

import { recordJevAttempt } from "./jev-observability";
import { addJevCost, addUsd, addTokens, type Meter } from "./cost";
import { addJevCost, addUsd, addTokens, JEV_ACCOUNT_MODEL, type Meter } from "./cost";

const API = "https://api.typesafe.ai/v1/systemone";
const MODEL = "jev-latest";
Expand Down Expand Up @@ -389,7 +389,7 @@ async function post(keys: JevKeys, body: JevBody, meter?: Meter): Promise<JevPay
// unversioned gateway or silently follow a new model behind jev-latest.
if (meter?.beforeCall) {
if (!keys.typesafe) throw new JevError("typesafe: no key configured", 0, "unconfigured");
return postTypesafe(keys.typesafe, { ...body, model: "jev-1.13.0" }, meter, keys.analytics);
return postTypesafe(keys.typesafe, { ...body, model: JEV_ACCOUNT_MODEL }, meter, keys.analytics);
}
// Without a TypeSafe key there is nothing to pause towards, so the gateway is always tried.
const tryGateway = keys.gateway && (!keys.typesafe || Date.now() >= gatewayPausedUntil);
Expand Down
26 changes: 17 additions & 9 deletions src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ const RATE_LIMIT_HEADERS = {
const TYPESAFE_REQUEST_ID_HEADER = {
"x-typesafe-request-id": { schema: { type: "string" }, description: "TypeSafe request ID, exposed by both official SDKs." },
};
const ACCOUNT_BILLING_HEADERS = {
"x-request-id": { schema: { type: "string" }, description: "Workspace usage request ID. Present when a classifier_agent_ key is used." },
"x-billing-status": { schema: { type: "string", enum: ["settled", "refunded", "review"] }, description: "Workspace charge result. Present when a classifier_agent_ key is used." },
};
const TYPESAFE_ENTRY = {
anyOf: [
{ type: "string" },
Expand Down Expand Up @@ -584,25 +588,29 @@ export const OPENAPI = {
summary: "Run the TypeSafe System One contract through classifier.dev.",
description:
"Wire-compatible with TypeSafe's POST /v1/systemone. The official JavaScript and Python SDKs work unchanged when their base URL is https://classifier.dev. " +
"Use any non-empty placeholder API key; classifier.dev never forwards it and authenticates upstream with its own credential. Choice, Noul, Score, structured state, model aliases, usage, validation errors and request IDs retain TypeSafe's shapes. " +
"Public fast-tier quota is counted by named questions, not requests. TypeSafe reference: https://docs.typesafe.ai/.",
"Use any non-empty placeholder API key for anonymous per-IP limits, or a classifier_agent_ workspace key to use workspace quota, credits and usage history. Free workspaces have the public ceilings and Pro workspaces get 10x limits. " +
"classifier.dev never forwards caller credentials to TypeSafe. Choice, Noul, Score, structured state, model aliases, usage, validation errors and request IDs retain TypeSafe's shapes. Quota is counted by named questions, not requests. TypeSafe reference: https://docs.typesafe.ai/.",
tags: ["classify"],
security: [],
security: [{ accountKey: [] }, {}],
requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TypeSafeSystemOneRequest" } } } },
responses: {
"200": {
description: "The native TypeSafe System One response.",
headers: { ...RATE_LIMIT_HEADERS, ...TYPESAFE_REQUEST_ID_HEADER },
headers: { ...RATE_LIMIT_HEADERS, ...TYPESAFE_REQUEST_ID_HEADER, ...ACCOUNT_BILLING_HEADERS },
content: { "application/json": { schema: { $ref: "#/components/schemas/TypeSafeSystemOneResponse" } } },
},
"422": {
description: "TypeSafe request validation failed; body and request ID are preserved.",
headers: TYPESAFE_REQUEST_ID_HEADER,
headers: { ...TYPESAFE_REQUEST_ID_HEADER, ...ACCOUNT_BILLING_HEADERS },
content: { "application/json": { schema: { $ref: "#/components/schemas/TypeSafeValidationError" } } },
},
"429": { description: "classifier.dev or TypeSafe rate limit; Retry-After or Retry-After-Ms is preserved.", content: { "application/json": { schema: { type: "object" } } } },
"502": { description: "TypeSafe could not be reached.", content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" } } } } } },
"503": { description: "The compatibility endpoint is not configured.", content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" } } } } } },
"401": { description: "The supplied classifier_agent_ workspace key is invalid or revoked.", content: { "application/json": { schema: { type: "object" } } } },
"402": { description: "The workspace balance cannot cover the provider reservation; TypeSafe is not called.", content: { "application/json": { schema: { type: "object" } } } },
"403": { description: "The workspace key is inactive or the workspace cannot authorize usage.", content: { "application/json": { schema: { type: "object" } } } },
"413": { description: "The request body exceeds 1 MB.", content: { "application/json": { schema: { type: "object" } } } },
"429": { description: "classifier.dev or TypeSafe rate limit; Retry-After or Retry-After-Ms is preserved.", headers: { ...RATE_LIMIT_HEADERS, ...TYPESAFE_REQUEST_ID_HEADER, ...ACCOUNT_BILLING_HEADERS }, content: { "application/json": { schema: { type: "object" } } } },
"502": { description: "TypeSafe could not be reached.", headers: ACCOUNT_BILLING_HEADERS, content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" } } } } } },
"503": { description: "The compatibility endpoint or workspace billing is temporarily unavailable.", headers: ACCOUNT_BILLING_HEADERS, content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" } } } } } },
},
},
},
Expand Down Expand Up @@ -1236,7 +1244,7 @@ export const OPENAPI = {
securitySchemes: {
accountKey: {
type: "http", scheme: "bearer",
description: "Workspace API key (classifier_agent_...) managed at /app/keys. Required for account reads.",
description: "Workspace API key (classifier_agent_...) managed at /app/keys. Required for account reads; optional on classification and TypeSafe-compatible endpoints to use workspace quota and credits.",
},
partnerKey: {
type: "http",
Expand Down
Loading
Loading