Skip to content

Provider seam (Anthropic solve) - #36

Merged
jpka merged 1 commit into
mainfrom
feat/27-provider-seam
Aug 7, 2026
Merged

Provider seam (Anthropic solve)#36
jpka merged 1 commit into
mainfrom
feat/27-provider-seam

Conversation

@jpka

@jpka jpka commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Closes #27.

The single module the rest of the app calls to get a coding-exercise answer out of a screenshot — the seam a second vision provider would replace without touching any other module.

What's here

File What it is
src/host/provider/types.ts The seam's vocabulary: Provider, SolveImage, SolveEvent, Usage, ProviderErrorKind. A second provider keeps this file and replaces the next two.
src/host/provider/transport.ts HTTP + SSE mechanics. Knows nothing about solving.
src/host/provider/anthropic.ts createProvider — the request shape, the failure taxonomy, the retry rule. Never touches the network.
test/host/provider.test.ts The seam against a fake transport.
test/host/provider-transport.test.ts The SSE parser and the fetch transport against a fake fetch.

createProvider(config) → Provider, Provider.solve(image, {signal}) → AsyncIterable<SolveEvent>, where SolveEvent is delta{text} | done{usage, stopReason} | error{kind, message} and kind ∈ {auth, refusal, transient}. Exactly one terminal event, always last — unless the caller aborts, in which case the iterable just ends.

Defaults per the spec: claude-sonnet-5, effort: medium (inside output_config, not top-level), max_tokens: 8000. The system prompt is fixed at construction, carried into the request unmodified, and always carries cache_control: {type: 'ephemeral', ttl: '1h'}.

No dependency on the HTTP server, capture, or config.

Acceptance criteria

Criterion Where
delta* then a terminal done{usage} with tokens populated streams deltas and finishes with a done carrying usage — asserts all four usage fields, including the cache pair
auth surfaces immediately, no retry surfaces an auth rejection immediately, treats a 403 as auth too — retries are configured in every test, so "1 call" means the taxonomy suppressed it
refusal surfaces immediately, no retry surfaces a refusal immediately
Transient-then-success retries internally, caller sees no error retries a rate limit…, …an overload reported mid-stream…, …a network failure, …a stream that ends before the answer is finished
Transient exhausted → error{transient} after retries surfaces error{transient} only once retries are exhausted — 3 calls, 1 event
Abort mid-stream stops the iterable without throwing stops the iterable mid-stream…, plus already-aborted and aborted-during-backoff
Prompt caching present on the system prompt enables prompt caching on the system prompt with a 1h ttl — asserts the block sent to the transport

Tests assert the emitted event sequence and the request body the fake transport actually received. Nothing asserts an internal call graph.

npm run typecheck clean; npm test 61 pass / 0 fail.

Judgment calls

No @anthropic-ai/sdk. package.json still has zero runtime dependencies. This call is one streaming POST; a fetch transport plus an SSE parser is ~200 lines and, behind the injected seam, buys a test suite that drives real stream and failure shapes through the production code path with no network and no mocking library. The SDK's headline features here would be typed events and automatic retries — but the retry policy is the thing this ticket specifies (transient retries, auth/refusal don't), so SDK retries would have to be disabled anyway. It also has to survive Electron packaging. Revisit if we need tool use, batches, or the Files API — none of which v1 wants. AGENTS.md now records this as the convention for outbound HTTP.

done carries stopReason. Spec §Model defaults: "truncation is made detectable via stop_reason rather than assumed not to happen." A code block guillotined at max_tokens looks complete enough to paste, and nothing downstream can notice without this. One extra field, no other cost.

Thinking deltas are dropped, not re-emitted. Anthropic streams thinking_delta and text_delta on the same event type; only text_delta becomes a delta, so thinking text can never reach the answer pane. .scratch/solver/spec/11-prompt/ recommends going further and emitting thinking as a fourth event kind for #29's status pill — deliberately not done here, since #25 synthesised that ticket and kept the union at three. Flagging it so whoever picks up #29 knows the option exists and costs one union member.

A 4xx the provider calls malformed surfaces as transient, without retrying. A 400 or 404 is a bug in this app, not a flaky moment — retrying spends two more billed calls to reach the same answer. transient is the only kind the taxonomy has for it, so it borrows that label but skips the retries.

Retrying stops once text has streamed. A retry after partial output would duplicate it in the pane. This matches the spec's failure table — "stream dies mid-answer → partial pane text stays visible with an appended error marker" — so a mid-answer failure surfaces immediately with the partial intact.

A stream that ends without saying it finished is a transient failure. A severed connection looks exactly like a clean end-of-body, so message_stop/stop_reason is required before a run counts as complete.

🤖 Generated with Claude Code

The single module the rest of the app calls to turn a screenshot into an
answer, and the one a second vision provider would replace.

createProvider(config) -> Provider, Provider.solve(image, {signal}) ->
AsyncIterable<SolveEvent>, where SolveEvent is delta{text} | done{usage} |
error{kind} with kind in {auth, refusal, transient}.

The system prompt is fixed at construction and carried into the request
unmodified, with cache_control {type: ephemeral, ttl: 1h} on it always — not a
config flag, since it is request mechanics that belong sealed inside the seam.
Defaults are claude-sonnet-5, effort medium (inside output_config), max_tokens
8000. done carries stopReason so a max_tokens truncation is detectable rather
than assumed away.

Transient failures — rate limit, overload, 5xx, network, a stream that ends
before it says it finished — retry inside the seam with no caller involvement.
auth and refusal surface on first occurrence with no retry, as does a 4xx the
provider called malformed: retrying a bug just spends two more calls. Retrying
stops once text has streamed, because a retry there would duplicate what is
already on screen; that case surfaces instead, leaving the partial answer for
the caller to mark.

No runtime dependency added. transport.ts is fetch plus an SSE parser and knows
nothing about solving; anthropic.ts is the taxonomy and the retry rule and never
touches the network. The transport is injected, so the suite drives real stream
shapes and real failure shapes through the production code path with no server,
no SDK, and no network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jpka
jpka merged commit 3ff3e18 into main Aug 7, 2026
@jpka
jpka deleted the feat/27-provider-seam branch August 7, 2026 02:24
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.

Provider seam (Anthropic solve)

1 participant