@alma-harness/providers
v0.41.0
Published
ModelClient adapters for Anthropic and OpenAI over Alma's neutral message format.
Readme
@alma-harness/providers
ModelClient adapters for Alma:
Anthropic and OpenAI first-party, plus the OpenRouter gateway bridge, over one
neutral message format.
Status: pre-1.0. The API is still moving; see the roadmap for where it stands.
What it owns
AnthropicModelClient— Messages API, streaming, with explicitcache_controlbreakpoints (at the wire's default duration, or the hour a policy asks for — spec: cache-ttl; the other two wires refuse the ask) at the stable-system boundary, on each declared system breakpoint, and at the conversation tail.OpenAIModelClient— Responses API, streaming, automatic prefix caching.OpenRouterModelClient— the gateway bridge (chat completions). A gateway does not answer "who processed this data?" by itself, so construction REQUIRES a declared upstream allowlist, fallbacks are off by default, data collection is denied by default, andrequire_parametersrides along whenever the request carries tools. Opening the catalogue changes nothing about what the routing policy permits — that stays a product/DPO decision.- The translation between Alma's neutral
Msg/Blockformat and each provider's wire format, in both directions.
Routing decides which client runs; the adapter only speaks the protocol.
import { OpenRouterModelClient } from "@alma-harness/providers";
const client = new OpenRouterModelClient({
upstreams: ["deepinfra", "together"], // the declared data-processing chain
});Credentials
The harness never owns credentials. Each client accepts an explicitly injected key or falls back to the SDK's own environment resolution — configuration comes from the hosting environment, never from a file this repo reads.
import { AnthropicModelClient } from "@alma-harness/providers";
const client = new AnthropicModelClient(); // reads ANTHROPIC_API_KEY
const explicit = new AnthropicModelClient({ apiKey }); // or inject itThe Anthropic clients speak API version 2026-10-21 (@anthropic-ai/sdk 1.x)
and send the key as Authorization: Bearer, not X-Api-Key. Without an
injected key the SDK uses ONE credential: the first set of
ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_PROFILE, then its
other sources; a profile that does not exist fails before any request, as a
ProviderError of kind provider_drift. A gateway that reads X-Api-Key
must read Authorization instead. An injected key is the only credential
sent: an Authorization or X-Api-Key in ANTHROPIC_CUSTOM_HEADERS cannot
replace it or ride beside it. The SDK's OpenTelemetry spans and trace
propagation are off on every Anthropic client, whatever the host registered
or the ANTHROPIC_OPEN_TELEMETRY* variables say (spec: anthropic-sdk-v1).
Logging
ANTHROPIC_LOG/OPENAI_LOG have no effect; clients log the SDK's message,
never its details (spec: sdk-log-level).
Jobs
AnthropicJobClient (Message Batches) and OpenAIJobClient (the Batch API,
over a JSONL file of /v1/responses requests) implement the ModelJobClient
seam (spec: model-jobs): the same request translations, minus streaming, and
a non-streaming translation of the complete answer. OpenRouter has no batch
API.
Reasoning
When the policy sets ModelChoice.reasoning (spec: reasoning-blocks), each
adapter asks for it and hands it back as one complete block per step:
| effort | Anthropic | OpenAI | OpenRouter |
|---|---|---|---|
| absent | nothing sent; default output dropped | nothing sent; default items dropped | nothing sent |
| none | thinking: disabled | reasoning.effort: none | reasoning.enabled: false |
| others | thinking: adaptive + output_config.effort | reasoning.effort + encrypted content | reasoning.effort |
An adapter replays only its own blocks, and only when reasoning is on for the request; every other reasoning block in the history is skipped.
Provider-executed web search
ModelRequest.providerTools (spec: provider-tools) declares the provider's
own search beside the registered tools; what comes back is two neutral
blocks, provider_tool_call and provider_tool_result, the provider's
payload kept opaque for replay.
| | Anthropic | OpenAI | OpenRouter |
|---|---|---|---|
| declaration | web_search_20250305 (the direct search; the agentic 2026-03-18 version drives code_execution and is another kind) with max_uses, allowed_domains, blocked_domains | { type: "web_search", filters: { allowed_domains } } + include: ["web_search_call.action.sources"]; blockedDomains is REFUSED before the network; maxUses is the loop's to enforce | refused before the network |
| on the stream | server_tool_use → call; web_search_tool_result → result (url, title, page age; an error code as error); usage.server_tool_use.web_search_requests | a completed web_search_call item → call (input: action) and result (the sources; failed as an error), counted | — |
| replay | its own two blocks as server_tool_use + web_search_tool_result with the encrypted content, only while the request declares the kind (a capped step replays the text and skips the search); another provider's skipped | its own result's opaque as the item, only while the request declares the kind; the call block skipped | skipped |
| pause_turn | → the neutral pause: the loop re-sends | — | — |
Media
| | Anthropic | OpenAI | OpenRouter |
|---|---|---|---|
| image by URL | source: { type: "url" } | input_image with the URL | image_url with the URL |
| image by bytes (spec: media-by-bytes) | base64 source; JPEG, PNG, GIF, WebP, else refused | input_image with a data URL | image_url with a data URL |
| document | by URL: document source · by bytes: base64 PDF, else refused | by bytes only: input_file with a data URL and the ref's filename, PDF · by URL refused | refused |
| audio | refused — products transcribe upstream | refused | refused |
A block carries bytes when the loop attached content from the product's MediaSource; the adapters never fetch anything and never see the ref's path on the bytes path.
Structured output and cache controls
ModelRequest.output selects native JSON schema output (spec:
provider-request-controls). It introduces no tool or business handler. Responses
still arrive as text and usage/stop events: validate the result and inspect
refusal/truncation before treating it as application data.
| Control | Anthropic | OpenAI | OpenRouter |
|---|---|---|---|
| output: {type: "json_schema", name, schema} | output_config.format with type/schema, preserving reasoning effort | text.format with strict: true | refused |
| format name | validated portable label; no wire slot, intentionally omitted | required wire name | refused |
| cache: false | removes every explicit breakpoint, declared ones included | refused | refused |
| cache: {ttl, conversationTail: false} | only the system breakpoints remain: the last stable block and the declared ones | refused | refused |
| absent controls | existing defaults | existing defaults | existing defaults |
Names use 1–64 ASCII letters, digits, underscores or dashes. Schemas must be objects and comply with the selected provider's strict schema subset; arbitrary tool input schemas are not automatically converted. Model-specific schema support is validated by the provider. Cache controls preserve message roles and order; disabling the tail prevents a system suffix from being included in that breakpoint. Disabling prompt cache is not a data-retention or erasure setting.
Declared system breakpoints (spec: system-breakpoints). A stable
SystemBlock with cacheBreakpoint: true marks where a prefix shared beyond
the conversation ends, such as instructions before a person's memory. Without
it, the one breakpoint at the end of the stable system covers the per-person
part, and a new person writes the shared prefix again.
| | Anthropic | OpenAI GPT-6 | OpenAI, older models · OpenRouter |
|---|---|---|---|
| declared breakpoint | cache_control on the block, at the request's duration | an explicit prompt_cache_breakpoint on the block | nothing: the system is one string, and automatic prefix caching (where the upstream has it) reuses any matching prefix |
| limit | four cache_control blocks, counting the declared ones, the last stable block and the conversation tail; more is refused | none documented | — |
The breakpoint is valid only on a stable block of the request before any volatile one, since a prefix holding volatile text changes every turn. Every adapter refuses one anywhere else, or a value other than a boolean, before the network. The loop sends configured volatile blocks in the message, so in a loop configuration only the stable blocks' order counts.
Streaming and batch reuse these mappings (toAnthropicBatchRequests and
toBatchLines expose the pure batch translations). Unsupported options reject
before network activity, including batch file upload. These fixtures prove
serialization against installed SDK types, not live model acceptance.
Deferred tools, loaded natively
A request carries the turn's deferred set in deferredTools, and a search's
result names what it loaded in loads (spec: stable-tool-advertisement).
Where the wire can, a load leaves the tool list, first in the cached prefix,
unchanged (spec: native-tool-loading):
| | Anthropic (models from 4.5) | OpenAI (gpt-5.4 and later) | OpenRouter, older models |
|---|---|---|---|
| deferred tools | declared on every request with defer_loading: true, after the others | not declared | not sent |
| a load | the result that first loaded it holds only tool_reference blocks (the API refuses them beside text); that search's report is not sent | an additional_tools item after that result's output | the tool joins tools |
A tool is loaded natively only when the request lists it both in tools and
in deferredTools and a result names it; one callable but no longer named by
any result is sent as an ordinary tool. A named toolChoice, or an Anthropic
request that would send no tool undeferred, renders as listed. A test renders
the host's reload turn through every adapter and checks each request extends
the last byte for byte (test/wire-prefix.test.ts).
Arguments the wire cut
A tool call whose arguments do not parse — a response cut by max_tokens
mid-arguments, or a model that emitted broken JSON — is still a tool_call
(spec: what-the-wire-cuts): input: {} and the raw text as malformed,
followed by the wire's own stop reason. The loop answers a malformed call
with an invalid-input result on tool_use, and closes it on max_tokens.
The translators never throw on it.
Errors
What an SDK throws leaves every client as a ProviderError from
@alma-harness/core (spec: error-taxonomy), classified through one
duck-typed table over the fields the SDKs share: OpenAI's out-of-credit
refusal (insufficient_quota, credit_balance_exhausted) → rejected, as a
429 or as an error inside the stream (spec: in-stream-quota-rejection); 429
→ rate_limited; 529, 503 or an overloaded body
→ overloaded; no status or 5xx → unavailable; a 400 that says the prompt
does not fit → context_window; the other 4xx → rejected. A user abort
passes through untouched. The translation error classes are
ProviderErrors too — rejected before the network, provider_drift
mid-stream — and toProviderError / classifyFailure are exported for a
product wrapping its own client. A single-dispatch drift names its case and the
last event's name, e.g. openai stream ended without a terminal event (last
event: response.output_text.delta) (spec: drift-detail).
What it must never do
- Decide routing, spend, or capability. Those are core, not adapter.
- Leak a provider type into the neutral format — the format is the boundary that makes a second provider cheap.
- Swallow provider drift: a stream that ends without a stop event fails loudly rather than looking like a successful empty turn.
Documentation
Docs index · Architecture §6.2 · Anthropic adapter spec · OpenAI adapter spec
Apache-2.0
Opt-in single-dispatch clients
createAnthropicSingleDispatchClient({ apiKey?, baseURL? }) and
createOpenAISingleDispatchClient({ apiKey?, baseURL? }) return a frozen
SingleDispatchModelClient. They disable SDK retries and HTTP redirects and
withhold authentication-provider, middleware and transport injection options.
The existing constructors keep their current retry/usage behavior.
The capability accepts the same neutral tools, tool results, supported media and
reasoning history as the first-party translators, plus supported system, JSON
schema, reasoning, tier and cache controls (spec: rich-single-dispatch-requests).
It executes no local tool handler; the host owns authorization and sensitivity. validate is pure and
streamEvidence validates/clones before its first network await. These checks
prove local adapter support, not model-specific schema acceptance. Clone errors
are content-free rejected ProviderErrors. Consume each returned stream once.
Known final counts are emitted before stop or a terminal translation error.
Missing/invalid/partial usage never becomes zero. unpriced preserves counts
for unknown served tiers or missing/mixed/inconsistent cache TTL attribution, and must
not be sent to pricing with a fallback. Anthropic final cumulative updates win;
OpenAI input excludes cache reads AND writes to avoid counting writes twice.
OpenAI search counts use unique completed item identities and terminal output when
present; missing/conflicting attribution remains unknown. Anthropic search activity
requires a valid positive cumulative search count. Known and unpriced evidence
retain search counts without copying queries or URLs into billing metadata.
Only bounded provider request IDs enter reference events, including HTTP errors.
Content events remain content: never store the whole evidence stream as a
financial row. OpenAI still sends store: false.
The legacy stream method on these new clients converts known evidence to a
usage event and fails on unknown/unpriced evidence. Drain streamEvidence to
retain known usage even if a later error occurs. Pre-abort is not-dispatched;
all ambiguous network outcomes require reconciliation. An early return aborts
transport even immediately after headers, but cannot promise final usage.
This is transport evidence, not durable deduplication or invoice reconciliation.
Reasoning history replays through the existing translator when reasoning is enabled and the block belongs to that provider. Preserve opaque replay content unchanged. This support does not itself adopt durable multi-turn execution. Reusing a stream fails with a rejected ProviderError. Caller cancellation preserves the AbortSignal reason, including custom errors.
Explicit temperature
temperature is optional and sent unchanged in streaming and batch bodies. The
matrix admits OpenAI GPT-5.1/GPT-5.2 base IDs or dated snapshots with explicit
reasoning none, in 0..2. Pro/chat/codex variants and OpenRouter reject it.
Anthropic rejects every value, 1 included: API version 2026-10-21 removed
temperature, top_p and top_k, and answers any of them with a 400
(spec: anthropic-sdk-v1). Omit it for provider defaults. Invalid/unsupported
values fail before network or batch upload; zero is preserved. There is no
automatic downgrade or determinism guarantee.
Single-dispatch batch evidence
createAnthropicSingleDispatchBatchClient and
createOpenAISingleDispatchBatchClient implement execution's additive
SingleDispatchBatchClient. Frozen capabilities admit apiKey/baseURL only,
with SDK retries disabled and redirects rejected. Legacy job clients are unchanged.
Validate 1..512 unique batch-tier items (IDs: ASCII alphanumeric, underscore or
hyphen, max64), one model, and a 16 MiB wire bound before network. Requests and
handles are snapshotted before asynchronous work. Use a trusted immutable
configuration for baseURL/account selection.
Consume submitEvidence exactly once after a durable dispatch:true decision. OpenAI yields input_file before its separate create POST, then accepted. Save needed file evidence before resuming. Lost responses never authorize another attempt; creating a new iterable is a new attempt. Resolve handles and expected item IDs from the scoped durable manifest before polling or collecting: the adapter itself provides no tenant authorization.
resultsEvidence requires terminal status and yields independently validated usage and content, including when translation fails. Cancellation in progress remains running. Both OpenAI result files are read, even for terminal cancellation. Anthropic result URLs must match the configured origin and batch results path before authenticated retrieval. JSONL is incremental, with a 2 MiB line/32 MiB collection limit, max512 expected IDs, and readers cancelled on abandonment. Unknown, duplicate or missing IDs fail the collection. Prior observations survive such failure and must not be interpreted as complete collection or free remainder.
Copies at the provider (spec: provider-batch-retention).
- Expiry: the OpenAI clients (
createOpenAISingleDispatchBatchClientand the legacyOpenAIJobClient) takeretentionSeconds, three days by default and 172,800–2,592,000 allowed. The input file and the output and error files expire that long after each is created, instead of OpenAI's default 30 days. Collect a batch to its end within that time after it finishes, or its results and usage are lost. Anthropic keeps a batch 29 days and offers no expiry. discard(handle)deletes a finished batch's copies: the OpenAI input, output and error files, or the Anthropic batch. A deletion counts only when the provider acknowledges that copy, or answers that it is already gone; then it returnsdiscarded. A running batch is cancelled once and reportedpending. It never retries. An OpenAI batch the account does not know, or one that does not name the files holding its input and results, throws.- What stays: the OpenAI batch object (identifiers, counts, error messages), which OpenAI cannot delete; prompt caches; and provider logs.
Batch transport supplies batch billing for compatible default tier fields; contradictory tiers and mixed cache TTLs remain unpriced. Missing counts stay unknown. Content has no duplicate usage field; provider errors have closed reasons. Server-tool content may be unusable while its validated search charges survive. These are adapter prerequisites, not durable collection or settlement.
Governed OpenRouter requests
createOpenRouterSingleDispatchClient({ upstreams: ['anthropic'], apiKey })
implements the same SingleDispatchModelClient capability as the first-party
factories. It snapshots the declared upstream list, denies data collection by
default, and disables gateway fallbacks, SDK retries and HTTP redirects. Optional
zeroDataRetention and explicit dataCollection retain the gateway translator's
controls. allowFallbacks: true is rejected; the legacy constructor remains
unchanged until cutover. The key defaults only from OPENROUTER_API_KEY.
The adapter preserves the current text/tool/image/reasoning translator and its unsupported-control refusals. Financial evidence uses native token counts from the final usage chunk: ordinary input excludes cache reads and writes, and reasoning is a subset of output. Positive cache writes without a TTL are unpriced; missing or malformed counts are unknown. A context-window HTTP error never proves zero usage. Candidate counts stay private until the stream closes or fails; contradictory observed chunks fail without an early known charge. The provider's cost field does not replace the runner's persisted price table. The promise is one Alma inference attempt, with gateway fallback disabled, not an attestation of the gateway's internal infrastructure. See spec openrouter-single-dispatch.
The Anthropic single-dispatch adapter attests anthropic_context_window_v1 only
for the exact structured HTTP 400 input-window rejection before SSE, with a
bounded request ID and known zero usage. Generic errors and post-stream failures
remain uncertain. OpenAI/OpenRouter do not attest this proof. No automatic SDK
retry is added; the governed conversation owns any continuation after settlement
(spec: safe-context-rejection-rotation).
Pre-stream rejections (spec: pre-stream-rejections). A documented refusal on the first HTTP attempt, before any stream, is known zero usage with a provider-bound proof:
| Proof | Condition |
|---|---|
| anthropic_rate_limit_v1 | 429 with error.type: "rate_limit_error" |
| anthropic_overloaded_v1 | 529 with error.type: "overloaded_error" |
| openai_rate_limit_v1 | 429 with code rate_limit_exceeded or slow_down, and type rate_limit_error, requests or tokens |
Each requires a closed JSON body and a bounded request ID. Each is attested
only against the official endpoint (https://api.anthropic.com,
https://api.openai.com/v1): a client with another baseURL, set directly or
through the SDK's environment variable, never attests. The client then throws
the same ProviderError as before, kind rate_limited or overloaded.
Anything else stays unknown, as before:
- quota and spend 429s;
- other 5xx;
- errors after the stream opened;
- contradictory fields.
Retryable is the taxonomy's word: an Anthropic spend cap also answers
rate_limit_error, and keeps failing. OpenAI documents the rate-limit refusal,
not its billing; the maintainer accepted that exception.
Both official single-dispatch batch clients support cancel(handle, {signal?})
(spec: governed-batch-cancellation-adoption). Each explicit invocation sends at
most one cancellation POST, with SDK retries and redirects disabled. Handles are
snapshotted before awaiting; returned identity, status and counts are validated.
Anthropic canceling and OpenAI cancelling remain running. Lost acknowledgements
and malformed responses reject without another request or invented terminal state.
Explicit client-tool selection
ModelRequest.toolChoice is optional host policy (spec: governed-tool-choice).
Absence sends no tool_choice and retains previous provider behavior; explicit
auto remains distinct from absence in execution identity. No choice grants
permission to a tool or runs its handler.
| Neutral control | Anthropic streaming / batch | OpenAI Responses / batch | OpenRouter |
|---|---|---|---|
| { type: "auto" } | { type: "auto" } | "auto" | refused |
| { type: "none" } | { type: "none" } | "none" | refused |
| { type: "required" } | { type: "any" } | "required" | refused |
| { type: "tool", name } | { type: "tool", name } | { type: "function", name } | refused |
Forced modes (required / tool) admit these exact IDs only:
- Anthropic:
claude-haiku-4-5,claude-haiku-4-5-20251001,claude-sonnet-4-5,claude-sonnet-4-5-20250929,claude-sonnet-4-6,claude-opus-4-5,claude-opus-4-5-20251101,claude-opus-4-6,claude-opus-4-7. - OpenAI:
gpt-4.1,gpt-4.1-mini,gpt-5.1,gpt-5.2,gpt-5.6-luna,gpt-6-sol,gpt-6-luna.
Other IDs, including unlisted dated snapshots, reject forcing before network.
Auto/none use the documented first-party wire without a forced-model allowlist;
model availability and general model capabilities remain provider concerns.
All explicit choices reject simultaneous output or nonempty providerTools.
Forced modes on other models reject an explicit reasoning effort other than none.
For exact OpenAI gpt-5.6-luna, use explicit reasoning none/low/medium/high/xhigh/max
in both execution controls and the loaded request (spec: luna-forced-reasoning).
Omitted, minimal, unknown and malformed efforts reject; no provider default is
silently selected. Active efforts retain the existing summary and encrypted
reasoning transport for stateless replay in Responses and batch. Reasoning tokens
are already part of output tokens and are not charged again.
Auto/none and omitted selection retain existing behavior. Omit temperature;
Luna temperature support is unchanged. The remaining conservative model/provider
guards are retained rather than extrapolated from Luna compatibility.
No control is silently downgraded, and absence bypasses this new matrix.
A named tool must match exactly one advertised ToolSpec; forced modes need
client tools. Invalid shapes, names and ambiguous registries reject before
stream dispatch or batch file upload. The single-dispatch clients snapshot
requests before asynchronous work; all first-party batch mappings share the
stream translator. OpenRouter explicit selection is refused until a reviewed
model/upstream matrix can guarantee local validation.
For a governed structured one-call result, use createGovernedStepRunner from
@alma-harness/single-call with the same choice in planned execution controls and its
loaded request. Inspect the tool calls, original stop and receipts; a forced
choice does not guarantee valid arguments, a successful stop or exactly one
block. The text/JSON createSingleCallRunner does not accept this control.
See the single-call composition. There is no automatic tool
execution or personal-memory write on the structured step path.
Sources checked 2026-09-17: Anthropic tool selection, Anthropic thinking compatibility, OpenAI function calling, Luna capabilities and default effort, OpenAI batch body parity, OpenRouter tool calling. Deterministic tests establish serialization, rejection and governed composition.
GPT-6 Sol and Luna
Exact gpt-6-sol and gpt-6-luna support Responses streaming, governed single
calls and per-item batch translation. Forced tools require explicit
none|low|medium|high|xhigh|max reasoning. Unsupported efforts reject before
HTTP even without forced selection. Without forcing, omitted reasoning preserves
the provider default. Temperature and simultaneous output/forced-tool guards
remain unchanged. No application model default is switched automatically.
These two models send system blocks as ordered developer input text, with an
explicit breakpoint at the end of the initial consecutive stable prefix (and on
each declared system breakpoint, spec: system-breakpoints) and
prompt_cache_options: {mode: "implicit", ttl: "30m"}. The provider selects the
conversation breakpoint. Keep instructions/tools/history stable and place changing
content after the stable prefix. A stable block following volatile content does
not receive a breakpoint. Existing model request shapes remain unchanged.
OpenAI requires at least 1,024 visible input tokens for cache eligibility; no
hit or latency reduction is guaranteed. Cache writes cost 1.25 times ordinary
input, reads 0.1 times, and each reuse renews the minimum 30-minute lifetime.
The adapter subtracts both counts from ordinary input on legacy and governed
paths, so writes are not billed twice. Reasoning remains included in output.
fast is normalized to neutral priority; a contradictory tier in governed
batch remains unpriced.
The existing cache field expresses Anthropic 5m/1h TTL controls. OpenAI still
rejects explicit values, including false; this release adds automatic prefix
placement, not a new public cache-policy contract. Prewarming, cache keys,
explicit-only mode and mid-conversation reasoning changes are not exposed.
store:false remains set; it does not disable provider KV caching or promise
zero cache retention. Confirm organizational data-retention requirements.
Host price table
Prices remain host-owned and versioned. Populate both cache rates rather than relying on core's ordinary-input fallback. Standard USD per million tokens, checked 2026-09-22:
| Model | Input | Cache read | Cache write | Output | |---|---:|---:|---:|---:| | gpt-6-sol | 2 | 0.20 | 2.50 | 10 | | gpt-6-luna | 0.10 | 0.01 | 0.125 | 0.50 |
For each tier row, add bands: [{aboveInputTokens: 272_000, ...rates}]
with input/read/write rates doubled and output multiplied by 1.5. The threshold
uses ordinary input plus reads plus writes and applies to the whole request.
Flex/batch multiply all standard rates by 0.5; priority/Fast by 2. Regional
processing adds 10% where applicable; EU residency supports Standard only.
These token prices do not cover separately billed provider tools.
const model = { provider: "openai" as const, id: "gpt-6-luna" };
const prices = [{
model, inputUsdPerMTok: 0.10, outputUsdPerMTok: 0.50,
cacheReadUsdPerMTok: 0.01, cacheWriteUsdPerMTok: 0.125,
bands: [{ aboveInputTokens: 272_000, inputUsdPerMTok: 0.20,
outputUsdPerMTok: 0.75, cacheReadUsdPerMTok: 0.02,
cacheWriteUsdPerMTok: 0.25 }],
}];Sources: Sol, Luna, prompt caching, Fast mode.
