Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,10 @@ dist/
*.tgz
artifacts/
coverage/

# ── Bounded-autonomy durable state (per-workspace, never committed) ──
# Agent runtime state: phase locks, memory receipts, L4 phase/release receipts,
# and PR handoffs, written under L9_AUTONOMY_STATE_DIR (default .l9/). It is
# machine- and session-local — it carries session IDs and absolute paths, and
# is not source. Matches the convention in the rest of the constellation.
.l9/
20 changes: 20 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,26 @@ validated execution task

Route resolution is pure. Request IDs and timestamps are added afterward and do not participate in routing equivalence.

## Search policy authority

The application declares the capability; the router selects the provider. That split is enforced by a single resolver, `resolveSearchPolicy()` in `src/matrices/search-policy.ts`:

```text
typeof task.requiresSearch === 'boolean'
-> { required: task.requiresSearch, source: EXPLICIT }
otherwise
-> { required: isSearchTask(task.type), source: TASK_DEFAULT }
```

There is exactly one implementation of this rule. `requiresSearchProvider()` is a boolean view of it and `isSearchTask()` supplies only the `TaskType` default. `resolveRoute()` consumes the resolution and copies `searchRequired` and `searchPolicySource` onto every `RoutingResolution`, so a decision is auditable without inferring intent from model names.

Two invariants keep the audit honest:

- A resolved Perplexity config always has `disableSearch: false`. A search decision can never dispatch a config with web search turned off.
- Before dispatch, `decision.searchRequired` must equal `decision.provider === Provider.PERPLEXITY`. A disagreement in either direction is a hard error, not a downgrade.

Search and vision have no combined provider contract. A vision `TaskType` carrying images with `requiresSearch: true` throws `UnsupportedCapabilityCombinationError` from route resolution — before request identity, budget reservation, circuit permit, or provider dispatch — so neither capability is silently discarded. Because the throw precedes reservation and permit acquisition, it cannot affect budget state or provider circuit health.

## Module ownership

```text
Expand Down
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,53 @@ const router = new L9LLMRouter({

Resolution precedence is explicit config, then `OPENROUTER_BASE_URL`, then the OpenRouter cloud default. Overrides are validated as absolute http(s) URLs at construction time and trailing slashes are normalized. Invalid values throw `InvalidBaseUrlError` (or `RouterConfigValidationError` at config parse time). Deployments that set neither are unaffected.

## Search policy

An application declares *what capability the task needs*. The router decides *which provider and model* serve it. `TaskDescriptor.requiresSearch` is the capability declaration, and when it is present it is authoritative:

```ts
shouldSearch(task) =
typeof task.requiresSearch === 'boolean'
? task.requiresSearch
: isSearchTask(task.type);
```

| `requiresSearch` | Result | `searchPolicySource` |
| --- | --- | --- |
| `true` | Search plane (Perplexity Sonar) | `EXPLICIT` |
| `false` | General plane, even for a research `TaskType` | `EXPLICIT` |
| omitted | The historical `TaskType` default | `TASK_DEFAULT` |

The `TaskType` default is unchanged: `COMPETITOR_RESEARCH`, `CITATION_CHECK`, `FACT_VERIFICATION`, `MARKET_RESEARCH`, and `LINK_PROSPECTING` still route to search when the flag is omitted. Explicit `false` lets a caller reason strategically over evidence a deterministic system already gathered without paying for redundant web search; explicit `true` lets an otherwise-general task reach fresh web context.

```ts
// Strategic synthesis over evidence we already hold — no web search.
await router.execute(
{ clientId: 'tenant-a', type: TaskType.COMPETITOR_RESEARCH, complexity: TaskComplexity.HIGH, requiresSearch: false },
'You are a strategist.',
'Synthesize the supplied competitor evidence.',
);
```

`TaskDescriptor` carries no `provider`, `model`, or fallback-chain field, and unknown keys are stripped during validation. Applications cannot select a provider or model.

### Auditing a routing decision

Every `RoutingDecision` — from `route()` and from `getCallLog()` — reports whether search was selected and on whose authority, alongside `taskType`, `complexity`, `provider`, `model`, `reason`, `estimatedCost`, `taskId`, `clientId`, `timestamp`, downgrade state, and (after execution) `actualCost` and `latencyMs`. No credentials or prompts are recorded.

```ts
const decision = router.route({ clientId: 'tenant-a', type: TaskType.MARKET_RESEARCH, complexity: TaskComplexity.HIGH, requiresSearch: false });
decision.searchRequired; // false
decision.searchPolicySource; // SearchPolicySource.EXPLICIT
decision.provider; // Provider.OPENROUTER
```

`searchRequired` always agrees with the plane actually dispatched; the router asserts this in both directions before any provider call.

### Unsupported capability combinations

No provider in this router serves search and vision together. A visual task that supplies images *and* sets `requiresSearch: true` is refused with `UnsupportedCapabilityCombinationError` (code `UNSUPPORTED_CAPABILITY_COMBINATION`) before any budget reservation, circuit permit, or provider dispatch — rather than silently dropping the images or silently skipping the search. Split such work into a vision task and a search task.

## Vision execution

Images supplied through execution options are merged into the validated task before routing. This ensures model selection and budget estimation use the same image count that reaches the provider.
Expand Down Expand Up @@ -110,6 +157,8 @@ Only HTTPS public URLs and bounded `data:image/*;base64` payloads are accepted.

For eligible high-complexity Perplexity tasks, `{ consensus: true }` executes the configured variations in parallel. The returned content is selected from the successful responses, while token and cost fields represent the aggregate successful consensus execution so budget reconciliation does not undercount spend.

Consensus is an execution modifier, not search-policy authority. It applies only to a route that already resolved to the search plane; on a general or vision route it is inert and never pulls the task onto a search provider.

## Budget semantics

The built-in tracker is process-local.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@quantum-l9/llm-router",
"version": "1.1.3",
"version": "1.2.0",
"type": "module",
"description": "Reusable multi-provider LLM routing module with governed l9-graphiti-memory hydration, task-to-model routing, budgets, search, vision, and provider resilience.",
"main": "dist/index.js",
Expand Down
24 changes: 22 additions & 2 deletions scripts/fixtures/declaration-consumer/consumer.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,32 @@
import { L9LLMRouter, TaskComplexity, TaskType, type TaskDescriptor } from '../../../dist/index.js';
import {
L9LLMRouter,
SearchPolicySource,
TaskComplexity,
TaskType,
UnsupportedCapabilityCombinationError,
resolveSearchPolicy,
type RoutingDecision,
type SearchPolicyResolution,
type TaskDescriptor,
} from '../../../dist/index.js';
import { OpenRouterClient } from '../../../dist/providers/openrouter.js';
import { PerplexityClient } from '../../../dist/providers/perplexity.js';
import { VIEWPORTS } from '../../../dist/vision/index.js';

const task: TaskDescriptor = { type: TaskType.CLASSIFICATION, complexity: TaskComplexity.LOW, clientId: 'fixture' };
// Capability declaration is the application's; provider and model are not.
const strategicWithoutSearch: TaskDescriptor = { type: TaskType.STRATEGIC_REASONING, complexity: TaskComplexity.HIGH, requiresSearch: false, clientId: 'fixture' };
const freshWebWithSearch: TaskDescriptor = { type: TaskType.STRATEGIC_REASONING, complexity: TaskComplexity.HIGH, requiresSearch: true, clientId: 'fixture' };
const policy: SearchPolicyResolution = resolveSearchPolicy(strategicWithoutSearch);
const policySource: SearchPolicySource = policy.source;
const decision: RoutingDecision | undefined = undefined;
const conflict: UnsupportedCapabilityCombinationError | undefined = undefined;
const router: L9LLMRouter | undefined = undefined;
const openrouter: OpenRouterClient | undefined = undefined;
const perplexity: PerplexityClient | undefined = undefined;
// Reference every imported symbol so the declaration build proves each public
// type and value is consumable from the packaged `dist/` entry points.
export const declarationConsumerProbe = [task, router, openrouter, perplexity, VIEWPORTS] as const;
export const declarationConsumerProbe = [
task, strategicWithoutSearch, freshWebWithSearch, policy, policySource, decision, conflict,
router, openrouter, perplexity, VIEWPORTS,
] as const;
40 changes: 34 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import { CircuitBreaker, CircuitOpenError, type CircuitPermit } from './circuit-breaker.js';
import { resolveGeneralConfig, getFallbackChain } from './matrices/general-matrix.js';
import { resolvePerplexityConfig } from './matrices/perplexity-matrix.js';
import { requiresSearchProvider } from './matrices/search-policy.js';
import { resolveSearchPolicy, UnsupportedCapabilityCombinationError } from './matrices/search-policy.js';
import { classifyProviderError, isCircuitFailure } from './provider-errors.js';
import { OpenRouterClient, validateImageUrl, type OpenRouterClientLike } from './providers/openrouter.js';
import { PerplexityClient, type PerplexityClientLike } from './providers/perplexity.js';
Expand Down Expand Up @@ -41,16 +41,30 @@ export interface RouterDependencies {
}

export function resolveRoute(task: TaskDescriptor): RoutingResolution {
if (requiresSearchProvider(task)) {
const policy = resolveSearchPolicy(task);
const audit = { taskType: task.type, complexity: task.complexity, searchRequired: policy.required, searchPolicySource: policy.source };
const imageCount = task.images?.length ?? 0;

// Fail closed before either capability can be silently discarded. The search
// plane has no multimodal transport, so a visual task carrying images cannot
// also be answered by web search.
if (policy.required && VISION_TASKS.has(task.type) && imageCount > 0) {
throw new UnsupportedCapabilityCombinationError(
`Task[${task.type}] supplied ${imageCount} image(s) and requires search, but no provider in this router serves search and vision together. Split the work into a vision task and a search task.`,
{ taskType: task.type, searchRequired: true, imageCount },
);
}

if (policy.required) {
const config = resolvePerplexityConfig(task);
return { taskType: task.type, complexity: task.complexity, provider: Provider.PERPLEXITY, model: config.model, estimatedCost: config.estimatedCostPerCall, reason: config.resolutionReason };
return { ...audit, provider: Provider.PERPLEXITY, model: config.model, estimatedCost: config.estimatedCostPerCall, reason: config.resolutionReason };
}
if (VISION_TASKS.has(task.type)) {
const config = resolveVisionConfig(task.type as TaskType.VISUAL_QA | TaskType.SCREENSHOT_ANALYSIS | TaskType.LAYOUT_VALIDATION, task.complexity, task.images?.length ?? 1);
return { taskType: task.type, complexity: task.complexity, provider: Provider.OPENROUTER, model: config.model, estimatedCost: config.estimatedCostPerCall, reason: config.resolutionReason };
return { ...audit, provider: Provider.OPENROUTER, model: config.model, estimatedCost: config.estimatedCostPerCall, reason: config.resolutionReason };
}
const config = resolveGeneralConfig(task);
return { taskType: task.type, complexity: task.complexity, provider: Provider.OPENROUTER, model: config.model, estimatedCost: config.estimatedCostPerCall, reason: config.resolutionReason };
return { ...audit, provider: Provider.OPENROUTER, model: config.model, estimatedCost: config.estimatedCostPerCall, reason: config.resolutionReason };
}

export function getDowngradedModel(
Expand Down Expand Up @@ -159,9 +173,18 @@ export class L9LLMRouter {
images: string[] | undefined,
options: { images?: string[]; assistantContext?: string; consensus?: boolean; signal?: AbortSignal } | undefined,
): Promise<LLMResponse> {
// The audited decision and the plane about to be dispatched must agree in
// both directions: a search decision may not execute on the general plane,
// and a non-search decision may not execute web search. Perplexity is the
// router's only search-capable provider.
if (decision.searchRequired !== (decision.provider === Provider.PERPLEXITY)) {
throw new Error(`Routing decision searchRequired=${decision.searchRequired} disagrees with provider ${decision.provider}`);
}
if (decision.provider === Provider.PERPLEXITY) {
const config = resolvePerplexityConfig(task);
if (!Object.values(SonarModel).includes(decision.model as SonarModel)) throw new Error('Perplexity route resolved a non-Sonar model');
// A search route may never dispatch a config that turns search off.
if (config.disableSearch) throw new Error('Search route resolved a Perplexity config with search disabled');
config.model = decision.model as SonarModel;
if (options?.consensus && config.variations > 1) {
return this.perplexity.completeWithConsensus(config, effectiveSystemPrompt, userPrompt, options.assistantContext, options.signal).then(consensus => ({
Expand Down Expand Up @@ -247,7 +270,12 @@ export { ProviderRequestError } from './provider-errors.js';
export { TaskValidationError, RouterConfigValidationError } from './schemas.js';
export { UnsafeImageUrlError, InvalidBaseUrlError, DEFAULT_OPENROUTER_BASE_URL, resolveOpenRouterBaseUrl } from './providers/openrouter.js';
export { VIEWPORTS } from './vision/index.js';
export { isSearchTask, requiresSearchProvider } from './matrices/search-policy.js';
export {
isSearchTask,
requiresSearchProvider,
resolveSearchPolicy,
UnsupportedCapabilityCombinationError,
} from './matrices/search-policy.js';

export { hydrateRouterPrompt } from './memory.js';
export type { RouterMemoryConfig } from './memory.js';
15 changes: 10 additions & 5 deletions src/matrices/perplexity-matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ import {
type PerplexityConfig,
type TaskDescriptor,
} from '../types.js';
import { isSearchTask } from './search-policy.js';

// Re-exported for backward compatibility: `isSearchTask` historically lived in
// this module. Its canonical home is now ./search-policy.ts.
export { isSearchTask };
// this module. Its canonical home is now ./search-policy.ts, and nothing here
// calls it any more — the search decision is made before a Perplexity config
// is ever resolved.
export { isSearchTask } from './search-policy.js';

function selectSonarModel(complexity: TaskComplexity, rank: number): SonarModel {
if (complexity === TaskComplexity.CRITICAL) return SonarModel.SONAR_DEEP_RESEARCH;
Expand Down Expand Up @@ -57,7 +57,12 @@ export function resolvePerplexityConfig(task: TaskDescriptor): PerplexityConfig
domainFilter: task.domainFilter ?? [],
variations,
reasoningEffort: selectReasoningEffort(model, task.complexity),
disableSearch: task.requiresSearch === false && !isSearchTask(task.type),
// A Perplexity config is only ever produced for a route that resolved to
// the search plane, so search is always on. The previous predicate
// (`requiresSearch === false && !isSearchTask(type)`) was unreachable on
// that route and, off-route, produced a search-provider config with search
// disabled — a config that contradicted the decision it belonged to.
disableSearch: false,
estimatedCostPerCall,
resolutionReason: `Task[${task.type}] complexity[${task.complexity}] uses ${model}`,
};
Expand Down
57 changes: 48 additions & 9 deletions src/matrices/search-policy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TaskType, type TaskDescriptor } from '../types.js';
import { SearchPolicySource, TaskType, type SearchPolicyResolution, type TaskDescriptor } from '../types.js';

/**
* Task types whose *default* capability implies a search-backed provider.
Expand All @@ -20,14 +20,15 @@ const DEFAULT_SEARCH_TASKS = new Set<TaskType>([
*
* Preserved verbatim so existing consumers that reason purely about a
* `TaskType` keep the same answer. New routing decisions should prefer
* {@link requiresSearchProvider}, which honours an explicit capability flag.
* {@link resolveSearchPolicy}, which honours an explicit capability flag and
* reports *why* the answer was reached.
*/
export function isSearchTask(type: TaskType): boolean {
return DEFAULT_SEARCH_TASKS.has(type);
}

/**
* Explicit capability declaration wins.
* Canonical search-policy resolver — the single implementation of the rule.
*
* Applications declare *whether the task needs a search provider* via
* `TaskDescriptor.requiresSearch`. When present, that declaration is
Expand All @@ -38,13 +39,51 @@ export function isSearchTask(type: TaskType): boolean {
* caller already had normalized evidence and explicitly did not require search.
*
* Semantics:
* requiresSearch === true -> search provider
* requiresSearch === false -> general reasoning provider
* requiresSearch === undefined -> legacy TaskType default (isSearchTask)
* requiresSearch === true -> search provider (source EXPLICIT)
* requiresSearch === false -> general provider (source EXPLICIT)
* requiresSearch === undefined -> isSearchTask(type) (source TASK_DEFAULT)
*
* The returned `source` is what makes an audited routing decision provable:
* it distinguishes "the caller asked for this" from "the task type implied it".
*/
export function requiresSearchProvider(task: TaskDescriptor): boolean {
export function resolveSearchPolicy(task: TaskDescriptor): SearchPolicyResolution {
if (typeof task.requiresSearch === 'boolean') {
return task.requiresSearch;
return { required: task.requiresSearch, source: SearchPolicySource.EXPLICIT };
}
return { required: isSearchTask(task.type), source: SearchPolicySource.TASK_DEFAULT };
}

/**
* Boolean view of {@link resolveSearchPolicy}. Retained as the 1.x public
* predicate; it delegates so there is exactly one implementation of the rule.
*/
export function requiresSearchProvider(task: TaskDescriptor): boolean {
return resolveSearchPolicy(task).required;
}

/**
* Fail-closed error for a task that asks for two capabilities the router has no
* provider contract able to satisfy together.
*
* Raised today for `vision task type + images + requiresSearch === true`: the
* search plane (Perplexity Sonar) has no multimodal transport in this router,
* so honouring one capability necessarily discards the other. Dropping either
* silently would make the routing audit a lie, so the request is rejected
* before any reservation, circuit permit, or provider dispatch.
*
* This is a caller-side contract error, not a provider failure: it must never
* count against provider circuit health.
*/
export class UnsupportedCapabilityCombinationError extends Error {
public readonly code = 'UNSUPPORTED_CAPABILITY_COMBINATION';
constructor(
message: string,
public readonly requested: Readonly<{ taskType: TaskType; searchRequired: boolean; imageCount: number }>,
) {
super(message);
this.name = 'UnsupportedCapabilityCombinationError';
}
toJSON(): Record<string, unknown> {
return { name: this.name, code: this.code, message: this.message, requested: this.requested };
}
return isSearchTask(task.type);
}
Loading
Loading