providers/transports

Every LLM call goes through a ProviderHandle. The runtime stays wire-agnostic (request shapes are LlmRequest, response shapes are LlmResponse) and each provider crate handles the schema normalization, auth flow, transport, model policy, and cache-marker placement specific to its vendor.

Provider Boundary

The runtime hands every turn's LlmRequest to whichever ProviderHandle the session was opened with and expects back a normalized LlmResponse stream. Anything wire-specific stays inside the provider crate.

Modes (standard, rlm) and canonical tool definitions never see provider-specific JSON. They operate on LlmRequest, LlmResponse, and ToolDefinition only.

First-Party Providers

Three provider crates ship with the workspace, contributing five provider kinds: lash-provider-openai supplies both the direct Responses path and the generic Chat Completions path. The CLI compiles in all five and materializes each from its provider factory keyed on the spec kind. App hosts pick the provider factories they need.

ProviderKindTransport / Auth
OpenAI API (direct)openaiBearer API-key auth against https://api.openai.com/v1/responses. Responses-only path; handles Responses reasoning replay and prompt-cache fields.
OpenAI-compatibleopenai-compatibleBearer API-key auth against a caller-supplied base_url; posts Chat Completions to {base_url}/chat/completions. Used for OpenRouter, Together, vLLM, etc.
Codex subscriptioncodexOAuth device-code flow against ChatGPT Codex Responses backend with Codex-specific headers.
AnthropicanthropicAPI-key auth against /v1/messages with Anthropic version header and beta flags.
Google Gemini / Code Assistgoogle_oauthGoogle OAuth PKCE / manual-code flow against Code Assist generateContent / streamGenerateContent.

Provider specs remain host config data. Runtime sessions persist only provider ids and bind them to the single live ProviderHandle supplied by the host when the session opens.

OpenAI Provider Split

OpenAI ships as two distinct provider kinds because the Responses API and the Chat Completions API are different enough to deserve different code paths.

openai

Direct OpenAI Responses. Posts to https://api.openai.com/v1/responses, keeps Responses reasoning replay, and maps shared ProviderOptions.cache_retention to prompt_cache_key derived from the Lash session id. Long retention adds prompt_cache_retention where the API supports it. No base_url accepted.

openai-compatible

Generic Chat Completions. Requires base_url. Converts LlmRequest to a messages array, emits Chat Completions tools, and maps structured output to response_format. Endpoint behavior is host-supplied through OpenAiCompat; OpenAiCompat::openrouter() explicitly enables OpenRouter reasoning format and session affinity for OpenRouter itself or a compatible proxy. The provider never infers those capabilities from base_url.

Attachment MIME Boundaries

Attachment sources are MIME-tagged in the Lash contract, but materialization is a transport capability decision. The Agent Workbench intentionally exposes a PNG-only upload control; that host-surface boundary must not be confused with the per-provider allowlists enforced before a request reaches the wire.

TransportCapability sourceWire shape
OpenAI ResponsesOPENAI_IMAGE_MIMES + OPENAI_FILE_MIMESinput_image.image_url for images; input_file.file_data for files
OpenAI-compatible ChatOPENAI_IMAGE_MIMESimage_url.url
Anthropic MessagesANTHROPIC_IMAGE_MIMES + ANTHROPIC_FILE_MIMESimage or document base64 source blocks
Google GeminiGOOGLE_IMAGE_MIMES + GOOGLE_FILE_MIMES + GOOGLE_MEDIA_FAMILIESinlineData.mimeType for inline/stored bytes; provider files use fileData

Every allowlisted image/file MIME has a deterministic provider fixture law. A valid but unsupported MIME or source combination returns unsupported_attachment_capability with ProviderFailureKind::Validation; adapters do not silently drop the attachment or delegate this contract to a raw provider error. Live provider probes are not part of this contract.

Host-Supplied Cache-Control Dialects

For OpenAI-compatible Chat Completions, the host attaches ModelCapability.cache_control to the selected model route. None emits no marker; CacheControlDialect::Anthropic and ::Gemini select the exact wire placement. Model ids and endpoint URLs are never inspected. Shared ProviderOptions.cache_retention then controls whether and how long the selected dialect caches:

none

No markers emitted. Each request is treated as fresh.

short

Emits {"type":"ephemeral"} at the provider-specific breakpoints for the default short cache window.

long

Adds "ttl":"1h" for the Anthropic dialect. The Gemini dialect remains plain ephemeral because that dialect does not support the one-hour TTL.

Breakpoints are placed at:

  1. The first system/developer text message.
  2. The last tool definition in the request.
  3. Any explicit LlmContentBlock::Text.cache_breakpoint the runtime asks for.

The Anthropic dialect uses all three canonical placements. The Gemini dialect emits exactly one marker: the explicit text breakpoint when present, otherwise the last user/assistant text content. Internal breakpoint markers are always removed from the wire request.

Session affinity is separate endpoint capability data. Set OpenAiCompat.cache_session_affinity (or explicitly select OpenAiCompat::openrouter()) to emit the bounded body session_id and call-specific x-client-request-id. It is disabled by default, including when base_url happens to equal the canonical OpenRouter URL.

Provider routing is likewise endpoint capability data. Set OpenAiCompat.provider_routing (or select OpenAiCompat::openrouter()) to emit the top-level provider object. With require_parameters, the gateway routes only to upstream backends supporting every parameter sent, rather than silently dropping the ones an upstream does not implement — a dropped response_format otherwise yields free-written JSON under a nominal finish_reason: "stop". The openrouter() preset does not set it: restricting the routing pool trades cost, latency and availability against contract enforcement, and that trade belongs to the host. Once set it is emitted on every request, not only schema'd ones — any parameter the adapter sends is one the caller relies on.

GenerationReceipt.cache reports the fate of explicit cache breakpoints. OpenAI and Anthropic report applied when their assembled body carries the intent; direct Google reports omitted_unsupported, making an RLM cache fence observable instead of silently dropping it.

Allowlisted Response Metadata

ProviderOptions.response_metadata_headers and response_metadata_body_paths configure one shared transport capture path for OpenAI, Anthropic, and Google. Header matching is case-insensitive; buffered JSON and every SSE event are probed by JSON pointer, with the last value winning. Both allowlists default empty, and no unlisted header or body field enters LlmResponse.response_metadata.

RLM Prompt Caching

RLM projects chronological history as append-only chat-shaped messages: user inputs remain user messages, prior RLM steps become assistant messages, tool observations become user messages, and the mutable current-iteration/finalization prompt stays as the final user message. The rolling cache_breakpoint is placed on the last stable history text block, so either host-selected Chat cache dialect can cache a real prefix instead of a rewritten history blob each turn.

The result: long RLM sessions get the same cache-hit rate as native multi-turn chats, even though Lashlang-driven reasoning regenerates a fresh prompt on every iteration.

Reliability Policy

ProviderOptions.reliability configures request timeouts, retry behavior, and local rate limiting for a ProviderHandle. The facade reexports the knobs under lash::provider.

timeouts

ProviderReliability.request_timeout and chunk_timeout map to LlmTimeouts. By default, requests have a 300 second overall timeout and streams have a 120 second chunk timeout. Set RequestTimeout::Disabled only when an outer workflow or transport layer owns cancellation.

retries

ProviderRetryPolicy defaults to four attempts with exponential delay capped at 10 seconds and Retry-After capped at 60 seconds. ProviderReliability::disabled() turns retries off for hosts that already retry at a higher layer.

stream termination

StreamTermination::RequireTerminalEvidence rejects clean EOF unless the wire dialect proved completion: Chat Completions needs a nonempty finish_reason, Responses/Codex needs a terminal response event, and Anthropic needs message_stop after message_start. Google defaults to EofTolerated; other hosts may select that compatibility policy explicitly through ModelCapability.stream_termination, OpenAiCompat.stream_termination, or the direct-provider builder. URLs and model names never select it.

A missing terminal is a retryable stream error. The failed attempt retains partial content, normalized usage, raw provider_usage, and execution evidence in the ADR 0032 ledger. A whole-call retry resets attempt-local parts first, so a truncated tool call is never executed or merged into the next attempt. User cancellation remains distinct and non-retryable.

throttles

A retryable quota failure carrying Retry-After (an HTTP 429) is a throttle, not a failure: the ladder waits as instructed without consuming a retry attempt. The deference is bounded by the cumulative throttle_wait_budget_ms (default 90 seconds per call; 0 disables it, and each deferred wait charges at least one second), after which throttles count as ordinary retryable failures. A 429 without Retry-After follows the normal backoff-and-count ladder.

rate limits

ProviderRateLimitPolicy can cap max concurrency, requests per window, and tokens per window before a provider request is admitted.

DialectDefaultRequired evidence
OpenAI Chat CompletionsRequireTerminalEvidenceNonempty finish_reason
OpenAI / Codex ResponsesRequireTerminalEvidenceresponse.completed, response.incomplete, response.failed, or response.done
Anthropic MessagesRequireTerminalEvidencemessage_stop after message_start
Google Gemini / Code AssistEofToleratedClean EOF; a present finishReason still determines the normalized terminal reason
use lash::provider::{ProviderReliability, RequestTimeout};

let reliability = ProviderReliability::default()
    .request_timeout(Some(RequestTimeout::Millis(120_000)))
    .stream_chunk_timeout_ms(Some(30_000))
    .max_attempts(3)
    .max_concurrency(Some(8))
    .requests_per_window(Some(120), Some(60_000));

Wrapping Providers

lash ships no admission-control machinery in the dispatch path (docs/adr/0015-admission-control-lives-in-provider-decorators.md). The Provider trait is deliberately dyn-compatible and decorator-friendly: hosts own admission — in-flight windows, priority lanes, circuit breakers, backpressure metrics — by wrapping the provider they install. A decorator's complete() sees every provider call, including each attempt of the retry ladder.

/// Host-owned admission: bounded in-flight windows per traffic class,
/// wrapped around the provider the host installs. Breakers, AIMD windows,
/// and backpressure metrics slot into the same `complete()` seam.
#[derive(Debug)]
struct AdmissionGate {
    inner: Box<dyn Provider>,
    interactive_slots: Arc<tokio::sync::Semaphore>,
    batch_slots: Arc<tokio::sync::Semaphore>,
}

impl Clone for AdmissionGate {
    fn clone(&self) -> Self {
        Self {
            inner: self.inner.clone_boxed(),
            // Shared handles: every clone admits through the same windows.
            interactive_slots: Arc::clone(&self.interactive_slots),
            batch_slots: Arc::clone(&self.batch_slots),
        }
    }
}

#[async_trait::async_trait]
impl Provider for AdmissionGate {
    async fn complete(&mut self, request: LlmRequest) -> Result<LlmResponse, LlmTransportError> {
        // Class traffic by the session identity the host already owns:
        // deliberate ids for its own pipelines, and a default lane for ids
        // it did not mint (lash-spawned child sessions).
        let lane = if request.scope.session_id.starts_with("batch:") {
            &self.batch_slots
        } else {
            &self.interactive_slots
        };
        // The permit drops on every exit path — success, failure, or a
        // cancelled turn — so an aborted call never leaks a slot.
        let _slot = lane.acquire().await.expect("admission gate closed");
        self.inner.complete(request).await
    }

    // Forward `close()` explicitly: the default impl is a no-op and would
    // silently skip the inner provider's transport shutdown.
    async fn close(&self) -> Result<(), LlmTransportError> {
        self.inner.close().await
    }

    fn kind(&self) -> &'static str {
        self.inner.kind()
    }
    fn route_identity(&self, model: &str) -> ProviderRouteIdentity {
        self.inner.route_identity(model)
    }
    fn options(&self) -> ProviderOptions {
        self.inner.options()
    }
    fn set_options(&mut self, options: ProviderOptions) {
        self.inner.set_options(options);
    }
    fn serialize_config(&self) -> serde_json::Value {
        self.inner.serialize_config()
    }
    fn requires_streaming(&self) -> bool {
        self.inner.requires_streaming()
    }
    fn clone_boxed(&self) -> Box<dyn Provider> {
        Box::new(self.clone())
    }
}

Install the wrapper with ProviderComponents::map_provider wherever the handle is constructed:

let interactive_slots = Arc::new(tokio::sync::Semaphore::new(8));
let batch_slots = Arc::new(tokio::sync::Semaphore::new(2));
let handle = ProviderHandle::new(components.map_provider(|inner| {
    Box::new(AdmissionGate {
        inner,
        interactive_slots,
        batch_slots,
    })
}));
forward close()

The trait's default close() is a no-op. A wrapper that omits the forward silently skips the inner provider's transport shutdown (Codex sends WebSocket Close frames on its cached sessions). Forward it explicitly.

re-wrap on rebuild

serialize_config forwards to the inner provider, so a host that persists that config and rebuilds from it gets the bare provider — the wrapper does not survive persistence. Apply map_provider at every construction site (host provider resolution, resume paths), not once at startup.

cancellation-safe admission

The runtime aborts the in-flight provider call when a turn is cancelled, dropping the complete() future at an await point. Anything acquired before the inner call must release on drop — semaphore permits do this naturally; a hand-rolled counter needs a drop guard.

class by session id

scope.session_id is contractually present on every provider request. Mint deliberate ids for pipelines you want to class (direct completions accept a host-supplied session id and otherwise scope as direct:{uuid}), and give ids you did not mint — lash-spawned child sessions — a default lane rather than rejecting them.

The retry ladder wraps the decorator, so core stays honest about throttling on its own (see the throttles row above): an admission gate cannot compensate for a ladder that would turn a 429 storm into attempt exhaustion. The admission use-case pairs with the metrics guidance on operations.

Usage And Cost Inputs

Every provider returns a normalized LlmUsage to the runtime usage ledger after each completion. Chat-parsing handles both streaming and non-streaming usage chunks, including OpenRouter cache fields. Cache-write tokens are tracked separately from cached reads, and reasoning tokens are treated as an output subset rather than an additive total, so downstream cost/export code can price each bucket without double-counting.

pub struct LlmUsage {
    pub input_tokens: i64,              // Uncached ordinary input
    pub cache_read_input_tokens: i64,   // Prompt input read from cache
    pub cache_write_input_tokens: i64,  // Prompt input written to cache
    pub output_tokens: i64,             // Total generated output
    pub reasoning_output_tokens: i64,   // Subset of output tokens
    // …provider-specific extras flow through the extended trace
}

Adding A Provider

New providers implement five focused components and a factory:

State

Per-handle config: API key, base URL, default model, options like thinking exposure.

Auth

Bearer header, OAuth refresh, device-code flow, whatever the vendor requires. Auth state is opaque to the runtime. OAuth backends (Codex, Google) share token storage and refresh primitives from lash-provider-auth; vendor-specific endpoints, device-code, and PKCE helpers live in each provider's OAuth module: oauth.rs for Google, codex/oauth.rs for Codex.

Readiness

Optional pre-flight check (token refresh, capability probe) that runs once per session.

Transport

The actual HTTP call. Translates LlmRequest to the wire format, streams the response, normalizes back to LlmResponse chunks.

Model policy

Maps user-facing model + variant names to model-native ids, declares structured-output / tool-call / thinking capabilities per model.

Factory

Materializes host config into provider components; ProviderHandle::new(components) assembles the five pieces into the live handle the runtime can use.

See lash-provider-openai as the most general template: it handles both the direct Responses path and the generic Chat Completions path in one crate.

read on ·