Docs Scanning
Scanning
Camera or upload, a client-side downscale, one POST, a chain of vision models, and nothing stored.
- Capturecamera or file
- Downscale1280px JPEG q0.85
- POST/api/identify
- Registrysymbol list, cached 1h
- Modelfallback chain
- resolveBasketaddresses, weights
- 200{ basket }
Model chain (env PHOTOSWAP_MODEL first, if set)
- anthropic/claude-sonnet-5
- google/gemini-2.5-flash-lite
- 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.
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.
| Order | Model | When |
|---|---|---|
| 1 | PHOTOSWAP_MODEL | Only if the env var is set. Any gateway id. |
| 2 | claude-sonnet-5 (direct) | Only if ANTHROPIC_API_KEY is set. Uses @ai-sdk/anthropic. |
| 3 | anthropic/claude-sonnet-5 | AI Gateway. Rate-limited on the free tier. |
| 4 | google/gemini-2.5-flash-lite | AI Gateway. Works on the free tier. |
| 5 | openai/gpt-4.1-mini | AI 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.
| Status | Meaning |
|---|---|
| 400 | Body is not JSON, or image is not a base64 image data URL |
| 413 | Image over 8 MB after encoding |
| 422 | Not an object, or none of its makers trade on Robinhood Chain |
| 502 | Every model in the chain failed |
What is kept
Nothing
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.
