Skip to content

docs: ADR + plan for OpenAI-compatible remote inference - #24

Merged
barakplasma merged 4 commits into
mainfrom
claude/openai-compatible-inference-qjlzqd
Aug 1, 2026
Merged

docs: ADR + plan for OpenAI-compatible remote inference#24
barakplasma merged 4 commits into
mainfrom
claude/openai-compatible-inference-qjlzqd

Conversation

@barakplasma

Copy link
Copy Markdown
Owner

Why

Every embedding is computed in the browser, and that caps three things at once.

Nothing happens until a large download finishes. The default sapiens2-fp16 is 229 MB (src/app.ts:206). Throughput is capped by the local GPU — and silently falls back to WASM when WebGPU is missing (src/app.ts:1175-1181). The model ceiling is the device ceiling: purpose-built multimodal retrieval models and video LLMs can't run in a tab at all.

customModelHost (src/types.ts:61) looks like it addresses this and doesn't — it redirects model-file downloads to a HuggingFace mirror. Inference still runs locally.

Meanwhile Ollama, LM Studio, llama.cpp, vLLM, Infinity and MLX all expose an OpenAI-compatible API on localhost, and OpenRouter, Venice and Jina expose the same shape over the internet. A user running any of them has capable inference sitting idle with no way to point the app at it.

What this adds

Documentation only. No source changes, following the IMPROVEMENT_PLAN.md precedent.

The decision, briefly

One new ModelVariant, openai, with two pipelines behind a single client, settings block, and consent gate:

Pipeline Request Produces Works with
A direct POST {baseUrl}/embeddings vector OpenRouter, vLLM, Jina, Infinity
B vlm POST {baseUrl}/chat/completions → embed the caption caption + vector every chat provider incl. Ollama; the only route for video LLMs

Pipeline A embeds the image itself, making the remote path a peer of nomic/sapiens2 rather than a variant of chrome-ai. It also gives a genuinely shared image/text embedding space, which makes semantic search better-founded than today's caption-mediated chrome-ai search.

Only downscaled JPEG thumbnails are uploaded — 384 px at q0.8, roughly 25–40 KB. createImageBitmap(blob, { resizeWidth }) does the downscale during decode, so a 48 MP original is never decoded at full size.

Two research findings that shape the design

There is no single wire format for image embeddings. /v1/chat/completions with an image_url part is universal; /v1/embeddings with an image is not:

Target Image input shape
OpenRouter input: [{ content: [{ type: 'image_url', image_url: { url } }] }]
vLLM messages: [...] — chat-shaped
Jina AI input: [{ image: '<b64>' }]
Infinity input: ['<data URI>']
llama.cpp { content: 'Image: [img-1]', image_data: [...] } — and reported unreliable
Ollama none — text only

So the format becomes a probeable configuration dimension rather than a hard-coded body. That's the main reason this is larger than "add a fetch call".

Ollama cannot do direct image embeddings (ollama#5304, open since June 2024). It runs VLMs fine, so it's fully served by pipeline B — but the UI has to say so rather than surfacing an HTTP 400.

On the plus side, OpenRouter's /v1/embeddings/models currently lists three image-capable models, one of them free (nvidia/llama-nemotron-embed-vl-1b-v2:free), which makes the whole direct path verifiable at zero cost. google/gemini-embedding-2 accepts video.

Nine latent defects this surfaces

None are introduced here; all are load-bearing for the feature, and the plan lands them as a behaviour-preserving M0. Three set the shape of the design:

  • isDownloadError() (src/modelFallback.ts:111-124) matches unauthorized and bare status codes, so an API 401 would open the HuggingFace model-upload fallback modal.
  • embedText() (src/app.ts:633) never calls l2normalize(), relying on Transformers.js normalize: true — but searchByCosine() is a raw dot product and remote responses aren't guaranteed unit-norm.
  • A zero vector scores exactly 0, which is above every genuinely dissimilar item. Failed embeddings currently rank high.

Trade-offs stated in the ADR

  • The privacy claim becomes conditional. README.md:15 and index.html:745 promise "no uploads, no server" unconditionally. That has to be rewritten. Watering it down is a real cost, accepted deliberately rather than hidden.
  • New failure modes the app has never had: per-image cost, rate limits, third-party availability, silent dimension changes.
  • A key in browser storage is readable by anything with access to that profile. Mitigated by a sessionStorage option and by saying so in the UI; not eliminated.
  • This ADR and ADR-0001 pull in opposite directions on purpose — 0001 optimises for a device that can't afford a network round-trip, 0002 for one that can. Both remain selectable.

Secret hygiene

captureConsoleIntegration({ levels: ['error'] }) (src/sentry.ts:15) ships every console.error to a remote sink, and Sentry breadcrumbs record fetch URLs. So: key in localStorage['mc_openai_key'], never in state or mc_settings; Authorization header only, never a query string; redaction at error-construction; no console.error on this path.

One assumption flagged for day-one confirmation

OpenRouter's embeddings docs show image inputs as https:// URLs. Local files can't be public URLs, so pipeline A on OpenRouter needs data: URIs to be accepted — inferred from the adjacent chat-completions and rerank endpoints documenting data-URI support, not observed on /embeddings. One curl settles it. If it fails, pipeline A still stands on Jina/vLLM/Infinity and OpenRouter users take pipeline B; nothing else changes.

Note on overlap with #23

#23 also adds docs/adr/README.md and numbers its ADR 0001. The convention text here is identical, so the only merge conflict should be the one-row index table. Whichever lands second adds its row.

REMOTE_INFERENCE_PLAN.md M6 (multi-frame video) overlaps VIDEO_LM_PLAN.md M3 — both need the same extractVideoFrames(file, n) generalisation. Noted in both directions; whichever lands first owns the function.

Verification

  • npm run type-check — clean
  • npm test — 9 files, 76 tests passed (proving no source was touched)
  • markdownlint-cli2 --config .markdownlint.json — 0 issues on all new files
  • All 77 src/file.ts:line citations checked programmatically for range, and every citation taken from exploration rather than direct reading was spot-checked against the code it claims

git diff --stat shows only the four documentation files.


Generated by Claude Code

Adds an opt-in backend that runs inference against any OpenAI-compatible
endpoint — localhost (Ollama, LM Studio, llama.cpp, vLLM, Infinity, MLX)
or internet-facing (OpenRouter, Venice, Jina) — instead of in the browser.

Documentation only, following the IMPROVEMENT_PLAN.md precedent. No source
changes.

The design embeds media directly rather than captioning it, so the remote
path is a peer of the nomic/sapiens2 image-feature-extraction backends
rather than a variant of chrome-ai. A second pipeline covers VLMs and video
LLMs, which emit text rather than vectors. Only downscaled JPEG thumbnails
are uploaded, and only after an explicit per-host consent gate.

Two findings shape the design and are recorded in the ADR:

- There is no single wire format for image embeddings. Five request shapes
  are needed to cover the named targets, so the format becomes a probeable
  configuration dimension rather than a hard-coded body.
- Ollama's embedding endpoints are text-only (ollama#5304, open since June
  2024), so it is served by the VLM pipeline, not the direct one. The UI
  must say so rather than surfacing an HTTP 400.

The plan also catalogues nine latent defects the change would hit — among
them isDownloadError() claiming API 401s and opening the HuggingFace
upload modal, embedText() skipping L2 normalisation, and zero vectors
outranking dissimilar items in cosine search — and lands them as a
behaviour-preserving first milestone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGZ5syBsRr54E7Cce6HthJ
@barakplasma
barakplasma marked this pull request as ready for review August 1, 2026 10:20

Copy link
Copy Markdown
Owner Author

CI status: the two red checks are pre-existing, not from this PR

validate-and-deploy — the check that runs type-check, tests and build — is ✅ green. The other two fail identically on #23 and on main, and this PR changes no source files.

MegaLinter ❌ — 4 failing linters, all pre-existing

Linter Result
markdownlint ✅ 0 errors (4 files)
markdown-table-formatter ✅ 0 errors (4 files)
gitleaks ❌ 1 — see below
grype / osv-scanner / trivy ❌ — package-lock.json CVEs

The two linters that actually touch this PR's files both pass with zero errors.

gitleaks flags src/app.ts:56 in commit ddc9934 ("feat: fetch random images from unsplash api per category on each demo load", 2026-04-06) — a hardcoded UNSPLASH_ACCESS_KEY. The key is gone from src/app.ts today (line 56 is now an import), but the command is gitleaks detect --source ., which scans all 192 commits of history, so the finding persists regardless of the working tree. git merge-base --is-ancestor ddc9934 origin/main confirms it is in main's history.

Worth noting it is a real, still-valid-looking credential sitting in public history. Fixing it properly means rotating the Unsplash key and either rewriting history or adding a .gitleaks.toml allowlist entry for that fingerprint — out of scope for a docs PR, but happy to open a separate one.

grype / osv-scanner / trivy all report the same two dependency advisories: adm-zip 0.5.17 (CVE-2026-39244, fixed in 0.6.0) and sharp 0.34.5 (GHSA-f88m-g3jw-g9cj, fixed in 0.35.0). Both are transitive dev dependencies untouched by this PR.

Cloudflare Pages ❌

Fails at started_at == completed_at (zero elapsed) on this PR and on #23 — the deploy integration erroring before it runs, not a build failure.

Verification on this branch

  • npm run type-check — clean
  • npm test — 9 files, 76 tests passed (unchanged from main, proving no source was touched)
  • markdownlint-cli2 --config .markdownlint.json — 0 issues on all new files
  • All 77 src/file.ts:line citations range-checked programmatically; those derived from exploration rather than direct reading were spot-checked against the code they cite

No action taken. If you'd like, I can open a follow-up PR for the gitleaks allowlist and the two dependency bumps.


Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2c7306cc48

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread REMOTE_INFERENCE_PLAN.md Outdated
Comment on lines +305 to +309
- Cache prefix from `currentCachePrefix()` (B2), namespaced on a synchronous FNV-1a hash of
`host | pipeline | model | embedderId | dim | wireFormat`. Deliberately **not** `crypto.subtle.digest`,
which is async and would force `currentCachePrefix()` and all its callers to become promises. Include
`dim` even though the model implies it — providers change dimensions, and that is the one mismatch
producing garbage rather than an error (B3).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Namespace every embedding-affecting setting

The proposed namespace omits the describe prompt, maximum image width, and video frame count even though each changes the media or caption being embedded. After a user changes one of these settings and reloads, readCachedEmbeddings() will therefore reuse vectors generated from the old inputs, making the setting appear ineffective and producing stale clustering/search results. Include every vector-affecting input in the namespace, as well as the full normalized base URL rather than only its host.

Useful? React with 👍 / 👎.

Comment thread REMOTE_INFERENCE_PLAN.md Outdated
Comment on lines +261 to +262
Changing base URL, model or pipeline **after** a load must trigger the same page reload as `#model-select`
(`:2827-2834`), because the cache namespace changes underneath `state.vectors`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reload when the embedding source changes

Pipeline B exposes an embedding-source switch between remote embeddings and local Nomic, but this reload requirement names only base URL, model, and pipeline. If the source is changed after media has been embedded, state.vectors remains in the old embedding space while subsequent queries use the newly selected embedder; when both happen to have the same dimension, the dimension guard cannot detect the mismatch and search returns plausible but meaningless rankings. Treat embedding-source changes as reload-required too.

Useful? React with 👍 / 👎.

Comment thread AGENT.md Outdated
Comment on lines +29 to +30
- **Embedding space**: index-time and query-time vectors must come from the same embedder with the same
prompt prefixes. Any change of embedder, model, or dimension must change the IndexedDB cache namespace

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve role-specific embedding prefixes

Requiring index-time and query-time vectors to use the same prompt prefixes contradicts the existing Nomic contract: indexing uses search_document: at src/app.ts:1590, while querying uses search_query: at src/app.ts:627, and the new plan explicitly preserves that distinction. Following this guide literally would place one side in the wrong task space and degrade semantic search; describe the invariant as using the same embedder with that model's required role-specific prefix scheme, not identical prefixes.

Useful? React with 👍 / 👎.

Three fixes from automated review, all correct.

Cache namespace: the first draft covered host, pipeline, model, embedder,
dim and wire format, omitting the describe prompt, image size and video
frame count — each of which changes what actually gets embedded. Leaving
them out is what makes a setting look broken: change it, reload, and
readCachedEmbeddings() serves vectors built from the old input. The rule
is now "every input that changes the resulting vector", and the namespace
keys on the full normalized base URL rather than the host alone, since two
paths on one host can be different services behind a gateway.

This reverses the earlier position that excluded the prompt on chrome-ai
precedent. That precedent is a bug rather than a contract, and it is
cheaper to be wrong about locally. Namespacing is non-destructive, so the
cost of including a setting is one re-embed and reverting restores the
prior cache for free.

Reload triggers: the embedding-source switch (remote /embeddings vs local
nomic-embed-text) was missing, and it is the most dangerous omission —
nomic-embed-text is 768-d and so are many remote models, so flipping it
after a run leaves state.vectors in the old space while queries use the
new one at matching width, which the dimension guard cannot detect.

Embedding-space invariant: "the same prompt prefixes" contradicted nomic's
contract, where the prefixes deliberately differ by role (search_document:
when indexing, search_query: when querying). Reworded to require the same
embedder under that model's own role-prefix scheme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGZ5syBsRr54E7Cce6HthJ
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 1, 2026

Copy link
Copy Markdown

Deploying media-clusterer with  Cloudflare Pages  Cloudflare Pages

Latest commit: 246321a
Status: ✅  Deploy successful!
Preview URL: https://e95085db.media-clusterer.pages.dev
Branch Preview URL: https://claude-openai-compatible-inf.media-clusterer.pages.dev

View logs

Copy link
Copy Markdown
Owner Author

All three review points were correct and are fixed in 7c5ca25.

P1 — namespace every embedding-affecting setting. Right, and the omission was the interesting kind: a setting left out of the namespace doesn't fail loudly, it just appears to do nothing. The rule is now "every input that changes the resulting vector," with a table covering the describe prompt, maxImageWidth/jpegQuality and framesPerVideo alongside the originals, and keying on the full normalized base URL rather than the host — two paths on one host can be different services behind a gateway.

This reverses my earlier reasoning, which excluded the prompt on the grounds that chrome-ai already behaves that way ("Changes apply to new embeddings only"). That precedent is real but it's a bug rather than a contract, and it's cheaper to be wrong about locally where re-embedding costs only time. What makes the strict rule affordable is that namespacing is non-destructive: old vectors stay in IndexedDB, so reverting a setting silently restores its cache instead of re-embedding. The cost of a change is one re-embed, which the cost guard already surfaces before anything is sent. The #chrome-ai-prompt hint gets rewritten for the remote path accordingly.

One imprecision left in deliberately, and noted as such: framesPerVideo only affects videos, so including it globally re-embeds images that didn't change. Correctness first; split per media type only if it proves annoying.

P1 — reload when the embedding source changes. The sharpest of the three. nomic-embed-text is 768-d and so are plenty of remote models, so the dimension guard is blind exactly here — flipping the source after a run leaves state.vectors in the old space while queries are embedded in the new one at matching width, and search returns plausible, meaningless rankings with nothing to trip on. The reload list now covers every namespaced input, and calls this one out specifically as the one most easily missed. Verification step 7 was extended to test it directly, plus a revert-and-confirm-cache-hit check for the non-destructive property above.

P2 — role-specific prefixes. Correct, my wording was wrong. "The same prompt prefixes" reads as identical, when nomic's contract is that they deliberately differ by role. Reworded to require the same embedder under that model's own role-prefix scheme — differing by role for nomic, absent on both sides for a model without one, never mixed across sides.

Verified on 7c5ca25: 76 tests passing (unchanged), markdownlint-cli2 clean on all new files, and all 79 src/file.ts:line citations range-checked, including the two newly added prefix citations spot-checked against src/app.ts:627 and :1590.

Still docs-only — no source changes.


Generated by Claude Code

barakplasma and others added 2 commits August 1, 2026 13:34
The merge of main in 246321a kept both header rows and both delimiter rows
from the two branches instead of merging the data rows under one header, so
#23's header and its row of dashes rendered as ordinary table rows.

Drops the duplicates, leaving one header, one delimiter, and the 0001/0002
rows. Compact pipe style, which is what this repo's markdownlint MD060
enforces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGZ5syBsRr54E7Cce6HthJ
@barakplasma
barakplasma merged commit 2669670 into main Aug 1, 2026
1 of 2 checks passed
@barakplasma
barakplasma deleted the claude/openai-compatible-inference-qjlzqd branch August 1, 2026 10:40
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.

2 participants