Skip to content

Give auto-chain fallback models the call options they would have received as primary - #4327

Open
keppo-bot[bot] wants to merge 2 commits into
mainfrom
fallback-model-options
Open

Give auto-chain fallback models the call options they would have received as primary#4327
keppo-bot[bot] wants to merge 2 commits into
mainfrom
fallback-model-options

Conversation

@keppo-bot

@keppo-bot keppo-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Summary

The auto-mode fallback chain reused the original request's call options when it
switched models, and those options were resolved against the selected model's
constraints before the request began. Observed in practice: a transient gpt-5.6
stream error failed over to the chain's Anthropic model, which 400'd on the
forwarded temperature β€” the resilience mechanism converted a recoverable blip
into a fatal stream error.

The deeper problem is that the fallback got an OpenAI-shaped request: the chain
client is built with builtinProviderId: "openai", so the request carried
providerOptions.openai and no providerOptions.anthropic β€” no adaptive
thinking at all (with which the temperature would have been legal). The
primary's maxOutputTokens is the same class of latent 400 for models with
smaller caps.

Fix, in two layers:

  • Chain build (get_model_client.ts, where catalog access exists): each
    chain entry gets the call options it would have received had it been the
    primary selection β€” its own temperature and output cap from the catalog,
    and its provider family's thinking/reasoning options at the user's chosen
    effort (getModelScopedProviderOptions).
  • Wrapper (fallback_ai_model.ts): on any non-primary call, apply that
    model's entry β€” temperature/cap replaced, family providerOptions merged
    over the request-scoped keys, which pass through untouched. Models without an
    entry keep a conservative default: temperature stripped (absent temperature
    is valid on every provider), everything else forwarded.

Decisions a reviewer should weigh:

  • Scope of "recompute": the model-derived subset only. prompt, tools,
    headers, and the dyad-engine request metadata are request-scoped and pass
    through unchanged. undefined in an override means unset, never "inherit
    the primary's" β€” inheriting is precisely the bug.
  • getModelScopedProviderOptions duplicates the family branches of
    getProviderOptions
    rather than refactoring the latter, to keep this PR's
    blast radius out of the primary request path. A test pins the two in
    lockstep: it asserts the scoped slice equals what getProviderOptions emits
    for the same family, so drift breaks CI. The google branch keys off the
    resolved model's API name where getProviderOptions uses the selected
    model's name β€” for chain entries the resolved name is the correct source.
  • The other two createFallback sites (openrouter chains) pass no
    overrides.
    They are same-provider chains where forwarded options are
    homogeneous; they get the conservative temperature-strip on failover, a
    minor behaviour change (provider default instead of forwarded value) that is
    safe by construction.
  • The sticky-index case is covered: after a failover the wrapper serves the
    fallback model for three minutes, so a fresh request's first attempt can
    target a non-primary model with primary-computed options β€” same bug without
    a same-request failover. optionsForCurrentModel keys on the current index,
    not on "did we just switch". Tested.
  • Existing get_model_client tests mock the catalog as empty, which exercises
    the conservative no-entry path; the override path is tested at the wrapper
    layer with explicit entries.

Call options are resolved for the selected model before a request is made, so
they encode that model's constraints. The fallback wrapper reused them verbatim
when switching models mid-stream or between attempts, so a chain that crosses
providers forwarded the primary's temperature to a model that rejects it β€”
observed as a gpt-5.6 stream blip failing over to an Anthropic thinking model,
whose 400 (temperature may only be set to 1 when thinking is enabled) turned a
recoverable error into a fatal one.

On any non-primary model, drop temperature and let the provider default apply:
an absent temperature is valid everywhere, and this layer has no catalog access
to recompute a per-model value. Covers the sticky-index case too β€” after a
failover the index stays on the fallback for three minutes, so a fresh
request's first call can already target a fallback model.

@dyad-assistant dyad-assistant Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude review: 3 inline finding(s).

private optionsForCurrentModel(
options: LanguageModelV3CallOptions,
): LanguageModelV3CallOptions {
if (this.currentModelIndex === 0) return options;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

Temperature is stripped even on same-provider fallback chains

The guard keys on currentModelIndex rather than on whether the fallback actually crosses providers, so the strip also fires on the two chains that never leave OpenRouter: the auto/free chain (FREE_OPENROUTER_MODEL_NAMES) and getOpenRouterAutoFallbackModelClient in get_model_client.ts:275, which builds [primaryModel, openrouter/free]. Both of those catalog entries specify temperature: 0 (language_model_constants.ts, auto/free and the OpenRouter model entries), so a failover inside a single provider silently swaps deterministic decoding for the provider default (typically 1.0) with none of the cross-provider 400 hazard that motivates the change. The user sees noticeably more variable code generation from a fallback that was configured to be deterministic, and nothing in the logs explains why.

πŸ’‘ Suggestion: Strip only when the provider actually changes, e.g. compare the primary model's provider against getUnderlyingModel().provider and return options unchanged when they match. If dropping unconditionally is the deliberate choice, say so in the comment so the single-provider chains are visibly in scope.

Comment thread src/ipc/utils/fallback_ai_model.ts Outdated
): LanguageModelV3CallOptions {
if (this.currentModelIndex === 0) return options;
if (options.temperature === undefined) return options;
const { temperature: _dropped, ...rest } = options;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

maxOutputTokens has the same cross-provider hazard and is forwarded

The PR description asks reviewers to weigh 'temperature only, deliberately' but argues only about providerOptions being provider-keyed and therefore inert. maxOutputTokens comes from the identical resolution path: local_agent_handler.ts:1030 calls getMaxTokens(settings.selectedModel) on the line directly above the getTemperature call this comment cites, and both are resolved against the selected model's catalog entry before the request. It is then forwarded verbatim across a provider switch, and a value above the fallback model's own output cap is a hard 400 in exactly the same way temperature was. Today's auto chain happens to be safe because auto/auto pins a conservative 32k, but that is incidental to a catalog value that can change, and OpenRouter chains inherit the user-selected model's cap instead.

πŸ’‘ Suggestion: Either extend the strip to maxOutputTokens (an absent value falls back to the provider default the same way temperature does), or amend the comment and PR description to state that maxOutputTokens is knowingly left forwarded and why the current catalog values make it safe.

} from "@ai-sdk/provider";
import { describe, expect, it } from "vitest";

import { createFallback } from "./fallback_ai_model";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

New unit test does not mock electron-log unlike every sibling test

fallback_ai_model.ts imports electron-log at module scope and calls log.scope() at import time, and defaultShouldRetryThisError calls logger.info on every retryable-error check, which both failover tests hit. All 26 existing test files under src/ipc/utils that transitively pull in electron-log declare vi.mock('electron-log', ...) first, including get_model_client.test.ts:15 which imports this very module; the unit project in vitest.config.ts has no setupFiles, so there is no shared mock to inherit. I could not run the suite to confirm a failure because node_modules is not installed in this checkout, so this is a convention/risk observation rather than an observed break, but the fix is a no-op if the import already works.

πŸ’‘ Suggestion: Add the standard three-line mock used across this directory: vi.mock("electron-log", () => ({ default: { scope: () => ({ debug: vi.fn(), info: vi.fn(), log: vi.fn(), warn: vi.fn(), error: vi.fn() }) } })), importing vi from vitest.

@dyad-assistant

Copy link
Copy Markdown
Contributor

πŸ” Dyadbot Code Review Summary

Verdict: πŸ€” NOT SURE - Potential issues
Recommendation: ready

The core fix is correct and narrowly scoped: optionsForCurrentModel is applied at both doStream call sites (the retry-loop path at fallback_ai_model.ts:296 and the mid-stream failover path at fallback_ai_model.ts:411), it is recomputed from the caller's original options each attempt rather than from a mutated copy, and it correctly reads currentModelIndex after switchToNextModel() in the streaming path. The sticky-index case called out in the description is real (checkAndResetModel keeps a non-zero index for modelResetInterval) and is genuinely covered by keying on the index rather than on "did we just switch". Index 0 keeps receiving untouched options, and the modulo wrap in switchToNextModel lands back on index 0 with the temperature restored, which is the right behaviour. Tests exercise the public createFallback surface rather than the private method, which is the right seam. No HIGH-severity problems found.

Three things worth weighing before merge, none of them blockers. The most substantive is that the strip is keyed on index, not on provider change, so it also fires on the two fallback chains that never cross providers β€” auto/free (all OpenRouter) and getOpenRouterAutoFallbackModelClient (get_model_client.ts:275, [primaryModel, openrouter/free]). Both of those catalog entries specify temperature: 0, so a failover there silently swaps deterministic decoding for the provider default with no cross-provider hazard to justify it. Second, the description asks reviewers to weigh "temperature only, deliberately" and argues only about providerOptions; maxOutputTokens comes from the identical resolution path (getMaxTokens(settings.selectedModel) at local_agent_handler.ts:1030, one line above the getTemperature call the comment cites) and is still forwarded verbatim.

Confidence notes: the diff is complete and not truncated. I could not execute the new test file β€” node_modules is not installed in this checkout β€” so the electron-log finding below is a convention/risk observation rather than an observed failure.

Issues Summary

Severity File Issue
🟑 MEDIUM src/ipc/utils/fallback_ai_model.ts:200 Temperature is stripped even on same-provider fallback chains
🟑 MEDIUM src/ipc/utils/fallback_ai_model.ts:202 maxOutputTokens has the same cross-provider hazard and is forwarded
🟑 MEDIUM src/ipc/utils/fallback_ai_model.test.ts:8 New unit test does not mock electron-log unlike every sibling test
🟒 Low Priority Notes (4 items)
  • Doc comment overstates what the options encode - The comment says call options "are resolved for the PRIMARY model". In the main auto-mode chain they are resolved for the auto/auto catalog pseudo-entry (temperature: 0, maxOutputTokens: 32_000 in language_model_constants.ts:357), not for the model that actually sits at index 0 (dyad/auto/openai β†’ gpt-5.5, whose own entry is temperature: 1). The currentModelIndex === 0 guard is a "preserve existing behaviour" heuristic rather than a faithful match, which is fine β€” but the comment reads as if it were faithful, and that will mislead the next person who touches this. (src/ipc/utils/fallback_ai_model.ts)
  • Silent option mutation is not logged - The wrapper logs every failover (Falling back to model ..., Stream error from model, falling back to ...) but not that it altered the outgoing call options. A one-line logger.info when temperature is dropped would make "why did the fallback produce different-looking output" reports diagnosable from existing logs. (src/ipc/utils/fallback_ai_model.ts)
  • Assertion can pass vacuously - expect(primarySeen.every((o) => o.temperature === 1)).toBe(true) is true for an empty array, so the third test would still pass if the primary were never called at all. expect(primarySeen).toHaveLength(1) alongside it would pin the intended shape. (src/ipc/utils/fallback_ai_model.test.ts)
  • Fake model relies on layered casts - fakeModel returns as unknown as LanguageModelV3 and each enqueued stream part is as any, so a future change to the V3 call-options or stream-part shape will not surface here as a type error. A small typed fixture helper would keep the fake honest against the SDK types. (src/ipc/utils/fallback_ai_model.test.ts)

Generated by Dyadbot persona-based code review

@github-actions github-actions Bot added the needs-human:review-issue ai agent flagged an issue that requires human review label Aug 20, 2026
…ceived as primary

Dropping temperature made the failover request valid but still wrong: the
Anthropic fallback ran without its adaptive-thinking providerOptions (the chain
client is built with builtinProviderId 'openai', so only providerOptions.openai
was on the request), and the primary's maxOutputTokens is a latent 400 for
models with smaller caps.

At chain-build time β€” where catalog access exists β€” compute each entry's own
temperature and output cap (getTemperature/getMaxTokens) and its provider
family's thinking/reasoning options at the user's chosen effort
(getModelScopedProviderOptions, kept in lockstep with getProviderOptions'
family branches and pinned to them by test). The wrapper applies the current
model's entry on any non-primary call: temperature and cap are replaced (an
undefined override means unset, never 'inherit the primary's'), family
providerOptions merge over the request-scoped keys, which pass through.
Models without an entry keep the conservative default: temperature stripped.
@keppo-bot keppo-bot Bot changed the title Stop forwarding the primary model's temperature to fallback models Give auto-chain fallback models the call options they would have received as primary Aug 20, 2026

@dyad-assistant dyad-assistant Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude review: 5 inline finding(s).

providerOptions: getModelScopedProviderOptions({
providerId: resolvedModel.providerId,
modelName: resolvedModel.apiName,
modelSelection: model,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ”΄ HIGH

Auto chain passes a model without effortLevel as ModelSelection

getProModelClient receives model: LargeLanguageModel (see the signature at line 289 and the call site at line 160, which passes getAutoSidekickRuntimeModel(selectedModel)), and LargeLanguageModel has no effortLevel. getModelScopedProviderOptions declares modelSelection: ModelSelection, where effortLevel is required, so this argument is a type error and tsc should reject the build. Even if the type were widened, getModelEffort reads modelSelection.effortLevel, so every chain entry would be built with reasoningEffort: undefined (OpenAI) and effort: undefined (Anthropic). Because the new optionsForCurrentModel replaces the whole providerOptions.openai / providerOptions.anthropic key rather than deep-merging, that undefined effort would also overwrite the correct effort the caller computed via getProviderOptions - so a fallback would silently lose the user's chosen reasoning effort. The surrounding scope in getModelClient already has a real ModelSelection (modelSelection, line 94) that is not threaded into getProModelClient.

πŸ’‘ Suggestion: Thread the resolved ModelSelection (getModelClient's modelSelection) into getProModelClient and pass that to getModelScopedProviderOptions instead of model.

private optionsForCurrentModel(
options: LanguageModelV3CallOptions,
): LanguageModelV3CallOptions {
if (this.currentModelIndex === 0) return options;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

Primary chain entry still gets the auto pseudo-model's temperature

optionsForCurrentModel short-circuits on index 0, so the first chain entry keeps the caller's options. In auto mode those options are not the primary model's: local_agent_handler computes getTemperature(settings.selectedModel) with selectedModel = {provider: "auto", name: "auto"}, and the catalog's auto/auto entry has temperature: 0, while the resolved index-0 OpenAI models are marked in the catalog as requiring temperature 1. So the exact mismatch this PR fixes for indexes 1..n is left in place for index 0, and modelCallOptions[0] is computed in get_model_client (a catalog lookup per request) but can never be read. Applying the entry's own options at index 0 too - or documenting explicitly why index 0 must not be touched - would make the mechanism consistent and remove the dead entry.

πŸ’‘ Suggestion: Either apply modelCallOptions[index] uniformly (including index 0) or stop computing/passing an entry for index 0 and note why the primary is exempt.

* Kept in lockstep with the family branches of getProviderOptions below β€”
* update both when a provider's thinking config changes.
*/
export function getModelScopedProviderOptions({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

Provider family options duplicated between two functions

getModelScopedProviderOptions re-implements the OpenAI, Anthropic and Google/Vertex branches of getProviderOptions verbatim, and the doc comment acknowledges the two must be kept 'in lockstep' by hand. The new provider_options.test.ts lockstep assertions cover only three provider/model combinations at one effort level, so a future change (a new provider family, a new gemini gating rule, or the reasoningEffortProviderId branch which the scoped version omits entirely) can drift without any test failing. This is exactly the kind of duplication that produces the next cross-provider option bug.

πŸ’‘ Suggestion: Have getProviderOptions build its family slice by calling getModelScopedProviderOptions so there is a single source of truth, rather than maintaining two copies.

if (providerId === "anthropic") {
return { anthropic: getAnthropicProviderOptions(modelSelection) };
}
if (providerId === "google" || providerId === "vertex") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

Google chain entry's providerOptions likely ignored by engine model

The Google/Vertex chain entry is created with provider(resolvedModelId, ...), i.e. an OpenAICompatibleChatLanguageModel whose provider name is dyad-engine. AI SDK OpenAI-compatible models only forward the providerOptions key matching their own provider name - which is why llm_engine_provider.ts reads dyad options out of the dyad-engine key and injects Google thinking config through getExtraProviderOptionsForEngine in the custom fetch instead. If that holds, the new providerOptions.google = { thinkingConfig } for the Google entry never reaches the request and is dead configuration, while the engine fetch wrapper already supplies the equivalent per-entry thinking options based on chatParams.providerId. I could not run the app or inspect node_modules in this environment, so please confirm against the installed AI SDK before keeping the branch.

πŸ’‘ Suggestion: Verify whether the OpenAI-compatible engine model reads providerOptions.google; if not, drop the google/vertex branch (or the whole scoped-providerOptions addition for non-Anthropic entries) rather than shipping inert config.

// (temperature/maxOutputTokens) via findLanguageModel -> getLanguageModels.
// An empty catalog means "no per-model data", which exercises the
// conservative path without inventing model entries these tests don't need.
getLanguageModels: vi.fn(async () => []),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

New per-model call-option wiring has no test coverage

The only test touching the new get_model_client code path mocks getLanguageModels to return an empty catalog, so temperature and maxOutputTokens resolve to undefined and nothing asserts that modelCallOptions is built or handed to createFallback in the right order. The fallback_ai_model tests exercise modelCallOptions only with hand-written literals. As a result no test covers the actual chain-building logic - including the missing effortLevel defect above, or a future off-by-one between the models and modelCallOptions arrays.

πŸ’‘ Suggestion: Add a get_model_client test with a non-empty mocked catalog that asserts createFallback receives per-index call options (temperature, maxOutputTokens, and the family providerOptions with the user's effort level).

@dyad-assistant

Copy link
Copy Markdown
Contributor

πŸ” Dyadbot Code Review Summary

Verdict: β›” NO - Do NOT merge
Recommendation: auto-fix

The core diagnosis is right and the fallback wrapper change is well-tested: call options are resolved once for the primary selection, and forwarding temperature across a provider switch turns a recoverable blip into a hard 400. optionsForCurrentModel correctly covers both the same-request failover and the sticky-index case, and it preserves the request-scoped dyad-engine key that the engine provider depends on.

The blocker is on the other side of the wiring: the auto chain builds each entry's provider options from a LargeLanguageModel, which has no effortLevel, where a ModelSelection is required. That is a type error at the call site, and semantically it would strip the user's reasoning effort from every fallback entry - the opposite of the PR's intent, since the new code replaces the caller's whole providerOptions.openai / providerOptions.anthropic key.

Note also that the PR title and description describe only "stop forwarding the primary model's temperature", while the implementation additionally forwards per-model maxOutputTokens and provider-family thinking/reasoning options. Worth updating before merge so the commit message matches what shipped.

The diff is complete (not truncated). I could not run tsc, the test suite, or inspect node_modules in this environment, so the type-error and AI-SDK-internals conclusions are from reading the repo rather than from an executed build.

Issues Summary

Severity File Issue
πŸ”΄ HIGH src/ipc/utils/get_model_client.ts:367 Auto chain passes a model without effortLevel as ModelSelection
🟑 MEDIUM src/ipc/utils/fallback_ai_model.ts:222 Primary chain entry still gets the auto pseudo-model's temperature
🟑 MEDIUM src/ipc/utils/provider_options.ts:45 Provider family options duplicated between two functions
🟑 MEDIUM src/ipc/utils/provider_options.ts:67 Google chain entry's providerOptions likely ignored by engine model
🟑 MEDIUM src/ipc/utils/get_model_client.test.ts:39 New per-model call-option wiring has no test coverage
🟒 Low Priority Notes (4 items)
  • Strip-temperature default silently reaches the OpenRouter chains - The two createFallback call sites for auto/free and the OpenRouter primary+free chain pass no modelCallOptions, so their fallbacks now lose the catalog temperature as well. Impact looks small (catalog temperatures for those models are the provider defaults), but it is a behaviour change outside the stated scope and is not mentioned in the description. (src/ipc/utils/get_model_client.ts)
  • Extra catalog/DB round-trips per request - Each auto-mode client creation now performs two additional findLanguageModel lookups per alias (getTemperature + getMaxTokens), each of which queries the DB and loads the builtin catalog. They run in parallel, but this is on the hot path of every auto-mode stream. (src/ipc/utils/get_model_client.ts)
  • providerOptions merge is shallow at the family key - {...options.providerOptions, ...overrides.providerOptions} replaces a whole family entry rather than merging within it, so any caller-supplied field the override does not restate is dropped. That is fine for today's shapes but is a sharp edge worth a comment at the merge site. (src/ipc/utils/fallback_ai_model.ts)
  • Always emits a providerOptions object - The override branch sets providerOptions even when both the caller's and the override's are absent, producing providerOptions: {} where the primary would have had undefined. Harmless today, but it makes the fallback and primary request shapes differ needlessly. (src/ipc/utils/fallback_ai_model.ts)

Generated by Dyadbot persona-based code review

@github-actions

Copy link
Copy Markdown
Contributor

🎭 Playwright Test Results

βœ… All tests passed!

OS Passed Flaky Skipped
🍎 macOS 289 0 12

Total: 289 tests passed (12 skipped)

πŸ“Š View full report

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-human:review-issue ai agent flagged an issue that requires human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant