Contact

Features

Governance & Model Routing

One routing decision with a reason per candidate, a context budget sized to the model that answers, data classes bound against provider trust zones, and per-call cost attributed to the calling extension.

Why it exists

A criteria-mode configuration names no model. The model is chosen at call time from the stored criteria. Selection used to return a match or nothing, so an operator asking why a newly added model was never used had nothing to read. The decision point now returns one routing decision: the chosen model, every eligible candidate with its score, and every refused candidate with its reason (ADR-142). A summary of each real decision is written to the telemetry row (ADR-156).

Because a criteria-mode configuration has no model relation, code that read the context window from the configuration fell back to a default and sized the budget against a model that was not on the wire. The send budget now comes from the model that actually serves the call (ADR-143). The trust zone of a criteria-mode send follows the same resolved model (ADR-149).

Data protection needs a declaration, not only a screener. Tools declare what kind of data they return. Snippets, skills and the system prompt can declare what they carry. Each provider sits in a trust zone with a ceiling. One switch decides whether something above the ceiling is removed and refused, or only recorded (ADR-094, ADR-144, ADR-155).

Administrators need to see the policy in force and the cost per caller without a second policy engine. The Governance tab reads every value through the resolvers the runtime uses, compares it with a named posture, and changes nothing (ADR-145). A call can name the extension that made it, and cost is aggregated per extension (ADR-177, ADR-178).

When to use it

  • You run criteria-mode configurations and need to know why a call ran on a given model: "Why this model?" explains a hypothetical call, "Calls that were routed" lists the decisions of the last seven days.
  • Confidential content must not reach an external provider: set trust_zone on the provider, data_class on snippets and skills, and system_prompt_data_class on the configuration.
  • You write a tool that returns sensitive data: implement ToolDataClassInterface so the tool gate can compare its class with the ceiling of the zone a run can reach.
  • You want to compare an installation with an intended posture (local-only, controlled-cloud, enterprise-strict, development): pick a profile on the Governance tab and read which values deviate and where they are set.
  • You need to know whether a backend user could run a tool through a configuration: run the simulation on the Governance tab before granting anything.
  • Your extension calls nr-llm and its spend should be visible: tag the options with withCallerSource(); the Analytics module groups cost by extension.

How it works

ModelSelectionServiceInterface::resolveModelForCall() returns a ModelResolution: the model and a RoutingSummary. Behind it, RoutingDecisionService runs discovery, then EligibilityEvaluator, then CandidateRanker. Eligibility is a hard constraint: a refused candidate gets a RoutingRejectionReason (capability missing, adapter not allowed, context too small, cost above limit, operation capability missing) and no score. Provider priority sorts above the score, because a priority is an instruction. routing.policyMode selects providerPriority (the default, no measured signals), balanced, quality or economy; a model with no measurement contributes nothing rather than a zero. ConfigurationCallPlanner records the summary on TelemetrySignals, and the telemetry row stores the policy mode, the candidate count, the distinct rejection reasons and which signals actually moved the decision. Fixed mode stores an empty mode, because nothing was chosen.

ContextWindowManager::fit() takes the resolved model and prefers it over the configuration's relation; an unknown context length falls back to 8192 tokens. LlmServiceManager binds chat, tool-calling and streaming sends at the pipeline terminal, the first place the resolved model exists, and tool schemas count against the same budget. A completion prompt is measured and an overflow is logged, but it is never pruned. A transcript that does not fit even at its floor is sent unpruned with a warning.

ToolDataClass is one scale from publicContent to secretAdjacent. TrustZone (local, privateHosted, externalEu, externalGlobal) maps each zone to a maximum class. TrustZoneResolver::zoneFor() returns the least trusted zone a run can reach: the configuration's provider, or the serving model's provider in criteria mode, plus one hop of the fallback chain; anything unknown is externalGlobal. The tool gate removes tools above the ceiling. InputContextTrustGate refuses a send whose strictest declared source exceeds the zone — snippets, skills, the system prompt, and the sources a run forces in (ADR-164). An undeclared source constrains nothing. tools.dataClassEnforcement decides for both directions: only a literal observe observes. Denials and refusals are written to tx_nrllm_governance_event.

On the Governance tab, EffectivePolicyReadout shows privacy.level, privacy.retentionDays, tools.dataClassEnforcement and skills.minTrustLevel as the runtime resolves them; a row that cannot be answered reads unknown. GovernanceProfile defines expected values per posture, and GovernanceProfileEvaluator::deviations() compares them with the readout's rows and names where each value is set. There is no apply path (ADR-140). GovernanceSimulator asks configuration access, the tool gate, the input-context gate, routing and human approval, and returns allow, allowWithApproval or block. Each telemetry row also carries actual_input_tokens, actual_output_tokens, actual_cost, response_model, provider_retries and model-independent facts_* columns; UsageMiddleware passes source_extension to UsageTrackerServiceInterface::trackUsage(), which aggregates cost per day and extension in tx_nrllm_service_usage.

Netresearch\NrLlm\Service\Routing\RoutingDecisionService

The routing decision point: discovery, eligibility, ranking. Returns a RoutingDecision with every candidate; used by the runtime and by the Governance tab.

Netresearch\NrLlm\Service\Routing\EligibilityEvaluator

The only implementation of "may this model serve this call". Returns a RoutingRejectionReason instead of false; modelMatchesCriteria() delegates to it.

Netresearch\NrLlm\Domain\ValueObject\RoutingSummary

Six values persisted per routed call: policy mode, candidate count, distinct rejection reasons, and whether quality, health and cost moved the decision.

Netresearch\NrLlm\Service\Context\ContextWindowManager

Sizes the send budget from the model that serves the call, prunes whole oldest turns, and reports an overflow instead of refusing the send.

Netresearch\NrLlm\Service\Governance\TrustZoneResolver

Least trusted zone a run can reach across the provider and one fallback hop; ceilingFor() turns it into the tool data-class ceiling. Unknown fails closed to externalGlobal.

Netresearch\NrLlm\Service\Context\InputContextTrustGate

Refuses (enforce) or records (observe) a send whose strictest declared snippet, skill, system-prompt or forced-source class exceeds the reachable zone.

Netresearch\NrLlm\Service\Governance\GovernanceProfileEvaluator

Compares the readout's rows with a GovernanceProfile and returns each deviation with where to change it. Reads nothing itself and writes nothing.

Netresearch\NrLlm\Service\Tool\GovernanceSimulator

The "Would this be allowed?" panel: calls the runtime's own gates for a configuration, a tool and optionally a backend user, and records nothing.

For developers

Name your extension on its calls, know where the routing decision is taken, declare what your tools return, and read cost per request shape from the telemetry table.

Name the calling extension

withCallerSource() tags the options with your extension key and an optional operation. Both values travel as call metadata, never to the provider. They are stored as source_extension and source_operation on the telemetry row, and source_extension joins the daily cost aggregate. Untagged calls are listed as unattributed. The name is not authenticated.

php
use Netresearch\NrLlm\Service\LlmServiceManagerInterface;
use Netresearch\NrLlm\Service\Option\ChatOptions;

final class TeaserGenerator
{
    public function __construct(
        private readonly LlmServiceManagerInterface $llm,
    ) {}

    public function teaser(string $text): string
    {
        $options = (new ChatOptions())->withCallerSource('my_extension', 'teaser');

        return $this->llm->complete('Write a teaser: ' . $text, $options)->content;
    }
}

One evaluation for the model and the reason

ConfigurationCallPlanner resolves the model through resolveModelForCall() and records the RoutingSummary on the call's TelemetrySignals. The same evaluation answers which model and why, so the recorded reason cannot describe a decision that never ran. For a fixed-mode configuration the summary is null.

php
// One evaluation answers both questions — which model, and why. See
// ModelSelectionServiceInterface::resolveModelForCall() for why this
// is not resolveModel() plus explainRouting().
$resolution = $this->modelSelectionService->resolveModelForCall($configuration, $operation);
$llmModel   = $resolution->model;
$signals?->recordRoutingSummary($resolution->routingSummary);

Declare what a tool returns

A tool whose group default understates its output implements ToolDataClassInterface. The value is part of the code and cannot be relabelled in the backend. The tool gate compares it with the ceiling of the least trusted zone the run can reach and removes the tool when it is above (enforce) or records it (observe).

php
final readonly class GetEnvRawTool implements ToolInterface, ToolDataClassInterface
{
    // getSpec(), execute(), isEnabledByDefault(), requiresAdmin(), getGroup() ...

    /**
     * Environment variables routinely carry database passwords, API keys and vault credentials.
     */
    public function getDataClass(): ToolDataClass
    {
        return ToolDataClass::SECRET_ADJACENT;
    }
}

Read cost per request shape

The per-call cost and the pre-routing facts sit on tx_nrllm_telemetry and are queried directly; the Governance tab does not show them. NULL means no available measurement: AVG() skips unknown costs. Filter on actual_cost IS NOT NULL to keep only calls with a cost.

sql
SELECT facts_shape,
       COUNT(*)                AS calls,
       AVG(actual_cost)        AS mean_cost,
       SUM(actual_cost)        AS total_cost,
       AVG(actual_input_tokens)  AS mean_in,
       AVG(actual_output_tokens) AS mean_out
  FROM tx_nrllm_telemetry
 WHERE crdate >= UNIX_TIMESTAMP() - 30*86400
   AND facts_shape <> ''
 GROUP BY facts_shape;

Gotchas

One external fallback lowers the zone

The reachable zone includes one hop of the fallback chain. A local primary with an external fallback is judged by the external ceiling, so tools and snippets that a local run could use are removed or refused. Remove the fallback, move it to a more trusted provider, or accept the lower ceiling.

Criteria mode reads the serving model on the send side only

The input-context gate takes the zone from the resolved model. The tool gate does not: ceilingFor() asks zoneFor() without a model, so a criteria-mode run is offered only the tools externalGlobal permits. A criteria-mode fallback hop also counts as externalGlobal. The simulation asks the input-context gate without a model as well.

observe is instance-wide, and a typo enforces

Only a literal observe (case and surrounding whitespace ignored) switches tools.dataClassEnforcement off; every other value enforces. In observe mode every configuration, run and user is affected. Tools from an MCP server are removed above the ceiling even in observe mode.

"Calls that were routed" is empty on fixed-mode installations

The reader filters on a non-empty routing_policy_mode in SQL. Fixed-mode calls chose nothing and write an empty mode, so they never appear. An empty table on a busy installation most likely means every configuration names its model, or telemetry.enabled is off.

Allowed in the simulation is not a promise

The simulation does not ask the budget check or the guardrail pipeline, because both decide on the call itself. It writes no governance event either, so who checked what cannot be read from the audit.