From 5dc1175d64106dd114001c0d96b4a4de0a32e4ab Mon Sep 17 00:00:00 2001 From: Aneesh Sathe Date: Sun, 18 Oct 2026 14:04:11 -0700 Subject: [PATCH] Optional change classifier on decision models (Clef, Jev), off by default A decision model labels each new version of an own item typo, minor or major before publish; a republish of the same words is refresh, decided locally from content_hash with no model call. The Version N dialog shows the label as an editable chip and, with change_category_in_note, writes it into the note where the author sees it. Failures are a null category, never an error. - decide() in src/ai/provider.ts: the System One request shape, Clef on the Workers AI binding, Jev over HTTP ({base_url}/systemone, bearer TYPESAFE_API_KEY); responses parsed against Cloudflare's published output schema and TypeSafe's API reference, defensively. - models.json: kind "decision" (Clef, Clef-flash, jev-latest) and a systemone provider; decision models only for ai_model_classify, never for text functions, enforced in settings and complete(). - POST /items/{id}/classify (owner:draft), change_category on publish, change_class on the owner's versions; migration 0030, studio-private. - Nothing on the wire (#19 / TN-1, #77): asserted by test. Co-Authored-By: Claude Opus 5.5 --- README.md | 16 ++ docs/api.md | 63 ++++++ docs/release-worker.md | 2 +- e2e/change-class.spec.ts | 79 +++++++ migrations/0030_change_class.sql | 30 +++ models.json | 10 +- openapi.json | 291 ++++++++++++++++++++++++ scripts/models-manifest.ts | 16 +- sdk/generated/index.ts | 4 +- sdk/generated/sdk.gen.ts | 12 +- sdk/generated/types.gen.ts | 126 +++++++++++ sdk/generated/zod.gen.ts | 49 ++++- src/ai/models.ts | 25 ++- src/ai/provider.ts | 140 +++++++++++- src/api.ts | 37 +++- src/change-category.ts | 77 +++++++ src/change-class.ts | 168 ++++++++++++++ src/contract/resources.ts | 4 + src/contract/routes.ts | 9 +- src/contract/schemas.ts | 2 + src/feeds/pace-layered.ts | 7 + src/model.ts | 3 + src/permissions.ts | 2 +- src/read-api.ts | 16 +- src/types.ts | 13 ++ src/ui/authoring.css | 5 + src/ui/authoring.tsx | 144 ++++++++++-- src/ui/data.ts | 3 +- src/ui/paced-write.ts | 9 +- src/ui/settings.tsx | 56 ++++- test/change-class.test.ts | 317 +++++++++++++++++++++++++++ test/mcp.oracle.test.ts | 2 +- test/provider.test.ts | 2 +- test/rest-permissions.oracle.test.ts | 2 +- test/sdk-operations.test.ts | 2 + 35 files changed, 1690 insertions(+), 53 deletions(-) create mode 100644 e2e/change-class.spec.ts create mode 100644 migrations/0030_change_class.sql create mode 100644 src/change-category.ts create mode 100644 src/change-class.ts create mode 100644 test/change-class.test.ts diff --git a/README.md b/README.md index 36dbb51..fe3eb46 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,22 @@ The list comes from `models.json`, merged at build with your gitignored | `gemini-generate` | Gemini generateContent | `key_secret` (shipped: `GOOGLE_AI_KEY`) | | `workers-ai` | Cloudflare Workers AI, through the Worker's binding | an `ai` binding (`binding`, default `AI`); no key | | `openai-chat` | any OpenAI-compatible Chat Completions endpoint | `base_url`, and `key_secret` unless the endpoint is keyless | +| `systemone` | TypeSafe's System One decision API (Jev), `POST {base_url}/systemone` | `key_secret` (shipped: `TYPESAFE_API_KEY`); `base_url` optional, default `https://api.typesafe.ai/v1` | + +**Decision models and the change classifier.** A model listed with +`"kind": "decision"`, and every `systemone` model, is a decision model: it +answers typed questions with probabilities and writes no text. Decision models +are offered only for the change classifier (Settings → AI → change classifier, +`ai_model_classify`), and text models never are. The classifier is **off by +default**; with a model chosen, the publish dialog labels each new version of +your own items `typo`, `minor` or `major` (a republish of the same words is +`refresh`, decided with no model call), and you can change or clear the label. +The shipped list has Clef and Clef-flash on Workers AI (the `ai` binding; their +`params` carry the request's `model` selector) and `jev-latest` on TypeSafe (set +`npx wrangler@4 secret put TYPESAFE_API_KEY`). Each classification is one call +against the daily AI budget. The label is kept privately with the version and +never sent as data: readers see it only as words in your change note. See +[`docs/api.md`](docs/api.md#change-classifier). **Workers AI.** The shipped list includes Gemma 4 26B and a few other `@cf/…` models, and any `@cf/…` id typed in with *other…* runs there too. Inference is diff --git a/docs/api.md b/docs/api.md index 82ce842..ea100b6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -41,8 +41,71 @@ Publication, withdrawal, restoration, and generation remain explicit operations: - `POST /items/{id}/generate` generates one TK scope. - `POST /subscriptions/{id}/resync` refreshes a Blyg subscription. - `POST /items/{id}/note-draft` drafts a changelog note from the change between the published version and the working copy. It never publishes. When the published version is unpinned, the note must not reproduce wording that only that version had; a draft that does returns 422. Send `note_generated: true` with `POST /items/{id}/publish` only when the note is that draft, unedited: it becomes `changelog[].generated` on the wire. +- `POST /items/{id}/classify` asks the optional change classifier what kind of change the working copy would publish. See [Change classifier](#change-classifier). - `POST /items/{id}/refresh` republishes a thread to re-bake quotes whose source has a newer version. See below. +## Change classifier + +Optional and **off by default**: nothing below happens until Settings → AI +names a decision model in `ai_model_classify`. A decision model (Cloudflare's +Clef or Clef-flash on Workers AI, or TypeSafe's Jev) answers typed questions +with probabilities and writes no text, so `GET /ai/models` marks it +`kind: "decision"`, `ai_model_classify` accepts only a decision model, and the +text functions (`ai_model_tk`, `ai_model_changelog`, `ai_model_feed`) refuse one +(400). + +The category set is fixed: `refresh`, `typo`, `minor`, `major`. + +| Category | Meaning | Decided by | +|---|---|---| +| `refresh` | a republish of the same words (the new `content_hash` equals the published one) | locally, with no model call | +| `typo` | spelling, punctuation, formatting or a link fixed; meaning unchanged | the model | +| `minor` | small edits to wording or detail; a returning reader need not reread | the model | +| `major` | the substance changed; a returning reader should read it again | the model | + +The model is asked one `choice` question over both versions and the word-level +change between them; the instruction and option descriptions are +`CLASSIFY_QUESTION` in `src/change-class.ts`. + +`POST /items/{id}/classify` (scope `owner:draft`, like `note-draft`) never +publishes. It returns `{ version, category, model, probability, reason? }`: + +- `409` when the classifier is off, for a first version, or when the published + version is a withdrawal. +- `200` with `category: null` and a `reason` when no category could be had: no + binding or key, a provider error, the daily AI budget (`AI_DAILY_CALL_LIMIT`; + every classify that calls a model counts, `refresh` does not), or an answer + that does not parse. **None of these is fatal**: publish as usual. + +The Studio calls it from the *Version N* publish dialog, which shows the answer +as a chip the author can change or clear. With `change_category_in_note` (on by +default; it matters only while the classifier is on) the dialog also writes the +category into the note, where the author sees it before confirming: + +- an empty note becomes the bare word, `minor`; +- a note becomes `minor: `; +- a note that already starts with a category word is left alone. + +The server never rewrites a note. Send `note_generated: true` only when the note +is exactly the machine's text: a drafted note led by the classifier's own +category, or the classifier's bare word on an empty note. A category changed by +the author, or a category prefixed to the author's own words, is not generated. + +`POST /items/{id}/publish` takes an optional `change_category`: the category the +author confirmed (`null` for cleared; omitted, a classification made for exactly +these words stands). It is kept with the version in studio-private storage +(migration 0030, `change_class`: category, model, probability, classified_at, +overridden) and read back as `change_class` on each version in +`GET /items/{id}`. + +**Nothing of this is on the wire.** Spec decision #19 / TN-1 refuses a +version-significance marker, and decision #77 makes "ignore this version" a +reader-side derivation from `content_hash`. So there is no new member in item +documents, `changelog[]`, `feed.xml` or `blyg.json`; the category reaches +readers only as words in the note the author confirmed. +`test/change-class.test.ts` asserts that a classified publish emits the same +document keys as an unclassified one. + Preview and search use `/preview` and `/search`. `GET /changes` returns `{ epoch, domains }`, with revision counters for items, Reading, subscriptions, hoppers, signals, settings and the public feed. It uses diff --git a/docs/release-worker.md b/docs/release-worker.md index 94dde16..3ef7efc 100644 --- a/docs/release-worker.md +++ b/docs/release-worker.md @@ -38,7 +38,7 @@ random bytes, such as 64 hex characters generated by a password manager). Keep both out of the config. Open `https://blyg.example.com/studio`, sign in, and set your title and author name in Settings. Add subscriptions and publish a fragment to check the setup. AI keys are optional: `AI_PROVIDER_KEY` (Anthropic), `OPENAI_API_KEY` or -`GOOGLE_AI_KEY`, for whichever provider's models you choose in Settings; set +`GOOGLE_AI_KEY`, for whichever provider's models you choose in Settings (and `TYPESAFE_API_KEY` if you turn on the optional change classifier with Jev); set one with `npx wrangler@4 secret put AI_PROVIDER_KEY`. The `@cf/…` models run on Cloudflare Workers AI with no key: uncomment the `"ai"` binding in the included `wrangler.jsonc` to offer them. This prebuilt worker diff --git a/e2e/change-class.spec.ts b/e2e/change-class.spec.ts new file mode 100644 index 0000000..ffa1054 --- /dev/null +++ b/e2e/change-class.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from "./fixture"; + +// The optional change classifier's chip in the Version N dialog (session 49). +// The classify route is fulfilled here: what is under test is what the dialog +// does with an answer, and what it sends at publish. +const CLEF = "@cf/cloudflare/clef-flash"; + +test.describe("change classifier", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/studio/login"); + await page.locator('[name="password"]').fill("test-password"); + await page.getByRole("button", { name: "log in", exact: true }).click(); + await expect(page.locator("#composer-text")).toBeVisible(); + }); + + const apiFor = (page: import("@playwright/test").Page) => (m: string, p: string, d?: unknown) => + page.evaluate(async ([m, p, d]) => (await fetch('/api' + p, { method: m, headers: { 'content-type': 'application/json' }, body: d === undefined ? undefined : JSON.stringify(d) })).json(), [m, p, d] as const); + + test("the chip writes the category into the note, the author can change it, and publish carries the choice", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + const api = apiFor(page); + await api("PATCH", "/settings", { ai_model_classify: CLEF }); + try { + await page.route("**/classify", (route) => route.fulfill({ json: { version: 2, category: "minor", model: CLEF, probability: 0.83 } })); + const publishes: any[] = []; + page.on("request", (r) => { if (r.url().endsWith("/publish")) publishes.push(r.postDataJSON()); }); + const id = (await api("POST", "/items", { content_md: `Classifier fixture ${Date.now()}` })).id; + await api("POST", `/items/${id}/publish`, {}); + await page.goto(`/studio/edit/${id}`); + const input = page.locator("#md-input"); + await input.fill(`${await input.inputValue()} Teh end.`); + + await page.locator("#publish-btn").click(); + const minor = page.locator('#change-category [data-category="minor"]'); + await expect(minor).toHaveAttribute("aria-checked", "true"); + await expect(minor).toContainText("83%"); + // An empty note becomes the bare word, which is the machine's text. + await expect(page.locator("#note-confirm-text")).toHaveValue("minor"); + await expect(page.locator("#note-confirm-generated")).toBeVisible(); + + // The author overrides: the note follows, and is no longer generated. + await page.locator('#change-category [data-category="typo"]').click(); + await expect(page.locator("#note-confirm-text")).toHaveValue("typo"); + await expect(page.locator("#note-confirm-generated")).toHaveCount(0); + // A note of their own keeps the prefix in front of it. + await page.locator("#note-confirm-text").fill("typo: Fixed a word."); + await page.locator('#change-category [data-category="minor"]').click(); + await expect(page.locator("#note-confirm-text")).toHaveValue("minor: Fixed a word."); + await page.locator("#note-confirm-ok").click(); + await expect(page.locator('[data-action="view-version"]')).toHaveCount(2); + expect(publishes.at(-1)).toMatchObject({ note: "minor: Fixed a word.", note_generated: false, change_category: "minor" }); + expect(errors).toEqual([]); + } finally { + await api("PATCH", "/settings", { ai_model_classify: "" }); + } + }); + + test("a classifier that gives no answer leaves the note alone and publishes with no category", async ({ page }) => { + const api = apiFor(page); + await api("PATCH", "/settings", { ai_model_classify: CLEF }); + try { + await page.route("**/classify", (route) => route.fulfill({ json: { version: 2, category: null, model: null, probability: null, reason: "upstream unavailable" } })); + const id = (await api("POST", "/items", { content_md: `Classifier failure fixture ${Date.now()}` })).id; + await api("POST", `/items/${id}/publish`, {}); + await page.goto(`/studio/edit/${id}`); + const input = page.locator("#md-input"); + await input.fill(`${await input.inputValue()} More.`); + const published = page.waitForRequest((r) => r.url().endsWith(`/items/${id}/publish`)); + await page.locator("#publish-btn").click(); + await expect(page.locator("#change-category-none")).toContainText("upstream unavailable"); + await expect(page.locator("#note-confirm-text")).toHaveValue(""); + await page.locator("#note-confirm-ok").click(); + expect((await published).postDataJSON()).toMatchObject({ note: "", note_generated: false, change_category: null }); + } finally { + await api("PATCH", "/settings", { ai_model_classify: "" }); + } + }); +}); diff --git a/migrations/0030_change_class.sql b/migrations/0030_change_class.sql new file mode 100644 index 0000000..3a1a55b --- /dev/null +++ b/migrations/0030_change_class.sql @@ -0,0 +1,30 @@ +-- Session 49. 0028 stays reserved for the upload guard (blygger-studio#7). +-- +-- The optional change classifier's studio-private record: one row per own +-- version it classified (or whose category the author set by hand). Never on +-- the wire: spec decision #19 / TN-1 refuse a version-significance marker, and +-- #77 keeps "ignore this version" reader-side. A category reaches readers only +-- as words in the changelog note, which the author confirms. +-- +-- A row is written by POST /items/{id}/classify for the version the working +-- copy would publish as (items.version + 1), with the content_hash of the text +-- it classified, and settled at publish: kept when the published hash matches, +-- marked overridden when the author changed or cleared the category, dropped +-- when the text changed after classifying. A row whose version was never +-- published is replaced by the next classify. +-- +-- No change trigger: the Studio reads these rows only inside an item's +-- versions, and the only write that matters to a reader of those (settling at +-- publish) happens in the same request as the versions insert, whose own +-- trigger already bumps the items domain. +CREATE TABLE change_class ( + item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + category TEXT CHECK (category IS NULL OR category IN ('refresh', 'typo', 'minor', 'major')), + model TEXT, + probability REAL, + classified_at TEXT NOT NULL, + overridden INTEGER NOT NULL DEFAULT 0, + content_hash TEXT NOT NULL, + PRIMARY KEY (item_id, version) +); diff --git a/models.json b/models.json index a2b1cf0..67e4521 100644 --- a/models.json +++ b/models.json @@ -1,10 +1,11 @@ { - "$comment": "The AI models offered in Settings → AI models. Edit this file to add, remove or relabel models, then redeploy. To keep your edits out of upgrade merges, put them in models.local.json instead (same shape, gitignored): its providers merge over these by key, its models replace or add by id, and ids listed in its \"remove\" array are dropped. Model ids not listed here can still be typed in with \"other…\"; the provider is then guessed from the id's prefix (claude-, gpt-, gemini-, @cf/). Provider apis: anthropic-messages, openai-responses and gemini-generate each need a key_secret; workers-ai calls the Worker's AI binding, with no key (optional \"binding\", default AI); openai-chat is any OpenAI-compatible Chat Completions endpoint and needs a base_url, plus a key_secret unless the endpoint is keyless. workers-ai and openai-chat providers and their models may carry \"params\", extra request-body members (a model's merge over its provider's). See README § AI models. Checked against each provider's model page 2026-10-09.", + "$comment": "The AI models offered in Settings → AI models. Edit this file to add, remove or relabel models, then redeploy. To keep your edits out of upgrade merges, put them in models.local.json instead (same shape, gitignored): its providers merge over these by key, its models replace or add by id, and ids listed in its \"remove\" array are dropped. Model ids not listed here can still be typed in with \"other…\"; the provider is then guessed from the id's prefix (claude-, gpt-, gemini-, @cf/). Provider apis: anthropic-messages, openai-responses and gemini-generate each need a key_secret; workers-ai calls the Worker's AI binding, with no key (optional \"binding\", default AI); openai-chat is any OpenAI-compatible Chat Completions endpoint and needs a base_url, plus a key_secret unless the endpoint is keyless. systemone is TypeSafe's System One decision API (Jev): a key_secret, and an optional base_url (default https://api.typesafe.ai/v1; requests go to {base_url}/systemone). A model with \"kind\": \"decision\" (and every systemone model) is a decision model: offered only for the change classifier, never for text generation, and a text model is never offered for the classifier. Clef runs on the workers-ai binding; its params carry the body's model selector (clef or clef-flash). workers-ai and openai-chat providers and their models may carry \"params\", extra request-body members (a model's merge over its provider's). See README § AI models. Checked against each provider's model page 2026-10-09.", "providers": { "anthropic": { "label": "Anthropic", "api": "anthropic-messages", "key_secret": "AI_PROVIDER_KEY", "prefixes": ["claude-"] }, "openai": { "label": "OpenAI", "api": "openai-responses", "key_secret": "OPENAI_API_KEY", "prefixes": ["gpt-", "o1", "o3", "o4"] }, "google": { "label": "Google", "api": "gemini-generate", "key_secret": "GOOGLE_AI_KEY", "prefixes": ["gemini-"] }, - "workers-ai": { "label": "Cloudflare Workers AI", "api": "workers-ai", "binding": "AI", "prefixes": ["@cf/"] } + "workers-ai": { "label": "Cloudflare Workers AI", "api": "workers-ai", "binding": "AI", "prefixes": ["@cf/"] }, + "typesafe": { "label": "TypeSafe", "api": "systemone", "key_secret": "TYPESAFE_API_KEY", "prefixes": ["jev-"] } }, "models": [ { "id": "claude-fable-5-1", "provider": "anthropic", "label": "Claude Fable 5.1", "note": "most capable; highest cost" }, @@ -20,6 +21,9 @@ { "id": "@cf/google/gemma-4-26b-a4b-it", "provider": "workers-ai", "label": "Gemma 4 26B", "note": "billed to your Cloudflare account", "params": { "chat_template_kwargs": { "enable_thinking": false } } }, { "id": "@cf/meta/llama-4-scout-17b-16e-instruct", "provider": "workers-ai", "label": "Llama 4 Scout 17B", "note": "billed to your Cloudflare account" }, { "id": "@cf/meta/llama-3.3-70b-instruct-fp8-fast", "provider": "workers-ai", "label": "Llama 3.3 70B (fast)", "note": "billed to your Cloudflare account" }, - { "id": "@cf/mistralai/mistral-small-3.1-24b-instruct", "provider": "workers-ai", "label": "Mistral Small 3.1 24B", "note": "billed to your Cloudflare account" } + { "id": "@cf/mistralai/mistral-small-3.1-24b-instruct", "provider": "workers-ai", "label": "Mistral Small 3.1 24B", "note": "billed to your Cloudflare account" }, + { "id": "@cf/cloudflare/clef-flash", "provider": "workers-ai", "kind": "decision", "label": "Clef-flash", "note": "decision model, fastest; billed to your Cloudflare account", "params": { "model": "clef-flash" } }, + { "id": "@cf/cloudflare/clef", "provider": "workers-ai", "kind": "decision", "label": "Clef", "note": "decision model; billed to your Cloudflare account", "params": { "model": "clef" } }, + { "id": "jev-latest", "provider": "typesafe", "kind": "decision", "label": "Jev (latest)", "note": "decision model" } ] } diff --git a/openapi.json b/openapi.json index 1ad20a4..40dfe54 100644 --- a/openapi.json +++ b/openapi.json @@ -499,6 +499,14 @@ "ai_model_feed": { "type": "string" }, + "ai_model_classify": { + "type": "string", + "description": "The change classifier's decision model (Clef, Jev); empty, the default, is off. Studio-private: a category reaches readers only as words in a changelog note the author confirms." + }, + "change_category_in_note": { + "type": "boolean", + "description": "Lead the changelog note with the classifier's category, in the publish dialog where the author sees it. Matters only while ai_model_classify is set." + }, "feed_prompt": { "type": "string" }, @@ -660,6 +668,8 @@ "ai_model_tk", "ai_model_changelog", "ai_model_feed", + "ai_model_classify", + "change_category_in_note", "feed_prompt", "ai_style_prompt", "accept_mentions", @@ -1007,6 +1017,55 @@ "type": "null" } ] + }, + "change_class": { + "type": [ + "object", + "null" + ], + "properties": { + "category": { + "type": [ + "string", + "null" + ], + "enum": [ + "refresh", + "typo", + "minor", + "major", + null + ] + }, + "model": { + "type": [ + "string", + "null" + ], + "description": "Null for refresh (decided locally) and for a category the author set by hand." + }, + "probability": { + "type": [ + "number", + "null" + ] + }, + "classified_at": { + "type": "string" + }, + "overridden": { + "type": "boolean", + "description": "The author changed or cleared the classifier's category before publishing." + } + }, + "required": [ + "category", + "model", + "probability", + "classified_at", + "overridden" + ], + "description": "The change classifier's studio-private record of this version, in an item's versions; absent elsewhere. Never on the wire." } }, "required": [ @@ -1444,6 +1503,13 @@ }, "note": { "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "decision" + ], + "description": "A decision model (Clef, Jev): offered only for the change classifier (ai_model_classify), never for text generation." } }, "required": [ @@ -4266,6 +4332,20 @@ }, "note_generated": { "type": "boolean" + }, + "change_category": { + "type": [ + "string", + "null" + ], + "enum": [ + "refresh", + "typo", + "minor", + "major", + null + ], + "description": "The change category the author confirmed in the publish dialog: kept with the version in studio-private storage (never on the wire). Omitted: a classification made for these words stands; null: cleared." } }, "additionalProperties": false @@ -4422,6 +4502,210 @@ } } }, + "/api/items/{id}/classify": { + "post": { + "operationId": "classifyChange", + "tags": [ + "studio" + ], + "security": [ + { + "ownerSession": [] + }, + { + "ownerOAuth": [ + "owner:draft" + ] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "version": { + "type": "integer", + "description": "The version the working copy would publish as." + }, + "category": { + "type": [ + "string", + "null" + ], + "enum": [ + "refresh", + "typo", + "minor", + "major", + null + ], + "description": "Null when no category could be had; see reason." + }, + "model": { + "type": [ + "string", + "null" + ], + "description": "The decision model that answered; null for refresh, decided locally from content_hash." + }, + "probability": { + "type": [ + "number", + "null" + ] + }, + "reason": { + "type": "string", + "description": "Why there is no category: a provider error, an exhausted AI budget, or an unusable answer. Never fatal: publish as usual." + } + }, + "required": [ + "version", + "category", + "model", + "probability" + ] + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "405": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "413": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "415": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "422": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/items/{id}/note-draft": { "post": { "operationId": "draftNote", @@ -5832,6 +6116,13 @@ "ai_model_feed": { "type": "string" }, + "ai_model_classify": { + "type": "string", + "description": "The change classifier's decision model (Clef, Jev); empty, the default, is off. Studio-private: a category reaches readers only as words in a changelog note the author confirms." + }, + "change_category_in_note": { + "type": "boolean" + }, "feed_prompt": { "type": "string" }, diff --git a/scripts/models-manifest.ts b/scripts/models-manifest.ts index 213b3cf..5df2d6d 100644 --- a/scripts/models-manifest.ts +++ b/scripts/models-manifest.ts @@ -11,14 +11,16 @@ export interface Provider { params?: Record; prefixes?: string[]; } -export interface Model { id: string; provider: string; label: string; note?: string; params?: Record } +export interface Model { id: string; provider: string; label: string; note?: string; params?: Record; kind?: "decision" } export interface Manifest { providers: Record; models: Model[] } export interface Local { providers?: Record>; models?: Model[]; remove?: string[] } /** Every request shape src/ai/provider.ts can speak. */ -export const APIS = ["anthropic-messages", "openai-responses", "gemini-generate", "workers-ai", "openai-chat"] as const; +export const APIS = ["anthropic-messages", "openai-responses", "gemini-generate", "workers-ai", "openai-chat", "systemone"] as const; /** The apis that take an operator-supplied endpoint or binding, and so `params`. */ const CHAT_APIS = new Set(["workers-ai", "openai-chat"]); +/** The apis that can serve a decision model (Clef on Workers AI, Jev on System One). */ +const DECISION_APIS = new Set(["workers-ai", "systemone"]); /** Worker secrets that are never an AI key: naming one would send it to a provider. */ const RESERVED_SECRETS = new Set(["OWNER_PASSWORD", "COOKIE_SECRET"]); const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/; @@ -40,8 +42,14 @@ export function providerProblems(key: string, p: Provider): string[] { if (p.api === "openai-chat") { const problem = baseUrlProblem(p.base_url); if (problem) out.push(`${at}: ${problem}`); + } else if (p.api === "systemone") { + // TypeSafe's System One API (decision models): a fixed default endpoint, + // which an operator may point at a proxy or gateway instead. + const problem = p.base_url === undefined ? null : typeof p.base_url === "string" && p.base_url ? baseUrlProblem(p.base_url) : "base_url must be a non-empty URL when given"; + if (problem) out.push(`${at}: ${problem}`); + if (!p.key_secret) out.push(`${at} needs a key_secret`); } else { - if (p.base_url !== undefined) out.push(`${at}: base_url is only for an openai-chat provider`); + if (p.base_url !== undefined) out.push(`${at}: base_url is only for an openai-chat or systemone provider`); if (p.api !== "workers-ai" && !p.key_secret) out.push(`${at} needs a key_secret`); } if (p.params !== undefined && (!isObject(p.params) || !CHAT_APIS.has(p.api))) out.push(`${at}: params must be an object, and only workers-ai and openai-chat providers take them`); @@ -83,6 +91,8 @@ export function mergeManifest(base: Manifest, local: Local | null): Manifest { for (const m of models) { if (!m.id || !m.label || !providers[m.provider]) problems.push(`model "${m.id}" needs a label and a known provider (got "${m.provider}")`); else if (m.params !== undefined && (!isObject(m.params) || !CHAT_APIS.has(providers[m.provider].api))) problems.push(`model "${m.id}": params must be an object, and only models of workers-ai and openai-chat providers take them`); + else if (m.kind !== undefined && m.kind !== "decision") problems.push(`model "${m.id}": kind must be "decision" or absent (got "${m.kind}")`); + else if (m.kind === "decision" && !DECISION_APIS.has(providers[m.provider].api)) problems.push(`model "${m.id}": a decision model runs on a workers-ai or systemone provider`); } if (problems.length) throw new Error(problems.join("\n")); return { providers, models }; diff --git a/sdk/generated/index.ts b/sdk/generated/index.ts index 7a7bed2..fbcb5ed 100644 --- a/sdk/generated/index.ts +++ b/sdk/generated/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { addHopperItem, createAuthorization, createHopper, createItem, createSubscription, deleteHopper, deleteItem, deleteMedia, deleteSignal, deleteSubscription, draftNote, extLineageGlyphGetLineage, extLineageGlyphListSummaries, generateItem, getAiModels, getChanges, getForkOptions, getHopper, getImportedHistory, getImportedItem, getImportedVersion, getItem, getItemFreshness, getMentionSource, getSettings, getSubscription, getUpdateState, getVersion, listAuthorizations, listHoppers, listImportedReading, listInteractions, listItems, listMentions, listReading, listSignals, listStaleThreads, listSubscriptions, listThumbs, markRead, markReadBatch, markUnread, markUnreadBatch, type Options, pinItem, pollAllSubscriptions, preview, publishItem, refreshItem, removeHopperItem, restoreItem, resyncSubscription, revokeAllAuthorizations, revokeAuthorization, search, setSignal, updateHopper, updateItem, updateMention, updateSettings, updateSubscription, uploadMedia, withdrawItem } from './sdk.gen.js'; -export type { AddHopperItemData, AddHopperItemError, AddHopperItemErrors, AddHopperItemResponse, AddHopperItemResponses, AiModels, ApiError, Authorization, ChangeState, Citation, ClientOptions, CreateAuthorizationData, CreateAuthorizationError, CreateAuthorizationErrors, CreateAuthorizationResponse, CreateAuthorizationResponses, CreateHopperData, CreateHopperError, CreateHopperErrors, CreateHopperResponse, CreateHopperResponses, CreateItemData, CreateItemError, CreateItemErrors, CreateItemResponse, CreateItemResponses, CreateSubscriptionData, CreateSubscriptionError, CreateSubscriptionErrors, CreateSubscriptionResponse, CreateSubscriptionResponses, CustomTheme, DeleteHopperData, DeleteHopperError, DeleteHopperErrors, DeleteHopperResponse, DeleteHopperResponses, DeleteItemData, DeleteItemError, DeleteItemErrors, DeleteItemResponse, DeleteItemResponses, DeleteMediaData, DeleteMediaError, DeleteMediaErrors, DeleteMediaResponse, DeleteMediaResponses, DeleteSignalData, DeleteSignalError, DeleteSignalErrors, DeleteSignalResponse, DeleteSignalResponses, DeleteSubscriptionData, DeleteSubscriptionError, DeleteSubscriptionErrors, DeleteSubscriptionResponse, DeleteSubscriptionResponses, DraftNoteData, DraftNoteError, DraftNoteErrors, DraftNoteResponse, DraftNoteResponses, ExtLineageGlyphGetLineageData, ExtLineageGlyphGetLineageError, ExtLineageGlyphGetLineageErrors, ExtLineageGlyphGetLineageResponse, ExtLineageGlyphGetLineageResponses, ExtLineageGlyphListSummariesData, ExtLineageGlyphListSummariesError, ExtLineageGlyphListSummariesErrors, ExtLineageGlyphListSummariesResponse, ExtLineageGlyphListSummariesResponses, GenerateItemData, GenerateItemError, GenerateItemErrors, GenerateItemResponse, GenerateItemResponses, GenerationProvenance, GenerationSource, GetAiModelsData, GetAiModelsError, GetAiModelsErrors, GetAiModelsResponse, GetAiModelsResponses, GetChangesData, GetChangesError, GetChangesErrors, GetChangesResponse, GetChangesResponses, GetForkOptionsData, GetForkOptionsError, GetForkOptionsErrors, GetForkOptionsResponse, GetForkOptionsResponses, GetHopperData, GetHopperError, GetHopperErrors, GetHopperResponse, GetHopperResponses, GetImportedHistoryData, GetImportedHistoryError, GetImportedHistoryErrors, GetImportedHistoryResponse, GetImportedHistoryResponses, GetImportedItemData, GetImportedItemError, GetImportedItemErrors, GetImportedItemResponse, GetImportedItemResponses, GetImportedVersionData, GetImportedVersionError, GetImportedVersionErrors, GetImportedVersionResponse, GetImportedVersionResponses, GetItemData, GetItemError, GetItemErrors, GetItemFreshnessData, GetItemFreshnessError, GetItemFreshnessErrors, GetItemFreshnessResponse, GetItemFreshnessResponses, GetItemResponse, GetItemResponses, GetMentionSourceData, GetMentionSourceError, GetMentionSourceErrors, GetMentionSourceResponse, GetMentionSourceResponses, GetSettingsData, GetSettingsError, GetSettingsErrors, GetSettingsResponse, GetSettingsResponses, GetSubscriptionData, GetSubscriptionError, GetSubscriptionErrors, GetSubscriptionResponse, GetSubscriptionResponses, GetUpdateStateData, GetUpdateStateError, GetUpdateStateErrors, GetUpdateStateResponse, GetUpdateStateResponses, GetVersionData, GetVersionError, GetVersionErrors, GetVersionResponse, GetVersionResponses, Hopper, HopperItemRow, ImportedItem, ImportedReadingEntry, Interaction, Item, LineageGlyphLineage, LineageGlyphNode, LineageGlyphSummary, ListAuthorizationsData, ListAuthorizationsError, ListAuthorizationsErrors, ListAuthorizationsResponse, ListAuthorizationsResponses, ListHoppersData, ListHoppersError, ListHoppersErrors, ListHoppersResponse, ListHoppersResponses, ListImportedReadingData, ListImportedReadingError, ListImportedReadingErrors, ListImportedReadingResponse, ListImportedReadingResponses, ListInteractionsData, ListInteractionsError, ListInteractionsErrors, ListInteractionsResponse, ListInteractionsResponses, ListItemsData, ListItemsError, ListItemsErrors, ListItemsResponse, ListItemsResponses, ListMentionsData, ListMentionsError, ListMentionsErrors, ListMentionsResponse, ListMentionsResponses, ListReadingData, ListReadingError, ListReadingErrors, ListReadingResponse, ListReadingResponses, ListSignalsData, ListSignalsError, ListSignalsErrors, ListSignalsResponse, ListSignalsResponses, ListStaleThreadsData, ListStaleThreadsError, ListStaleThreadsErrors, ListStaleThreadsResponse, ListStaleThreadsResponses, ListSubscriptionsData, ListSubscriptionsError, ListSubscriptionsErrors, ListSubscriptionsResponse, ListSubscriptionsResponses, ListThumbsData, ListThumbsError, ListThumbsErrors, ListThumbsResponse, ListThumbsResponses, MarkReadBatchData, MarkReadBatchError, MarkReadBatchErrors, MarkReadBatchResponse, MarkReadBatchResponses, MarkReadData, MarkReadError, MarkReadErrors, MarkReadResponse, MarkReadResponses, MarkUnreadBatchData, MarkUnreadBatchError, MarkUnreadBatchErrors, MarkUnreadBatchResponse, MarkUnreadBatchResponses, MarkUnreadData, MarkUnreadError, MarkUnreadErrors, MarkUnreadResponse, MarkUnreadResponses, Media, Mention, MentionOutRow, PinItemData, PinItemError, PinItemErrors, PinItemResponse, PinItemResponses, PollAllSubscriptionsData, PollAllSubscriptionsError, PollAllSubscriptionsErrors, PollAllSubscriptionsResponse, PollAllSubscriptionsResponses, PreviewData, PreviewError, PreviewErrors, PreviewResponse, PreviewResponses, PublishItemData, PublishItemError, PublishItemErrors, PublishItemResponse, PublishItemResponses, QuoteFreshness, ReadingEntry, RefreshItemData, RefreshItemError, RefreshItemErrors, RefreshItemResponse, RefreshItemResponses, RemoveHopperItemData, RemoveHopperItemError, RemoveHopperItemErrors, RemoveHopperItemResponse, RemoveHopperItemResponses, RestoreItemData, RestoreItemError, RestoreItemErrors, RestoreItemResponse, RestoreItemResponses, ResyncSubscriptionData, ResyncSubscriptionError, ResyncSubscriptionErrors, ResyncSubscriptionResponse, ResyncSubscriptionResponses, RevokeAllAuthorizationsData, RevokeAllAuthorizationsError, RevokeAllAuthorizationsErrors, RevokeAllAuthorizationsResponse, RevokeAllAuthorizationsResponses, RevokeAuthorizationData, RevokeAuthorizationError, RevokeAuthorizationErrors, RevokeAuthorizationResponse, RevokeAuthorizationResponses, SearchData, SearchError, SearchErrors, SearchResponse, SearchResponses, SetSignalData, SetSignalError, SetSignalErrors, SetSignalResponse, SetSignalResponses, Settings, SignalRow, Subscription, ThreadFreshness, Thumb, Transclusion, UpdateHopperData, UpdateHopperError, UpdateHopperErrors, UpdateHopperResponse, UpdateHopperResponses, UpdateItemData, UpdateItemError, UpdateItemErrors, UpdateItemResponse, UpdateItemResponses, UpdateMentionData, UpdateMentionError, UpdateMentionErrors, UpdateMentionResponse, UpdateMentionResponses, UpdateSettingsData, UpdateSettingsError, UpdateSettingsErrors, UpdateSettingsResponse, UpdateSettingsResponses, UpdateSubscriptionData, UpdateSubscriptionError, UpdateSubscriptionErrors, UpdateSubscriptionResponse, UpdateSubscriptionResponses, UploadMediaData, UploadMediaError, UploadMediaErrors, UploadMediaResponse, UploadMediaResponses, Version, VersionReference, WithdrawItemData, WithdrawItemError, WithdrawItemErrors, WithdrawItemResponse, WithdrawItemResponses } from './types.gen.js'; +export { addHopperItem, classifyChange, createAuthorization, createHopper, createItem, createSubscription, deleteHopper, deleteItem, deleteMedia, deleteSignal, deleteSubscription, draftNote, extLineageGlyphGetLineage, extLineageGlyphListSummaries, generateItem, getAiModels, getChanges, getForkOptions, getHopper, getImportedHistory, getImportedItem, getImportedVersion, getItem, getItemFreshness, getMentionSource, getSettings, getSubscription, getUpdateState, getVersion, listAuthorizations, listHoppers, listImportedReading, listInteractions, listItems, listMentions, listReading, listSignals, listStaleThreads, listSubscriptions, listThumbs, markRead, markReadBatch, markUnread, markUnreadBatch, type Options, pinItem, pollAllSubscriptions, preview, publishItem, refreshItem, removeHopperItem, restoreItem, resyncSubscription, revokeAllAuthorizations, revokeAuthorization, search, setSignal, updateHopper, updateItem, updateMention, updateSettings, updateSubscription, uploadMedia, withdrawItem } from './sdk.gen.js'; +export type { AddHopperItemData, AddHopperItemError, AddHopperItemErrors, AddHopperItemResponse, AddHopperItemResponses, AiModels, ApiError, Authorization, ChangeState, Citation, ClassifyChangeData, ClassifyChangeError, ClassifyChangeErrors, ClassifyChangeResponse, ClassifyChangeResponses, ClientOptions, CreateAuthorizationData, CreateAuthorizationError, CreateAuthorizationErrors, CreateAuthorizationResponse, CreateAuthorizationResponses, CreateHopperData, CreateHopperError, CreateHopperErrors, CreateHopperResponse, CreateHopperResponses, CreateItemData, CreateItemError, CreateItemErrors, CreateItemResponse, CreateItemResponses, CreateSubscriptionData, CreateSubscriptionError, CreateSubscriptionErrors, CreateSubscriptionResponse, CreateSubscriptionResponses, CustomTheme, DeleteHopperData, DeleteHopperError, DeleteHopperErrors, DeleteHopperResponse, DeleteHopperResponses, DeleteItemData, DeleteItemError, DeleteItemErrors, DeleteItemResponse, DeleteItemResponses, DeleteMediaData, DeleteMediaError, DeleteMediaErrors, DeleteMediaResponse, DeleteMediaResponses, DeleteSignalData, DeleteSignalError, DeleteSignalErrors, DeleteSignalResponse, DeleteSignalResponses, DeleteSubscriptionData, DeleteSubscriptionError, DeleteSubscriptionErrors, DeleteSubscriptionResponse, DeleteSubscriptionResponses, DraftNoteData, DraftNoteError, DraftNoteErrors, DraftNoteResponse, DraftNoteResponses, ExtLineageGlyphGetLineageData, ExtLineageGlyphGetLineageError, ExtLineageGlyphGetLineageErrors, ExtLineageGlyphGetLineageResponse, ExtLineageGlyphGetLineageResponses, ExtLineageGlyphListSummariesData, ExtLineageGlyphListSummariesError, ExtLineageGlyphListSummariesErrors, ExtLineageGlyphListSummariesResponse, ExtLineageGlyphListSummariesResponses, GenerateItemData, GenerateItemError, GenerateItemErrors, GenerateItemResponse, GenerateItemResponses, GenerationProvenance, GenerationSource, GetAiModelsData, GetAiModelsError, GetAiModelsErrors, GetAiModelsResponse, GetAiModelsResponses, GetChangesData, GetChangesError, GetChangesErrors, GetChangesResponse, GetChangesResponses, GetForkOptionsData, GetForkOptionsError, GetForkOptionsErrors, GetForkOptionsResponse, GetForkOptionsResponses, GetHopperData, GetHopperError, GetHopperErrors, GetHopperResponse, GetHopperResponses, GetImportedHistoryData, GetImportedHistoryError, GetImportedHistoryErrors, GetImportedHistoryResponse, GetImportedHistoryResponses, GetImportedItemData, GetImportedItemError, GetImportedItemErrors, GetImportedItemResponse, GetImportedItemResponses, GetImportedVersionData, GetImportedVersionError, GetImportedVersionErrors, GetImportedVersionResponse, GetImportedVersionResponses, GetItemData, GetItemError, GetItemErrors, GetItemFreshnessData, GetItemFreshnessError, GetItemFreshnessErrors, GetItemFreshnessResponse, GetItemFreshnessResponses, GetItemResponse, GetItemResponses, GetMentionSourceData, GetMentionSourceError, GetMentionSourceErrors, GetMentionSourceResponse, GetMentionSourceResponses, GetSettingsData, GetSettingsError, GetSettingsErrors, GetSettingsResponse, GetSettingsResponses, GetSubscriptionData, GetSubscriptionError, GetSubscriptionErrors, GetSubscriptionResponse, GetSubscriptionResponses, GetUpdateStateData, GetUpdateStateError, GetUpdateStateErrors, GetUpdateStateResponse, GetUpdateStateResponses, GetVersionData, GetVersionError, GetVersionErrors, GetVersionResponse, GetVersionResponses, Hopper, HopperItemRow, ImportedItem, ImportedReadingEntry, Interaction, Item, LineageGlyphLineage, LineageGlyphNode, LineageGlyphSummary, ListAuthorizationsData, ListAuthorizationsError, ListAuthorizationsErrors, ListAuthorizationsResponse, ListAuthorizationsResponses, ListHoppersData, ListHoppersError, ListHoppersErrors, ListHoppersResponse, ListHoppersResponses, ListImportedReadingData, ListImportedReadingError, ListImportedReadingErrors, ListImportedReadingResponse, ListImportedReadingResponses, ListInteractionsData, ListInteractionsError, ListInteractionsErrors, ListInteractionsResponse, ListInteractionsResponses, ListItemsData, ListItemsError, ListItemsErrors, ListItemsResponse, ListItemsResponses, ListMentionsData, ListMentionsError, ListMentionsErrors, ListMentionsResponse, ListMentionsResponses, ListReadingData, ListReadingError, ListReadingErrors, ListReadingResponse, ListReadingResponses, ListSignalsData, ListSignalsError, ListSignalsErrors, ListSignalsResponse, ListSignalsResponses, ListStaleThreadsData, ListStaleThreadsError, ListStaleThreadsErrors, ListStaleThreadsResponse, ListStaleThreadsResponses, ListSubscriptionsData, ListSubscriptionsError, ListSubscriptionsErrors, ListSubscriptionsResponse, ListSubscriptionsResponses, ListThumbsData, ListThumbsError, ListThumbsErrors, ListThumbsResponse, ListThumbsResponses, MarkReadBatchData, MarkReadBatchError, MarkReadBatchErrors, MarkReadBatchResponse, MarkReadBatchResponses, MarkReadData, MarkReadError, MarkReadErrors, MarkReadResponse, MarkReadResponses, MarkUnreadBatchData, MarkUnreadBatchError, MarkUnreadBatchErrors, MarkUnreadBatchResponse, MarkUnreadBatchResponses, MarkUnreadData, MarkUnreadError, MarkUnreadErrors, MarkUnreadResponse, MarkUnreadResponses, Media, Mention, MentionOutRow, PinItemData, PinItemError, PinItemErrors, PinItemResponse, PinItemResponses, PollAllSubscriptionsData, PollAllSubscriptionsError, PollAllSubscriptionsErrors, PollAllSubscriptionsResponse, PollAllSubscriptionsResponses, PreviewData, PreviewError, PreviewErrors, PreviewResponse, PreviewResponses, PublishItemData, PublishItemError, PublishItemErrors, PublishItemResponse, PublishItemResponses, QuoteFreshness, ReadingEntry, RefreshItemData, RefreshItemError, RefreshItemErrors, RefreshItemResponse, RefreshItemResponses, RemoveHopperItemData, RemoveHopperItemError, RemoveHopperItemErrors, RemoveHopperItemResponse, RemoveHopperItemResponses, RestoreItemData, RestoreItemError, RestoreItemErrors, RestoreItemResponse, RestoreItemResponses, ResyncSubscriptionData, ResyncSubscriptionError, ResyncSubscriptionErrors, ResyncSubscriptionResponse, ResyncSubscriptionResponses, RevokeAllAuthorizationsData, RevokeAllAuthorizationsError, RevokeAllAuthorizationsErrors, RevokeAllAuthorizationsResponse, RevokeAllAuthorizationsResponses, RevokeAuthorizationData, RevokeAuthorizationError, RevokeAuthorizationErrors, RevokeAuthorizationResponse, RevokeAuthorizationResponses, SearchData, SearchError, SearchErrors, SearchResponse, SearchResponses, SetSignalData, SetSignalError, SetSignalErrors, SetSignalResponse, SetSignalResponses, Settings, SignalRow, Subscription, ThreadFreshness, Thumb, Transclusion, UpdateHopperData, UpdateHopperError, UpdateHopperErrors, UpdateHopperResponse, UpdateHopperResponses, UpdateItemData, UpdateItemError, UpdateItemErrors, UpdateItemResponse, UpdateItemResponses, UpdateMentionData, UpdateMentionError, UpdateMentionErrors, UpdateMentionResponse, UpdateMentionResponses, UpdateSettingsData, UpdateSettingsError, UpdateSettingsErrors, UpdateSettingsResponse, UpdateSettingsResponses, UpdateSubscriptionData, UpdateSubscriptionError, UpdateSubscriptionErrors, UpdateSubscriptionResponse, UpdateSubscriptionResponses, UploadMediaData, UploadMediaError, UploadMediaErrors, UploadMediaResponse, UploadMediaResponses, Version, VersionReference, WithdrawItemData, WithdrawItemError, WithdrawItemErrors, WithdrawItemResponse, WithdrawItemResponses } from './types.gen.js'; diff --git a/sdk/generated/sdk.gen.ts b/sdk/generated/sdk.gen.ts index 96e04c5..62cde22 100644 --- a/sdk/generated/sdk.gen.ts +++ b/sdk/generated/sdk.gen.ts @@ -2,7 +2,7 @@ import { client } from './client.gen.js'; import { type Client, type ClientMeta, formDataBodySerializer, type Options as Options2, type RequestResult, type TDataShape } from './client/index.js'; -import type { AddHopperItemData, AddHopperItemErrors, AddHopperItemResponses, CreateAuthorizationData, CreateAuthorizationErrors, CreateAuthorizationResponses, CreateHopperData, CreateHopperErrors, CreateHopperResponses, CreateItemData, CreateItemErrors, CreateItemResponses, CreateSubscriptionData, CreateSubscriptionErrors, CreateSubscriptionResponses, DeleteHopperData, DeleteHopperErrors, DeleteHopperResponses, DeleteItemData, DeleteItemErrors, DeleteItemResponses, DeleteMediaData, DeleteMediaErrors, DeleteMediaResponses, DeleteSignalData, DeleteSignalErrors, DeleteSignalResponses, DeleteSubscriptionData, DeleteSubscriptionErrors, DeleteSubscriptionResponses, DraftNoteData, DraftNoteErrors, DraftNoteResponses, ExtLineageGlyphGetLineageData, ExtLineageGlyphGetLineageErrors, ExtLineageGlyphGetLineageResponses, ExtLineageGlyphListSummariesData, ExtLineageGlyphListSummariesErrors, ExtLineageGlyphListSummariesResponses, GenerateItemData, GenerateItemErrors, GenerateItemResponses, GetAiModelsData, GetAiModelsErrors, GetAiModelsResponses, GetChangesData, GetChangesErrors, GetChangesResponses, GetForkOptionsData, GetForkOptionsErrors, GetForkOptionsResponses, GetHopperData, GetHopperErrors, GetHopperResponses, GetImportedHistoryData, GetImportedHistoryErrors, GetImportedHistoryResponses, GetImportedItemData, GetImportedItemErrors, GetImportedItemResponses, GetImportedVersionData, GetImportedVersionErrors, GetImportedVersionResponses, GetItemData, GetItemErrors, GetItemFreshnessData, GetItemFreshnessErrors, GetItemFreshnessResponses, GetItemResponses, GetMentionSourceData, GetMentionSourceErrors, GetMentionSourceResponses, GetSettingsData, GetSettingsErrors, GetSettingsResponses, GetSubscriptionData, GetSubscriptionErrors, GetSubscriptionResponses, GetUpdateStateData, GetUpdateStateErrors, GetUpdateStateResponses, GetVersionData, GetVersionErrors, GetVersionResponses, ListAuthorizationsData, ListAuthorizationsErrors, ListAuthorizationsResponses, ListHoppersData, ListHoppersErrors, ListHoppersResponses, ListImportedReadingData, ListImportedReadingErrors, ListImportedReadingResponses, ListInteractionsData, ListInteractionsErrors, ListInteractionsResponses, ListItemsData, ListItemsErrors, ListItemsResponses, ListMentionsData, ListMentionsErrors, ListMentionsResponses, ListReadingData, ListReadingErrors, ListReadingResponses, ListSignalsData, ListSignalsErrors, ListSignalsResponses, ListStaleThreadsData, ListStaleThreadsErrors, ListStaleThreadsResponses, ListSubscriptionsData, ListSubscriptionsErrors, ListSubscriptionsResponses, ListThumbsData, ListThumbsErrors, ListThumbsResponses, MarkReadBatchData, MarkReadBatchErrors, MarkReadBatchResponses, MarkReadData, MarkReadErrors, MarkReadResponses, MarkUnreadBatchData, MarkUnreadBatchErrors, MarkUnreadBatchResponses, MarkUnreadData, MarkUnreadErrors, MarkUnreadResponses, PinItemData, PinItemErrors, PinItemResponses, PollAllSubscriptionsData, PollAllSubscriptionsErrors, PollAllSubscriptionsResponses, PreviewData, PreviewErrors, PreviewResponses, PublishItemData, PublishItemErrors, PublishItemResponses, RefreshItemData, RefreshItemErrors, RefreshItemResponses, RemoveHopperItemData, RemoveHopperItemErrors, RemoveHopperItemResponses, RestoreItemData, RestoreItemErrors, RestoreItemResponses, ResyncSubscriptionData, ResyncSubscriptionErrors, ResyncSubscriptionResponses, RevokeAllAuthorizationsData, RevokeAllAuthorizationsErrors, RevokeAllAuthorizationsResponses, RevokeAuthorizationData, RevokeAuthorizationErrors, RevokeAuthorizationResponses, SearchData, SearchErrors, SearchResponses, SetSignalData, SetSignalErrors, SetSignalResponses, UpdateHopperData, UpdateHopperErrors, UpdateHopperResponses, UpdateItemData, UpdateItemErrors, UpdateItemResponses, UpdateMentionData, UpdateMentionErrors, UpdateMentionResponses, UpdateSettingsData, UpdateSettingsErrors, UpdateSettingsResponses, UpdateSubscriptionData, UpdateSubscriptionErrors, UpdateSubscriptionResponses, UploadMediaData, UploadMediaErrors, UploadMediaResponses, WithdrawItemData, WithdrawItemErrors, WithdrawItemResponses } from './types.gen.js'; +import type { AddHopperItemData, AddHopperItemErrors, AddHopperItemResponses, ClassifyChangeData, ClassifyChangeErrors, ClassifyChangeResponses, CreateAuthorizationData, CreateAuthorizationErrors, CreateAuthorizationResponses, CreateHopperData, CreateHopperErrors, CreateHopperResponses, CreateItemData, CreateItemErrors, CreateItemResponses, CreateSubscriptionData, CreateSubscriptionErrors, CreateSubscriptionResponses, DeleteHopperData, DeleteHopperErrors, DeleteHopperResponses, DeleteItemData, DeleteItemErrors, DeleteItemResponses, DeleteMediaData, DeleteMediaErrors, DeleteMediaResponses, DeleteSignalData, DeleteSignalErrors, DeleteSignalResponses, DeleteSubscriptionData, DeleteSubscriptionErrors, DeleteSubscriptionResponses, DraftNoteData, DraftNoteErrors, DraftNoteResponses, ExtLineageGlyphGetLineageData, ExtLineageGlyphGetLineageErrors, ExtLineageGlyphGetLineageResponses, ExtLineageGlyphListSummariesData, ExtLineageGlyphListSummariesErrors, ExtLineageGlyphListSummariesResponses, GenerateItemData, GenerateItemErrors, GenerateItemResponses, GetAiModelsData, GetAiModelsErrors, GetAiModelsResponses, GetChangesData, GetChangesErrors, GetChangesResponses, GetForkOptionsData, GetForkOptionsErrors, GetForkOptionsResponses, GetHopperData, GetHopperErrors, GetHopperResponses, GetImportedHistoryData, GetImportedHistoryErrors, GetImportedHistoryResponses, GetImportedItemData, GetImportedItemErrors, GetImportedItemResponses, GetImportedVersionData, GetImportedVersionErrors, GetImportedVersionResponses, GetItemData, GetItemErrors, GetItemFreshnessData, GetItemFreshnessErrors, GetItemFreshnessResponses, GetItemResponses, GetMentionSourceData, GetMentionSourceErrors, GetMentionSourceResponses, GetSettingsData, GetSettingsErrors, GetSettingsResponses, GetSubscriptionData, GetSubscriptionErrors, GetSubscriptionResponses, GetUpdateStateData, GetUpdateStateErrors, GetUpdateStateResponses, GetVersionData, GetVersionErrors, GetVersionResponses, ListAuthorizationsData, ListAuthorizationsErrors, ListAuthorizationsResponses, ListHoppersData, ListHoppersErrors, ListHoppersResponses, ListImportedReadingData, ListImportedReadingErrors, ListImportedReadingResponses, ListInteractionsData, ListInteractionsErrors, ListInteractionsResponses, ListItemsData, ListItemsErrors, ListItemsResponses, ListMentionsData, ListMentionsErrors, ListMentionsResponses, ListReadingData, ListReadingErrors, ListReadingResponses, ListSignalsData, ListSignalsErrors, ListSignalsResponses, ListStaleThreadsData, ListStaleThreadsErrors, ListStaleThreadsResponses, ListSubscriptionsData, ListSubscriptionsErrors, ListSubscriptionsResponses, ListThumbsData, ListThumbsErrors, ListThumbsResponses, MarkReadBatchData, MarkReadBatchErrors, MarkReadBatchResponses, MarkReadData, MarkReadErrors, MarkReadResponses, MarkUnreadBatchData, MarkUnreadBatchErrors, MarkUnreadBatchResponses, MarkUnreadData, MarkUnreadErrors, MarkUnreadResponses, PinItemData, PinItemErrors, PinItemResponses, PollAllSubscriptionsData, PollAllSubscriptionsErrors, PollAllSubscriptionsResponses, PreviewData, PreviewErrors, PreviewResponses, PublishItemData, PublishItemErrors, PublishItemResponses, RefreshItemData, RefreshItemErrors, RefreshItemResponses, RemoveHopperItemData, RemoveHopperItemErrors, RemoveHopperItemResponses, RestoreItemData, RestoreItemErrors, RestoreItemResponses, ResyncSubscriptionData, ResyncSubscriptionErrors, ResyncSubscriptionResponses, RevokeAllAuthorizationsData, RevokeAllAuthorizationsErrors, RevokeAllAuthorizationsResponses, RevokeAuthorizationData, RevokeAuthorizationErrors, RevokeAuthorizationResponses, SearchData, SearchErrors, SearchResponses, SetSignalData, SetSignalErrors, SetSignalResponses, UpdateHopperData, UpdateHopperErrors, UpdateHopperResponses, UpdateItemData, UpdateItemErrors, UpdateItemResponses, UpdateMentionData, UpdateMentionErrors, UpdateMentionResponses, UpdateSettingsData, UpdateSettingsErrors, UpdateSettingsResponses, UpdateSubscriptionData, UpdateSubscriptionErrors, UpdateSubscriptionResponses, UploadMediaData, UploadMediaErrors, UploadMediaResponses, WithdrawItemData, WithdrawItemErrors, WithdrawItemResponses } from './types.gen.js'; export type Options = Options2 & { /** @@ -144,6 +144,16 @@ export const publishItem = (options: Optio } }); +export const classifyChange = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ + in: 'cookie', + name: 'blyg_session', + type: 'apiKey' + }, { scheme: 'bearer', type: 'http' }], + url: '/api/items/{id}/classify', + ...options +}); + export const draftNote = (options: Options): RequestResult => (options.client ?? client).post({ security: [{ in: 'cookie', diff --git a/sdk/generated/types.gen.ts b/sdk/generated/types.gen.ts index 67ca1d3..8500cf4 100644 --- a/sdk/generated/types.gen.ts +++ b/sdk/generated/types.gen.ts @@ -118,6 +118,14 @@ export type Settings = { ai_model_tk: string; ai_model_changelog: string; ai_model_feed: string; + /** + * The change classifier's decision model (Clef, Jev); empty, the default, is off. Studio-private: a category reaches readers only as words in a changelog note the author confirms. + */ + ai_model_classify: string; + /** + * Lead the changelog note with the classifier's category, in the publish dialog where the author sees it. Matters only while ai_model_classify is set. + */ + change_category_in_note: boolean; feed_prompt: string; ai_style_prompt: string; accept_mentions: boolean; @@ -266,6 +274,22 @@ export type Version = { cited?: Citation; } | null; stub_cite: Citation | null; + /** + * The change classifier's studio-private record of this version, in an item's versions; absent elsewhere. Never on the wire. + */ + change_class?: { + category: 'refresh' | 'typo' | 'minor' | 'major' | null; + /** + * Null for refresh (decided locally) and for a category the author set by hand. + */ + model: string | null; + probability: number | null; + classified_at: string; + /** + * The author changed or cleared the classifier's category before publishing. + */ + overridden: boolean; + } | null; }; export type Transclusion = { @@ -367,6 +391,10 @@ export type AiModels = { provider: string; label: string; note?: string; + /** + * A decision model (Clef, Jev): offered only for the change classifier (ai_model_classify), never for text generation. + */ + kind?: 'decision'; }>; local: boolean; }; @@ -1279,6 +1307,10 @@ export type PublishItemData = { body?: { note?: string; note_generated?: boolean; + /** + * The change category the author confirmed in the publish dialog: kept with the version in studio-private storage (never on the wire). Omitted: a classification made for these words stands; null: cleared. + */ + change_category?: 'refresh' | 'typo' | 'minor' | 'major' | null; }; path: { id: string; @@ -1353,6 +1385,95 @@ export type PublishItemResponses = { export type PublishItemResponse = PublishItemResponses[keyof PublishItemResponses]; +export type ClassifyChangeData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/api/items/{id}/classify'; +}; + +export type ClassifyChangeErrors = { + /** + * Request failed + */ + 400: ApiError; + /** + * Request failed + */ + 401: ApiError; + /** + * Request failed + */ + 403: ApiError; + /** + * Request failed + */ + 404: ApiError; + /** + * Request failed + */ + 405: ApiError; + /** + * Request failed + */ + 409: ApiError; + /** + * Request failed + */ + 413: ApiError; + /** + * Request failed + */ + 415: ApiError; + /** + * Request failed + */ + 422: ApiError; + /** + * Request failed + */ + 429: ApiError; + /** + * Request failed + */ + 500: ApiError; + /** + * Request failed + */ + 502: ApiError; +}; + +export type ClassifyChangeError = ClassifyChangeErrors[keyof ClassifyChangeErrors]; + +export type ClassifyChangeResponses = { + /** + * Success + */ + 200: { + /** + * The version the working copy would publish as. + */ + version: number; + /** + * Null when no category could be had; see reason. + */ + category: 'refresh' | 'typo' | 'minor' | 'major' | null; + /** + * The decision model that answered; null for refresh, decided locally from content_hash. + */ + model: string | null; + probability: number | null; + /** + * Why there is no category: a provider error, an exhausted AI budget, or an unusable answer. Never fatal: publish as usual. + */ + reason?: string; + }; +}; + +export type ClassifyChangeResponse = ClassifyChangeResponses[keyof ClassifyChangeResponses]; + export type DraftNoteData = { body?: never; path: { @@ -1977,6 +2098,11 @@ export type UpdateSettingsData = { ai_model_tk?: string; ai_model_changelog?: string; ai_model_feed?: string; + /** + * The change classifier's decision model (Clef, Jev); empty, the default, is off. Studio-private: a category reaches readers only as words in a changelog note the author confirms. + */ + ai_model_classify?: string; + change_category_in_note?: boolean; feed_prompt?: string; ai_style_prompt?: string; accept_mentions?: boolean; diff --git a/sdk/generated/zod.gen.ts b/sdk/generated/zod.gen.ts index d103ace..4e60e30 100644 --- a/sdk/generated/zod.gen.ts +++ b/sdk/generated/zod.gen.ts @@ -141,6 +141,8 @@ export const zSettings = z.object({ ai_model_tk: z.string(), ai_model_changelog: z.string(), ai_model_feed: z.string(), + ai_model_classify: z.string(), + change_category_in_note: z.boolean(), feed_prompt: z.string(), ai_style_prompt: z.string(), accept_mentions: z.boolean(), @@ -267,7 +269,19 @@ export const zVersion = z.object({ cited: zCitation.optional() }).strict() ]).nullable(), - stub_cite: zCitation.nullable() + stub_cite: zCitation.nullable(), + change_class: z.object({ + category: z.enum([ + 'refresh', + 'typo', + 'minor', + 'major' + ]).nullable(), + model: z.string().nullable(), + probability: z.number().nullable(), + classified_at: z.string(), + overridden: z.boolean() + }).nullish() }); export const zHopperItemRow = z.object({ @@ -359,7 +373,8 @@ export const zAiModels = z.object({ id: z.string(), provider: z.string(), label: z.string(), - note: z.string().optional() + note: z.string().optional(), + kind: z.enum(['decision']).optional() })), local: z.boolean() }); @@ -758,7 +773,13 @@ export const zUpdateItemResponse = zItem; export const zPublishItemBody = z.object({ note: z.string().optional(), - note_generated: z.boolean().optional() + note_generated: z.boolean().optional(), + change_category: z.enum([ + 'refresh', + 'typo', + 'minor', + 'major' + ]).nullish() }).strict(); export const zPublishItemPath = z.object({ @@ -774,6 +795,26 @@ export const zPublishItemResponse = z.object({ warning: z.string().optional() }); +export const zClassifyChangePath = z.object({ + id: z.string().min(1) +}); + +/** + * Success + */ +export const zClassifyChangeResponse = z.object({ + version: z.int(), + category: z.enum([ + 'refresh', + 'typo', + 'minor', + 'major' + ]).nullable(), + model: z.string().nullable(), + probability: z.number().nullable(), + reason: z.string().optional() +}); + export const zDraftNotePath = z.object({ id: z.string().min(1) }); @@ -905,6 +946,8 @@ export const zUpdateSettingsBody = z.object({ ai_model_tk: z.string().optional(), ai_model_changelog: z.string().optional(), ai_model_feed: z.string().optional(), + ai_model_classify: z.string().optional(), + change_category_in_note: z.boolean().optional(), feed_prompt: z.string().optional(), ai_style_prompt: z.string().optional(), accept_mentions: z.boolean().optional(), diff --git a/src/ai/models.ts b/src/ai/models.ts index f68c4a8..94ae9d6 100644 --- a/src/ai/models.ts +++ b/src/ai/models.ts @@ -4,7 +4,7 @@ // (or binding) each provider needs, and where it is called, is the manifest's. import manifest from "../../build/models.json"; -export type ProviderApi = "anthropic-messages" | "openai-responses" | "gemini-generate" | "workers-ai" | "openai-chat"; +export type ProviderApi = "anthropic-messages" | "openai-responses" | "gemini-generate" | "workers-ai" | "openai-chat" | "systemone"; export interface ProviderSpec { label: string; @@ -17,7 +17,10 @@ export interface ProviderSpec { key_secret?: string; /** `workers-ai` only: the Worker binding to call (default "AI"). */ binding?: string; - /** `openai-chat` only: the base URL; requests go to `{base_url}/chat/completions`. */ + /** + * `openai-chat`: the base URL; requests go to `{base_url}/chat/completions`. + * `systemone`: optional, default `https://api.typesafe.ai/v1`; requests go to `{base_url}/systemone`. + */ base_url?: string; /** Extra request-body members sent with every call to this provider. */ params?: Record; @@ -30,6 +33,12 @@ export interface ModelSpec { note?: string; /** Extra request-body members for this model, merged over its provider's `params`. */ params?: Record; + /** + * "decision" marks a decision model (Clef, Jev): it answers typed questions + * with probabilities and generates no text, so it is offered only for the + * change classifier, and a text model is never offered for the classifier. + */ + kind?: "decision"; } export interface Manifest { providers: Record; @@ -54,6 +63,18 @@ export function providerFor(modelId: string, m: Manifest = MODELS): { key: strin return null; } +/** + * Whether a model id names a decision model: listed with `kind: "decision"`, + * or served by a `systemone` provider (every System One model is one). An + * unlisted `@cf/…` id is a text model; a decision model on Workers AI has to be + * listed so its kind is known. + */ +export function isDecisionModel(modelId: string, m: Manifest = MODELS): boolean { + const provider = providerFor(modelId, m); + if (!provider) return false; + return provider.model?.kind === "decision" || provider.spec.api === "systemone"; +} + /** The binding a `workers-ai` provider calls. */ export function bindingName(spec: ProviderSpec): string { return spec.binding || "AI"; diff --git a/src/ai/provider.ts b/src/ai/provider.ts index bb0ac9d..25c94da 100644 --- a/src/ai/provider.ts +++ b/src/ai/provider.ts @@ -10,7 +10,7 @@ import { admitAi } from "../security-budgets.ts"; import { getSettings } from "../model.ts"; import type { Env, Settings } from "../types.ts"; -import { MODELS, bindingName, providerConfigured, providerFor, type AiPurpose, type Manifest, type ProviderSpec } from "./models.ts"; +import { MODELS, bindingName, isDecisionModel, providerConfigured, providerFor, type AiPurpose, type Manifest, type ProviderSpec } from "./models.ts"; export interface GenerateRequest { instruction: string; @@ -136,6 +136,7 @@ export async function complete( const provider = providerFor(model, models); if (!provider) throw new ProviderError(`no provider is known for model "${model}": add it to models.json (or models.local.json)`); const { spec } = provider; + if (isDecisionModel(model, models)) throw new ProviderError(`"${model}" is a decision model: it answers typed questions and writes no text, so it cannot serve ${PURPOSE_LABELS[purpose]}`); if (!providerConfigured(env, spec)) throw new ProviderError(notConfigured(spec)); const vars = env as unknown as Record; const apiKey = spec.key_secret ? (vars[spec.key_secret] as string) : ""; @@ -153,6 +154,8 @@ export async function complete( return workersAi(vars[bindingName(spec)] as WorkersAiBinding, model, system, user, params); case "openai-chat": return openaiChat(spec.base_url ?? "", apiKey, model, system, user, params, fetchImpl); + case "systemone": + throw new ProviderError(`"${model}" is served by a decision-model API, which writes no text`); } } @@ -328,3 +331,138 @@ async function openaiChat(baseUrl: string, apiKey: string, model: string, system const json = (await postJson(fetchImpl, url, headers, { max_tokens: MAX_TOKENS, ...params, model, messages: chatMessages(system, user) })) as ChatCompletion; return { text: chatText(json), model: json.model ?? model }; } + +/* ---------------- Decision models ---------------- */ +// +// A decision model (Cloudflare's Clef and Clef-flash on Workers AI, TypeSafe's +// Jev on its System One API) takes a `state` and a map of typed questions and +// returns, per question, the chosen option with a probability for every +// option. It writes no text, so it is a separate call from `complete()`, and +// the two never share a model (models.ts isDecisionModel). +// +// Request shape: the System One API, which Clef implements ("fully Jev-API +// compatible", https://blog.cloudflare.com/clef-decision-models): +// { model, state, questions: { : { type: "choice", instructions, criteria: {