feat: signal a blocked credential so a caller can route around it - #12
Merged
Conversation
Every bad outcome the library reported was a statement about the request, and one class is not: when an allowance is spent or a credential is rejected, the request was never considered. It is the only class where retrying against a different provider is right, and IsError was the same true for it as for a failed task — so a caller could not tell "route elsewhere" from "this fails wherever it runs". Result.Blocked names the reason. BlockExhausted lifts on a clock and carries ResetsAt where the provider reports one; BlockRejected never lifts without a human. BlockReporter declares which reasons a dialect can actually recognise, so a chain is not built on a provider that will never fire it. claudecode reads both from api_error_status: a blocked run reports subtype "success" with is_error set and the status in that field, exactly as a rejected token does. codex implements BlockReporter not at all — its only event stream is `codex exec --json`, whose turn.failed carries prose and nothing else, and a dialect matching that English would recognise only the wording it was written against. The library signals and does not route. A Driver binds one provider, and a Request is not portable between dialects, so retrying "the same request" elsewhere would silently run a different one.
|
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.
Every bad outcome this library reported was a statement about the request. One class is not: when an allowance is spent or a credential is rejected, the request was never considered at all. It is the only class where retrying against a different provider is the right response — and
IsErrorwas the sametruefor it as for a task the agent simply failed, so a caller could not tell "route elsewhere" from "this fails wherever it runs".What this adds
Result.Blockednames the reason:BlockExhausted— the allowance for the current window is spent. Lifts on a clock;ResetsAtsays when, where the provider reports one.BlockRejected— the credential is invalid or expired. Never lifts without a human.A reason earns a place in the vocabulary only where the caller's correct response differs. An unrecognised failure is not a block:
IsErrorwith noBlocked, which correctly says "do not route on this".BlockReporterdeclares which reasons a dialect can actually recognise, surfaced asDriver.DetectableBlocks(). It carries the set rather than a yes, because the two providers differ and the difference is load-bearing.Why the providers answer differently
subtype: "success"withis_errorset and the HTTP status inapi_error_status— exactly how the captured 401 behaves — so the status is the whole signal.codex exec --json, whose terminalturn.failedcarries a prose message and nothing else; the error-code vocabulary the CLI keeps internally (usageLimitExceeded,unauthorized) never reaches the wire. Verified empirically against the pinned 0.153.4. A dialect matching that English would recognise only the wording it was written against.That asymmetry is what the capability is for. Without it, a chain built on codex looks identical to one built on claudecode right up to the night a window runs out.
The library signals; it does not route
A
Driverbinds one provider atNew, and aRequestis not portable between dialects —AllowedToolsandPermissionModeare spelled in the provider's own vocabulary,SessionIDdoes not cross at all,MaxTurnsis counted in incomparable units. Retrying "the same request" elsewhere would silently run a different one. Composition over two drivers belongs above this library.Fixture provenance
claudecode/testdata/blocked-exhausted.jsonis derived, not captured — producing an exhausted run on demand means genuinely spending a subscription window. It is built from wire tokens in the pinned 2.1.266 artifact, andclaudecode/testdata/README.mdrecords that and the rule: recognition keys on a status code, never on display prose. It is replaced by a capture the first time one is obtainable.Deferred
Throttledand pay-as-you-go credit exhaustion are real distinctions both vendors make, but neither reaches the wire yet. A block that arrives with no envelope at all also has no home:Decodersees only stdout, and closing that gap means a second capability that stays unbuilt until a run is observed taking that path.Decisions and rejected alternatives: ADR 0006. Glossary:
CONTEXT.md.Checks
go test ./...andgo vet ./...pass;gofmtclean.agtk code-reviewdeep panel (6 reviewer runs + judge) returned 0 findings.