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
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@types/node": "^22.20.4",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@typesafe-ai/sdk": "0.6.0",
"@vitejs/plugin-react": "^6.1.1",
"ajv": "^8.20.0",
"atmn": "2.0.8",
Expand Down
44 changes: 44 additions & 0 deletions src/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,50 @@ Agents: the OpenAPI 3.1 description is at https://classifier.dev/openapi.json
and a short index at https://classifier.dev/llms.txt


TYPESAFE SDK COMPATIBILITY

classifier.dev implements TypeSafe's System One wire contract at the same
paths as TypeSafe. Point the official JavaScript or Python SDK at this origin;
Choice, Noul and Score questions, model listing, usage, request IDs,
validation errors and retry headers keep their native shapes.

npm install @typesafe-ai/sdk

import { choice, TypeSafeClient } from "@typesafe-ai/sdk";

const client = new TypeSafeClient({
apiKey: "unused", // the SDK requires a non-empty value
baseURL: "https://classifier.dev",
});
const result = await client.systemOne({
state: "I was charged twice. Please fix this today.",
questions: {
category: choice("Which team should handle this?", {
billing: null,
technical: null,
}),
},
});
console.log(result.answers.category.choice);

# uv add typesafe-sdk
from typesafe_sdk import Choice, TypeSafeClient

with TypeSafeClient(api_key="unused", base_url="https://classifier.dev") as client:
result = client.system_one(
state="I was charged twice. Please fix this today.",
questions={"category": Choice(
instructions="Which team?",
criteria={"billing": None, "technical": None},
)},
)

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.


LAYA TRIAL

Calls with neither model nor processing use Jev. To try Laya, send a POST
Expand Down
59 changes: 56 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export { RateLimiter } from "./limiter";
export { QuotaCoordinator } from "./admission";
import { admit, type AdmissionResult, type Quota } from "./admission";
import { pricingHtml } from "./pricingui";
import { typeSafeCompatibleResponse, typeSafeDecisionCount } from "./typesafe-compat";

export interface Env extends LayaEnv {
QUOTAS?: DurableObjectNamespace;
Expand Down Expand Up @@ -182,8 +183,8 @@ export function primaryModels(): string[] {
const CORS = {
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET, POST, OPTIONS",
"access-control-allow-headers": "Content-Type, Authorization, Accept, Idempotency-Key, If-None-Match, Mcp-Session-Id, MCP-Protocol-Version",
"access-control-expose-headers": "RateLimit-Limit, RateLimit-Remaining, RateLimit-Policy, Retry-After, x-api-version, Idempotency-Key",
"access-control-allow-headers": "Content-Type, Authorization, Accept, Idempotency-Key, If-None-Match, Mcp-Session-Id, MCP-Protocol-Version, X-TypeSafe-SDK, X-TypeSafe-Runtime, X-TypeSafe-Retry-Count",
"access-control-expose-headers": "RateLimit-Limit, RateLimit-Remaining, RateLimit-Policy, Retry-After, Retry-After-Ms, x-api-version, x-typesafe-request-id, Idempotency-Key",
};

/**
Expand Down Expand Up @@ -275,6 +276,8 @@ 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_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}`,
content_type: "application/json", body: { email: "agent@example.com", wants: ["faster"], desired_latency_ms: 100 },
Expand Down Expand Up @@ -314,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" },
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" } },
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 @@ -1332,6 +1335,56 @@ const worker = {
{ "cache-control": "no-store" },
);
}
// The official TypeSafe clients can point their base URL at classifier.dev
// without changing the request or response contract. Their Authorization
// value only satisfies the SDK's local non-empty-key check; it is never
// forwarded. The service credential is the sole credential sent upstream.
if (path === "v1/systemone" || path === "v1/models") {
const hasBody = req.method !== "GET" && req.method !== "HEAD";
const body = hasBody ? await req.text() : undefined;
const decisions = path === "v1/systemone" && req.method === "POST"
? typeSafeDecisionCount(body ?? "")
: 0;
let remaining = -1;
if (decisions && !enterprise) {
const gate = await limited(env, "fast", ip, decisions);
remaining = gate.remaining;
if (gate.limited) {
const retryAfter = gate.resetIn ?? 60;
return json(
{ error: `Rate limit reached; retry in ${retryAfter}s.` },
429,
{
"retry-after": String(retryAfter),
"ratelimit-limit": String(TIERS.fast.rpm),
"ratelimit-remaining": "0",
"ratelimit-policy": `${TIERS.fast.rpm};w=60, ${TIERS.fast.daily};w=86400`,
},
);
}
}

const response = await typeSafeCompatibleResponse(req, env.TYPESAFE_API_KEY, body);
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
// SDK response unreadable in browsers.
for (const name of [
"access-control-allow-credentials",
"access-control-allow-headers",
"access-control-allow-methods",
"access-control-allow-origin",
"access-control-expose-headers",
"access-control-max-age",
]) 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`);
if (remaining >= 0) headers.set("ratelimit-remaining", String(remaining));
}
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
}
// .md twins for the machine-readable files, so appending .md to any URL works.
if (path === "openapi.json.md") {
const ops = Object.entries(OPENAPI.paths as Record<string, Record<string, { operationId?: string; summary?: string }>>).flatMap(([route, methods]) =>
Expand Down
136 changes: 136 additions & 0 deletions src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,58 @@ const RATE_LIMIT_HEADERS = {
"x-api-version": { schema: { type: "string" }, description: "The API major that answered, e.g. v1." },
"Idempotency-Key": { schema: { type: "string" }, description: "Echoed when sent." },
};
const TYPESAFE_REQUEST_ID_HEADER = {
"x-typesafe-request-id": { schema: { type: "string" }, description: "TypeSafe request ID, exposed by both official SDKs." },
};
const TYPESAFE_ENTRY = {
anyOf: [
{ type: "string" },
{ type: "object", additionalProperties: true },
{ type: "array", items: {} },
{ type: "null" },
],
};
const TYPESAFE_QUESTION = {
oneOf: [
{
type: "object", required: ["type"],
properties: {
type: { const: "noul" }, instructions: TYPESAFE_ENTRY,
criteria: { anyOf: [{ type: "object", properties: { true: TYPESAFE_ENTRY, false: TYPESAFE_ENTRY } }, { type: "null" }] },
},
},
{
type: "object", required: ["type", "criteria"],
properties: {
type: { const: "choice" }, instructions: TYPESAFE_ENTRY,
criteria: { type: "object", additionalProperties: TYPESAFE_ENTRY },
},
},
{
type: "object", required: ["type", "criteria"],
properties: {
type: { const: "score" }, instructions: TYPESAFE_ENTRY,
criteria: { type: "array", minItems: 1, items: TYPESAFE_ENTRY },
},
},
],
discriminator: { propertyName: "type" },
};
const TYPESAFE_ANSWER = {
oneOf: [
{ type: "object", required: ["type", "noul"], properties: { type: { const: "noul" }, noul: { type: "number", minimum: 0, maximum: 1 } } },
{ type: "object", required: ["type", "choice", "confidence", "probabilities"], properties: {
type: { const: "choice" }, choice: { type: "string" }, confidence: { type: "number", minimum: 0, maximum: 1 },
probabilities: { type: "object", additionalProperties: { type: "number", minimum: 0, maximum: 1 } },
} },
{ type: "object", required: ["type", "score", "confidence", "legend", "probabilities"], properties: {
type: { const: "score" }, score: { type: "number" }, confidence: { type: "number", minimum: 0, maximum: 1 },
legend: { type: "object", additionalProperties: TYPESAFE_ENTRY },
probabilities: { type: "object", additionalProperties: { type: "number", minimum: 0, maximum: 1 } },
} },
],
discriminator: { propertyName: "type" },
};
const errors = (plain: boolean) => ({
"400": err("Malformed request: fewer than 2 labels, more than 1,000 inputs, empty or oversized text, an unknown tier, or a body that is not a JSON object. `code` says which; on the GET forms a 400 also carries `usage` and `try`, a URL built from what was sent that would have worked.", RATE_LIMIT_HEADERS, plain),
"401": err("Invalid API key. Create a workspace key at /app/keys."),
Expand Down Expand Up @@ -526,6 +578,52 @@ export const OPENAPI = {
},
},
},
"/v1/systemone": {
post: {
operationId: "typeSafeSystemOne",
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/.",
tags: ["classify"],
security: [],
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 },
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,
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" } } } } } },
},
},
},
"/v1/models": {
get: {
operationId: "listTypeSafeModels",
summary: "List TypeSafe models and aliases in the official SDK shape.",
description: "Wire-compatible with TypeSafe's GET /v1/models and consumed by client.models.list() / client.models.list().",
tags: ["classify"],
security: [],
responses: {
"200": {
description: "Models available through classifier.dev's TypeSafe account.",
headers: TYPESAFE_REQUEST_ID_HEADER,
content: { "application/json": { schema: { $ref: "#/components/schemas/TypeSafeModelsResponse" } } },
},
"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" } } } } } },
},
},
},
"/v1/classify": {
post: {
operationId: "classifyV1",
Expand Down Expand Up @@ -838,6 +936,44 @@ export const OPENAPI = {
},
example: { category: "friction", summary: "The 404 body names an endpoint that also 404s.", surface: "POST /v1/classify", domain: "classifier.dev" },
},
TypeSafeSystemOneRequest: {
type: "object",
required: ["state", "model", "questions"],
properties: {
state: TYPESAFE_ENTRY,
model: { type: "string", description: "A name or alias returned by GET /v1/models." },
questions: { type: "object", minProperties: 1, additionalProperties: TYPESAFE_QUESTION },
},
example: {
state: { ticket: "I was charged twice. Please fix this today." },
model: "jev-latest",
questions: { category: { type: "choice", instructions: "Which team?", criteria: { billing: null, technical: null } } },
},
},
TypeSafeSystemOneResponse: {
type: "object",
required: ["model", "answers", "usage"],
properties: {
model: { type: "string" },
answers: { type: "object", minProperties: 1, additionalProperties: TYPESAFE_ANSWER },
usage: { type: "object", required: ["input_tokens", "output_tokens"], properties: {
input_tokens: { type: "integer" }, output_tokens: { type: "integer" },
} },
},
},
TypeSafeModelsResponse: {
type: "object", required: ["models"],
properties: { models: { type: "array", items: { type: "object", required: ["name", "description", "release_date"], properties: {
name: { type: "string" }, description: { type: "string" }, release_date: { type: "string", format: "date" },
} } } },
},
TypeSafeValidationError: {
type: "object",
properties: { detail: { type: "array", items: { type: "object", required: ["loc", "msg", "type"], properties: {
loc: { type: "array", items: { anyOf: [{ type: "string" }, { type: "integer" }] } },
msg: { type: "string" }, type: { type: "string" }, input: {}, ctx: { type: "object" },
} } } },
},
ClassifyRequest: {
type: "object",
oneOf: [
Expand Down
Loading
Loading