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
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.
- 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)
Summary
/force-model <name>(and thex-weave-force-modelheader) 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 asreason:"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_KEYset,ROUTER_EXCLUDED_PROVIDERS=makora.deepseek/deepseek-v4-flashhas two catalog bindings —makora(index 0, excluded) andopenrouter(available). Automatic routing correctly falls over to theopenrouterbinding (resolveProviderForininternal/router/cluster/scorer.gore-walks bindings against the live provider set). Force-model does not do this.Server logs for the second turn:
Root cause
internal/proxy/force_model.go,resolveForceModelWithEffort: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 viaresolveProviderFor(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
resolveForceModelWithEffortshould accept (or be handed) the deployment's enabled-provider set and pick the first binding that's actually available, matchingresolveProviderFor's behavior — falling back to an explicit "no available provider for this model" response instead of a wrong/dead one.Warn/Error, and ideally a user-visible router message like✦ Weave Router → force-model pin unavailable (provider excluded); resuming automatic selection) rather than a silentno_pinfallback.Environment
ROUTER_DEPLOYMENT_MODE=selfhosted, docker composeanthropic(env key),openrouter(env key),makora(excluded viaROUTER_EXCLUDED_PROVIDERS)