Skip to main content

Resource-aware routing

The Routing module separates model selection from model execution. A model_resource_profile describes one model identifier, its context and output limits, token pricing, normalized quality and latency scores, and supported capabilities. resource_aware_router evaluates every registered profile against hard requirements before applying a selection strategy.

namespace routing = wuwe::agent::routing;

auto router = std::make_shared<routing::resource_aware_router>();
router->add({
.model = "economy-model",
.provider = "openai-compatible",
.context_window_tokens = 128'000,
.max_output_tokens = 8'000,
.input_cost_per_million_tokens = 0.20,
.output_cost_per_million_tokens = 0.80,
.quality_score = 0.65,
.latency_score = 0.90,
.capabilities = { .tools = true, .streaming = true },
});
router->add({
.model = "premium-model",
.provider = "openai-compatible",
.context_window_tokens = 256'000,
.max_output_tokens = 32'000,
.input_cost_per_million_tokens = 2.00,
.output_cost_per_million_tokens = 8.00,
.quality_score = 0.95,
.latency_score = 0.60,
.capabilities = {
.tools = true,
.streaming = true,
.reasoning = true,
},
});

The provider field is a stable runtime binding ID, not necessarily a protocol name. For example, two OpenAI-compatible endpoints can be registered as primary-openai and private-gateway while using the same adapter type. Profiles are unique by the (provider, model) binding, so the same upstream model can be registered at multiple providers with different pricing, quality, latency, availability, and capability data. find(model, provider) performs an exact lookup, find_all(model) returns every binding, and the compatibility find(model) overload returns the first registered binding for that model.

Both input and output prices must be provided together. Use explicit zero prices for a free or local model. Unpriced models are excluded when pricing is required unless allow_unpriced_models is enabled deliberately.

Profiles, request budgets, quality and latency scores, and routing weights must be finite and non-negative within their documented ranges. Extreme but valid token counts and prices saturate at the largest finite representable cost, so candidate sorting and JSON diagnostics never receive NaN or infinity.

Selection contract

model_route_request supplies the preferred model, estimated input and output tokens, the remaining per-call cost allowance, and model_route_requirements. Hard constraints cover:

  • model availability;
  • context window and maximum output size;
  • tools, streaming, reasoning, JSON response, and local-runtime capabilities;
  • minimum quality;
  • model pinning through allow_model_override = false;
  • provider pinning through allow_provider_override = false;
  • known pricing and estimated cost budget.

Eligible candidates are ranked using balanced, lowest_cost, highest_quality, or lowest_latency. The result includes the selected profile, estimated cost, all candidate scores, and stable rejection reasons. No silent fallback occurs when no model satisfies the hard constraints.

Reasoning integration

Attach a router to reasoning_runner_options. Routing runs immediately before every provider call, including later Tool Loop rounds and Reflection retries, so the selected model can change as the remaining budget changes. An explicit reasoning_request::provider is pinned by default. Set model_routing.allow_provider_override = true only when the router is allowed to send the request to another registered provider. When override is enabled, the preferred provider receives preferred_provider_bonus under the balanced strategy; hard capability and budget constraints still take precedence.

reasoning::reasoning_runner runner({
.client = client.get(),
.model_router = router,
});

const auto result = runner.run({
.input = "Analyze the data and use tools when needed.",
.model = "premium-model", // preferred, not pinned by default
.policy = {
.mode = reasoning::reasoning_mode::react,
.budget = {
.max_model_calls = 6,
.max_total_tokens = 20'000,
.max_cost_usd = 0.25,
.estimated_output_tokens_per_call = 1'000,
},
},
.model_routing = {
.strategy = routing::model_selection_strategy::highest_quality,
.require_tools = true,
},
});

reasoning_budget supports separate prompt, completion, total-token, and USD cost limits. Preflight routing uses estimated tokens and remaining cost, and the remaining output allowance is propagated through llm_request::max_output_tokens to the built-in OpenAI-compatible, Anthropic, Gemini, and Ollama clients. Provider-reported usage is applied after each call; when detailed usage is absent, the run accounts the preflight estimate and increments reasoning_usage::estimated_token_calls. reasoning_result::model_routes and model_routed trace events expose every decision.

Tool and JSON capabilities are inferred from each concrete model request. Streaming is also required automatically when the configured client supports streaming and the Reasoning policy enables it; custom dispatchers can set require_streaming explicitly when their capability cannot be inferred.

The default estimator is deterministic and approximate. Supply reasoning_runner_options::token_estimator when the deployed tokenizer or provider has a more accurate implementation.

Execution boundary

The router selects both llm_request::model and llm_request::provider. Use llm::dispatching_llm_client when profiles span multiple provider clients:

namespace llm = wuwe::agent::llm;

auto clients = std::make_shared<llm::llm_client_registry>();
clients->add({
.provider = "economy-provider",
.client = economy_client,
});
clients->add({
.provider = "premium-provider",
.client = premium_client,
});

llm::dispatching_llm_client dispatch(clients);
reasoning::reasoning_runner runner({
.client = &dispatch,
.model_router = router,
});

Every routed request is delivered only to the exact registered provider. An unknown provider returns llm_error_code::model_unavailable; multiple registered clients without an explicit or configured default provider return llm_error_code::invalid_request. Dispatch never silently falls back across providers. Wrap individual bindings in resilient_llm_client when retry, circuit breaking, or ordered fallback is required.

The registry is thread-safe. Replacement and removal affect new calls atomically, while in-flight calls retain shared ownership of the client they selected. The dispatcher validates the exact selected client's capability contract before execution and preserves the selected provider and model in response metadata. Registry mutations reject direct and indirect Wuwe dispatcher ownership cycles before retaining the new client, so invalid graphs do not create shared_ptr leaks. Runtime dispatch still rejects cycles introduced through custom forwarding clients with llm_error_code::invalid_request. Dispatch stores lineage inside an opaque request runtime context, so cycles remain detectable across worker-thread handoffs while an independent request started by a stream callback receives a fresh context. The context is not application metadata and is never included in durable continuation serialization. Synchronous cycles encountered during capability discovery are also contained. Custom forwarding clients that retain other clients are responsible for avoiding their own strong-ownership cycles. Stream consumer exceptions are propagated to the caller after the backend outcome is recorded, so they are not misclassified as provider failures. A request started by a dispatch observer is allowed, while events from that nested request are not fed back into the same observer recursively.

Planning has its own planner client and model configuration. A Reasoning router controls model-driven Reasoning calls; applications that require routed planning should provide a planner whose client performs the corresponding dispatch.

Telemetry

Router options accept a typed observer and the common observability::event_sink. Telemetry failures are isolated and counted by default; routing_telemetry_failure_mode::propagate makes them fail the caller. Events contain model identifiers, strategy, candidate count, and stable outcomes, but no prompt content.