Provider seam (Anthropic solve) - #36
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
src/host/provider/types.tsProvider,SolveImage,SolveEvent,Usage,ProviderErrorKind. A second provider keeps this file and replaces the next two.src/host/provider/transport.tssrc/host/provider/anthropic.tscreateProvider— the request shape, the failure taxonomy, the retry rule. Never touches the network.test/host/provider.test.tstest/host/provider-transport.test.tsfetch.createProvider(config) → Provider,Provider.solve(image, {signal}) → AsyncIterable<SolveEvent>, whereSolveEventisdelta{text}|done{usage, stopReason}|error{kind, message}andkind ∈ {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(insideoutput_config, not top-level),max_tokens: 8000. The system prompt is fixed at construction, carried into the request unmodified, and always carriescache_control: {type: 'ephemeral', ttl: '1h'}.No dependency on the HTTP server, capture, or config.
Acceptance criteria
delta* then a terminaldone{usage}with tokens populatedstreams deltas and finishes with a done carrying usage— asserts all four usage fields, including the cache pairauthsurfaces immediately, no retrysurfaces an auth rejection immediately,treats a 403 as auth too— retries are configured in every test, so "1 call" means the taxonomy suppressed itrefusalsurfaces immediately, no retrysurfaces a refusal immediatelyretries a rate limit…,…an overload reported mid-stream…,…a network failure,…a stream that ends before the answer is finishederror{transient}after retriessurfaces error{transient} only once retries are exhausted— 3 calls, 1 eventstops the iterable mid-stream…, plus already-aborted and aborted-during-backoffenables prompt caching on the system prompt with a 1h ttl— asserts the block sent to the transportTests assert the emitted event sequence and the request body the fake transport actually received. Nothing asserts an internal call graph.
npm run typecheckclean;npm test61 pass / 0 fail.Judgment calls
No
@anthropic-ai/sdk.package.jsonstill 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 (transientretries,auth/refusaldon'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.donecarriesstopReason. Spec §Model defaults: "truncation is made detectable viastop_reasonrather than assumed not to happen." A code block guillotined atmax_tokenslooks 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_deltaandtext_deltaon the same event type; onlytext_deltabecomes adelta, 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.transientis 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_reasonis required before a run counts as complete.🤖 Generated with Claude Code