Skip to content

feat(ai): add Cloudflare Workers AI as a second provider - #21

Open
cygmris wants to merge 2 commits into
shuaiplus:mainfrom
cygmris:feat/cloudflare-ai-provider
Open

feat(ai): add Cloudflare Workers AI as a second provider#21
cygmris wants to merge 2 commits into
shuaiplus:mainfrom
cygmris:feat/cloudflare-ai-provider

Conversation

@cygmris

@cygmris cygmris commented Aug 22, 2026

Copy link
Copy Markdown

Stacked on #20 (feat/local-ollama-markdown). That PR adds the engine, the loss guards, and the settings panel; this one adds a second provider on top. Please review that one first — this diff will shrink to the cloud-specific parts once it lands.

What this adds

Cloudflare Workers AI as an optional second provider for the same feature. Same engine, same loss guards, same panel — only the endpoint differs.

It earns its place by working where the local option cannot: Safari, phones, and any machine that is not the one running Ollama. It also sidesteps the loopback permission prompt entirely, since the request is same-origin.

No new credentials — the AI binding already exists for semantic search.

How it fits

A thin same-origin route translates Workers AI's stream into the OpenAI-compatible SSE shape the client already speaks, so the client needs no branching beyond one URL and one header.

File Role
src/shared/ai-models.ts Allow list of 7 models within the free allowance, shared by server (to reject anything else) and client (to fill the dropdown). One list, because two would drift into "selectable in the UI, rejected by the server".
src/worker/lib/workers-ai-stream.ts Pure-function stream translator, unit tested. Deliberately imports no cloudflare: module so it stays loadable under the repo's vitest setup.
src/worker/routes/ai.ts POST /api/ai/chat behind requireAuth, with allow-list validation.

The allow-list check is load-bearing, not ceremony: without it the route would proxy arbitrary Workers AI models on the deployment's own quota.

Two measured details

Upstream chunks carry both shapes at once. Observed traffic has {"response": "..."} at the top level and choices[0].delta.content, mirroring each other. The translator reads whichever is non-empty rather than betting on one, and skips unreadable chunks instead of aborting the stream.

Hidden reasoning dominates the token budget. For one prompt, qwen3-30b spent 121 completion tokens to produce 12 visible characters — roughly 90% on reasoning the user never sees. Passing chat_template_kwargs: {enable_thinking: false} cuts that to 3 tokens, about a twentyfold reduction in neurons. Measured across the list:

Model completion_tokens before → after
qwen3-30b-a3b-fp8 117 → 3
gemma-4-26b-a4b-it 120 → 3
llama-3.3-70b-fp8-fast ~120 → 3
gpt-oss-20b 61 → 22

⚠️ Mistral rejects that parameter (chat_template is not supported for Mistral tokenizers) and emits no reasoning anyway, so support is declared per model in the shared list rather than branched on a model name at the call site. A test asserts every entry declares the flag explicitly, so a newly added model cannot silently inherit a default.

One non-obvious constraint

X-Inkstone-Client is sent only in cloud mode. Adding it unconditionally breaks the local path, because Ollama's CORS preflight does not allow that header through. There are tests pinning that difference in both directions.

Privacy

The default provider stays local Ollama. The cloud option states plainly in the UI that note text is sent to Cloudflare, and that the free allowance is shared with this notebook's semantic search — that trade-off should be the user's to make explicitly, not a default they discover later.

Checks run

npm run typecheck      PASS
npm run i18n:check     PASS
npm run comments:check PASS
npm run test:unit      PASS   (155 tests)
npm run build          PASS

Assertions were reverse-verified by breaking each implementation in turn and confirming the matching test goes red.

npm run test:e2e does not complete on my machine, and neither does it on an unmodified checkout of main — wrangler crashes partway through with its own "please create an issue at workers-sdk" message. I did not upgrade wrangler since that would be an unrelated package-lock.json change. The route was instead exercised against a real deployment: unauthenticated → 401, missing client header → 403, paid-only model → 400, arbitrary string → 400, empty messages → 400, malformed role → 400, valid request → 200 text/event-stream.

Not included

No AI Gateway integration, no usage dashboard, no per-user quota. The quota-exhausted path maps to 429 by standard semantics but I never actually hit the limit, so that branch is unverified — flagging it rather than implying it was tested.

Adds an opt-in "Local AI" settings section plus two entry points — a command
palette action to convert pasted text into a new note, and a toolbar button to
tidy the current note (or just the selection). Requests go straight from the
browser to the user's own Ollama; note text never reaches the server.

Because the Worker runs at the edge and cannot reach the user's loopback
address, there is no server-side proxy option here; the CSP `connect-src` is
widened to exactly two hardcoded loopback endpoints and nothing else.

Two failure modes drove most of the design:

1. Ollama silently drops the front of an over-long prompt and the model then
   stops cleanly on the part it did see, reporting `finish_reason: "stop"`.
   Nothing in the API surfaces this. `detectLoss` therefore uses a second,
   independent heuristic — output far shorter than input on a first-turn
   conversion — and long input is chunked paragraph-first, then by line, then
   hard-cut.

2. Offsets captured when the command fires go stale while the model works.
   With realtime sync enabled the note can change underneath, so a selection
   edit would land in the wrong place and a whole-note edit would clobber a
   concurrent change — both silently. Writes now carry the original text and
   are refused if it no longer matches, keeping the result on screen so it can
   be copied instead.

Browser support is Chrome/Firefox only, stated in the UI rather than only in
docs: Safari blocks an HTTPS page from calling http://localhost. Chrome 138+
additionally gates loopback access behind a permission prompt, and an
unanswered prompt leaves the request hanging rather than failing, so the
connection test carries its own timeout and says so.

Note content is treated as untrusted data: the system prompt tells the model to
reformat rather than answer, and that instruction follows the user's UI
language so weaker local models weight it properly.

Tests: 138 passing. Assertions were reverse-verified by breaking each
implementation in turn and confirming the matching test goes red.
Builds on the local Ollama provider: same engine, same loss guards, same
panel — only the endpoint differs. A thin same-origin route translates
Workers AI's stream into the OpenAI-compatible SSE shape the client already
speaks, so the client needs no branching beyond one URL and one header.

This is worth having alongside the local option because it works where the
local one cannot: Safari, phones, and any machine that is not the one running
Ollama. It also sidesteps the loopback permission prompt entirely, since the
request is same-origin.

No new credentials: the `AI` binding already exists for semantic search.

- `src/shared/ai-models.ts` — allow list of 7 models within the free
  allowance, shared by the server (to reject anything else) and the client (to
  fill the dropdown). One list, because two would drift into "selectable in the
  UI, rejected by the server".
- `src/worker/lib/workers-ai-stream.ts` — pure-function translator, unit
  tested. Accepts both upstream chunk shapes; observed traffic carries
  `{response}` and `{choices[0].delta.content}` side by side. Unreadable
  chunks are skipped rather than aborting the stream. Deliberately imports no
  `cloudflare:` module so it stays loadable under the repo's vitest setup.
- `src/worker/routes/ai.ts` — `POST /api/ai/chat` behind `requireAuth`, with
  allow-list validation. Without that check the route would proxy arbitrary
  Workers AI models on the account's quota.

Two measured details shaped the implementation:

Every model tested spends most of its output budget on hidden reasoning — for
one prompt, qwen3-30b used 121 completion tokens to produce 12 visible
characters. Passing `chat_template_kwargs: {enable_thinking: false}` cuts that
to 3, roughly a twentyfold reduction. Mistral rejects the parameter
("chat_template is not supported for Mistral tokenizers") and emits no
reasoning anyway, so support is declared per model in the shared list rather
than branched on a model name at the call site.

The `X-Inkstone-Client` header is sent only in cloud mode. Adding it
unconditionally would break the local path, since Ollama's CORS preflight does
not allow that header through.

The default provider stays local Ollama, and the cloud option states plainly
in the UI that note text is sent to Cloudflare — that trade-off should be the
user's to make explicitly.

Tests: 155 passing, assertions reverse-verified.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant