From 4c2915fab1b72b4a1391a4b610a49953ecbcdeac Mon Sep 17 00:00:00 2001 From: Michael Ryaboy Date: Mon, 21 Sep 2026 04:43:36 -0700 Subject: [PATCH] Add TypeSafe SDK-compatible API --- package-lock.json | 11 ++ package.json | 1 + src/docs.ts | 44 +++++++ src/index.ts | 59 ++++++++- src/openapi.ts | 136 ++++++++++++++++++++ src/pages.ts | 32 +++++ src/typesafe-compat.ts | 76 +++++++++++ test/api-contract.test.ts | 24 +++- test/discovery.test.ts | 15 ++- test/typesafe-compat.test.ts | 236 +++++++++++++++++++++++++++++++++++ 10 files changed, 626 insertions(+), 8 deletions(-) create mode 100644 src/typesafe-compat.ts create mode 100644 test/typesafe-compat.test.ts diff --git a/package-lock.json b/package-lock.json index 602f2e1..194ce75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,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", @@ -3831,6 +3832,16 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@typesafe-ai/sdk": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@typesafe-ai/sdk/-/sdk-0.6.0.tgz", + "integrity": "sha512-IddX+Q0XM+VagOUZFeP7wZjaO4SHMdvnh2zEBdrZZnXedWI3BNK1lKhMx3ayrkFWvVLbVcUHJy6AVZlY+e6Jaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", diff --git a/package.json b/package.json index 7c25909..a60b405 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/docs.ts b/src/docs.ts index 57b1cf4..f4c350b 100644 --- a/src/docs.ts +++ b/src/docs.ts @@ -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 diff --git a/src/index.ts b/src/index.ts index c706e0c..017dcbd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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; @@ -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", }; /** @@ -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 }, @@ -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` }, @@ -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>).flatMap(([route, methods]) => diff --git a/src/openapi.ts b/src/openapi.ts index 18f3c9c..05966d7 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -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."), @@ -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", @@ -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: [ diff --git a/src/pages.ts b/src/pages.ts index a12d733..0313304 100644 --- a/src/pages.ts +++ b/src/pages.ts @@ -184,6 +184,23 @@ QUICKSTART a calibrated confidence and a score per label. Full reference: https://classifier.dev (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: + + import { choice, TypeSafeClient } from "@typesafe-ai/sdk"; + + const client = new TypeSafeClient({ + apiKey: "unused", + baseURL: "https://classifier.dev", + }); + const result = await client.systemOne({ + state: "I was charged twice. Please fix this today.", + questions: { + category: choice("Which team?", { billing: null, technical: null }), + }, + }); + COMING SOON @@ -207,6 +224,8 @@ SURFACES REST API POST https://classifier.dev or GET https://classifier.dev/{labels}/{text} Query form: GET https://classifier.dev/?labels={a,b}&text={text} Versioned alias: POST https://classifier.dev/v1/classify (same body, same answer) + TypeSafe SDK Base URL https://classifier.dev; POST /v1/systemone and GET /v1/models + JavaScript: @typesafe-ai/sdk Python: typesafe-sdk OpenAPI 3.1 https://classifier.dev/openapi.json MCP https://classifier.dev/mcp (tools) and https://classifier.dev/mcp/docs (documentation) Setup for Claude, ChatGPT, Codex, Cursor: https://classifier.dev/mcp-setup @@ -229,6 +248,8 @@ ENDPOINTS POST /v1/classify Classify 1-1,000 texts. Body: {inputs, labels, tier?, instructions?, multi?, max_labels?} POST / Alias of /v1/classify, tracks the current major POST /v1/classify/batch Alias, for callers that look for a batch endpoint by name + POST /v1/systemone TypeSafe System One wire-compatible endpoint + GET /v1/models TypeSafe model aliases in the official SDK response shape GET /{labels}/{text} One text in the URL: /spam,not+spam/Win+a+free+iPhone -> "spam" GET /?labels=&text= The same as query parameters: /?labels=spam,not+spam&text=Win+a+free+iPhone GET /v1/health {ok, version, time} @@ -253,6 +274,11 @@ AUTHENTICATION Send Authorization: Bearer on REST or MCP. 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. + EXAMPLES @@ -310,6 +336,12 @@ 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. + SANDBOX diff --git a/src/typesafe-compat.ts b/src/typesafe-compat.ts new file mode 100644 index 0000000..33c9ab6 --- /dev/null +++ b/src/typesafe-compat.ts @@ -0,0 +1,76 @@ +/** + * Wire-compatible TypeSafe API surface. + * + * The official clients already own request construction, validation and + * response decoding. Keeping this adapter as a transparent pass-through means + * new question fields and model aliases continue to work without a second, + * subtly different implementation in classifier.dev. + */ + +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; + +/** One System One question is one decision for classifier.dev quota purposes. */ +export function typeSafeDecisionCount(body: string): number { + try { + const parsed: unknown = JSON.parse(body); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return 1; + const questions = (parsed as Record).questions; + if (!questions || typeof questions !== "object" || Array.isArray(questions)) return 1; + return Math.max(1, Object.keys(questions).length); + } catch { + // Let TypeSafe return its native validation response. Invalid requests are + // still admitted as one decision so they cannot bypass the request limit. + return 1; + } +} + +/** Forward one official TypeSafe SDK request using the service credential. */ +export async function typeSafeCompatibleResponse( + request: Request, + apiKey: string | undefined, + body?: string, +): Promise { + if (!apiKey) { + return Response.json( + { error: "The TypeSafe-compatible endpoint is temporarily unavailable." }, + { status: 503, headers: { "cache-control": "no-store" } }, + ); + } + + const incoming = new URL(request.url); + const upstream = new URL(`${incoming.pathname}${incoming.search}`, TYPESAFE_ORIGIN); + // Preserve SDK-supplied request options, including custom tracing headers, + // while keeping the caller's credential, cookies and edge identity private. + const headers = new Headers(); + for (const [name, value] of request.headers) { + if (!PRIVATE_REQUEST_HEADERS.test(name)) headers.append(name, value); + } + headers.set("authorization", `Bearer ${apiKey}`); + + let response: Response; + try { + response = await fetch(upstream, { + method: request.method, + headers, + body, + signal: request.signal, + }); + } catch { + return Response.json( + { error: "TypeSafe is temporarily unavailable." }, + { status: 502, headers: { "cache-control": "no-store" } }, + ); + } + + // 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); + responseHeaders.delete("set-cookie"); + responseHeaders.set("cache-control", "no-store"); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); +} diff --git a/test/api-contract.test.ts b/test/api-contract.test.ts index 26e0085..4bb5a6f 100644 --- a/test/api-contract.test.ts +++ b/test/api-contract.test.ts @@ -29,11 +29,25 @@ function fakeJev(seen: { calls: number } = { calls: 0 }) { const url = String(input instanceof Request ? input.url : input); if (!url.includes("api.typesafe.ai")) throw new Error(`unexpected fetch ${url}`); seen.calls++; + if (url.endsWith("/v1/models")) { + return Response.json({ models: [{ name: "jev-latest", description: "test", release_date: "2026-09-15" }] }); + } const body = JSON.parse(String(init?.body)) as { - state: { id: string; text: string }[]; - questions: Record }>; + state: string | { id: string; text: string }[]; + questions: Record | string[] }>; }; const answers: Record = {}; + if (!Array.isArray(body.state)) { + for (const [qid, q] of Object.entries(body.questions)) { + if (q.type === "noul") answers[qid] = { type: "noul", noul: 0.9 }; + else if (q.type === "score") answers[qid] = { type: "score", score: 1, confidence: 0.9, legend: { "0": "low", "1": "high" }, probabilities: { "0": 0.1, "1": 0.9 } }; + else { + const choices = Object.keys(q.criteria ?? {}); + answers[qid] = { type: "choice", choice: choices[0], confidence: 0.9, probabilities: Object.fromEntries(choices.map((choice, index) => [choice, index ? 0.1 : 0.9])) }; + } + } + return Response.json({ model: "jev-test", answers, usage: { input_tokens: 10, output_tokens: 1 } }); + } for (const [qid, q] of Object.entries(body.questions)) { const itemId = q.type === "noul" ? qid.slice(0, qid.lastIndexOf("_")) : qid; const item = body.state.find((s) => s.id === itemId)!; @@ -287,7 +301,9 @@ describe("openapi.json", () => { expect((methods[method] as { security: unknown }).security).toEqual([{ accountKey: [] }]); continue; } - if (method === "post") init.body = route.startsWith("/api/") ? "{}" : '{"input":"b please","labels":["a","b"]}'; + if (method === "post") init.body = route === "/v1/systemone" + ? '{"state":"b please","model":"jev-latest","questions":{"category":{"type":"choice","criteria":{"a":null,"b":null}}}}' + : route.startsWith("/api/") ? "{}" : '{"input":"b please","labels":["a","b"]}'; const res = await worker.fetch(new Request(`https://classifier.dev${fill(route)}`, init), env, ctx); // A 404 would mean the spec names a path the worker does not serve; // the receipt and skill lookups are the paths whose 404 is the documented answer for an unknown id. @@ -295,7 +311,7 @@ describe("openapi.json", () => { else expect([200, 202, 400]).toContain(res.status); } } - for (const route of ["/v1/classify", "/v1/classify/batch", "/v1/sandbox/classify", "/", "/{labels}/{text}", "/v1/health", "/v1/docs", "/api", + for (const route of ["/v1/classify", "/v1/classify/batch", "/v1/systemone", "/v1/models", "/v1/sandbox/classify", "/", "/{labels}/{text}", "/v1/health", "/v1/docs", "/api", "/api/v1/feedback", "/api/v1/observations", "/api/v1/feedback/{id}/attachments", "/api/v1/receipts/{id}", "/api/v1/policy", "/benchmark"]) { expect(Object.keys(OPENAPI.paths)).toContain(route); } diff --git a/test/discovery.test.ts b/test/discovery.test.ts index 64cd8ca..005dd16 100644 --- a/test/discovery.test.ts +++ b/test/discovery.test.ts @@ -111,7 +111,20 @@ describe("every discovery document", () => { continue; } let status: number; - if (p === "/v1/classify" || p === "/subscribe" || p === "/subscribe/confirm") { + if (p === "/v1/systemone" || p === "/v1/models") { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => String(input).endsWith("/v1/models") + ? Response.json({ models: [{ name: "jev-latest", description: "test", release_date: "2026-09-15" }] }) + : Response.json({ model: "jev-test", answers: { urgent: { type: "noul", noul: 0.9 } }, usage: { input_tokens: 10, output_tokens: 1 } })) as typeof fetch; + try { + const request = p === "/v1/models" + ? new Request(`${ORIGIN}${p}`) + : new Request(`${ORIGIN}${p}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ state: "ticket", model: "jev-latest", questions: { urgent: { type: "noul" } } }) }); + status = (await worker.fetch(request, { ...env, TYPESAFE_API_KEY: "test" }, ctx)).status; + } finally { + globalThis.fetch = originalFetch; + } + } else if (p === "/v1/classify" || p === "/subscribe" || p === "/subscribe/confirm") { const res = await worker.fetch( new Request(`${ORIGIN}${p}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ inputs: ["x"], labels: ["only"] }) }), env, diff --git a/test/typesafe-compat.test.ts b/test/typesafe-compat.test.ts new file mode 100644 index 0000000..9981f73 --- /dev/null +++ b/test/typesafe-compat.test.ts @@ -0,0 +1,236 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + choice, + noul, + RateLimitError, + score, + TypeSafeClient, + UnprocessableEntityError, +} from "@typesafe-ai/sdk"; + +import worker, { type Env } from "../src/index"; +import { DOCS } from "../src/docs"; +import { OPENAPI } from "../src/openapi"; +import { DEVELOPERS } from "../src/pages"; + +const realFetch = globalThis.fetch; +const ctx = { waitUntil: () => {}, passThroughOnException: () => {} } as unknown as ExecutionContext; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +function harness() { + const quotaCosts: number[] = []; + const upstream: Array<{ url: string; headers: Headers; body: unknown }> = []; + const env = { + TYPESAFE_API_KEY: "provider-secret", + LIMITER: { + idFromName: (name: string) => name, + get: () => ({ + fetch: async (url: string) => { + const cost = Number(new URL(url).searchParams.get("cost")); + quotaCosts.push(cost); + return Response.json({ limited: false, remaining: 3000 - cost }); + }, + }), + }, + } as unknown as Env; + + const sdkFetch = (input: string | URL | Request, init?: RequestInit) => + worker.fetch(new Request(input, init), env, ctx); + + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + const headers = new Headers(init?.headers); + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + upstream.push({ url, headers, body }); + if (url.endsWith("/v1/models")) { + return Response.json({ + models: [{ name: "jev-latest", description: "General-purpose system one model.", release_date: "2026-09-15" }], + }, { headers: { "x-typesafe-request-id": "req_models" } }); + } + return Response.json({ + model: "jev-1.13.0", + answers: { + category: { + type: "choice", + choice: "billing", + confidence: 0.91, + probabilities: { billing: 0.91, technical: 0.09 }, + }, + urgent: { type: "noul", noul: 0.87 }, + frustration: { + type: "score", + score: 1.7, + confidence: 0.82, + legend: { "0": "calm", "1": "concerned", "2": "angry" }, + probabilities: { "0": 0.05, "1": 0.2, "2": 0.75 }, + }, + }, + usage: { input_tokens: 123, output_tokens: 9 }, + }, { headers: { + "access-control-allow-credentials": "true", + "x-typesafe-request-id": "req_systemone", + "x-typesafe-feature": "future-metadata", + } }); + }) as typeof fetch; + + return { env, quotaCosts, sdkFetch, upstream }; +} + +describe("the TypeSafe-compatible API", () => { + test("runs the unmodified official JavaScript SDK for every question type and model listing", async () => { + const h = harness(); + const client = new TypeSafeClient({ + apiKey: "unused-by-classifier.dev", + baseURL: "https://classifier.dev", + fetch: h.sdkFetch, + retry: { maxRetries: 0 }, + defaultHeaders: { "x-trace": "trace-123", "cf-connecting-ip": "must-not-leak" }, + }); + + const result = await client.systemOne({ + state: { ticket: "I was charged twice and need this fixed today." }, + questions: { + category: choice("Which team should handle this?", { billing: null, technical: null }), + urgent: noul("Does this need prompt attention?"), + frustration: score("How frustrated is the customer?", ["calm", "concerned", "angry"]), + }, + }).withResponse(); + + expect(result.data.answers.category.choice).toBe("billing"); + expect(result.data.answers.urgent.noul).toBe(0.87); + expect(result.data.answers.frustration.score).toBe(1.7); + expect(result.data.usage).toEqual({ input_tokens: 123, output_tokens: 9 }); + expect(result.requestId).toBe("req_systemone"); + expect(result.response.headers.get("x-typesafe-feature")).toBe("future-metadata"); + expect(result.response.headers.get("access-control-allow-origin")).toBe("*"); + expect(result.response.headers.has("access-control-allow-credentials")).toBe(false); + expect(await client.models.list()).toEqual([ + { name: "jev-latest", description: "General-purpose system one model.", release_date: "2026-09-15" }, + ]); + + expect(h.quotaCosts).toEqual([3]); + expect(h.upstream.map((call) => call.url)).toEqual([ + "https://api.typesafe.ai/v1/systemone", + "https://api.typesafe.ai/v1/models", + ]); + expect(h.upstream[0].headers.get("authorization")).toBe("Bearer provider-secret"); + expect(h.upstream[0].headers.get("authorization")).not.toContain("unused-by-classifier.dev"); + expect(h.upstream[0].headers.get("x-typesafe-sdk")).toMatch(/^typesafe-sdk\//); + expect(h.upstream[0].headers.get("x-trace")).toBe("trace-123"); + expect(h.upstream[0].headers.has("cf-connecting-ip")).toBe(false); + expect(h.upstream[0].body).toMatchObject({ model: "jev-latest" }); + }); + + test("preserves TypeSafe validation bodies, status, request IDs and retry headers", async () => { + const h = harness(); + globalThis.fetch = (async () => Response.json({ + detail: [{ loc: ["body", "questions", "category", "criteria"], msg: "Field required", type: "missing" }], + }, { + status: 422, + headers: { + "x-typesafe-request-id": "req_invalid", + "retry-after-ms": "125", + }, + })) as typeof fetch; + const client = new TypeSafeClient({ + apiKey: "unused", + baseURL: "https://classifier.dev", + fetch: h.sdkFetch, + retry: { maxRetries: 0 }, + }); + + let error: unknown; + try { + await client.systemOne({ + state: "ticket", + questions: { category: choice("Category?", { billing: null, technical: null }) }, + }); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(UnprocessableEntityError); + expect(error).toMatchObject({ status: 422, requestId: "req_invalid" }); + expect((error as UnprocessableEntityError).body).toEqual({ + detail: [{ loc: ["body", "questions", "category", "criteria"], msg: "Field required", type: "missing" }], + }); + expect((error as UnprocessableEntityError).headers.get("retry-after-ms")).toBe("125"); + }); + + test("surfaces classifier.dev quota through the SDK without spending an upstream call", async () => { + let upstreamCalls = 0; + globalThis.fetch = (async () => { upstreamCalls++; return Response.json({}); }) as typeof fetch; + const env = { + TYPESAFE_API_KEY: "provider-secret", + LIMITER: { + idFromName: (name: string) => name, + get: () => ({ fetch: async () => Response.json({ limited: true, remaining: 0, resetIn: 17 }) }), + }, + } as unknown as Env; + const client = new TypeSafeClient({ + apiKey: "unused", + baseURL: "https://classifier.dev", + fetch: (input, init) => worker.fetch(new Request(input, init), env, ctx), + retry: { maxRetries: 0 }, + }); + + let error: unknown; + try { + await client.systemOne({ state: "ticket", questions: { urgent: noul("Is this urgent?") } }); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(RateLimitError); + expect(error).toMatchObject({ status: 429, retryAfterMs: 17_000 }); + expect(upstreamCalls).toBe(0); + }); + + test("admits browser SDK headers in preflight and exposes TypeSafe response metadata", async () => { + const h = harness(); + const response = await worker.fetch(new Request("https://classifier.dev/v1/systemone", { + method: "OPTIONS", + headers: { + origin: "https://example.com", + "access-control-request-method": "POST", + "access-control-request-headers": "authorization,content-type,x-typesafe-sdk,x-typesafe-runtime,x-typesafe-retry-count", + }, + }), h.env, ctx); + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-headers")).toContain("X-TypeSafe-SDK"); + expect(response.headers.get("access-control-allow-headers")).toContain("X-TypeSafe-Runtime"); + expect(response.headers.get("access-control-expose-headers")).toContain("x-typesafe-request-id"); + expect(response.headers.get("access-control-expose-headers")).toContain("Retry-After-Ms"); + }); + + test("returns an SDK-readable service error without sending a request when the provider is unconfigured", async () => { + let calls = 0; + globalThis.fetch = (async () => { calls++; return Response.json({}); }) as typeof fetch; + const response = await worker.fetch(new Request("https://classifier.dev/v1/systemone", { + method: "POST", + headers: { authorization: "Bearer unused", "content-type": "application/json" }, + body: JSON.stringify({ state: "ticket", model: "jev-latest", questions: { urgent: { type: "noul" } } }), + }), {} as Env, ctx); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: "The TypeSafe-compatible endpoint is temporarily unavailable." }); + expect(calls).toBe(0); + }); + + test("publishes the SDK setup in human, agent and OpenAPI documentation", async () => { + expect(DOCS).toContain("TYPESAFE SDK COMPATIBILITY"); + expect(DOCS).toContain('baseURL: "https://classifier.dev"'); + expect(DOCS).toContain('base_url="https://classifier.dev"'); + expect(DEVELOPERS).toContain("POST /v1/systemone"); + expect(DEVELOPERS).toContain("GET /v1/models"); + expect(OPENAPI.paths).toHaveProperty("/v1/systemone"); + expect(OPENAPI.paths).toHaveProperty("/v1/models"); + + 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 }; + 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"); + }); +});