From 4192d5ddc893462442089942518ffcca18520de5 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:54:16 +1100 Subject: [PATCH] Add TypeSafe provider with POST /v1/systemone and Jev pricing (#621) The public OpenAPI document is assembled at GET /v1/openapi/typesafe. Jev 1.14.0 and 1.13.0 are decision models at $0.042 per million input tokens, with output tokens free. jev-latest and jev-preview alias 1.14.0. --- src/server/provider-openapi.worker.test.ts | 76 +++++++ src/server/providers/adapters/hints.json | 11 + .../providers/adapters/typesafe.test.ts | 151 +++++++++++++ src/server/providers/adapters/typesafe.ts | 92 ++++++++ src/server/providers/types.ts | 5 + src/server/providers/typesafe-models.test.ts | 79 +++++++ src/server/providers/typesafe-models.ts | 210 ++++++++++++++++++ 7 files changed, 624 insertions(+) create mode 100644 src/server/providers/adapters/typesafe.test.ts create mode 100644 src/server/providers/adapters/typesafe.ts create mode 100644 src/server/providers/typesafe-models.test.ts create mode 100644 src/server/providers/typesafe-models.ts diff --git a/src/server/provider-openapi.worker.test.ts b/src/server/provider-openapi.worker.test.ts index 912848ad..78df0313 100644 --- a/src/server/provider-openapi.worker.test.ts +++ b/src/server/provider-openapi.worker.test.ts @@ -1579,6 +1579,82 @@ describe('openModelNames', () => { expect(schema?.properties?.model).toBeDefined() expect(schema?.properties?.model?.enum).toBeUndefined() }) + + it('serves TypeSafe POST /v1/systemone with model left open', async () => { + await db + .insert(providers) + .values({ + id: 'typesafe', + displayName: 'TypeSafe', + specSourceUrl: 'https://api.typesafe.ai/openapi.json', + }) + .onConflictDoNothing() + await db + .insert(endpoints) + .values({ + id: 'typesafe/v1/systemone', + providerId: 'typesafe', + activity: 'decision', + method: 'POST', + path: '/v1/systemone', + }) + .onConflictDoNothing() + await db + .insert(schemaVersions) + .values([ + { + id: 'typesafe/v1/systemone:input', + endpointId: 'typesafe/v1/systemone', + kind: 'input', + contentHash: 'd'.repeat(64), + schema: JSON.stringify(INPUT), + createdAt: NOW, + }, + { + id: 'typesafe/v1/systemone:output', + endpointId: 'typesafe/v1/systemone', + kind: 'output', + contentHash: 'e'.repeat(64), + schema: JSON.stringify(OUTPUT), + createdAt: NOW, + }, + ]) + .onConflictDoNothing() + await db + .insert(models) + .values({ + id: 'typesafe-jev-1-14-0', + providerId: 'typesafe', + rawId: 'jev-1.14.0', + activity: 'decision', + firstSeenAt: NOW, + lastSeenAt: NOW, + }) + .onConflictDoNothing() + const result = await assembleProviderOpenApi(db, 'typesafe') + expect(result.ok).toBe(true) + if (!result.ok) return + const paths = result.document.paths as Record< + string, + { + post?: { + requestBody?: { + content: { + 'application/json': { + schema: { properties?: { model?: { enum?: unknown } } } + } + } + } + } + } + > + const post = paths['/v1/systemone']?.post + expect(post).toBeDefined() + expect( + post?.requestBody?.content['application/json'].schema?.properties?.model + ?.enum, + ).toBeUndefined() + }) }) describe('modelHeader', () => { diff --git a/src/server/providers/adapters/hints.json b/src/server/providers/adapters/hints.json index 4d249421..13a9b302 100644 --- a/src/server/providers/adapters/hints.json +++ b/src/server/providers/adapters/hints.json @@ -264,5 +264,16 @@ ], "activities": ["world"], "notes": "Mintlify OpenAPI 3.1. api.worldlabs.ai/openapi.json 404s. Catalog and spec are public. Auth is the WLT-Api-Key header. classifyGets so operations, world get, media-asset get, and credits are kept. Marble 1.1 Plus stays unpriced while its totals are ranges." + }, + "typesafe": { + "displayName": "TypeSafe", + "secret": "TYPESAFE_API_KEY", + "strategy": "upstream-spec", + "specUrl": "https://api.typesafe.ai/openapi.json", + "modelsUrl": "https://docs.typesafe.ai/models.md", + "serverUrl": "https://api.typesafe.ai", + "include": ["/v1/systemone"], + "activities": ["decision"], + "notes": "Public OpenAPI 3.1. POST /v1/systemone is the decision call; GET /v1/models is the account list and is dropped. Multipart is stripped so the stored body stays the JSON SystemOneRequest. Catalog and prices are the public models page (output tokens free). model stays an open string." } } diff --git a/src/server/providers/adapters/typesafe.test.ts b/src/server/providers/adapters/typesafe.test.ts new file mode 100644 index 00000000..18c534b6 --- /dev/null +++ b/src/server/providers/adapters/typesafe.test.ts @@ -0,0 +1,151 @@ +import { Validator } from '@cfworker/json-schema' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { classifyAndBundle } from '../../ingest/sync.ts' +import type { OpenApiDocument } from '../types.ts' +import { jsonSystemOne, provider } from './typesafe.ts' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +const SPEC: OpenApiDocument = { + openapi: '3.1.0', + paths: { + '/v1/models': { + get: { + responses: { + '200': { + description: 'Models', + content: { + 'application/json': { + schema: { type: 'object' }, + }, + }, + }, + }, + }, + }, + '/v1/systemone': { + post: { + summary: 'System One', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/SystemOneRequest' }, + }, + 'multipart/form-data': { + schema: { + type: 'object', + required: ['request'], + properties: { + request: { + contentMediaType: 'application/json', + contentSchema: { + $ref: '#/components/schemas/SystemOneRequest', + }, + }, + }, + additionalProperties: { + type: 'string', + contentMediaType: 'image/*', + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Answers', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/SystemOneResponse' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + SystemOneRequest: { + type: 'object', + required: ['model', 'state', 'questions'], + properties: { + model: { type: 'string' }, + state: { type: 'string' }, + questions: { + type: 'object', + minProperties: 1, + additionalProperties: { + type: 'object', + properties: { type: { type: 'string', const: 'noul' } }, + required: ['type'], + }, + }, + }, + }, + SystemOneResponse: { + type: 'object', + required: ['model', 'answers', 'usage'], + properties: { + model: { type: 'string' }, + answers: { type: 'object', minProperties: 1 }, + usage: { + type: 'object', + required: ['input_tokens', 'output_tokens'], + properties: { + input_tokens: { type: 'integer' }, + output_tokens: { type: 'integer' }, + }, + }, + }, + }, + }, + }, +} + +describe('typesafe', () => { + it('keeps POST /v1/systemone and drops the model list', () => { + expect(provider.classify('/v1/systemone', {})).toBe('decision') + expect(provider.classify('/v1/models', {})).toBeNull() + expect(provider.openModelNames).toBe(true) + expect(provider.authEnvVar).toBe('TYPESAFE_API_KEY') + }) + + it('bundles the JSON body, not the multipart form', () => { + const bundled = classifyAndBundle(provider, { + specs: [jsonSystemOne(SPEC)], + sources: [{ url: 'https://api.typesafe.ai/openapi.json', hash: 'ab' }], + outputStrategy: 'post-200', + }) + expect(bundled.endpoints).toHaveLength(1) + const endpoint = bundled.endpoints[0] + expect(endpoint?.path).toBe('/v1/systemone') + expect(endpoint?.activity).toBe('decision') + expect(endpoint?.input).not.toHaveProperty('properties.request') + + const input = new Validator(endpoint?.input as object, '2020-12', false) + expect( + input.validate({ + model: 'jev-latest', + state: 'Charged twice.', + questions: { billing: { type: 'noul' } }, + }).valid, + ).toBe(true) + expect( + input.validate({ model: 'jev-latest', state: 'x', questions: {} }).valid, + ).toBe(false) + + const output = new Validator(endpoint?.output as object, '2020-12', false) + expect( + output.validate({ + model: 'jev-1.14.0', + answers: { billing: { type: 'noul', noul: 0.98 } }, + usage: { input_tokens: 120, output_tokens: 12 }, + }).valid, + ).toBe(true) + }) +}) diff --git a/src/server/providers/adapters/typesafe.ts b/src/server/providers/adapters/typesafe.ts new file mode 100644 index 00000000..9922532c --- /dev/null +++ b/src/server/providers/adapters/typesafe.ts @@ -0,0 +1,92 @@ +/** + * TypeSafe — Jev, the System One decision model. The public OpenAPI + * document is https://api.typesafe.ai/openapi.json. `POST /v1/systemone` + * also accepts multipart (a JSON part plus image parts). The extractor + * would keep that form and drop the JSON body, which is the request + * TanStack AI sends, so the multipart alternative is removed before + * classify. Images still go in the JSON body as `assets`. + * + * The catalog is the public models page, not `GET /v1/models` (that list + * needs a key and currently names aliases only). Versioned ids are + * accepted whether or not the account list shows them, so `model` stays + * an open string. + */ +import type { Activity } from '#/db/schema.ts' +import { bearerConnect } from '../connect.ts' +import { fetchOpenApi, fetchText } from '../types.ts' +import type { + OpenApiDocument, + ProviderConfig, + ProviderSecrets, + SpecFetchResult, +} from '../types.ts' +import { TYPESAFE_MODELS_URL, typesafeModelInfos } from '../typesafe-models.ts' + +export const TYPESAFE_OPENAPI_URL = 'https://api.typesafe.ai/openapi.json' +export const TYPESAFE_API_BASE = 'https://api.typesafe.ai' + +/** Keep the JSON body. Multipart is the same request plus image parts. */ +export function jsonSystemOne(spec: OpenApiDocument): OpenApiDocument { + const paths = spec.paths + const path = paths?.['/v1/systemone'] + const post = path?.post + const requestBody = post?.requestBody + if ( + requestBody === null || + typeof requestBody !== 'object' || + Array.isArray(requestBody) + ) { + throw new Error('typesafe: POST /v1/systemone has no request body') + } + const content = (requestBody as { content?: Record }).content + if (!content?.['application/json']) { + throw new Error('typesafe: POST /v1/systemone has no JSON body') + } + if (!content['multipart/form-data']) return spec + const next = { ...content } + delete next['multipart/form-data'] + return { + ...spec, + paths: { + ...paths, + '/v1/systemone': { + ...path, + post: { + ...post, + requestBody: { ...requestBody, content: next }, + }, + }, + }, + } +} + +function classify(path: string): Activity | null { + return path === '/v1/systemone' ? 'decision' : null +} + +async function fetchSpec(_env: ProviderSecrets): Promise { + const { spec, hash } = await fetchOpenApi(TYPESAFE_OPENAPI_URL) + return { + specs: [jsonSystemOne(spec)], + sources: [{ url: TYPESAFE_OPENAPI_URL, hash }], + outputStrategy: 'post-200', + } +} + +export const provider: ProviderConfig = { + id: 'typesafe', + displayName: 'TypeSafe', + authEnvVar: 'TYPESAFE_API_KEY', + specSourceUrl: TYPESAFE_OPENAPI_URL, + modelsEndpoint: TYPESAFE_MODELS_URL, + defaultDerivation: 'upstream-spec', + connect: bearerConnect(TYPESAFE_API_BASE), + openModelNames: true, + fetchSpec, + listModels: async () => ({ + models: await typesafeModelInfos(await fetchText(TYPESAFE_MODELS_URL)), + }), + classify, + generationEndpointId: ({ activity }) => + activity === 'decision' ? 'v1/systemone' : null, +} diff --git a/src/server/providers/types.ts b/src/server/providers/types.ts index bafd1efa..438f86a9 100644 --- a/src/server/providers/types.ts +++ b/src/server/providers/types.ts @@ -133,6 +133,11 @@ export interface ProviderSecrets { * the key is `Authorization: Bearer`. Sync and poll do not read it. */ FISH_AUDIO_API_KEY?: string + /** + * TypeSafe Jev. The OpenAPI document and the models page are public. + * Sync and poll do not read this. It is the data-plane bearer key. + */ + TYPESAFE_API_KEY?: string /** * PostHog project token (phc_…) for ingest failure events. Optional: * absent means capture is a no-op. US cloud, project 643425. diff --git a/src/server/providers/typesafe-models.test.ts b/src/server/providers/typesafe-models.test.ts new file mode 100644 index 00000000..3cedf2ec --- /dev/null +++ b/src/server/providers/typesafe-models.test.ts @@ -0,0 +1,79 @@ +import { price } from '@modelschemas/rate-card' +import type { RateCard } from '@modelschemas/rate-card' +import { describe, expect, it } from 'vitest' + +import { parseTypesafeModels, typesafeModelInfos } from './typesafe-models.ts' + +/** Excerpt of https://docs.typesafe.ai/models.md (2026-11-10). */ +const PAGE = ` +| | Jev 1.14 | Jev 1.13 | +| :- | :- | :- | +| Model ID | \`jev-1.14.0\` | \`jev-1.13.0\` | +| Price (per Btok / per Mtok) | \\$42 / \\$0.042 | \\$42 / \\$0.042 | +| Context length | 64k per-request / 32k per-question | 64k per-request / 32k per-question | +| Input | Text, images | Text, images | +| Question types | Choice, Score, Noul, Box | Choice, Score, Noul | + +Jev 1.14 is the newest Jev release, available from October 14, 2026. + +* **Price:** You pay per input token. Output tokens are free. A Btok is a billion tokens and an Mtok is a million tokens. + +| Alias | Points to | Meaning | +| :- | :- | :- | +| \`jev-latest\` | \`jev-1.14.0\` | The most recent stable release. | +| \`jev-preview\` | \`jev-1.14.0\` | The most recent release, official or not. | +` + +describe('typesafe models page', () => { + it('lists versioned ids, with aliases and a free output', () => { + expect(parseTypesafeModels(PAGE)).toEqual([ + { + rawId: 'jev-1.13.0', + displayName: 'Jev 1.13', + aliases: [], + inputPerToken: 42 / 1e9, + contextWindow: 64_000, + input: ['text', 'image'], + releasedAt: null, + }, + { + rawId: 'jev-1.14.0', + displayName: 'Jev 1.14', + aliases: ['jev-latest', 'jev-preview'], + inputPerToken: 42 / 1e9, + contextWindow: 64_000, + input: ['text', 'image'], + releasedAt: Date.UTC(2026, 9, 14) / 1000, + }, + ]) + }) + + it('compiles a card that bills input only', async () => { + const models = await typesafeModelInfos(PAGE) + const card = models.find((model) => model.rawId === 'jev-1.14.0') + ?.pricing as RateCard + expect( + price(card, {}, { input_tokens: 1e6, output_tokens: 1e6 }), + ).toBeCloseTo(0.042) + expect(card.tables.rate?.base).toEqual({ + input_tokens: 42 / 1e9, + output_tokens: 0, + }) + expect(models.find((model) => model.rawId === 'jev-latest')).toBeUndefined() + expect(models.every((model) => model.activity === 'decision')).toBe(true) + }) + + it('throws when the Btok and Mtok prices disagree', () => { + expect(() => + parseTypesafeModels(PAGE.replace('\\$42 / \\$0.042', '\\$42 / \\$0.050')), + ).toThrow(/disagree/) + }) + + it('throws when the page stops saying output is free', () => { + expect(() => + parseTypesafeModels( + PAGE.replace('Output tokens are free.', 'Output is metered.'), + ), + ).toThrow(/free/) + }) +}) diff --git a/src/server/providers/typesafe-models.ts b/src/server/providers/typesafe-models.ts new file mode 100644 index 00000000..1610c185 --- /dev/null +++ b/src/server/providers/typesafe-models.ts @@ -0,0 +1,210 @@ +/** + * Jev catalog from https://docs.typesafe.ai/models (issue #621). The + * comparison table is the versioned model ids, the per-request context, + * and the price. Aliases (`jev-latest`, `jev-preview`) are names for a + * column in that table, not separate models. Output tokens are free only + * while the page says so. A page that cannot be read throws. + */ +import { compileTokenCard } from '@modelschemas/rate-card' +import type { RateCard } from '@modelschemas/rate-card' + +import { tagDocsFacts } from './fact-sources.ts' +import { markdownTableRows, parseDay, tokenCount } from './model-facts.ts' +import { sha256Text } from './types.ts' +import type { ModelInfo } from './types.ts' + +export const TYPESAFE_MODELS_URL = 'https://docs.typesafe.ai/models.md' + +const JEV_ID = /^jev-[a-z0-9.]+$/ +const PRICE = /^\\?\$(\d+(?:\.\d+)?) \/ \\?\$(\d+(?:\.\d+)?)$/ +const CONTEXT = + /^(\d+(?:\.\d+)?[kKmM]?) per-request \/ (\d+(?:\.\d+)?[kKmM]?) per-question$/ +const MEDIA: Record = { + text: 'text', + image: 'image', + images: 'image', + audio: 'audio', + video: 'video', +} +const MEDIA_ORDER = ['text', 'image', 'audio', 'video'] + +export interface TypesafeModelRow { + rawId: string + displayName: string + aliases: Array + /** USD per input token. Output tokens are free. */ + inputPerToken: number + contextWindow: number + input: Array + releasedAt: number | null +} + +function unquote(cell: string): string { + return cell.replace(/`/g, '').trim() +} + +/** Comparison table plus the alias table. */ +export function parseTypesafeModels(page: string): Array { + if (!/output tokens are (?:currently )?free/i.test(page)) { + throw new Error( + 'typesafe: models page does not state that output tokens are free', + ) + } + const tables = markdownTableRows(page).map((cells) => cells.map(unquote)) + const idAt = tables.findIndex((cells) => cells[0] === 'Model ID') + const idRow = tables[idAt] + const headers = tables[idAt - 1] + if ( + !idRow || + !headers || + headers[0] !== '' || + headers.length !== idRow.length + ) { + throw new Error('typesafe: models page has no model table') + } + const ids = idRow.slice(1) + if (ids.length === 0 || ids.some((id) => id === '')) { + throw new Error('typesafe: models page has an empty model id') + } + for (const id of ids) { + if (!JEV_ID.test(id)) { + throw new Error(`typesafe: model id ${id} is not a jev id`) + } + } + if (new Set(ids).size !== ids.length) { + throw new Error('typesafe: models page repeats a model id') + } + + const priceRow = tables.find((cells) => cells[0]?.startsWith('Price')) + const contextRow = tables.find((cells) => cells[0] === 'Context length') + const inputRow = tables.find((cells) => cells[0] === 'Input') + if (!priceRow || !contextRow || !inputRow) { + throw new Error('typesafe: models page omits price, context, or input') + } + for (const cells of [priceRow, contextRow, inputRow]) { + if (cells.length !== idRow.length) { + throw new Error(`typesafe: ${cells[0]} row does not cover every model`) + } + } + + const release = page.match( + /([A-Z][\w .'-]*?) is the newest\b[\s\S]*?available from ([A-Z][a-z]+ \d{1,2}, \d{4})/, + ) + let releasedId: string | null = null + let releasedAt: number | null = null + if (release?.[1] && release[2]) { + const name = release[1].trim() + const column = headers.indexOf(name) + const id = ids[column - 1] + if (!id) { + throw new Error( + `typesafe: release names ${name}, which is not a model column`, + ) + } + const day = parseDay(release[2]) + if (day === null) throw new Error('typesafe: release date is unreadable') + releasedId = id + releasedAt = day / 1000 + } + + const byId = new Map() + ids.forEach((rawId, index) => { + const column = index + 1 + const priced = priceRow[column]?.match(PRICE) + if (!priced?.[1] || !priced[2]) { + throw new Error(`typesafe: ${rawId} price is not $Btok / $Mtok`) + } + const perBtok = Number(priced[1]) + const perMtok = Number(priced[2]) + const inputPerToken = perBtok / 1e9 + if (Math.abs(inputPerToken - perMtok / 1e6) > inputPerToken * 1e-9) { + throw new Error(`typesafe: ${rawId} Btok and Mtok prices disagree`) + } + const context = contextRow[column]?.match(CONTEXT) + const contextWindow = tokenCount(context?.[1]) + if (!context || contextWindow === null || tokenCount(context[2]) === null) { + throw new Error(`typesafe: ${rawId} context length is unreadable`) + } + const input = (inputRow[column] ?? '') + .split(',') + .map((part) => MEDIA[part.trim().toLowerCase()]) + .filter((part): part is string => part !== undefined) + const named = (inputRow[column] ?? '') + .split(',') + .map((part) => part.trim()) + .filter((part) => part !== '') + if (named.length === 0 || input.length !== named.length) { + throw new Error(`typesafe: ${rawId} input modalities are unreadable`) + } + byId.set(rawId, { + rawId, + displayName: headers[column] ?? rawId, + aliases: [], + inputPerToken, + contextWindow, + input: MEDIA_ORDER.filter((medium) => input.includes(medium)), + releasedAt: rawId === releasedId ? releasedAt : null, + }) + }) + + for (const cells of tables) { + const alias = cells[0] + const target = cells[1] + if (!alias || !target || !JEV_ID.test(alias) || !JEV_ID.test(target)) { + continue + } + if (byId.has(alias)) continue + const model = byId.get(target) + if (!model) { + throw new Error( + `typesafe: alias ${alias} points at ${target}, which the model table omits`, + ) + } + if (model.aliases.includes(alias)) { + throw new Error(`typesafe: duplicate alias ${alias}`) + } + model.aliases.push(alias) + } + for (const model of byId.values()) model.aliases.sort() + return [...byId.values()].sort((a, b) => (a.rawId < b.rawId ? -1 : 1)) +} + +/** One card per versioned id. Aliases are not their own rows. */ +export async function typesafeModelInfos( + page: string, +): Promise> { + const parsed = parseTypesafeModels(page) + if (parsed.length === 0) { + throw new Error('typesafe: models page listed no ids') + } + const source: RateCard['source'] = { + url: TYPESAFE_MODELS_URL, + hash: await sha256Text(page), + extractedAt: new Date().toISOString(), + } + return parsed.map((row) => { + const pricing = compileTokenCard( + { input_tokens: row.inputPerToken, output_tokens: 0 }, + [], + source, + ) + if (!pricing) + throw new Error(`typesafe: ${row.rawId} price did not compile`) + const modalities = { input: row.input, output: [] as Array } + return { + rawId: row.rawId, + displayName: row.displayName, + activity: 'decision' as const, + aliases: row.aliases.length > 0 ? row.aliases : null, + contextWindow: row.contextWindow, + modalities, + pricing, + releasedAt: row.releasedAt, + factSources: tagDocsFacts( + { pricing, contextWindow: row.contextWindow, modalities }, + TYPESAFE_MODELS_URL, + source.hash, + ), + } + }) +}