Skip to content

force-model resolves provider from catalog Providers[0], ignoring excluded/unregistered providers; pin silently dropped next turn #874

Description

@NathanLively

Summary

/force-model <name> (and the x-weave-force-model header) resolve the model's provider as the catalog's first provider binding (Model.Providers[0]), unconditionally — with no check against which providers are actually registered/enabled on this deployment. If binding [0] belongs to an excluded or unregistered provider, the pin silently points at a dead provider. The very next turn, the eviction logic treats the dead pin as reason:"no_pin" and the cluster scorer overwrites it with an automatic decision — with no error or warning surfaced anywhere. The force-model ack message reports success the whole time.

Repro

Deployment config: ANTHROPIC_API_KEY + OPENROUTER_API_KEY set, ROUTER_EXCLUDED_PROVIDERS=makora.

deepseek/deepseek-v4-flash has two catalog bindings — makora (index 0, excluded) and openrouter (available). Automatic routing correctly falls over to the openrouter binding (resolveProviderFor in internal/router/cluster/scorer.go re-walks bindings against the live provider set). Force-model does not do this.

❯ /fm deepseek/deepseek-v4-flash
✦ Weave Router → force-model applied: deepseek/deepseek-v4-flash (makora) · Use /unforce-model to clear

❯ what's 1+1?
✦ Weave Router → claude-opus-5 · best pick for this turn
2

Server logs for the second turn:

turnloop pin lookup hit   pin_model=deepseek/deepseek-v4-flash pin_provider=makora pin_reason=user_forced
turnloop scorer decision  fresh_model=claude-opus-5 fresh_provider=anthropic
router switched models    from=deepseek/deepseek-v4-flash to=claude-opus-5 reason=no_pin

Root cause

internal/proxy/force_model.go, resolveForceModelWithEffort:

if m, ok := catalog.ByID(model); ok && len(m.Providers) > 0 && (requiredProvider == "" || m.Providers[0].Provider == requiredProvider) {
    return m.ID, m.Providers[0].Provider, true, effort
}
...
if matches == 1 && len(matched.Providers) > 0 {
    return matched.ID, matched.Providers[0].Provider, true, effort
}

Both return paths hard-pick Providers[0] with no awareness of the deployment's registered/excluded provider set. Contrast with the automatic routing path (internal/router/cluster/scorer.go), which resolves a model's provider via resolveProviderFor(model, provider, req.EnabledProviders) against the live set on every request.

Downstream compounding bug: whatever consumes the pin on the next turn treats a pin whose provider isn't in the available set as equivalent to no pin at all (reason:"no_pin" in the "router switched models" log), rather than erroring or logging a warning. The user sees a normal-looking automatic routing message with no indication the force-model pin was dropped.

Suggested fix

  1. resolveForceModelWithEffort should accept (or be handed) the deployment's enabled-provider set and pick the first binding that's actually available, matching resolveProviderFor's behavior — falling back to an explicit "no available provider for this model" response instead of a wrong/dead one.
  2. When a stored pin's provider isn't in the available set, that should surface as a loud, visible condition (log at Warn/Error, and ideally a user-visible router message like ✦ Weave Router → force-model pin unavailable (provider excluded); resuming automatic selection) rather than a silent no_pin fallback.

Environment

  • ROUTER_DEPLOYMENT_MODE=selfhosted, docker compose
  • Providers: anthropic (env key), openrouter (env key), makora (excluded via ROUTER_EXCLUDED_PROVIDERS)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions