diff --git a/src/cost.ts b/src/cost.ts index 1ae0092..640eba6 100644 --- a/src/cost.ts +++ b/src/cost.ts @@ -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. */ diff --git a/src/docs.ts b/src/docs.ts index 4cec3df..21014e5 100644 --- a/src/docs.ts +++ b/src/docs.ts @@ -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({ @@ -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 diff --git a/src/http/classification.ts b/src/http/classification.ts index bffde56..1958b93 100644 --- a/src/http/classification.ts +++ b/src/http/classification.ts @@ -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) { void promise.catch(() => {}); } } as ExecutionContext; @@ -16,22 +17,31 @@ const background = { waitUntil(promise: Promise) { void promise.catch(( export async function accountClassification(request: Request, env: AppEnv & Partial, source: "API" | "MCP" = "API", ctx: ExecutionContext = background): Promise { 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; - 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 = {}; + 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(); @@ -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), diff --git a/src/index.ts b/src/index.ts index f3d4221..8376615 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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}`, @@ -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` }, @@ -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; @@ -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 @@ -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 }); diff --git a/src/jev.ts b/src/jev.ts index 87f1175..18af37c 100644 --- a/src/jev.ts +++ b/src/jev.ts @@ -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"; @@ -389,7 +389,7 @@ async function post(keys: JevKeys, body: JevBody, meter?: Meter): Promise= gatewayPausedUntil); diff --git a/src/openapi.ts b/src/openapi.ts index 05966d7..b328193 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -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" }, @@ -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" } } } } } }, }, }, }, @@ -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", diff --git a/src/pages.ts b/src/pages.ts index 0313304..3cf232b 100644 --- a/src/pages.ts +++ b/src/pages.ts @@ -185,13 +185,13 @@ QUICKSTART (the same document as \`curl classifier.dev\`). Existing TypeSafe code can use the same official SDK and call shape. Change - only the API root; the placeholder key satisfies the SDK's local check and is - never sent upstream: + the API root and use a classifier.dev workspace key, or "unused" for the + anonymous free tier. Caller credentials are never sent to TypeSafe: import { choice, TypeSafeClient } from "@typesafe-ai/sdk"; const client = new TypeSafeClient({ - apiKey: "unused", + apiKey: process.env.CLASSIFIER_API_KEY ?? "unused", baseURL: "https://classifier.dev", }); const result = await client.systemOne({ @@ -275,9 +275,12 @@ AUTHENTICATION Partner keys remain supported. https://classifier.dev/auth.md The official TypeSafe SDK requires a non-empty apiKey when it constructs a - client. On the TypeSafe-compatible endpoints, use any placeholder such as - "unused". classifier.dev does not authenticate or forward that value; public - limits remain per IP. Never put a real TypeSafe credential in the placeholder. + client. Use "unused" for anonymous free access; classifier.dev ignores that + value and applies the per-IP public limits. To attach requests to a workspace, + pass a classifier_agent_ key from /app/keys as the SDK apiKey. The key is + validated by classifier.dev, uses the workspace quota and credit balance, and + makes billing headers available on the SDK response. Never put a real TypeSafe + credential here: caller credentials are not forwarded to TypeSafe. EXAMPLES @@ -336,11 +339,15 @@ KEYS AND LIMITS 1,000 inputs per request on both tiers. Use --api-key with the CLI, or set CLASSIFY_API_KEY (CLASSIFIER_API_KEY also works). https://classifier.dev/auth.md - POST /v1/systemone uses the same public fast-tier limits, counting named - questions rather than HTTP requests. GET /v1/models does not spend quota. - TypeSafe-native upstream validation, rate-limit and service errors retain - their status and body; x-typesafe-request-id, Retry-After and Retry-After-Ms - are preserved for the official SDKs. + POST /v1/systemone counts named questions rather than HTTP requests. A + placeholder uses the public fast-tier quota per IP. A classifier_agent_ key + uses the workspace's shared quota and credit balance; returned TypeSafe token + usage determines the charge, and Pro gets the same 10x allowance as the REST + and MCP APIs. GET /v1/models does not spend quota or credits. TypeSafe-native + validation, rate-limit and service errors retain their status and body; + x-typesafe-request-id, Retry-After and Retry-After-Ms are preserved. Workspace + responses also expose x-request-id and x-billing-status (settled, refunded or + review). SANDBOX diff --git a/src/server/token-reservation.ts b/src/server/token-reservation.ts index 626ad2f..d35fa03 100644 --- a/src/server/token-reservation.ts +++ b/src/server/token-reservation.ts @@ -1,10 +1,10 @@ import { AppError, type AppDatabase } from "./db"; -import type { ModelTokenUsage } from "../cost"; +import { JEV_ACCOUNT_MODEL, type ModelTokenUsage } from "../cost"; import type { TokenRateCard } from "./token-pricing"; /** Provider context limits, not token estimates. Unknown models cannot spend. */ export function providerCallBound(card: TokenRateCard, provider: ModelTokenUsage["provider"], model: string, maxOutput: number): number { - const inputLimit = provider === "typesafe" && model === "jev-1.13.0" ? 65_536 + const inputLimit = provider === "typesafe" && model === JEV_ACCOUNT_MODEL ? 65_536 : provider === "modal" && ["laya-0.3.4-routed-fast", "laya-0.3.4-routed-bulk"].includes(model) ? 64 * 1024 : provider === "openrouter" && model === "google/gemini-3.8-flash" ? 1_048_576 : null; const rate = card.models.find((row) => row.provider === provider && row.model === model); diff --git a/src/typesafe-compat.ts b/src/typesafe-compat.ts index 33c9ab6..ac97db6 100644 --- a/src/typesafe-compat.ts +++ b/src/typesafe-compat.ts @@ -7,9 +7,15 @@ * subtly different implementation in classifier.dev. */ +import { addJevCost, addTokens, JEV_ACCOUNT_MODEL, type Meter } from "./cost"; + const TYPESAFE_ORIGIN = "https://api.typesafe.ai"; const PRIVATE_REQUEST_HEADERS = /^(authorization|cookie|host|content-length|connection|forwarded|cf-|x-forwarded-|x-real-ip$|true-client-ip$|fly-client-ip$)/i; +function object(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + /** One System One question is one decision for classifier.dev quota purposes. */ export function typeSafeDecisionCount(body: string): number { try { @@ -30,6 +36,7 @@ export async function typeSafeCompatibleResponse( request: Request, apiKey: string | undefined, body?: string, + meter?: Meter, ): Promise { if (!apiKey) { return Response.json( @@ -48,6 +55,12 @@ export async function typeSafeCompatibleResponse( } headers.set("authorization", `Bearer ${apiKey}`); + // Only trusted account execution supplies this hook. Reserve the maximum + // provider exposure before the paid request leaves the Worker. + // The request itself remains untouched, so TypeSafe aliases retain their + // native behavior; an unexpected future response model stays held for review. + await meter?.beforeCall?.("typesafe", JEV_ACCOUNT_MODEL, 0); + let response: Response; try { response = await fetch(upstream, { @@ -63,6 +76,19 @@ export async function typeSafeCompatibleResponse( ); } + if (response.ok && meter) { + const payload = object(await response.clone().json().catch(() => null)); + const usage = object(payload?.usage); + const model = typeof payload?.model === "string" && payload.model ? payload.model : ""; + if (model) { + addJevCost(meter, usage?.input_tokens); + addTokens(meter, "typesafe", model, { + inputTokens: usage?.input_tokens, + outputTokens: usage?.output_tokens, + }); + } + } + // Keep response metadata additive so future official SDK behavior does not // require a matching deploy here. API responses must never set browser state. const responseHeaders = new Headers(response.headers); diff --git a/src/wellknown.ts b/src/wellknown.ts index 07a4e3e..a48d6d0 100644 --- a/src/wellknown.ts +++ b/src/wellknown.ts @@ -18,7 +18,7 @@ export const MCP_REGISTRY_AUTH = "v=MCPv1; k=ed25519; p=aWvcKpRNSyAPr+bh7ba+Hiiy export const MCP_REGISTRY_ENTRY = "https://registry.modelcontextprotocol.io/v0/servers?search=dev.classifier"; /** Bumped when any public page changes materially; feeds sitemap lastmod. */ -export const SITE_UPDATED = "2026-09-20"; +export const SITE_UPDATED = "2026-09-21"; export const SITE = { name: "classifier.dev", @@ -339,6 +339,8 @@ classifier.dev does not implement that spec's agent registration or token exchan ## Discover - REST: POST https://classifier.dev/v1/classify with inputs and labels. +- TypeSafe SDK: POST https://classifier.dev/v1/systemone and GET + https://classifier.dev/v1/models with base URL https://classifier.dev. - MCP: https://classifier.dev/mcp; docs: https://classifier.dev/mcp/docs. - OpenAPI: https://classifier.dev/openapi.json. - RFC 9728 metadata: https://classifier.dev/.well-known/oauth-protected-resource. @@ -347,7 +349,8 @@ classifier.dev does not implement that spec's agent registration or token exchan ## Pick a method - **anonymous** — free, per IP: fast 3,000/minute and 20,000/day; - smart 200/minute and 2,000/day. No account or card. + smart 200/minute and 2,000/day. No account or card. The TypeSafe SDK requires + an apiKey value, so use a non-empty placeholder such as "unused"; it is ignored. - **service_auth (workspace key)** — classifier_agent_ keys charge the workspace credit balance. Create and manage keys in /app/keys. Free workspaces have the public ceilings, shared across keys. Pro workspaces get 10x limits: @@ -368,6 +371,11 @@ registration. Use the same header on REST and MCP. For the CLI, use --api-key or set CLASSIFY_API_KEY (CLASSIFIER_API_KEY also works). Keep keys out of URLs. +For the official TypeSafe SDK, use the classifier_agent_ key as its apiKey and +set baseURL/base_url to https://classifier.dev. POST /v1/systemone then charges +the workspace from TypeSafe's returned token usage and uses its shared quota; +GET /v1/models remains public and free. Never use a real TypeSafe API key with +classifier.dev: caller credentials are not forwarded to TypeSafe. No token exchange or refresh is needed. Your browser billing session is not an API credential. diff --git a/test/typesafe-compat.test.ts b/test/typesafe-compat.test.ts index 9981f73..3e7c596 100644 --- a/test/typesafe-compat.test.ts +++ b/test/typesafe-compat.test.ts @@ -221,16 +221,19 @@ describe("the TypeSafe-compatible API", () => { expect(DOCS).toContain("TYPESAFE SDK COMPATIBILITY"); expect(DOCS).toContain('baseURL: "https://classifier.dev"'); expect(DOCS).toContain('base_url="https://classifier.dev"'); + expect(DOCS).toContain("classifier_agent_..."); + expect(DOCS).toContain("Do not put a real TypeSafe API key here"); expect(DEVELOPERS).toContain("POST /v1/systemone"); expect(DEVELOPERS).toContain("GET /v1/models"); expect(OPENAPI.paths).toHaveProperty("/v1/systemone"); expect(OPENAPI.paths).toHaveProperty("/v1/models"); + expect(OPENAPI.paths["/v1/systemone"].post.security).toEqual([{ accountKey: [] }, {}]); const h = harness(); const agentIndex = await worker.fetch(new Request("https://classifier.dev/api"), h.env, ctx); - const body = await agentIndex.json() as { api: Record; sdks: Record }; + const body = await agentIndex.json() as { api: Record; sdks: { typesafe: { api_key: { workspace: string } } } }; expect(body.api.typesafe_system_one.url).toBe("https://classifier.dev/v1/systemone"); expect(body.api.typesafe_models.url).toBe("https://classifier.dev/v1/models"); - expect(body.sdks).toHaveProperty("typesafe"); + expect(body.sdks.typesafe.api_key.workspace).toContain("classifier_agent_"); }); }); diff --git a/tests/app-http.test.ts b/tests/app-http.test.ts index 81705ab..c3ca378 100644 --- a/tests/app-http.test.ts +++ b/tests/app-http.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; +import { choice, noul, score, TypeSafeClient } from "@typesafe-ai/sdk"; import { database } from "./support/postgres"; import { provisionTestAccount } from "./support/account"; import { performAction } from "../src/server/agents"; @@ -130,6 +131,144 @@ test("classification adapter uses provider credential, meters actual tokens, and expect(calls).toBe(1); }); +test("the official TypeSafe SDK applies a workspace key to quota, billing, and model discovery", async () => { + const quota: Array<{ owner: string; url: string }> = []; + env.LIMITER = { + idFromName: (owner: string) => owner, + get: (owner: string) => ({ fetch: async (url: string) => { + quota.push({ owner, url }); + return Response.json({ limited: false, remaining: 2997 }); + } }), + } as unknown as DurableObjectNamespace; + let systemOneCalls = 0; + let modelCalls = 0; + globalThis.fetch = (async (url, init) => { + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer provider-fixture"); + if (String(url).endsWith("/v1/models")) { + modelCalls++; + return Response.json({ models: [{ name: "jev-latest", description: "Latest stable Jev.", release_date: "2026-09-15" }] }); + } + systemOneCalls++; + return Response.json({ + model: "jev-1.13.0", + answers: { + category: { type: "choice", choice: "billing", confidence: .99, probabilities: { billing: .99, support: .01 } }, + urgent: { type: "noul", noul: .8 }, + frustration: { type: "score", score: 1.5, confidence: .75, legend: { "0": "calm", "1": "concerned", "2": "angry" }, probabilities: { "0": .1, "1": .3, "2": .6 } }, + }, + usage: { input_tokens: 41, output_tokens: 9 }, + }, { headers: { "x-typesafe-request-id": "req_workspace" } }); + }) as typeof fetch; + + const client = new TypeSafeClient({ + apiKey: token, + baseURL: "http://localhost", + fetch: (input, init) => dispatch(new Request(input, init)), + retry: { maxRetries: 0 }, + }); + const result = await client.systemOne({ + state: "I was charged twice and need this fixed today.", + questions: { + category: choice("Which team?", { billing: null, support: null }), + urgent: noul("Is this urgent?"), + frustration: score("How frustrated?", ["calm", "concerned", "angry"]), + }, + }).withResponse(); + const models = await client.models.list(); + + expect(result.data.answers.category.choice).toBe("billing"); + expect(result.requestId).toBe("req_workspace"); + expect(result.response.headers.get("x-billing-status")).toBe("settled"); + expect(result.response.headers.get("x-request-id")).toBeTruthy(); + expect(models.map(model => model.name)).toEqual(["jev-latest"]); + expect(systemOneCalls).toBe(1); + expect(modelCalls).toBe(1); + expect(quota).toHaveLength(1); + expect(quota[0].owner).toBe("fast:account:local-demo"); + expect(new URL(quota[0].url).searchParams.get("cost")).toBe("3"); + expect(new URL(quota[0].url).searchParams.get("limit")).toBe("3000"); + expect(new URL(quota[0].url).searchParams.get("daily")).toBe("20000"); + expect((await getSnapshot("local-demo", env)).credits.balance).toBe(499999); + expect(await env.APP_DB.prepare( + "SELECT items,credits,usage_type,input_tokens,output_tokens,actual_nano::text AS actual_nano,status FROM app_usage WHERE account_id='local-demo'", + ).first()).toEqual({ + items: 3, + credits: 1, + usage_type: "API · TypeSafe System One", + input_tokens: 41, + output_tokens: 9, + actual_nano: "1722", + status: "completed", + }); +}); + +test("TypeSafe workspace requests refund native provider errors and stop before inference without credit", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return Response.json({ detail: [{ loc: ["body", "questions"], msg: "Field required", type: "missing" }] }, { + status: 422, + headers: { "x-typesafe-request-id": "req_invalid" }, + }); + }) as typeof fetch; + const invalid = await dispatch(new Request("http://localhost/v1/systemone", { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ state: "ticket", questions: {} }), + })); + expect(invalid.status).toBe(422); + expect(invalid.headers.get("x-typesafe-request-id")).toBe("req_invalid"); + expect(invalid.headers.get("x-billing-status")).toBe("refunded"); + expect(await invalid.json()).toEqual({ detail: [{ loc: ["body", "questions"], msg: "Field required", type: "missing" }] }); + expect((await getSnapshot("local-demo", env)).credits.balance).toBe(500000); + expect(await env.APP_DB.prepare("SELECT status FROM app_usage WHERE account_id='local-demo'").first()).toEqual({ status: "refunded" }); + + await env.APP_DB.prepare("UPDATE app_accounts SET balance=1 WHERE id='local-demo'").run(); + const before = calls; + const insufficient = await dispatch(new Request("http://localhost/v1/systemone", { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ state: "ticket", questions: { urgent: { type: "noul" } } }), + })); + expect(insufficient.status).toBe(402); + expect(await insufficient.json()).toMatchObject({ error: expect.stringContaining("Insufficient") }); + expect(calls).toBe(before); + expect((await getSnapshot("local-demo", env)).credits.balance).toBe(1); +}); + +test("TypeSafe workspace requests use paid-plan quota and reject revoked keys before inference", async () => { + await env.APP_DB.prepare("UPDATE app_accounts SET billing_plan='pro' WHERE id='local-demo'").run(); + const quota: string[] = []; + env.LIMITER = { + idFromName: (owner: string) => owner, + get: () => ({ fetch: async (url: string) => { + quota.push(url); + return Response.json({ limited: false, remaining: 29999 }); + } }), + } as unknown as DurableObjectNamespace; + let calls = 0; + globalThis.fetch = (async (_url, init) => { + calls++; + return jevResponse(init, 20); + }) as typeof fetch; + const call = () => dispatch(new Request("http://localhost/v1/systemone", { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ state: "ticket", questions: { category: { type: "choice", criteria: { billing: null, support: null } } } }), + })); + const response = await call(); + expect(response.status).toBe(200); + expect(response.headers.get("ratelimit-limit")).toBe("30000"); + expect(response.headers.get("ratelimit-policy")).toBe("30000;w=60, 200000;w=86400"); + expect(new URL(quota[0]).searchParams.get("limit")).toBe("30000"); + expect(new URL(quota[0]).searchParams.get("daily")).toBe("200000"); + + await performAction("local-demo", { type: "revoke", agentId }, env); + const revoked = await call(); + expect(revoked.status).toBe(401); + expect(calls).toBe(1); +}); + test.each(["input", "inputs"])("account billing accepts the public API's scalar %s field", async (field) => { globalThis.fetch = (async (_url, init) => jevResponse(init, 41)) as typeof fetch; const response = await accountClassification(request({ [field]: "Help with my invoice", labels: ["billing", "sales"] }), env); @@ -153,8 +292,8 @@ test.each(["/sandbox/classify", "/v1/sandbox/classify"])("account sandbox alias await expect(accountClassification(new Request(`http://localhost${path}`, request({ input: "Invoice", labels: ["billing", "sales"] })), env)).rejects.toThrow("revoked"); }); -test("account authorization headers do not capture public documents or MCP discovery", async () => { - for (const path of ["/", "/developers", "/openapi.json", "/mcp/docs", "/v1/health"]) { +test("account authorization headers do not capture public documents or unmetered model discovery", async () => { + for (const path of ["/", "/developers", "/openapi.json", "/mcp/docs", "/v1/health", "/v1/models"]) { expect(await accountClassification(new Request(`http://localhost${path}`, { headers: { authorization: `Bearer ${token}` } }), env)).toBeNull(); } }); @@ -163,7 +302,7 @@ const context = { waitUntil(promise: Promise) { void promise.catch(() = const dispatch = async (req: Request) => await accountApi(req, env as AppEnv & Env, context) ?? legacy.fetch(req, env as Env, context); test("browser account clients can preflight and read balances, errors, and billing headers", async () => { - for (const path of ["/v1/account/balance", "/v1/account/usage/summary", "/v1/classify", "/mcp"]) { + for (const path of ["/v1/account/balance", "/v1/account/usage/summary", "/v1/classify", "/v1/systemone", "/mcp"]) { const response = await dispatch(new Request(`http://localhost${path}`, { method: "OPTIONS", headers: { origin: "https://client.example", "access-control-request-headers": "authorization,content-type" }, }));