Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions cmd/router/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,11 @@ func main() {
// below can be overridden per deployment.
plannerEnabled := config.GetOr("ROUTER_PLANNER_ENABLED", "true") == "true"
scoreToolResultTurns := config.GetOr("ROUTER_SCORE_TOOL_RESULT_TURNS", "true") == "true"
// ROUTER_TOOL_RESULT_TIER_CEILING (default off): excludes above-tier models
// from ToolResult turns before the scorer runs, capping candidates at or
// below the turn's requested-model tier. Eval/debug lever — see
// Service.applyToolResultTierCeiling.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// ROUTER_TOOL_RESULT_TIER_CEILING (default off): excludes above-tier models
// from ToolResult turns before the scorer runs, capping candidates at or
// below the turn's requested-model tier. Eval/debug lever — see
// Service.applyToolResultTierCeiling.
// ROUTER_TOOL_RESULT_TIER_CEILING: caps ToolResult candidates at the requested-model tier (default off).

Was 4 lines restating the env-var name; the key WHY fits in one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — collapsed to a single why line in 3e926b8, matching the terseness of the neighbouring flag lines. The env var name is on the assignment immediately below, so the comment no longer repeats it.

toolResultTierCeiling := config.GetOr("ROUTER_TOOL_RESULT_TIER_CEILING", "false") == "true"
// Defensive backstop for Anthropic's cyber safety classifier: re-pin a
// session off a model that returned a safety refusal. Default off; enabled
// on the defensive deploy. Fallback target when the pin has no runner-up.
Expand Down Expand Up @@ -771,6 +776,7 @@ func main() {
WithSubAgentOverride(subAgentProvider, subAgentModel).
WithPlannerEnabled(plannerEnabled).
WithScoreToolResultTurns(scoreToolResultTurns).
WithToolResultTierCeiling(toolResultTierCeiling).
WithCyberRefusalRepin(cyberRefusalRepin).
WithCyberRefusalFallbackModel(cyberRefusalFallbackModel).
WithPrefixTrimFreeSwitch(prefixTrimFreeSwitch).
Expand Down Expand Up @@ -846,6 +852,18 @@ func main() {
logger.Info("Provider exclusion override active", "excluded_providers", cleaned)
}

if respectRaw := strings.TrimSpace(config.GetOr("ROUTER_RESPECT_REQUESTED_MODEL", "")); respectRaw != "" {
parts := strings.Split(respectRaw, ",")
cleaned := make([]string, 0, len(parts))
for _, p := range parts {
if trimmed := strings.TrimSpace(p); trimmed != "" {
cleaned = append(cleaned, trimmed)
}
}
proxySvc = proxySvc.WithRespectRequestedModel(cleaned)
logger.Info("Requested-model passthrough active", "honored_models", cleaned)
}

// The usage observer is always wired (cheap, side-effect-free) even though
// the cost discount below is env-gated: it also feeds the per-installation
// usage-bypass gate, which is DB-gated and can't know the env flag's state.
Expand Down Expand Up @@ -1070,6 +1088,13 @@ func buildClusterScorer(availableProviders map[string]struct{}) (router.Router,
logger.Info("Cluster top_p overridden", "top_p", n)
}
}
if v := strings.TrimSpace(config.GetOr("ROUTER_STATIC_CLUSTER_PIN", "")); v != "" {
pins := parseStaticClusterPins(v, logger)
if len(pins) > 0 {
cfg.StaticClusterPin = pins
logger.Info("Static cluster pin active", "pins", pins)
}
}
scorers := make(map[string]*cluster.Scorer, len(versions))
warmed := make(map[string]cluster.Embedder)
var defaultEmbedderID string
Expand Down
38 changes: 38 additions & 0 deletions cmd/router/static_cluster_pin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package main

import (
"fmt"
"log/slog"
"strconv"
"strings"

"workweave/router/internal/router/catalog"
)

func parseStaticClusterPins(raw string, logger *slog.Logger) map[int]string {
pins := make(map[int]string)
for _, pair := range strings.Split(raw, ",") {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
clusterID, model, found := strings.Cut(pair, ":")
if !found || strings.TrimSpace(model) == "" {
panic(fmt.Sprintf("invalid ROUTER_STATIC_CLUSTER_PIN entry %q: expected cluster_id:model", pair))
}
id, err := strconv.Atoi(strings.TrimSpace(clusterID))
if err != nil {
panic(fmt.Sprintf("invalid ROUTER_STATIC_CLUSTER_PIN cluster ID in %q", pair))
}
model = strings.TrimSpace(model)
if _, known := catalog.ByID(model); !known {
logger.Warn("Static cluster pin model is not in catalog; skipping",
"cluster_id", id,
"model", model,
)
continue
}
pins[id] = model
}
return pins
}
26 changes: 26 additions & 0 deletions cmd/router/static_cluster_pin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package main

import (
"io"
"log/slog"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestParseStaticClusterPinsSkipsUnknownModels(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))

got := parseStaticClusterPins("0:claude-haiku-4-5,1:not-in-catalog", logger)

require.Equal(t, map[int]string{0: "claude-haiku-4-5"}, got)
}

func TestParseStaticClusterPinsPanicsOnMalformedEntry(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))

assert.Panics(t, func() {
parseStaticClusterPins("not-a-pair", logger)
})
}
3 changes: 3 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ Set `DATABASE_URL` directly, or compose it from the individual vars:
| `ROUTER_HARD_PIN_EXPLORE` | `true` | Pin Claude Code Task-tool sub-agent turns to `ROUTER_HARD_PIN_MODEL`/`ROUTER_HARD_PIN_PROVIDER` (or the cheapest deployed model, if those are unset). Set `false` to route sub-agents through the scorer like any other turn. |
| `ROUTER_SUBAGENT_MODEL` | *(none)* | Route Claude Code Task-tool sub-agent turns to a distinct model, independent of `ROUTER_HARD_PIN_MODEL` — e.g. a local/self-hosted OpenAI-compatible model (point `OPENROUTER_BASE_URL` at your local server) while the main loop keeps using Anthropic/whatever the scorer picks. Requires `ROUTER_SUBAGENT_PROVIDER`; either alone is ignored. Takes effect regardless of `ROUTER_HARD_PIN_EXPLORE`, but the HMM strategy keeps its own sub-agent handling and isn't affected. |
| `ROUTER_SUBAGENT_PROVIDER` | *(none)* | Pair with `ROUTER_SUBAGENT_MODEL`. |
| `ROUTER_RESPECT_REQUESTED_MODEL` | *(none)* | Comma-separated models served verbatim when requested, bypassing planner and scorer. Automatic hard pins still win; an explicit force-model override also wins over this list. Excluded or ineligible honored models fall back to automatic routing. |
| `ROUTER_STATIC_CLUSTER_PIN` | *(none)* | `clusterID:model` pairs, comma-separated. A nearest-cluster match skips `blendScoresV2`; an ineligible pinned model falls through to normal routing. Malformed pairs or cluster IDs abort startup; valid pins naming models absent from the catalog are warned and skipped. `Reason` is tagged `cluster-pin:`. |
| `ROUTER_TOOL_RESULT_TIER_CEILING` | `false` | On `ToolResult` turns, excludes models above the requested model's tier on Messages, OpenAI Chat Completions, and Gemini surfaces. It is a no-op when no at-or-below-tier model survives, and a session-pin fit-check cannot lift its exclusions. Unknown requested-model tiers are a no-op. |
| `ROUTER_TRANSLATION_COMPATIBILITY_MODE` | `shadow` | Translation representability rollout: `off` disables broad filtering, `shadow` records candidate exclusions without changing routes, and `enforce` makes declared semantic requirements hard routing constraints. Native-only safety paths (such as unsupported Responses tool unions and native Gemini ingress) remain protected unless mode is `off`. |
| `ROUTER_COMPACTION_PCT` | `0.85` | Fraction of the largest eligible model's context window at which the proactive compaction cascade engages (clear old tool results → structured summary → trim). Range `(0,1]`; `0` disables compaction (over-window requests then 413). Mirrors Claude Code's ~0.85 auto-compact trigger. |
| `ROUTER_ONNX_ASSETS_DIR` | `/opt/router/assets` | Directory containing `model.onnx` + `tokenizer.json`. |
Expand Down
7 changes: 6 additions & 1 deletion internal/proxy/force_model_tier_fallback_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
type tierProbeRouter struct {
available map[string]struct{}
captured []router.Request
provider string
}

func (r *tierProbeRouter) Route(_ context.Context, req router.Request) (router.Decision, error) {
Expand All @@ -41,7 +42,11 @@ func (r *tierProbeRouter) Route(_ context.Context, req router.Request) (router.D
if best == "" {
return router.Decision{}, errors.New("no eligible candidate")
}
return router.Decision{Provider: providers.ProviderAnthropic, Model: best, Reason: "fake"}, nil
provider := r.provider
if provider == "" {
provider = providers.ProviderAnthropic
}
return router.Decision{Provider: provider, Model: best, Reason: "fake"}, nil
}

// forcedPinStore returns a single user-forced pin for every lookup.
Expand Down
Loading
Loading