diff --git a/internal/proxy/agent_shadow_eval.go b/internal/proxy/agent_shadow_eval.go index fbe16cc30..c3666586f 100644 --- a/internal/proxy/agent_shadow_eval.go +++ b/internal/proxy/agent_shadow_eval.go @@ -50,7 +50,8 @@ func (s *Service) runAgentShadowEvaluationRoute( return turnLoopResult{}, err } rawModel := strings.TrimSpace(evaluation.Model) - model, provider, known, _ := resolveForceModelWithEffort(rawModel) + // This call site applies its own availability gate below. + model, provider, known, _ := resolveForceModelWithEffort(rawModel, nil) if !known || model != strings.ToLower(rawModel) { return turnLoopResult{}, fmt.Errorf("agent-shadow model must be a canonical catalog id: %q", rawModel) } diff --git a/internal/proxy/force_model.go b/internal/proxy/force_model.go index 886c70090..06d581579 100644 --- a/internal/proxy/force_model.go +++ b/internal/proxy/force_model.go @@ -110,17 +110,16 @@ var forceModelAliases = map[string]string{ "mistral": "mistralai/mistral-small-2603", } -// resolveForceModel is the legacy two-return surface. New pin-and-effort -// callers use resolveForceModelWithEffort. -func resolveForceModel(model string) (canonicalID, provider string, known bool) { - canon, prov, kn, _ := resolveForceModelWithEffort(model) +// resolveForceModel is the legacy two-return surface; nil available means +// unrestricted. New pin-and-effort callers use resolveForceModelWithEffort. +func resolveForceModel(model string, available map[string]struct{}) (canonicalID, provider string, known bool) { + canon, prov, kn, _ := resolveForceModelWithEffort(model, available) return canon, prov, kn } -// resolveForceModelWithEffort is like resolveForceModel but also strips a -// `:level` suffix. `known` is true only for catalog matches; known=false + -// effort!="" lets callers surface "model not found" without losing the effort. -func resolveForceModelWithEffort(model string) (canonicalID, provider string, known bool, effort string) { +// resolveForceModelWithEffort strips a `:level` suffix and resolves catalog +// bindings in order against available; known stays true when no binding is available. +func resolveForceModelWithEffort(model string, available map[string]struct{}) (canonicalID, provider string, known bool, effort string) { effortLevel, stripped := stripEffortSuffix(model) model = stripped model = strings.ToLower(strings.TrimSpace(model)) @@ -135,21 +134,25 @@ func resolveForceModelWithEffort(model string) (canonicalID, provider string, kn if alias, ok := forceModelAliases[model]; ok { model = alias } - 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 m, ok := catalog.ByID(model); ok && len(m.Providers) > 0 { + if bound, nativeMismatch := resolveForcedBinding(m, requiredProvider, available); !nativeMismatch { + return m.ID, bound, true, effort + } } if !strings.Contains(model, "/") { suffix := "/" + model var matched catalog.Model var matches int for _, m := range catalog.Models { - if strings.HasSuffix(m.ID, suffix) && len(m.Providers) > 0 && (requiredProvider == "" || m.Providers[0].Provider == requiredProvider) { + if strings.HasSuffix(m.ID, suffix) && len(m.Providers) > 0 { matched = m matches++ } } - if matches == 1 && len(matched.Providers) > 0 { - return matched.ID, matched.Providers[0].Provider, true, effort + if matches == 1 { + if bound, nativeMismatch := resolveForcedBinding(matched, requiredProvider, available); !nativeMismatch { + return matched.ID, bound, true, effort + } } } if requiredProvider != "" { @@ -171,6 +174,33 @@ func resolveForceModelWithEffort(model string) (canonicalID, provider string, kn } } +// resolveForcedBinding walks bindings in catalog order against available. +// nativeMismatch is true when requiredProvider has no binding; nil available is unrestricted. +func resolveForcedBinding(m catalog.Model, requiredProvider string, available map[string]struct{}) (provider string, nativeMismatch bool) { + bindings := m.Providers + if requiredProvider != "" { + filtered := make([]catalog.ProviderBinding, 0, len(bindings)) + for _, b := range bindings { + if b.Provider == requiredProvider { + filtered = append(filtered, b) + } + } + if len(filtered) == 0 { + return "", true + } + bindings = filtered + } + if available == nil { + return bindings[0].Provider, false + } + for _, b := range bindings { + if _, ok := available[b.Provider]; ok { + return b.Provider, false + } + } + return "", false +} + // stripEffortSuffix splits a `:level` suffix off model, canonicalizes it via // CanonicalizeEffort, and returns ("", model) when no recognized suffix found. func stripEffortSuffix(model string) (effort string, modelOut string) { @@ -236,26 +266,22 @@ func (s *Service) setForceModelPin( return s.pinStore.Upsert(context.Background(), forced) } -// applyForceModelHeader honors the x-weave-force-model request header, -// writing the same session pin the /force-model command writes. It's -// (re)written on every request carrying the header. Unrecognized models are -// ignored (routing proceeds automatically) rather than failing the request. -// -// A `:level` suffix is stashed on context as router.Overrides.ForceEffort -// so pin + effort land in one header. +// applyForceModelHeader pins against live provider availability (#874). A +// `:level` suffix is stored in routing context so pin and effort apply together. func (s *Service) applyForceModelHeader( ctx context.Context, r *http.Request, env *translate.RequestEnvelope, installationID uuid.UUID, sessionKey [sessionpin.SessionKeyLen]byte, + enabledProviders map[string]struct{}, ) string { raw := strings.TrimSpace(r.Header.Get(ForceModelHeader)) if raw == "" { return "" } log := observability.FromContext(ctx) - canonicalModel, provider, known, effortLevel := resolveForceModelWithEffort(raw) + canonicalModel, provider, known, effortLevel := resolveForceModelWithEffort(raw, enabledProviders) if effortLevel != "" { // Merge with any existing knobs so ForceEffort doesn't drop Alpha/QualityBias. merged := router.Overrides{ForceEffort: effortLevel} @@ -278,6 +304,16 @@ func (s *Service) applyForceModelHeader( ) return "" } + if provider == "" { + // No available binding; do not pin an unusable provider (#874). + log.Warn("x-weave-force-model: no available provider for model; routing automatically", + "input_model", raw, + "canonical_model", canonicalModel, + "enabled_providers", sortedEnabledKeys(enabledProviders), + "session_key_hex", fmt.Sprintf("%x", sessionKey), + ) + return "" + } if s.pinStore == nil { return canonicalModel } @@ -297,11 +333,8 @@ func (s *Service) applyForceModelHeader( return canonicalModel } -// handleForceModelCommand processes a /force-model or /unforce-model directive: -// writes (or expires) the session pin and returns a synthetic acknowledgment -// response without dispatching upstream. inputTokens should be the request's -// RoutingFeatures.Tokens so the token counter reflects actual turn input, not -// just the synthetic response text. +// handleForceModelCommand writes or expires a session pin and returns a +// synthetic acknowledgment. nil enabledProviders means unrestricted. func (s *Service) handleForceModelCommand( ctx context.Context, w http.ResponseWriter, @@ -310,6 +343,7 @@ func (s *Service) handleForceModelCommand( installationID uuid.UUID, sessionKey [sessionpin.SessionKeyLen]byte, inputTokens int, + enabledProviders map[string]struct{}, ) error { log := observability.FromContext(ctx) role := roleForTier(catalog.TierFor(env.Model())) @@ -318,7 +352,8 @@ func (s *Service) handleForceModelCommand( // StripRoutingMarkerFromMessages strips it from later inbound requests; // otherwise it'd persist in history and leak router internals upstream. var msg string - if cmd.Clear { + switch { + case cmd.Clear: if s.pinStore != nil && installationID != uuid.Nil { if err := s.expireSessionPin(ctx, installationID, sessionKey, role, "user_unforced"); err != nil { log.Error("/unforce-model: pin store upsert failed", "err", err) @@ -334,34 +369,50 @@ func (s *Service) handleForceModelCommand( "session_key_hex", fmt.Sprintf("%x", sessionKey), "role", role, ) - } else if canonicalModel, provider, known := resolveForceModel(cmd.Model); !known { - // Not in the catalog (e.g. truncated "/force-model gpt-") — reject - // rather than pin something we can't honor; prior pin left untouched. - log.Info("/force-model: rejected unknown model", - "input_model", cmd.Model, - "session_key_hex", fmt.Sprintf("%x", sessionKey), - "role", role, - ) - msg = fmt.Sprintf("✦ **Weave Router** → force-model: %q isn't a recognized model · keeping automatic routing. Use a full model ID, e.g. claude-opus-5, gpt-5.5, or gemini-3-pro-preview.\n\n", cmd.Model) - if env.SourceFormat() == translate.FormatOpenAI { - msg = fmt.Sprintf("Weave Router: force-model: %q isn't a recognized model; keeping automatic routing. Use a full model ID, e.g. claude-opus-5, gpt-5.5, or gemini-3-pro-preview.", cmd.Model) - } - } else { - if err := s.setForceModelPin(ctx, sessionKey, role, installationID, canonicalModel, provider); err != nil { - log.Error("/force-model: pin store upsert failed", "err", err) - return err - } - msg = fmt.Sprintf("✦ **Weave Router** → force-model applied: %s (%s) · Use /unforce-model to clear\n\n", canonicalModel, provider) - if env.SourceFormat() == translate.FormatOpenAI { - msg = fmt.Sprintf("Weave Router: force-model applied: %s (%s). Use /unforce-model to clear.", canonicalModel, provider) + default: + canonicalModel, provider, known := resolveForceModel(cmd.Model, enabledProviders) + switch { + case !known: + // Reject unknown input rather than pinning it. + log.Info("/force-model: rejected unknown model", + "input_model", cmd.Model, + "session_key_hex", fmt.Sprintf("%x", sessionKey), + "role", role, + ) + msg = fmt.Sprintf("✦ **Weave Router** → force-model: %q isn't a recognized model · keeping automatic routing. Use a full model ID, e.g. claude-opus-5, gpt-5.5, or gemini-3-pro-preview.\n\n", cmd.Model) + if env.SourceFormat() == translate.FormatOpenAI { + msg = fmt.Sprintf("Weave Router: force-model: %q isn't a recognized model; keeping automatic routing. Use a full model ID, e.g. claude-opus-5, gpt-5.5, or gemini-3-pro-preview.", cmd.Model) + } + case provider == "": + // Reject recognized models with no available binding (#874). + log.Warn("/force-model: rejected model with no available provider", + "input_model", cmd.Model, + "canonical_model", canonicalModel, + "enabled_providers", sortedEnabledKeys(enabledProviders), + "session_key_hex", fmt.Sprintf("%x", sessionKey), + "role", role, + ) + msg = fmt.Sprintf("✦ **Weave Router** → force-model: %s has no available provider on this deployment · keeping automatic routing\n\n", canonicalModel) + if env.SourceFormat() == translate.FormatOpenAI { + msg = fmt.Sprintf("Weave Router: force-model: %s has no available provider on this deployment; keeping automatic routing.", canonicalModel) + } + default: + if err := s.setForceModelPin(ctx, sessionKey, role, installationID, canonicalModel, provider); err != nil { + log.Error("/force-model: pin store upsert failed", "err", err) + return err + } + msg = fmt.Sprintf("✦ **Weave Router** → force-model applied: %s (%s) · Use /unforce-model to clear\n\n", canonicalModel, provider) + if env.SourceFormat() == translate.FormatOpenAI { + msg = fmt.Sprintf("Weave Router: force-model applied: %s (%s). Use /unforce-model to clear.", canonicalModel, provider) + } + log.Debug("/force-model: session pin set", + "input_model", cmd.Model, + "canonical_model", canonicalModel, + "provider", provider, + "session_key_hex", fmt.Sprintf("%x", sessionKey), + "role", role, + ) } - log.Debug("/force-model: session pin set", - "input_model", cmd.Model, - "canonical_model", canonicalModel, - "provider", provider, - "session_key_hex", fmt.Sprintf("%x", sessionKey), - "role", role, - ) } switch env.SourceFormat() { diff --git a/internal/proxy/force_model_internal_test.go b/internal/proxy/force_model_internal_test.go index be6eb7a1c..a2f65a494 100644 --- a/internal/proxy/force_model_internal_test.go +++ b/internal/proxy/force_model_internal_test.go @@ -12,6 +12,7 @@ func TestResolveForceModel(t *testing.T) { tests := []struct { name string input string + available map[string]struct{} wantID string wantProvider string wantKnown bool @@ -171,11 +172,37 @@ func TestResolveForceModel(t *testing.T) { wantProvider: providers.ProviderOpenAI, wantKnown: false, }, + // Availability-aware resolution follows the ordered binding walk (#874). + { + name: "deepseek-flash falls over to available fallback binding when primary excluded", + input: "deepseek-flash", + available: map[string]struct{}{providers.ProviderOpenRouter: {}}, + wantID: "deepseek/deepseek-v4-flash", + wantProvider: providers.ProviderOpenRouter, + wantKnown: true, + }, + { + name: "deepseek-flash resolves to primary binding when it is available", + input: "deepseek-flash", + available: map[string]struct{}{providers.ProviderMakora: {}, providers.ProviderOpenRouter: {}}, + wantID: "deepseek/deepseek-v4-flash", + wantProvider: providers.ProviderMakora, + wantKnown: true, + }, + { + name: "known model with no available provider resolves with empty provider", + input: "deepseek-flash", + available: map[string]struct{}{providers.ProviderFireworks: {}}, + wantID: "deepseek/deepseek-v4-flash", + // Empty provider means recognized but currently unservable. + wantProvider: "", + wantKnown: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotID, gotProvider, gotKnown := resolveForceModel(tt.input) + gotID, gotProvider, gotKnown := resolveForceModel(tt.input, tt.available) assert.Equal(t, tt.wantID, gotID, "canonical id") assert.Equal(t, tt.wantProvider, gotProvider, "provider") assert.Equal(t, tt.wantKnown, gotKnown, "known") diff --git a/internal/proxy/force_model_provider_unavailable_internal_test.go b/internal/proxy/force_model_provider_unavailable_internal_test.go new file mode 100644 index 000000000..76d61a540 --- /dev/null +++ b/internal/proxy/force_model_provider_unavailable_internal_test.go @@ -0,0 +1,49 @@ +package proxy + +import ( + "context" + "testing" + "time" + + "workweave/router/internal/providers" + "workweave/router/internal/router" + "workweave/router/internal/router/sessionpin" + "workweave/router/internal/translate" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A pin with an unavailable provider falls through to automatic routing (#874). +func TestRunTurnLoop_ForcedPin_FallsThroughWhenProviderUnavailable(t *testing.T) { + fr := &tierProbeRouter{available: map[string]struct{}{ + "deepseek/deepseek-v4-flash": {}, + "claude-sonnet-4-6": {}, + }} + store := &forcedPinStore{pin: sessionpin.Pin{ + Provider: providers.ProviderMakora, + Model: "deepseek/deepseek-v4-flash", + Reason: translate.ReasonUserForceModel, + PinnedUntil: time.Now().Add(time.Hour), + }} + svc := NewService(fr, nil, nil, false, nil, store, false, + providers.ProviderAnthropic, "claude-haiku-4-5", nil). + WithAvailableModels(fr.available). + WithPlannerEnabled(false) + + env, err := translate.ParseAnthropic([]byte(`{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"hi"}]}`)) + require.NoError(t, err) + feats := env.RoutingFeatures(false) + + res, err := svc.runTurnLoop(context.Background(), env, feats, "key-1", uuid.New(), "", nil, router.Request{ + RequestedModel: feats.Model, + // Makora is excluded from this turn's enabled set. + EnabledProviders: map[string]struct{}{providers.ProviderOpenRouter: {}, providers.ProviderAnthropic: {}}, + }) + require.NoError(t, err) + + assert.False(t, res.StickyHit, "a pin on an unavailable provider must not be served as a sticky hit") + assert.NotEqual(t, providers.ProviderMakora, res.Decision.Provider, + "routing must not serve the excluded provider") +} diff --git a/internal/proxy/force_model_tier_fallback_internal_test.go b/internal/proxy/force_model_tier_fallback_internal_test.go index 71d60c4f5..d10542a44 100644 --- a/internal/proxy/force_model_tier_fallback_internal_test.go +++ b/internal/proxy/force_model_tier_fallback_internal_test.go @@ -286,7 +286,7 @@ func TestForceModelHeader_OverridesHardPin(t *testing.T) { headerReq, err := http.NewRequest(http.MethodPost, "/v1/messages", nil) require.NoError(t, err) headerReq.Header.Set(ForceModelHeader, "opus") - svc.applyForceModelHeader(context.Background(), headerReq, env, uuid.New(), key) + svc.applyForceModelHeader(context.Background(), headerReq, env, uuid.New(), key, nil) feats := env.RoutingFeatures(false) res, err := svc.runTurnLoop(context.Background(), env, feats, "key-1", uuid.New(), "", headerReq.Header, router.Request{ diff --git a/internal/proxy/service.go b/internal/proxy/service.go index 6f26c28d6..b93783120 100644 --- a/internal/proxy/service.go +++ b/internal/proxy/service.go @@ -2144,6 +2144,13 @@ func (s *Service) ProxyMessages(ctx context.Context, body []byte, w http.Respons "prompt_preview", observability.Preview(promptText, 200), ) + // Resolved before force-model handling so a pin can only land on a + // provider this deployment can actually serve (#874). + enabledProviders := s.enabledProvidersForRequest(ctx, providers.ProviderAnthropic, r.Header) + if billing.SubscriptionOnlyFromContext(ctx) { + enabledProviders = restrictToSubscriptionProviders(ctx, r.Header, enabledProviders) + } + // Handle /force-model and /unforce-model before routing (stripped from // env.body so the upstream never sees it). Session key is derived before // extraction: DeriveSessionKey can fall back to prompt text, and deriving @@ -2151,7 +2158,7 @@ func (s *Service) ProxyMessages(ctx context.Context, body []byte, w http.Respons if !agentShadowMode && s.pinStore != nil { if cmd, hasCmd := env.ExtractForceModelCommand(); hasCmd { log.Info("ProxyMessages force-model command", "force_model_cmd", cmd) - return s.handleForceModelCommand(ctx, w, env, cmd, installationID, sessionKey, feats.Tokens) + return s.handleForceModelCommand(ctx, w, env, cmd, installationID, sessionKey, feats.Tokens, enabledProviders) } } if !agentShadowMode { @@ -2166,7 +2173,7 @@ func (s *Service) ProxyMessages(ctx context.Context, body []byte, w http.Respons // the pin up and serves the requested model on this same turn. forceModel := "" if !agentShadowMode { - forceModel = s.applyForceModelHeader(ctx, r, env, installationID, sessionKey) + forceModel = s.applyForceModelHeader(ctx, r, env, installationID, sessionKey, enabledProviders) } // Tool-call loop break: catches runaway OSS-model tool-call cycles (qwen3 @@ -2196,18 +2203,6 @@ func (s *Service) ProxyMessages(ctx context.Context, body []byte, w http.Respons // tool_result or an out-of-shape tool spec, without dumping the whole body. logInboundRequestDiagnostics(log, env) - // Anthropic packs sub-agent identity into metadata.user_id; the - // x-weave-subagent-type header is for non-Anthropic ingress only. - enabledProviders := s.enabledProvidersForRequest(ctx, providers.ProviderAnthropic, r.Header) - - // Subscription-only mode: restrict - // routing to the providers the caller's own subscription can serve, so the - // scorer can't pick a paid model. The post-routing guard below refuses if a - // turn (e.g. a hard-pin or force-model) still didn't resolve onto the sub. - if billing.SubscriptionOnlyFromContext(ctx) { - enabledProviders = restrictToSubscriptionProviders(ctx, r.Header, enabledProviders) - } - // Pre-filter models whose context window cannot fit this request. // FullTokenEstimate uses raw body bytes (÷5) to capture tool definitions, // tool calls, and tool results that feats.Tokens (text-only) misses. @@ -4296,6 +4291,13 @@ func (s *Service) ProxyOpenAIChatCompletion(ctx context.Context, body []byte, w "prompt_preview", observability.Preview(promptText, 200), ) + // Resolved before force-model handling so a pin can only land on a + // provider this deployment can actually serve (#874). + enabledProviders := s.enabledProvidersForRequest(ctx, providers.ProviderOpenAI, r.Header) + if billing.SubscriptionOnlyFromContext(ctx) { + enabledProviders = restrictToSubscriptionProviders(ctx, r.Header, enabledProviders) + } + // Handle /force-model and /unforce-model before routing (stripped from // env.body so the upstream never sees it). Session key is derived before // extraction: DeriveSessionKey can fall back to prompt text, and deriving @@ -4303,7 +4305,7 @@ func (s *Service) ProxyOpenAIChatCompletion(ctx context.Context, body []byte, w if s.pinStore != nil { if cmd, hasCmd := env.ExtractForceModelCommand(); hasCmd { log.Info("ProxyOpenAIChatCompletion force-model command", "force_model_cmd", cmd) - return s.handleForceModelCommand(ctx, w, env, cmd, installationID, sessionKey, feats.Tokens) + return s.handleForceModelCommand(ctx, w, env, cmd, installationID, sessionKey, feats.Tokens, enabledProviders) } } if cmd, hasCmd := env.ExtractRouterFeedbackCommand(); hasCmd { @@ -4314,7 +4316,7 @@ func (s *Service) ProxyOpenAIChatCompletion(ctx context.Context, body []byte, w // Honor the x-weave-force-model header (headless equivalent of /force-model). // Writes the user-forced pin and falls through to normal routing, which picks // the pin up and serves the requested model on this same turn. - forceModel := s.applyForceModelHeader(ctx, r, env, installationID, sessionKey) + forceModel := s.applyForceModelHeader(ctx, r, env, installationID, sessionKey, enabledProviders) // Wide cyclic re-read loop → escalate to opus (same path as the Anthropic // ingress). See detectCyclicToolCallLoop / handleLoopEscalation. @@ -4339,17 +4341,6 @@ func (s *Service) ProxyOpenAIChatCompletion(ctx context.Context, body []byte, w // OpenAI signals sub-agent identity via x-weave-subagent-type (no metadata.user_id). subAgentHint := r.Header.Get("x-weave-subagent-type") - enabledProviders := s.enabledProvidersForRequest(ctx, providers.ProviderOpenAI, r.Header) - - // Subscription-only mode: restrict - // routing to the providers the caller's own subscription can serve, so the - // scorer can't pick a paid model. Mirrors the Anthropic path's forced - // usage-bypass; the post-routing guard below refuses if it still can't serve - // on the subscription. - if billing.SubscriptionOnlyFromContext(ctx) { - enabledProviders = restrictToSubscriptionProviders(ctx, r.Header, enabledProviders) - } - // Codex (ChatGPT) subscription passthrough: ProxyOpenAIResponses stashed the // caller's original Responses body. Such turns skip the routing marker + // semantic cache below, and dispatch the verbatim body to the Codex diff --git a/internal/proxy/turnloop.go b/internal/proxy/turnloop.go index df9d7651c..335f7ed7e 100644 --- a/internal/proxy/turnloop.go +++ b/internal/proxy/turnloop.go @@ -552,6 +552,15 @@ func (s *Service) runTurnLoop( // to it below rather than losing the intent entirely. forcedTierFloor = catalog.TierFor(pin.Model) } + if !providerEligible { + // The pinned provider is unavailable; preserve the pin for later + // re-enablement while routing this turn automatically (#874). + log.Warn("turnloop: forced pin's provider unavailable; falling through to automatic routing", + "pin_model", pin.Model, + "pin_provider", pin.Provider, + "pin_reason", pin.Reason, + ) + } if !imageCapable { // The scorer's own image filter fails open when no image-capable // candidate survives, so make the drop explicit here instead of