feat(modelerrors): surface structured provider error details on non-2xx responses#2549
Merged
dgageot merged 3 commits intodocker:mainfrom Apr 28, 2026
Conversation
Parse the JSON body that providers attach to non-2xx responses and lift
the structured fields (error.type, error.message, error.code, error.param,
status, request id) into StatusError.Error(), stripping the URL noise.
Recognised shapes:
Anthropic {"type":"error","error":{"type":"...","message":"..."}}
OpenAI {"error":{"message":"...","type":"...","code":"...","param":"..."}}
Gemini {"error":{"code":N,"message":"...","status":"..."}}
Proxies {"message":"Bad Request"}
When the body is opaque (Docker AI gateway returning only
{"message":"Bad Request"}) we still strip the URL so the user gets
"HTTP 400: Bad Request" instead of a 200-character POST/URL/JSON wall.
Falls back to the original err.Error() text whenever no JSON object is
found or the body has no recognised fields, so non-SDK errors are
unchanged (e.g. "HTTP 429: rate limit exceeded").
Assisted-By: docker-agent
Same behaviour, less code, easier to read:
* StatusError.Error() 14 -> 9 lines (request-id concat moved into
parseProviderError, no second branch here)
* extractFirstJSONObject 35 -> 9 lines, renamed firstJSONObject:
replace the hand-rolled brace/string/escape
state machine with json.Decoder, which
handles escapes correctly by construction
* parseProviderErrorDetails split into two single-purpose helpers:
- parseProviderError finds the JSON body, parses it, appends
the request id to the rendered details
- formatProviderError renders the parsed struct as a one-liner
* unquoteJSONScalar renamed scalarString and switched to plain
any/float64 instead of json.RawMessage +
per-scalar json.Unmarshal calls
All existing tests still pass unchanged; lint clean.
Assisted-By: docker-agent
- Add 1MB size limit to JSON parsing to prevent memory exhaustion from
malicious or accidentally huge error responses
- Fix firstJSONObject to try each '{' position until valid JSON is found,
handling cases where '{' appears in URLs (e.g., param={value})
- Add comprehensive edge case tests for empty JSON, malformed JSON,
multiple JSON objects, unicode, large numbers, special characters in
Request-ID, braces in URLs, and nested JSON in message fields
Security: Prevents potential DoS via unbounded JSON parsing
Bug fix: Correctly extracts JSON body when '{' appears in URL parameters
Assisted-By: docker-agent
rumpl
approved these changes
Apr 28, 2026
rumpl
approved these changes
Apr 28, 2026
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.
What
Adds the four lifecycle hook events that Claude Code, OpenCode, and
piall expose but docker-agent did not, plus a small documentation fix.Why
While auditing how docker-agent's hook system compares to peer AI coding agents, four events stood out as universally supported by competitors but missing from us. Each one unlocks a real use case that today either has to be hacked in via
pre_tool_use/on_user_inputor simply isn't possible.New hook events
user_prompt_submitRunStream, aftersession_startand before the first model call. Skipped for sub-sessions (whose kick-off message is synthesised by the runtime).additional_contextfor that turn.pre_compactsummarizeWithSourcebefore context compaction. Trigger source (manual/auto/overflow/tool_overflow) is reported inInput.Source.subagent_stoprunSubSessionForwardingandrunSubSessionCollectingcomplete — success or failure (deferred dispatch).permission_requestaskUserForConfirmationjust before the runtime would prompt the user.permission_decision: allow / deny), mirroringpre_tool_use. Returning nothing falls through to the interactive confirmation.Also fixed
post_tool_usedoc / schema / example wording: it fires on both success and failure, withtool_response.is_errordistinguishing them. The previous "after a tool completes successfully" claim was wrong.EventPermissionRequest's doc comment now spells out the asymmetry withpre_tool_use(where allow is the implicit default vs. permission_request where it's an explicit auto-approve verdict). That's whyResult.PermissionAllowedexists separately fromResult.Allowed.Files touched
pkg/config/latest/types.go— 4 new fields onHooksConfig+ validationpkg/hooks/{types,executor,config}.go— 4 newEventTypeconstants,Result.PermissionAllowed,Input.{Prompt, AgentName, ParentSessionID}, executor wiringpkg/runtime/{hooks,loop,runtime,agent_delegation,skill_runner,tool_dispatch}.go— dispatch helpers and call-site integrationagent-schema.json,examples/hooks.yaml,docs/configuration/hooks/index.md— schema, example yaml demonstrating all events, and user-facing docsTesting
pkg/hooks/contract_widening_test.gopin the wire format for every new event (block-produces-deny, allow-produces-permission-allowed, fields-reach-the-hook, …).pkg/runtime/user_prompt_submit_test.gopin the gating contract: fires-once for top-level submissions, never for sub-sessions (SendUserMessage=false).examples/hooks.yamlparses throughconfig.Loadand validates againstagent-schema.json.mise run lint→ 0 issues.go test -count=1 ./...→ 0 failures across ~150 packages.Commits
feat(hooks): add 4 new hook events to match Claude Code / OpenCode / pi— the featurerefactor(hooks): simplify the call sites added for the new events— small in-place readability cleanup (merge duplicated guards, switch onPermissionDecision, stop double-decoding tool args, take*agent.Agentdirectly instead of name + lookup)fix(hooks): address review findings on the new hook events—subagent_stopnow fires on the error path of both sub-session helpers (defer);EventPermissionRequestdoc clarification;examples/hooks.yamljq-dependency note; user_prompt_submit gating regression testBackward compatibility
HooksConfigare allomitempty; existing configs continue to parse unchanged.Summarize's public signature is unchanged; internal call-sites use a privatesummarizeWithSourceto attribute the trigger topre_compacthooks.runSubSessionForwarding's parameter list changed (string →*agent.Agent) but the function is package-private; both call-sites updated.