Skip to content

v0.8.65: Resolve every provider/model switch through a ReadyRouteCandidate #3384

Description

@Hmbown

Goal

Make provider/model switching atomic. The TUI, slash commands, fallback switch, model picker, and engine restart path must resolve a complete route candidate before mutating app state, config state, persisted settings, compaction budgets, or engine clients.

This issue implements the route-resolution mutation gate described in #2608.

Architecture contract

A route candidate is runtime-derived. It is not a catalog row and not a naked (provider, model) pair.

Target shape:

pub struct ReadyRouteCandidate {
    pub provider_id: ProviderId,
    pub provider_kind: ProviderKind,
    pub logical_model: LogicalModelRef,
    pub canonical_model: Option<ModelId>,
    pub wire_model_id: WireModelId,
    pub endpoint: ResolvedEndpoint,
    pub auth: ResolvedAuthSource,
    pub protocol: RequestProtocol,
    pub capabilities: CapabilityProfile,
    pub pricing: Option<PricingSku>,
    pub validation: ValidationReport,
    pub config_snapshot: Config,
}

Only the route resolver may construct a ready candidate. Engine-facing code should move toward accepting this candidate rather than provider/model strings.

Route resolution must be driven by explicit user choice, saved config, Fleet role/slot/loadout policy, hard capability requirements, fallback policy, or an explicit user-enabled auto router. It must not inspect freeform prompt text and silently change provider, model, Fleet model class, or reasoning mode.

Wire Id And Namespace Rule

Provider-prefixed or organization-prefixed model strings are provider wire ids or namespace hints, not global provider-selection proof.

Examples:

  • deepseek-ai/DeepSeek-V4-Pro may be a Together wire id.
  • deepseek/deepseek-v4-pro may be an OpenRouter wire id.
  • anthropic/claude-* may be an aggregator wire id rather than direct Anthropic provider state.
  • A custom OpenAI-compatible endpoint may legitimately use a string that resembles a hosted catalog namespace.

The resolver must interpret these strings only inside provider scope unless the user explicitly invoked global search. Do not infer provider switching from deepseek-ai/, deepseek/, anthropic/, openai/, qwen/, or similar prefixes.

Resolution order

  1. Explicit invocation wins: --provider, --model, --base-url, request fields, or wrapper env values are highest priority and session-scoped unless explicitly saved.
  2. Resolve provider first: explicit provider, then saved/default provider, then legacy base URL inference, then default provider.
  3. Treat auto as an explicit opt-in sentinel, never a model ID. Resolve it from metadata/policy such as Fleet role, command purpose, capability requirements, cost/latency preference, and context limits. Do not resolve it by hidden prompt-content classification.
  4. If a concrete model is explicit:
    • try active provider first;
    • if no explicit provider was supplied, allow routing to a unique authenticated/catalog provider match;
    • if multiple providers match, reject as ambiguous and ask for a provider;
    • if no catalog match, keep active provider and apply that provider's pass-through/validation rules.
  5. If no explicit model:
    • use [providers.<active>].model first;
    • then root/project default only if coherent for active provider or endpoint is pass-through/custom;
    • then provider default.
  6. Map logical/canonical model to provider wire ID using provider-scoped offerings.
  7. Custom model IDs are never auto-picked. They are honored only when explicit or saved for that provider/endpoint.

Local rejection vs provider deferral

Reject locally only when CodeWhale is confident the route is invalid:

  • empty model or invalid provider;
  • ambiguous concrete model across multiple configured providers;
  • official DeepSeek provider with a non-DeepSeek model;
  • direct non-DeepSeek provider with a known foreign route, such as zai + deepseek-v4-pro;
  • a request wire protocol CodeWhale cannot serialize correctly.

Defer syntactically valid unknown IDs to provider API when provider is a hosted aggregator, local runtime, custom/OpenAI-compatible endpoint, or account/region/tier/live-catalog dependent.

Required behavior

  • /provider <target> with no model must not inherit stale previous-provider models.
  • /provider <target> <model> validates and normalizes within target provider scope before mutation.
  • /model cross-provider route rows, if present, must emit explicit provider-scoped refs.
  • Fallback chain switches go through the same resolver.
  • Persistence happens only after candidate resolution and client construction succeed.
  • On failure, provider, model, config, engine, header/footer/status, model pass-through flag, and compaction budget remain on the previous route.

Acceptance criteria

  • Add a route resolver API that returns ReadyRouteCandidate or structured validation errors.
  • Provider picker, slash /provider, fallback switch, and model-picker cross-provider rows all use that resolver.
  • Resolver tests cover explicit provider/model, implicit provider default, saved provider model, compatible root default, custom endpoint pass-through, ambiguous global match, prefixed wire-id namespace handling, no-hidden-prompt-router behavior, and direct-provider foreign-model rejection.
  • Rollback tests inject validation/client-construction failures and assert app/config/engine state is unchanged.
  • Route explanation includes logical model, wire model when different, endpoint, auth source class, wire protocol, capability summary, and validation messages.
  • No new per-provider/model match tables outside catalog/offering data.

Verification

cargo test -p codewhale-tui route_resolver
cargo test -p codewhale-tui config::tests::validate_route
cargo test -p codewhale-tui commands::groups::core::provider
cargo test -p codewhale-tui tui::model_picker

Manual smoke:

/provider openrouter
/model qwen/qwen3.6-max-preview
/provider together
/provider zai
/provider deepseek deepseek-v4-pro
/provider zai GLM-5.2

Expected: every switch either applies one coherent route or fails before any visible/runtime state changes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingenhancementNew feature or requestreliabilityReliability, flaky behavior, retries, fallbacks, and robustnesstuiTerminal UI behavior, rendering, or interactionv0.8.65Targeting v0.8.65

    Projects

    Status
    Done

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions