Docs Scanning

Scanning

Camera or upload, a client-side downscale, one POST, a chain of vision models, and nothing stored.

  1. Capturecamera or file
  2. Downscale1280px JPEG q0.85
  3. POST/api/identify
  4. Registrysymbol list, cached 1h
  5. Modelfallback chain
  6. resolveBasketaddresses, weights
  7. 200{ basket }

Model chain (env PHOTOSWAP_MODEL first, if set)

  1. anthropic/claude-sonnet-5
  2. google/gemini-2.5-flash-lite
  3. openai/gpt-4.1-mini

Each model gets one retry and a 45 s abort. A 422 (not an object) ends the chain: it is an answer, not a failure.

One identify request, start to finish. The model row shows what happens when the first model is throttled: the next one answers.

Capture

The scanner in components/Scanner.tsx has two modes. demo preloads a catalog object and plays the scan sequence. full opens the camera on phones (getUserMedia, rear camera) and accepts a file on everything else. Either way you end with a Blob.

On /scan you can also pass ?slug= to preload a catalog object through GET /api/identify, so the demo and the real thing share one code path.

Downscale

Before anything leaves the browser the image is drawn onto a canvas at most 1280px on its long side and re-encoded as JPEG at quality 0.85. A 12 MP photo becomes a few hundred kilobytes. That keeps the request fast and keeps well under the server cap of MAX_IMAGE_BYTES (8 MB), which returns 413 if exceeded.

const max = 1280;
const scale = Math.min(1, max / Math.max(img.naturalWidth, img.naturalHeight));
canvas.width  = Math.round(img.naturalWidth * scale);
canvas.height = Math.round(img.naturalHeight * scale);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL("image/jpeg", 0.85);

The request

The data URL goes to POST /api/identify as { image }. The route runs on Node with a 60 second budget (maxDuration = 60). It loads the live symbol list and display names from the registry (cached one hour), calls identifyObject, then resolveBasket, and answers with { basket }.

The prompt hands the model every live ticker with its short name, one per line, and tells it to use those verbatim. A ticker that is not on the list cannot be in the basket. It goes to unlisted with the reason Not tokenized on Robinhood Chain.

Model fallback chain

modelChain() in lib/ai.ts builds the list at request time. Every candidate is tried in order; any error moves to the next one. The first model that returns valid structured output wins, and the route logs which one answered.

OrderModelWhen
1PHOTOSWAP_MODELOnly if the env var is set. Any gateway id.
2claude-sonnet-5 (direct)Only if ANTHROPIC_API_KEY is set. Uses @ai-sdk/anthropic.
3anthropic/claude-sonnet-5AI Gateway. Rate-limited on the free tier.
4google/gemini-2.5-flash-liteAI Gateway. Works on the free tier.
5openai/gpt-4.1-miniAI Gateway. Works on the free tier.

Auth for the gateway is the Vercel OIDC token: VERCEL_OIDC_TOKEN in .env.local when running locally (pulled with vercel env pull), automatic on Vercel. Every call uses temperature 0.2, maxOutputTokens 1500, one retry and a 45 second abort, so a throttled chain still fails inside the route budget.

Rejections

The schema has a notAnObject flag. A face, a landscape, a screenshot or a blank frame sets it, and the route returns 422 with the model's one-line reason. A 422 is an answer, not a failure: it does not trigger the next model.

StatusMeaning
400Body is not JSON, or image is not a base64 image data URL
413Image over 8 MB after encoding
422Not an object, or none of its makers trade on Robinhood Chain
502Every model in the chain failed

What is kept

Nothing

The image is held in memory for the duration of the request and forwarded to the model provider once. Photoswap has no database, no object store and no analytics on the image. The basket id for an AI result is a ten-character hash of the object name and its tickers, computed by shortId, so two scans of the same thing get the same id without anything being saved.

Next: how the weights in that basket are meant to be read. Supply-chain weighting.