Skip to content

feat(router): make portfolio routing the Auto default - #238

Merged
VickyXAI merged 12 commits into
BlockRunAI:mainfrom
KillerQueen-Z:feat/router-v3-4-auto
Aug 7, 2026
Merged

feat(router): make portfolio routing the Auto default#238
VickyXAI merged 12 commits into
BlockRunAI:mainfrom
KillerQueen-Z:feat/router-v3-4-auto

Conversation

@KillerQueen-Z

@KillerQueen-Z KillerQueen-Z commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

  • make the deterministic Router v3.4 portfolio strategy the default for Auto requests
  • classify task shape locally, enforce tool, vision, structured-output, and context constraints, then rank an ordered fallback portfolio
  • extract the product-neutral implementation into BlockRunAI/router-core and pin this product to immutable commit 6a790eb
  • keep @blockrun/clawrouter/router as a backwards-compatible re-export for existing SDK consumers
  • inject ClawRouter's live model catalog capabilities into Router Core so the product catalog remains authoritative
  • retain strategy: "rules" as an immediate rollback and optional local-only shadow comparison with no second model call
  • bump the release version to 0.12.241

Why

The previous Auto path used a fixed tier primary after rule classification. The evaluated portfolio policy improves model selection for agent workflows while keeping routing local and deterministic. In the frozen 100-task, three-arm Franklin evaluation, the new policy scored 57% versus 49% for the previous router and reduced normalized cost per successful task by 6.44%.

Extracting the selector is also a cleaner ownership boundary: Router Core owns classification, hard constraints, portfolio scoring, and fallback ordering; ClawRouter owns its gateway, wallet, payment, proxy, session, and live model catalog. The benchmark remains a separate consumer and is not shipped in either product.

Safety and rollout

  • no benchmark settlement or grading code is included in production
  • no prompt is persisted by shadow mode
  • shadow mode never issues an additional paid completion
  • operators can roll back with routing.strategy = "rules"
  • tool_choice: "none" is authoritative, and host tool descriptions do not create false per-turn tool requirements
  • malformed performance telemetry and empty candidate overrides fail safely
  • explicit user pins stay sticky only while they satisfy hard context, exclusion, tool, and vision constraints
  • the Router Core dependency is an immutable, checksummed HTTPS tarball; it has zero runtime dependencies

Validation

  • Router Core: typecheck, build, package dry-run, and 94 / 94 tests passed
  • ClawRouter: typecheck and production build passed, including dist import smoke checks
  • ClawRouter product suites: 713 passed, 3 skipped
  • packed/build artifact smoke suite: 62 / 62 passed
  • real blockrun/auto Proxy request completed end to end; the existing insufficient-balance path correctly fell back to the configured free model
  • latest main model-lifecycle update is synchronized: the retired seed-oss-36b fallback was removed once in Router Core and inherited by both products
  • Franklin consumer validation against the independent package: 646 / 646 tests passed and 5 / 5 real Auto CLI E2E paths passed
  • 340k-character Router microbenchmark: 0.125 ms mean over 200 warm iterations on the local test machine

Summary by CodeRabbit

  • New Features

    • Added portfolio-based routing that considers task type, tools, vision, context limits, pricing, performance, and model affinity.
    • Added a public router package export and additional routing utilities and types.
    • Added optional shadow-routing telemetry without affecting the serving request.
    • Added model performance profiles and configurable routing strategies.
  • Bug Fixes

    • Improved fallback handling for capacity, pinned models, tools, compliance, and vision requirements.
    • Added clear errors when no eligible model can handle a request.
  • Tests

    • Expanded coverage for routing, fallback behavior, tool detection, capacity filtering, and shadow routing.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The router now uses the V3 portfolio strategy by default. It classifies requests, ranks capable models with pricing and performance data, supports tool inference and capacity-safe fallbacks, and exposes local shadow-routing comparisons.

Changes

Portfolio routing and proxy integration

Layer / File(s) Summary
Routing contracts and eligibility signals
src/router/types.ts, src/router/config.ts, src/router/model-profiles.ts, src/router/tool-intent.ts, src/router/selector.ts, src/router/*test.ts
Routing contracts now support portfolio decisions, task metadata, tool requirements, performance profiles, shadow settings, and capacity filtering.
Portfolio strategy and model scoring
src/router/portfolio.ts, src/router/index.ts, src/router/strategy.ts, src/router/*test.ts
PortfolioStrategy classifies requests, filters candidates, applies affinity and performance scoring, and returns ranked routing metadata. Portfolio routing is the default, with rules-based selection still configurable.
Proxy routing and shadow comparison
src/proxy.ts, src/index.ts, src/proxy.session-pinning.test.ts
The proxy passes request capability signals, builds capacity-safe fallbacks, and reports sampled shadow comparisons without sending a second upstream request.
Router package build and distribution
tsup.config.ts, package.json, scripts/smoke-dist.mjs, src/index.ts
The router bundle has a public package export. Distribution checks validate its runtime entry, route export, and portfolio default configuration.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Proxy
  participant route
  participant PortfolioStrategy
  participant UpstreamModel
  participant onShadowRouted
  Client->>Proxy: submit request with tools and output requirements
  Proxy->>route: provide routing signals
  route->>PortfolioStrategy: classify and rank candidates
  PortfolioStrategy-->>route: return selected model and fallbacks
  route-->>Proxy: return serving decision
  Proxy->>UpstreamModel: send one completion request
  Proxy->>route: evaluate sampled shadow strategy locally
  Proxy->>onShadowRouted: report routing comparison
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.85% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes making portfolio routing the default for Auto requests.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🧹 Nitpick comments (7)
scripts/smoke-dist.mjs (1)

70-78: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Validate the public ./router export.

This check imports dist/router/index.js directly. It bypasses package.json's ./router export and does not verify dist/router/index.d.ts. A broken export map or declaration path can pass this smoke test. Import @blockrun/clawrouter/router and check that the declaration file exists.

This follows the PR objective that the package export must resolve both runtime and declaration artifacts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/smoke-dist.mjs` around lines 70 - 78, Update the smoke test around
the router import to validate the public `@blockrun/clawrouter/router` export
instead of importing dist/router/index.js directly. Preserve the existing route
and default portfolio configuration checks, and additionally verify that
dist/router/index.d.ts exists so both runtime and declaration artifacts are
covered.
package.json (1)

24-27: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Put the types condition before import.

TypeScript conditional exports are order-sensitive. With import first, TypeScript can bypass the explicit declaration branch and rely on adjacent-file fallback. Put types first to make the public type contract unambiguous.

Suggested export order
     "./router": {
-      "import": "./dist/router/index.js",
-      "types": "./dist/router/index.d.ts"
+      "types": "./dist/router/index.d.ts",
+      "import": "./dist/router/index.js"
     }

Verify the result with a NodeNext TypeScript consumer of @blockrun/clawrouter/router.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` around lines 24 - 27, Update the "./router" conditional export
in package.json so the "types" condition precedes "import", while preserving
both existing targets. Verify the package with a NodeNext TypeScript consumer
importing `@blockrun/clawrouter/router`.
src/proxy.session-pinning.test.ts (1)

262-272: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert both shadow-routing methods.

The test can pass if both decisions use the same strategy. Assert that shadows[0].executed.method is "portfolio" and shadows[0].shadow.method is "rules".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/proxy.session-pinning.test.ts` around lines 262 - 272, Extend the
assertions in the session-pinning test to verify both shadow-routing decisions:
assert that shadows[0].executed.method equals "portfolio" and
shadows[0].shadow.method equals "rules", while preserving the existing shadow
payload assertions.

Source: Coding guidelines

src/router/portfolio.ts (3)

38-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the fallback weights from DEFAULT_ROUTING_CONFIG instead of duplicating them.

DEFAULT_PORTFOLIO_WEIGHTS repeats the exact values in src/router/config.ts Lines 15-43. Two sources of truth exist for the same tuned numbers. A future tuning change in config.ts leaves this fallback stale without any test failure, because the fallback only applies when options.config.portfolio is undefined.

config.ts imports only ./types.js, so importing it here introduces no module cycle.

♻️ Proposed deduplication
+import { DEFAULT_ROUTING_CONFIG } from "./config.js";
+
-const DEFAULT_PORTFOLIO_WEIGHTS = {
-  auto: { quality: 0.47, capability: 0.2, cost: 0.18, speed: 0.07, reliability: 0.03, legacy: 0.05 },
-  eco: { quality: 0.36, capability: 0.2, cost: 0.28, speed: 0.1, reliability: 0.04, legacy: 0.02 },
-  premium: { quality: 0.58, capability: 0.2, cost: 0.08, speed: 0.06, reliability: 0.06, legacy: 0.02 },
-  highStakesBoost: { quality: 0.08, reliability: 0.05 },
-  latencySensitiveSpeedBoost: 0.08,
-  affinityFloorGap: { auto: 0.1, eco: 0.22, premium: 0.05 },
-} as const;
+const DEFAULT_PORTFOLIO_WEIGHTS = DEFAULT_ROUTING_CONFIG.portfolio!;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/router/portfolio.ts` around lines 38 - 45, Replace the duplicated numeric
values in DEFAULT_PORTFOLIO_WEIGHTS with values derived from
DEFAULT_ROUTING_CONFIG imported from the router configuration module. Preserve
the existing portfolio shape, including highStakesBoost,
latencySensitiveSpeedBoost, and affinityFloorGap, while ensuring changes to
DEFAULT_ROUTING_CONFIG automatically update this fallback.

589-593: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Reuse the precomputed costs array, and note that capabilityScore cannot discriminate.

Two points in the scoring map:

  1. Line 591 recomputes estimatedCost(...) for each candidate. Line 582 already built the costs array over the same candidates in the same order. Use costs[index].

  2. Line 593 sets capabilityScore from isEligible(model, features, maxOutputTokens). When eligible.length > 0 at Line 562, every entry in candidates already passed isEligible. capabilityScore is then 1 for every candidate, so the capability weight (0.2 in all three profiles) adds the same constant to every score and never changes the ranking. The term only varies on the degenerate path where no model was eligible. Either document this or fold the weight into the discriminating dimensions.

♻️ Proposed reuse of `costs`
     const rankedEntries = candidates
       .map((model, index) => {
-        const cost = estimatedCost(model, options, features.estimatedInputTokens, maxOutputTokens);
+        const cost = costs[index];
         const costScore = Number.isFinite(cost) && maxCost > minCost ? 1 - (cost - minCost) / (maxCost - minCost) : 0.5;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/router/portfolio.ts` around lines 589 - 593, Update the ranking map in
rankedEntries to reuse costs[index] instead of recomputing estimatedCost for
each candidate. Since candidates is derived from eligible models,
capabilityScore is constant whenever candidates are non-empty; document this
invariant or remove/fold the capability weight so scoring only includes
discriminating dimensions, while preserving the no-eligible-model behavior.

632-640: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The tool_agent branch is dead code; both arms are identical.

The second and third arms of the ternary produce the same array:

[...scoredModels, ...eligibleCandidates.filter((model) => !scoredModels.includes(model))]

The condition features.taskType === "tool_agent" || (features.taskType === "tool_agent_parallel" && features.agentDomain !== "other") therefore has no effect on ranked. Either the branch is leftover from a refactor, or an intended tool-agent-specific ordering was lost.

♻️ Proposed simplification if no distinct ordering is intended
     const ranked = features.agentDomain === "web_research"
       ? [
           ...scoredModels,
           ...webResearchFallbackOrder.filter((model) => eligibleCandidates.includes(model) && !scoredModels.includes(model)),
           ...eligibleCandidates.filter((model) => !scoredModels.includes(model) && !webResearchFallbackOrder.includes(model)),
         ]
-      : features.taskType === "tool_agent" || (features.taskType === "tool_agent_parallel" && features.agentDomain !== "other")
-      ? [
-          ...scoredModels,
-          ...eligibleCandidates.filter((model) => !scoredModels.includes(model)),
-        ]
       : [
           ...scoredModels,
           ...eligibleCandidates.filter((model) => !scoredModels.includes(model)),
         ];

If a distinct tool-agent ordering was intended, tell me and I can draft it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/router/portfolio.ts` around lines 632 - 640, Remove the redundant ternary
condition around the ranked-model construction and assign the shared array
expression directly to the result. Update the surrounding logic in the visible
portfolio ranking flow so taskType and agentDomain no longer control identical
branches, while preserving the existing scoredModels-first ordering and
eligible-candidate deduplication.
src/router/portfolio.test.ts (1)

32-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the eco and premium routing profiles.

Every test in this file uses routingProfile: "auto" or omits the field, which defaults to auto at src/router/portfolio.ts Line 563. The new portfolio config block introduces materially different behavior per profile:

  • affinityFloorGap: auto 0.1, eco 0.22, premium 0.05.
  • Weight vectors differ, most notably cost (0.18 / 0.28 / 0.08) and quality (0.47 / 0.36 / 0.58).

None of that is exercised. A regression in the eco or premium weights would pass this suite. Add at least one test per profile that asserts a different selected model for the same prompt, so the profile weighting is locked in.

As per coding guidelines: "Use Vitest tests to cover error and lifecycle resilience, end-to-end tool ID sanitization, and Docker installation, edge-case, and integration behavior where applicable."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/router/portfolio.test.ts` around lines 32 - 48, Add Vitest coverage in
the PortfolioStrategy tests for both routingProfile "eco" and "premium", using
the same prompt and routing inputs while explicitly setting each profile. Assert
that each profile selects its expected model and that the selected models differ
from the auto-profile result, exercising the profile-specific weighting and
affinity behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/proxy.ts`:
- Around line 5088-5100: Update the candidate pipeline around
filterCandidatesByCapacity and every later prependStickyExplicitModel call so
filtering can remove an ineligible stickyExplicitModel without reintroducing it.
Keep the sticky model first before capability filtering, but only retain it
afterward if it remains in the filtered candidates; apply the same behavior
after exclusion, tool, and vision filters.
- Around line 4417-4424: The onShadowRouted telemetry currently reports
routingDecision before session pin resolution, so comparison.executed may not
identify the model that served the request. Move the onShadowRouted callback
until after the session-selection logic that can replace routingDecision, while
preserving its existing payload fields and comparison behavior.
- Around line 4389-4390: Update RulesStrategy’s structured-output classification
to honor requiresStructuredOutput in addition to JSON-related systemPrompt
checks, ensuring requests with response_format reach structuredOutputMinTier and
rules-based shadow results use the same determination.

In `@src/router/model-profiles.ts`:
- Around line 29-31: Move the liveProfiles import from
./model-profiles.generated.json above the exported LIVE_MODEL_PROFILES
declaration in model-profiles.ts, preserving the existing export implementation
and project import ordering.

In `@src/router/portfolio.ts`:
- Around line 103-107: Bound classification regex inputs in classifyTask using
the existing classifier.promptTruncationChars setting: derive truncated
scannedPrompt and scannedFullText values, use them for all feature regexes and
match-based signals including clueConnectors, multipleChoiceSignals, and
numericSignals, while keeping estimatedInputTokens based on the complete
fullText.
- Around line 1-8: Format all affected router files with Prettier: run it on
src/router/portfolio.ts, src/router/model-profiles.ts,
src/router/tool-intent.ts, src/router/tool-intent.test.ts, and
src/router/portfolio.test.ts, or the entire src/router directory. In
src/router/model-profiles.ts, move the trailing import to the top before
formatting; ensure PortfolioStrategy.route has consistent indentation and its
sort chain is properly terminated. The formatter should also wrap long regex
literals and remove the stray space in tool-intent.test.ts.
- Around line 530-539: Validate the parsed timestamp in the profile-scoring flow
before calculating ageDays: if Date.parse(profile.measuredAt) is not finite,
return undefined for that profile. Keep valid timestamps and all subsequent
freshness and ranking calculations unchanged.
- Around line 557-573: Guard the candidate-selection flow after constructing
`chain`: when no candidate exists, return the rules decision using `base.model`
before calculating affinity or indexing `eligibleCandidates`. Replace the unsafe
`base.tierConfigs!` access with appropriate handling for missing tier
configuration, while preserving the existing eligibility and affinity behavior
when candidates are available.

In `@src/router/tool-intent.test.ts`:
- Around line 19-28: Update inferToolRequirement to immediately return false
when toolChoice is "none", before prose matching, preserving required,
function-selection, and auto behavior. Extend the tool-choice tests with a
tool_choice "none" case using a tool-relevant prompt, and add coverage that
passes a non-empty systemPrompt to validate the existing routing behavior.

In `@src/router/tool-intent.ts`:
- Around line 26-31: Update inferToolRequirement so explicitTool and webAction
are evaluated against prompt only, preventing tool descriptions in systemPrompt
from triggering action detection; retain combined text for codeEnvironment and
statefulAction as appropriate. Add a regression test covering a tool-describing
system prompt with a factual user question that must not be classified as
requiring a tool.

---

Nitpick comments:
In `@package.json`:
- Around line 24-27: Update the "./router" conditional export in package.json so
the "types" condition precedes "import", while preserving both existing targets.
Verify the package with a NodeNext TypeScript consumer importing
`@blockrun/clawrouter/router`.

In `@scripts/smoke-dist.mjs`:
- Around line 70-78: Update the smoke test around the router import to validate
the public `@blockrun/clawrouter/router` export instead of importing
dist/router/index.js directly. Preserve the existing route and default portfolio
configuration checks, and additionally verify that dist/router/index.d.ts exists
so both runtime and declaration artifacts are covered.

In `@src/proxy.session-pinning.test.ts`:
- Around line 262-272: Extend the assertions in the session-pinning test to
verify both shadow-routing decisions: assert that shadows[0].executed.method
equals "portfolio" and shadows[0].shadow.method equals "rules", while preserving
the existing shadow payload assertions.

In `@src/router/portfolio.test.ts`:
- Around line 32-48: Add Vitest coverage in the PortfolioStrategy tests for both
routingProfile "eco" and "premium", using the same prompt and routing inputs
while explicitly setting each profile. Assert that each profile selects its
expected model and that the selected models differ from the auto-profile result,
exercising the profile-specific weighting and affinity behavior.

In `@src/router/portfolio.ts`:
- Around line 38-45: Replace the duplicated numeric values in
DEFAULT_PORTFOLIO_WEIGHTS with values derived from DEFAULT_ROUTING_CONFIG
imported from the router configuration module. Preserve the existing portfolio
shape, including highStakesBoost, latencySensitiveSpeedBoost, and
affinityFloorGap, while ensuring changes to DEFAULT_ROUTING_CONFIG automatically
update this fallback.
- Around line 589-593: Update the ranking map in rankedEntries to reuse
costs[index] instead of recomputing estimatedCost for each candidate. Since
candidates is derived from eligible models, capabilityScore is constant whenever
candidates are non-empty; document this invariant or remove/fold the capability
weight so scoring only includes discriminating dimensions, while preserving the
no-eligible-model behavior.
- Around line 632-640: Remove the redundant ternary condition around the
ranked-model construction and assign the shared array expression directly to the
result. Update the surrounding logic in the visible portfolio ranking flow so
taskType and agentDomain no longer control identical branches, while preserving
the existing scoredModels-first ordering and eligible-candidate deduplication.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f31eaf15-f3a4-48c5-90b7-900f673f80ce

📥 Commits

Reviewing files that changed from the base of the PR and between 92f9631 and 91c8f1c.

⛔ Files ignored due to path filters (1)
  • src/router/model-profiles.generated.json is excluded by !**/*.generated.*
📒 Files selected for processing (18)
  • package.json
  • scripts/smoke-dist.mjs
  • src/index.ts
  • src/proxy.session-pinning.test.ts
  • src/proxy.ts
  • src/router/config.ts
  • src/router/index.ts
  • src/router/model-profiles.ts
  • src/router/portfolio.test.ts
  • src/router/portfolio.ts
  • src/router/selector.test.ts
  • src/router/selector.ts
  • src/router/strategy.test.ts
  • src/router/strategy.ts
  • src/router/tool-intent.test.ts
  • src/router/tool-intent.ts
  • src/router/types.ts
  • tsup.config.ts

Comment thread src/proxy.ts
Comment on lines +4389 to +4390
requiresStructuredOutput:
typeof parsed.response_format === "object" && parsed.response_format !== null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Apply requiresStructuredOutput in RulesStrategy.

The proxy passes this protocol signal, but RulesStrategy only checks JSON-related text in systemPrompt. A request with response_format and no matching system prompt can stay below structuredOutputMinTier when strategy: "rules" is active. This also makes rules-based shadow results inconsistent with the requested output constraint.

Proposed fix
-    const hasStructuredOutput = systemPrompt ? /json|structured|schema/i.test(systemPrompt) : false;
+    const hasStructuredOutput =
+      options.requiresStructuredOutput ||
+      (systemPrompt ? /json|structured|schema/i.test(systemPrompt) : false);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/proxy.ts` around lines 4389 - 4390, Update RulesStrategy’s
structured-output classification to honor requiresStructuredOutput in addition
to JSON-related systemPrompt checks, ensuring requests with response_format
reach structuredOutputMinTier and rules-based shadow results use the same
determination.

Comment thread src/proxy.ts Outdated
Comment thread src/proxy.ts Outdated
Comment thread src/router/model-profiles.ts Outdated
Comment thread src/router/portfolio.ts Outdated
Comment thread src/router/portfolio.ts Outdated
Comment thread src/router/portfolio.ts Outdated
Comment thread src/router/portfolio.ts Outdated
Comment thread src/router/tool-intent.test.ts Outdated
Comment on lines +19 to +28
it("honors the OpenAI tool_choice contract", () => {
expect(inferToolRequirement("Retrieve the account details.", undefined, "required")).toBe(true);
expect(
inferToolRequirement("Retrieve the account details.", undefined, {
type: "function",
function: { name: "get_account" },
}),
).toBe(true);
expect(inferToolRequirement("What is 17 times 9?", undefined, "auto")).toBe(false);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add coverage for tool_choice: "none" and for a non-empty systemPrompt.

The tool_choice block asserts "required", a function selection, and "auto". It does not assert "none". The OpenAI contract states that "none" forbids tool calls, but inferToolRequirement ignores that value and falls through to prose matching. A prompt such as "Cancel my flight booking and refund the ticket." with tool_choice: "none" currently returns true.

The systemPrompt parameter is never exercised with a value in this file. That parameter carries the over-routing risk noted on src/router/tool-intent.ts Lines 26-31.

💚 Proposed added assertions
     expect(inferToolRequirement("What is 17 times 9?", undefined, "auto")).toBe(false);
+    expect(
+      inferToolRequirement("Cancel my flight booking and refund the ticket.", undefined, "none"),
+    ).toBe(false);
+  });
+
+  it("does not treat a tool-describing system prompt as a tool requirement", () => {
+    const systemPrompt = "You can use the web_search tool to look up documentation.";
+    expect(inferToolRequirement("What is 17 times 9?", systemPrompt)).toBe(false);
   });

The "none" assertion fails against the current implementation. Add the short-circuit in src/router/tool-intent.ts alongside it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/router/tool-intent.test.ts` around lines 19 - 28, Update
inferToolRequirement to immediately return false when toolChoice is "none",
before prose matching, preserving required, function-selection, and auto
behavior. Extend the tool-choice tests with a tool_choice "none" case using a
tool-relevant prompt, and add coverage that passes a non-empty systemPrompt to
validate the existing routing behavior.

Source: Coding guidelines

Comment thread src/router/tool-intent.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/router/portfolio.ts (1)

1113-1133: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Two branches of the ranked ternary are identical. The tool_agent / tool_agent_parallel branch at Lines 1126-1129 and the default branch at Lines 1130-1133 build the same array. The condition at Lines 1124-1125 has no effect. Collapse it to a single non-web-research branch, or implement the intended distinct ordering.

♻️ Proposed simplification
     const ranked =
       features.agentDomain === "web_research"
         ? [
             ...scoredModels,
             ...webResearchFallbackOrder.filter(
               (model) => eligibleCandidates.includes(model) && !scoredModels.includes(model),
             ),
             ...eligibleCandidates.filter(
               (model) => !scoredModels.includes(model) && !webResearchFallbackOrder.includes(model),
             ),
           ]
-        : features.taskType === "tool_agent" ||
-            (features.taskType === "tool_agent_parallel" && features.agentDomain !== "other")
-          ? [
-              ...scoredModels,
-              ...eligibleCandidates.filter((model) => !scoredModels.includes(model)),
-            ]
-          : [
-              ...scoredModels,
-              ...eligibleCandidates.filter((model) => !scoredModels.includes(model)),
-            ];
+        : [...scoredModels, ...eligibleCandidates.filter((model) => !scoredModels.includes(model))];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/router/portfolio.ts` around lines 1113 - 1133, Collapse the redundant
tool-agent ternary branch in the ranked construction: after the web_research
case, use one shared non-web-research array combining scoredModels with
remaining eligibleCandidates. Remove the ineffective features.taskType condition
while preserving the existing ordering.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/router/portfolio.ts`:
- Around line 1113-1133: Collapse the redundant tool-agent ternary branch in the
ranked construction: after the web_research case, use one shared
non-web-research array combining scoredModels with remaining eligibleCandidates.
Remove the ineffective features.taskType condition while preserving the existing
ordering.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1657c6f9-206b-4847-a210-9e9dbccc219d

📥 Commits

Reviewing files that changed from the base of the PR and between 91c8f1c and e670a81.

📒 Files selected for processing (9)
  • scripts/smoke-dist.mjs
  • src/proxy.ts
  • src/router/model-profiles.ts
  • src/router/portfolio.test.ts
  • src/router/portfolio.ts
  • src/router/selector.test.ts
  • src/router/selector.ts
  • src/router/tool-intent.test.ts
  • src/router/tool-intent.ts
🚧 Files skipped from review as they are similar to previous changes (8)
  • scripts/smoke-dist.mjs
  • src/router/tool-intent.ts
  • src/router/selector.test.ts
  • src/router/selector.ts
  • src/router/tool-intent.test.ts
  • src/router/model-profiles.ts
  • src/router/portfolio.test.ts
  • src/proxy.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/router/tool-intent.ts (1)

30-43: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Bound the text scanned by the tool-intent regexes.

inferToolRequirement scans the complete user prompt four times. proxyRequest calls it before request compression. An unbounded prompt cannot meet the 1 ms routing budget. Sample a fixed prefix and suffix before matching, as the portfolio classifier does.

As per coding guidelines, src/**/*.{ts,tsx} must keep model-routing dimension scoring local and under 1 ms.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/router/tool-intent.ts` around lines 30 - 43, Update inferToolRequirement
to match the explicitTool, codeEnvironment, webAction, and statefulAction
regexes against a bounded sample of prompt text rather than the complete prompt.
Build the sample from a fixed-size prefix and suffix, reusing the portfolio
classifier’s established bounds, then preserve the existing four-match boolean
result and ensure proxyRequest benefits before compression.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/proxy.ts`:
- Around line 5100-5109: Update the capacity-filtering flow around
filterCandidatesByCapacity so an entirely capacity-ineligible fullChain produces
an empty candidate result rather than restoring fullChain. Ensure the caller
emits a clear capacity error and does not retry rejected models, while
preserving normal filtering for eligible candidates; add Vitest coverage for the
case where every configured candidate is too small.
- Around line 4580-4589: Wrap the optional onShadowRouted callback invocation in
the routing flow with isolated error handling so callback exceptions cannot
escape the outer try or prevent modified request-body serialization and upstream
completion. Preserve the existing callback payload and continue routing after
failures. Add a Vitest test in the applicable *.test.ts or *.test.tsx file where
onShadowRouted throws and verify the sampled upstream request still succeeds.

---

Outside diff comments:
In `@src/router/tool-intent.ts`:
- Around line 30-43: Update inferToolRequirement to match the explicitTool,
codeEnvironment, webAction, and statefulAction regexes against a bounded sample
of prompt text rather than the complete prompt. Build the sample from a
fixed-size prefix and suffix, reusing the portfolio classifier’s established
bounds, then preserve the existing four-match boolean result and ensure
proxyRequest benefits before compression.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1cc4af9f-a42e-4867-8056-5f2a2d8a0d03

📥 Commits

Reviewing files that changed from the base of the PR and between a7e8e95 and a5f3c8a.

📒 Files selected for processing (9)
  • src/proxy.session-pinning.test.ts
  • src/proxy.ts
  • src/router/model-profiles.ts
  • src/router/portfolio.test.ts
  • src/router/portfolio.ts
  • src/router/strategy.test.ts
  • src/router/strategy.ts
  • src/router/tool-intent.test.ts
  • src/router/tool-intent.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/router/tool-intent.test.ts
  • src/router/model-profiles.ts
  • src/router/strategy.test.ts
  • src/router/portfolio.ts

Comment thread src/proxy.ts
Comment thread src/proxy.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/proxy.session-pinning.test.ts`:
- Around line 353-360: Update the test around the onShadowRouted callback to
increment an invocation counter before throwing, then assert the counter equals
one after postChat. Preserve the existing response-status and receivedModels
assertions while explicitly verifying the throwing callback was invoked.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c92c7036-8236-4f66-9c98-1284db5e1ec4

📥 Commits

Reviewing files that changed from the base of the PR and between a5f3c8a and 1309237.

📒 Files selected for processing (4)
  • src/proxy.session-pinning.test.ts
  • src/proxy.ts
  • src/router/selector.test.ts
  • src/router/selector.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/router/selector.test.ts
  • src/router/selector.ts
  • src/proxy.ts

Comment thread src/proxy.session-pinning.test.ts
KillerQueen-Z and others added 5 commits August 8, 2026 00:39
…d liveness

Review follow-up on BlockRunAI#238. Three blocking items, all mechanical; the router
extraction and the portfolio policy are unchanged.

1. Committed dist/ was stale relative to the pinned router-core.

   dist/router/index.js carried "free/seed-oss-36b" in three fallback chains
   (lines 3013/3131/3221) with the pre-fix comments intact. That model went
   HTTP 410 EOL at NVIDIA on 2026-08-03 and v0.12.241 removed it.

   The pin itself was already correct: router-core 6a790eb ("fix: retire
   unavailable seed fallback") fixes all three sites in config.ts, and its own
   dist is clean. Only ClawRouter's committed artifact was built against an
   older snapshot. Rebuilt; the chains are now clean.

   This mattered because BlockRun server-redirects retired free ids, so routing
   to one SILENTLY DEFEATS /exclude: the caller excludes a model, the router
   hands it the request anyway, and the gateway answers from the redirect
   target.

2. @blockrun/router-core moved to devDependencies.

   tsup's noExternal inlines it (dist/router/index.js line 3 is
   "// node_modules/@blockrun/router-core/dist/index.js"), so nothing imports
   it at runtime. In dependencies it made every `npm install
   @blockrun/clawrouter` fetch a codeload.github.com tarball at install time
   for code already in the bundle.

   The integrity hash protects `npm ci` here, but the published package does
   not ship our lockfile: consumers resolve the bare URL from package.json with
   no verification. It also made installs fail whenever GitHub is unreachable
   or the org is renamed, which has happened before. The published tarball now
   declares zero non-registry dependencies again.

3. Version 0.12.241 -> 0.12.242.

   0.12.241 is already on npm. publish.yml guards on `pkg != npm`, so merging
   at 0.12.241 would land on main and silently never publish.

Also adds src/router/free-model-liveness.test.ts. Router selection coverage
left this repo with the code (713 tests here vs 731 on main, selector.test.ts
and strategy.test.ts deleted, none added), and item 1 is what walked through
the gap. The guard walks every tier container's primary and fallback entries
and asserts each free/* id is live in src/top-models.json, plus the two
deliberate gpt-oss defaults. The allowed set is derived from the picker, not
hard-coded, so a future free-tier resync updates it automatically.

Verified to FAIL against the regression: re-injecting seed-oss into one chain
produces "tiers.SIMPLE.fallback[3] -> free/seed-oss-36b" and fails two of the
three assertions. A third assertion fails loudly if the config shape changes
and the walk stops finding tiers, so it cannot pass vacuously.

Verified: typecheck, lint, prettier, build + dist smoke all clean. 716 tests
pass (713 + 3 new). Packed artifact confirmed to declare no URL dependency,
and dist/ confirmed free of seed-oss routing entries; the four remaining
mentions in dist/index.js are the deliberate pins and catalog entry that
v0.12.241 kept routable.
@VickyXAI

VickyXAI commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Reviewed and pushed three fixes to this branch (46d6e9c). The router extraction and the portfolio policy are unchanged — all three were mechanical.

1. The committed dist/ was stale relative to your pin

dist/router/index.js carried "free/seed-oss-36b" in three fallback chains (3013 / 3131 / 3221), with the pre-fix comments intact. That model went HTTP 410 EOL at NVIDIA on 2026-08-03 and v0.12.241 removed it.

Credit where it's due: your pin was already correct. router-core 6a790eb ("fix: retire unavailable seed fallback") fixes all three sites in config.ts, and router-core's own dist/ is clean. Only ClawRouter's committed artifact was built against an older snapshot — c714217 refreshed it, but from a pre-fix install. Rebuilt.

Why it was blocking rather than cosmetic: BlockRun server-redirects retired free ids, so routing to one silently defeats /exclude. The caller excludes a model, the router hands it the request anyway, and the gateway answers from the redirect target.

2. @blockrun/router-core moved to devDependencies

tsup's noExternal inlines it — dist/router/index.js line 3 is // node_modules/@blockrun/router-core/dist/index.js, and nothing imports it at runtime. Sitting in dependencies, it made every npm install @blockrun/clawrouter fetch a codeload.github.com tarball at install time for code already in the bundle.

The integrity hash does protect npm ci in this repo. But the published package doesn't ship our lockfile, so consumers resolve the bare URL straight from package.json with no verification — and installs break whenever GitHub is unreachable or the org is renamed, which has happened here before. The packed tarball now declares zero non-registry dependencies again.

3. Version 0.12.2410.12.242

0.12.241 is already on npm (it's the seed-oss fix). publish.yml guards on pkg != npm, so merging at 0.12.241 would land on main and silently never publish.

Added: src/router/free-model-liveness.test.ts

Router selection coverage left this repo with the code — measured 713 tests here vs 731 on main, with selector.test.ts and strategy.test.ts deleted and none added. Item 1 is exactly what walked through that gap. The 94 tests in router-core are good, but they can't catch a stale build on this side.

The guard walks every tier container's primary + fallback and asserts each free/* id is live in src/top-models.json, plus the two deliberate gpt-oss defaults. The allowed set is derived from the picker, not hard-coded, so the next free-tier resync updates it for free.

Verified to fail against the regression — re-injecting seed-oss into one chain yields tiers.SIMPLE.fallback[3] -> free/seed-oss-36b and fails two of three assertions. A third assertion fails loudly if the config shape changes and the walk stops finding tiers, so it can't pass vacuously.

Verification

typecheck, lint, prettier, build + dist smoke all clean. 716 tests pass (713 + 3 new). Packed artifact confirmed to declare no URL dependency; dist/ confirmed free of seed-oss routing entries (the four remaining mentions in dist/index.js are the deliberate pins and catalog entry that v0.12.241 keeps routable, since the gateway redirects them).

One thing worth deciding separately

Model lifecycle now has two owners. Every upstream EOL — blockrun re-probes roughly weekly — needs a change in both repos, and the first drift happened within four days. The guard test above is the cheap hedge; a tighter coupling is a real design question, not a blocker for this PR.

Nice work on the extraction. smoke-dist.mjs being extended to cover the new entry point, rather than loosened around it, is the right instinct.

@VickyXAI
VickyXAI merged commit ef8f67c into BlockRunAI:main Aug 7, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants