Symptom
On a single-provider self-host (AI_PROVIDER=claude-code), every AI review fails. Engine logs show, for every PR, three attempts then exhaustion:
{"level":"warn","event":"ai_review_provider_attempt_failed","model":"claude-code","attempt":0,"error":"claude_code_exit_1: "}
...
{"level":"error","event":"ai_review_provider_exhausted","primary":"claude-code","fallback":"claude-code","error":"claude_code_exit_1: "}
Root cause
resolveAiReviewerPlan builds the single-provider plan as reviewers: [{ model: "claude-code" }] — i.e. the reviewer 'model' is the router address (the provider name), matching the 2-provider convention. runGittensoryAiReview then calls env.AI.run("claude-code", …).
But createSelfHostAi returns the bare provider for a single-provider setup (providers.length === 1 ? providers[0].ai : routeProviders(...)), bypassing routeProviders's name-stripping. So createClaudeCodeAi.run("claude-code", …) runs resolveModel(undefined, "claude-code", "claude-sonnet-4-6"), which returns "claude-code" verbatim (it only strips @cf/ ids) → claude --model claude-code → 404 (is_error:true, api_error_status:404, "There's an issue with the selected model (claude-code)").
Verified in-container: claude --model claude-code ⇒ exit 1 / 404; claude --model claude-sonnet-4-6 (what the router yields) ⇒ exit 0. The 2-provider path already works because routeProviders strips the name (its test even documents this: 'the router never passes the provider NAME through as a model id').
Fix
createSelfHostAi must wrap a single provider in routeProviders too, so the provider-name address resolves to the provider's own default model instead of reaching it as a literal --model. Add a regression test pinning that a single-provider env.AI.run(<providerName>) sends the provider default, never the name.
Symptom
On a single-provider self-host (
AI_PROVIDER=claude-code), every AI review fails. Engine logs show, for every PR, three attempts then exhaustion:Root cause
resolveAiReviewerPlanbuilds the single-provider plan asreviewers: [{ model: "claude-code" }]— i.e. the reviewer 'model' is the router address (the provider name), matching the 2-provider convention.runGittensoryAiReviewthen callsenv.AI.run("claude-code", …).But
createSelfHostAireturns the bare provider for a single-provider setup (providers.length === 1 ? providers[0].ai : routeProviders(...)), bypassingrouteProviders's name-stripping. SocreateClaudeCodeAi.run("claude-code", …)runsresolveModel(undefined, "claude-code", "claude-sonnet-4-6"), which returns"claude-code"verbatim (it only strips@cf/ids) →claude --model claude-code→ 404 (is_error:true, api_error_status:404, "There's an issue with the selected model (claude-code)").Verified in-container:
claude --model claude-code⇒ exit 1 / 404;claude --model claude-sonnet-4-6(what the router yields) ⇒ exit 0. The 2-provider path already works becauserouteProvidersstrips the name (its test even documents this: 'the router never passes the provider NAME through as a model id').Fix
createSelfHostAimust wrap a single provider inrouteProviderstoo, so the provider-name address resolves to the provider's own default model instead of reaching it as a literal--model. Add a regression test pinning that a single-providerenv.AI.run(<providerName>)sends the provider default, never the name.