Agents/Providers — Model Selection

Focus Deep-Dive

Model resolution, fallback chains & context window sizing

The model selection system resolves which LLM model to use for each agent run, implements cascading fallback strategies when models fail, and calculates effective context windows. It bridges agent configuration, plugin hooks, and runtime overrides into a single resolved model with known capabilities.

Model Resolution Fallback Chains Context Windows Error Recovery

📚 What is this & why does it matter?

When an agent starts a run, it needs to know which model to call, how large its context window is, and what to do if that model is unavailable. Model selection answers all three questions, ensuring uninterrupted operation even when providers hit rate limits or go down entirely.

💡 Real-world analogy

Think of booking a flight. Your first choice is a direct flight on your preferred airline (primary model). If that flight is full (rate limit), the system automatically checks partner airlines (fallback chain). If your luggage is too heavy for a small plane (context overflow), it rebooks you onto a larger aircraft or repacks your bag (compaction). If every airline at the airport is grounded (all providers down), you get a detailed status board showing when each airline expects to resume (FallbackSummaryError with soonestCooldownExpiry).

Contents

Overview

Primary role

Resolve the effective model for each agent run, including context window size and provider capabilities.

Main inputs

Agent config (agents.list[].model), plugin hooks, per-run overrides, model registry from model.ts.

Key decisions

Which model to use (config vs. hook vs. default), whether to attempt fallback, which fallback strategy (next model vs. compaction).

Outputs / side effects

Resolved effectiveModel + ContextWindowInfo passed to stream wrappers, or FallbackSummaryError if all options exhausted.

flowchart TD
  subgraph Config["Configuration Sources"]
    AGENT["agents.list[].model\nAgent config"]
    HOOK["resolveHookModelSelection()\nPlugin hooks"]
    OVERRIDE["Per-run model override"]
    DEFAULT["Default model\n(system fallback)"]
  end
  subgraph Resolution["Model Resolution Pipeline"]
    CHECK["Check per-run override"]
    PLUGIN["Check plugin hooks"]
    RESOLVE["resolveEffectiveRuntimeModel()"]
    CONTEXT["Calculate ContextWindowInfo"]
  end
  subgraph Fallback["Fallback Chain"]
    FB["model-fallback.ts"]
    CLASSIFY["Error classification"]
    NEXT["Next model in chain"]
    COMPACT["Context compaction"]
    PROBE["Cooldown probe"]
  end
  subgraph Output["Result"]
    MODEL["effectiveModel +\nContextWindowInfo"]
    ERROR["FallbackSummaryError\nattempts[] + soonestCooldownExpiry"]
  end
  OVERRIDE --> CHECK
  CHECK -->|"present"| RESOLVE
  CHECK -->|"absent"| PLUGIN
  PLUGIN -->|"hook returns model"| RESOLVE
  PLUGIN -->|"no hook"| AGENT
  AGENT --> RESOLVE
  DEFAULT -.->|"last resort"| RESOLVE
  RESOLVE --> CONTEXT
  CONTEXT --> MODEL
  MODEL -->|"API error"| FB
  FB --> CLASSIFY
  CLASSIFY -->|"rate_limit"| NEXT
  CLASSIFY -->|"context_overflow"| COMPACT
  CLASSIFY -->|"timeout"| PROBE
  CLASSIFY -->|"auth"| NEXT
  NEXT --> RESOLVE
  COMPACT --> RESOLVE
  PROBE --> RESOLVE
  NEXT -->|"chain exhausted"| ERROR

The resolution pipeline checks configuration sources in priority order: per-run override first, then plugin hooks via resolveHookModelSelection(), then the agent’s configured model, and finally the system default. Once resolved, resolveEffectiveRuntimeModel() calculates the effective context window. If the model fails at runtime, the fallback chain in model-fallback.ts classifies the error and selects an appropriate recovery strategy.

Decision Points

DecisionWhat controls itWhy it matters
Per-run override vs. configPresence of modelOverride in run paramsAllows runtime model switching without changing agent config
Plugin hook modelresolveHookModelSelection() return valuePlugins can dynamically select models based on task context
Fallback strategyError category from failed attemptDifferent errors require different recovery: rate_limit ≠ context_overflow
Compaction vs. smaller modelContext overflow + compaction eligibilityCompaction preserves model quality; smaller model sacrifices capability
Cooldown probeshouldAllowCooldownProbeForReason()Avoids permanent blacklisting of transiently-failed providers
Chain exhaustionAll fallback models attempted or in cooldownDetermines whether to throw FallbackSummaryError with retry info

How It Works

Model selection involves three distinct phases: resolution (determining the initial model), context sizing (calculating effective window), and fallback (recovering from runtime failures).

Phase 1: Model Resolution

The resolveHookModelSelection() function in run/setup.ts checks agent configuration and plugin hooks in priority order to determine the starting model for a run.

// run/setup.ts — model resolution pipeline
export async function resolveHookModelSelection(
  agentConfig: AgentConfig,
  runParams: RunParams,
  pluginHooks: PluginHooks,
): Promise<ResolvedModel> {
  // Priority 1: Per-run model override
  if (runParams.modelOverride) {
    return resolveModelFromRegistry(runParams.modelOverride);
  }

  // Priority 2: Plugin hook (can inspect task context)
  if (pluginHooks.resolveModel) {
    const hookResult = await pluginHooks.resolveModel({
      agent: agentConfig,
      task: runParams.task,
      history: runParams.history,
    });
    if (hookResult?.model) {
      return resolveModelFromRegistry(hookResult.model);
    }
  }

  // Priority 3: Agent config model
  if (agentConfig.model) {
    return resolveModelFromRegistry(agentConfig.model);
  }

  // Priority 4: System default
  return resolveModelFromRegistry(DEFAULT_MODEL);
}
Key detail: Plugin hooks receive the full task context including conversation history. This allows plugins to select more capable models for complex tasks or cheaper models for simple ones, implementing dynamic model routing.

Phase 2: Context Window Sizing

Once the model is resolved, resolveEffectiveRuntimeModel() calculates the effective context window. The result is a ContextWindowInfo object used by the streaming pipeline to manage token budgets.

// run/setup.ts — context window calculation
export function resolveEffectiveRuntimeModel(
  resolved: ResolvedModel,
  thinkingLevel: ThinkingLevel,
): EffectiveRuntimeModel {
  const baseWindow = resolved.contextWindow;

  // Thinking modes reserve a portion of the context window
  const thinkingReserve = getThinkingReserve(thinkingLevel, baseWindow);

  // Effective window = base - thinking reserve - safety margin
  const effectiveWindow = Math.floor(
    (baseWindow - thinkingReserve) / SAFETY_MARGIN,
  );

  return {
    effectiveModel: resolved.modelId,
    provider: resolved.provider,
    contextWindow: {
      total: baseWindow,
      effective: effectiveWindow,
      thinkingReserve,
      safetyMargin: SAFETY_MARGIN,
    },
  };
}

interface ContextWindowInfo {
  total: number;        // Raw model context window (e.g., 200000)
  effective: number;    // Usable tokens after reserves
  thinkingReserve: number; // Tokens reserved for thinking/reasoning
  safetyMargin: number;    // Buffer multiplier (1.2 = 20%)
}
Thinking reserve impact: At high thinking levels (high, xhigh), up to 40% of the context window is reserved for internal reasoning tokens. This significantly reduces the effective window available for conversation history, which may trigger earlier compaction.

Phase 3: Fallback Chains

The model-fallback.ts module implements cascading fallback when a model fails at runtime. Error classification drives the fallback strategy:

Error CategoryFallback StrategyRationale
rate_limitNext model in chainCurrent model/provider is temporarily unavailable
context_overflowCompaction or smaller modelInput exceeds model’s context window
timeoutRetry with different providerProvider may be slow; try alternative endpoint
authSkip provider entirelyCredentials invalid; provider unusable until refreshed
server_errorRetry same model once, then nextTransient 5xx errors often resolve on retry

Fallback Cascade State Diagram

stateDiagram-v2
  [*] --> PrimaryModel : Agent run starts
  PrimaryModel --> Success : API success
  PrimaryModel --> ErrorClassify : API error

  ErrorClassify --> TryNextModel : rate_limit / auth
  ErrorClassify --> TryCompaction : context_overflow
  ErrorClassify --> RetryWithProbe : timeout

  TryNextModel --> FallbackModel1 : Chain position 2
  FallbackModel1 --> Success : API success
  FallbackModel1 --> ErrorClassify2 : API error

  ErrorClassify2 --> FallbackModel2 : Chain position 3
  FallbackModel2 --> Success : API success
  FallbackModel2 --> ChainExhausted : No more fallbacks

  TryCompaction --> CompactedRetry : History compacted
  CompactedRetry --> Success : API success
  CompactedRetry --> TryNextModel : Still overflows

  RetryWithProbe --> ProbedRetry : Short timeout retry
  ProbedRetry --> Success : API success
  ProbedRetry --> TryNextModel : Probe failed

  ChainExhausted --> FallbackSummaryError : All options exhausted

  Success --> [*]
  FallbackSummaryError --> [*]

  note right of FallbackSummaryError
    Contains:
    - attempts: FallbackAttempt[]
    - soonestCooldownExpiry: number | null
  end note

The state diagram shows the full fallback cascade. Each failed attempt is recorded as a FallbackAttempt in the summary. If all options are exhausted, a FallbackSummaryError is thrown containing the full attempt history and the soonest cooldown expiry time across all tried providers.

FallbackSummaryError contract: Callers receiving this error can inspect soonestCooldownExpiry to determine when to retry. If it’s null, all failures were permanent (auth errors) and retrying won’t help without credential changes.

Annotated Code Walkthrough

Walk through the fallback chain logic in model-fallback.ts.

The fallback runner wraps the main execution function and catches model-level errors:

// model-fallback.ts — fallback orchestrator
export async function runWithFallback<T>(
  chain: FallbackChain,
  execute: (model: ResolvedModel) => Promise<T>,
): Promise<T> {
  const attempts: FallbackAttempt[] = [];
  let soonestCooldown: number | null = null;

  for (const entry of chain.models) {
    try {
      const resolved = resolveModelFromRegistry(entry.modelId);
      const result = await execute(resolved);
      return result; // Success — return immediately
    } catch (err) {
      const category = classifyError(err);
      attempts.push({
        modelId: entry.modelId,
        error: category,
        timestamp: Date.now(),
      });

      // Track cooldown for retry-after info
      if (category === "rate_limit" && err.retryAfter) {
        const expiry = Date.now() + err.retryAfter * 1000;
        if (!soonestCooldown || expiry < soonestCooldown) {
          soonestCooldown = expiry;
        }
      }

      // Context overflow: try compaction before next model
      if (category === "context_overflow" && !entry.compactionAttempted) {
        entry.compactionAttempted = true;
        // Re-queue same model after compaction
        chain.models.splice(chain.models.indexOf(entry) + 1, 0, entry);
        continue;
      }

      // Auth errors: skip entire provider
      if (category === "auth") {
        markAuthProfileFailure(entry.modelId, err);
        continue;
      }
    }
  }

  throw new FallbackSummaryError(attempts, soonestCooldown);
}

Error classification maps HTTP status codes and error messages to fallback categories:

// model-fallback.ts — error classification
function classifyError(err: unknown): ErrorCategory {
  if (err instanceof ProviderError) {
    switch (err.status) {
      case 429: return "rate_limit";
      case 401:
      case 403: return "auth";
      case 413: return "context_overflow";
      default:
        if (err.status >= 500) return "server_error";
    }

    // Check error message for context overflow signals
    if (err.message?.includes("context_length_exceeded")) {
      return "context_overflow";
    }
    if (err.message?.includes("rate limit")) {
      return "rate_limit";
    }
  }

  if (err instanceof TimeoutError) return "timeout";

  return "unknown";
}
Defense in depth: The classifier checks both status codes and error message content. Some providers return 400 (not 413) for context overflow, embedding the real reason in the message body. The message-based fallback catches these cases.

The cooldown probe mechanism allows a single retry of recently-failed providers:

// model-fallback.ts — cooldown probe
export function shouldAllowCooldownProbeForReason(
  reason: ErrorCategory,
  lastFailure: FallbackAttempt,
): boolean {
  // Only allow probes for transient errors
  if (reason !== "rate_limit" && reason !== "timeout") {
    return false;
  }

  // Must have been at least 10s since last failure
  const elapsed = Date.now() - lastFailure.timestamp;
  if (elapsed < 10_000) return false;

  // Only one probe per cooldown cycle
  if (lastFailure.probeAttempted) return false;

  lastFailure.probeAttempted = true;
  return true;
}

OpenRouter Capability Resolution

When the resolved model is served through OpenRouter, openrouter-model-capabilities.ts fetches capability flags from OpenRouter’s API to determine which features the underlying model supports:

// openrouter-model-capabilities.ts — capability resolution
interface OpenRouterCapabilities {
  supportsToolCalls: boolean;
  supportsStreaming: boolean;
  supportsJsonMode: boolean;
  maxContextWindow: number;
  maxOutputTokens: number;
}

export async function resolveOpenRouterCapabilities(
  modelId: string,
): Promise<OpenRouterCapabilities> {
  const metadata = await fetchOpenRouterModelMetadata(modelId);
  return {
    supportsToolCalls: metadata.supported_parameters?.includes("tools") ?? false,
    supportsStreaming: metadata.supported_parameters?.includes("stream") ?? true,
    supportsJsonMode: metadata.supported_parameters?.includes("response_format") ?? false,
    maxContextWindow: metadata.context_length ?? 4096,
    maxOutputTokens: metadata.top_provider?.max_completion_tokens ?? 4096,
  };
}
OpenRouter diversity: Because OpenRouter proxies to many different providers, capability flags can vary widely. A model that supports tool calls through one OpenRouter provider may not support them through another. The capability resolution ensures the agent adapts its behavior accordingly.

Forward Compatibility Testing

The model.forward-compat.test.ts file ensures that new models added to the registry don’t break existing resolution logic. These tests verify that:

Edge Cases & Error Handling

ScenarioBehaviorCode Location
Unknown model IDFalls through to system default modelresolveHookModelSelection()
Plugin hook throwsError caught; falls through to agent config modelrun/setup.ts
All fallback models in cooldownFallbackSummaryError with soonestCooldownExpirymodel-fallback.ts
Context overflow after compactionMoves to next model in fallback chain with smaller context requirementmodel-fallback.ts
OpenRouter API unreachableFalls back to default capability flags (streaming only, no tools)openrouter-model-capabilities.ts
Thinking level exceeds context budgetThinking reserve is clamped to max 50% of context windowresolveEffectiveRuntimeModel()
Concurrent run with different overridesEach run resolves independently; no shared state mutationrun/setup.ts

Configuration

Knob / ParameterDefaultDescription
agents.list[].modelSystem defaultPer-agent model configuration in the agent list
Fallback chainConfigured per agentOrdered list of models to try on failure
Thinking leveloffThinking/reasoning mode: off, minimal, low, medium, high, xhigh, adaptive
SAFETY_MARGIN1.220% buffer applied to context window calculations
Probe timeout10000 (10s)Minimum time before allowing a cooldown probe retry
Max compaction attempts1Number of compaction retries before moving to next fallback model
DEFAULT_MODELSystem-configuredLast-resort model when no config or hooks provide a model

See also:

Directory Map

Source files for model selection
pi-embedded-runner/
├── model.ts                              ← Model registry & resolution (24 KB)
├── model.provider-normalization.ts       ← Provider ID normalization
├── model-fallback.ts                     ← Cascading fallback strategy
├── model.forward-compat.test.ts          ← Forward compatibility tests
├── openrouter-model-capabilities.ts      ← OpenRouter capability flags
└── run/
    └── setup.ts                          ← resolveHookModelSelection(), resolveEffectiveRuntimeModel()