Model providers - agbossjs/agboss GitHub Wiki
Every model a workflow uses β an agent's, compaction's, a router's, a guard's β comes from a model
provider: an @ModelProvider class from agboss/models. The provider says which models it
serves, how it is reached (base URL, key, timeout), what a call costs, how its models reason and
cache by default, and what happens when a call fails (retry policy, circuit breaker). Introduced in
#151 (ADR-0016).
import {
CachedPart, CacheRetention, EnvironmentVariable, ModelCost, ModelFailure, ModelProvider,
OpenRouterProvider, PromptCaching, Reasoning, RetryPolicy, type OpenRouterFields,
} from "agboss/models";
import { minutes, seconds } from "agboss/units";
@ModelProvider({
name: "openrouter", // identifies it β never its base URL
description: "OpenRouter β many models behind one OpenAI-compatible API",
serves: [/.*/], // which model names it serves
baseUrl: "https://openrouter.ai/api/v1", // or EnvironmentVariable.named("β¦", { defaultValue })
apiKey: EnvironmentVariable.named("OPENROUTER_API_KEY", { secret: true }),
timeout: minutes(2), // per attempt
cost: ModelCost.fromResponse(), // OpenRouter returns usage.cost
reasoning: Reasoning.modelDecides(), // required here; components may override
promptCaching: PromptCaching.whereSupported({ // required here; components may override
retention: CacheRetention.FiveMinutes,
cachedParts: [CachedPart.SystemPrompt, CachedPart.Tools, CachedPart.History],
}),
retryPolicy: RetryPolicy.exponential({
maxAttempts: 3, initialDelay: seconds(1), maxDelay: seconds(20), jitter: true,
retryOn: [ModelFailure.Timeout, ModelFailure.RateLimited, ModelFailure.ServerError],
}),
circuitBreakerPolicy: { failureThreshold: 5, window: minutes(1), openFor: seconds(30) }, // fallback?: another provider
})
export class OpenRouterModelProvider extends OpenRouterProvider {
override readonly requestFields: OpenRouterFields = { provider: { ignore: ["Inceptron"] } };
}Register them in the workflow's settings():
settings(): WorkflowSettings {
return WorkflowSettings.builder()
.limits({ perRun: { cost: usd(0.1) } })
.modelProviders([OpenRouterModelProvider, DecisionsModelProvider])
.defaultModelProvider(OpenRouterModelProvider) // optional: breaks a tie
.build();
}A workflow that registers none gets the built-in OpenRouterModelProvider (every chat model) and
DecisionsModelProvider (the decision models, DECISION_MODELS). job-scout: src/model-providers/openrouter.model-provider.ts; agboss create
generates the same.
| Class | What it is |
|---|---|
OpenAiCompatibleProvider<Fields> |
any OpenAI-compatible server; requestFields is a typed member (override readonly requestFields: Fields = { β¦ }) β a typo is a compile error. OpenAI's dialect: reasoning_effort, prompt_cache_key, prompt_cache_retention. |
OpenRouterProvider<Fields> |
OpenRouter's dialect: reasoning: { effort | max_tokens, exclude }, usage: { include: true } (the cost in every answer), cache_control markers for Anthropic and Google models, a model list that says what each model supports. OpenRouterFields: provider routing, transforms. |
OpenRouterModelProvider |
the default chat provider (above, without request fields) |
DecisionsModelProvider |
OpenRouter's Decisions API: serves the decision models (DECISION_MODELS: typesafe/jev-*, openai/gpt-6-luna-decisions, pplx-decider, Clef, β¦) for decisions only (routers, guards, decision judges); JevModelProvider is its deprecated alias |
| own provider | implement ModelProviderHandler: chat?(request) β a LangChain chat model, routeTo?(request), capabilities(model, connection) (undefined = not served) |
- A component's override wins (
overrideModelProviderarrives on the decorators with #152; the resolver supports it already). - Otherwise exactly one registered provider must match the model name for its purpose β a
chat model (agents, compaction, LLM routers) needs a provider with
chat, a decision (Jev) one with decisions. So OpenRouter on/.*/and the decision models never clash. - None β
model.no-provider; several βmodel.ambiguous-providerunless one is thedefaultModelProvider. Both fail at startup, before any model call.
-
ModelCost.fromResponse()β the provider reports each call's cost in its answer (usage.cost), recorded ascostSource: "api". -
ModelCost.fromPrices({ "<model>": { inputPerMillion: usd(β¦), outputPerMillion: usd(β¦), cachedInputPerMillion?: usd(β¦) } })β tokens Γ the table (price-table); a model it does not price βmodel.no-priceat startup. - The reported cost always comes first; a component's own
priceoverrides the table. A call with neither fails (ModelCostError) β an unaccounted call is never silently free. Limits per run and per day (FinOps) use these costs.
Reasoning.modelDecides() (nothing sent) Β· Reasoning.on({ effort?: ReasoningEffort.Low | Medium | High | Minimal | ExtraHigh, budget?: { tokens } })
(at least one, both allowed where the model takes both) Β· Reasoning.off().
Required on the provider; a component overrides it with today's thinking field until #152
("default" = model decides, "none" = off, an effort, { budgetTokens }, or a Reasoning value).
Unset on the component = the provider's.
PromptCaching.whereSupported({ retention: CacheRetention.FiveMinutes | OneHour | OneDay, cachedParts: [CachedPart.SystemPrompt | Tools | History], key? })
Β· PromptCaching.off(). Where the model caches automatically (OpenAI, Kimi, DeepSeekβ¦) nothing is
marked; Anthropic and Google models get cache_control after the stable prefix (the system prompt,
which ends the tools + system prefix) and at the previous turn's end (history as a growing prefix),
with ttl: "1h" for OneHour; OpenAI's dialect sends prompt_cache_key and
prompt_cache_retention: "24h". A component overrides it with cache until #152 (true = the
provider's, false = off, or a PromptCaching value).
-
RetryPolicy.exponential({ maxAttempts, initialDelay, maxDelay, jitter, retryOn })Β·RetryPolicy.fixed({ maxAttempts, delay, retryOn })Β·RetryPolicy.none()β every field required. Only the listedModelFailures (Timeout,RateLimited,ServerError,NetworkError) are retried; a client error (4xx) is the answer. -
Retry-Afteris honoured; one longer than the policy's longest wait (maxDelay, ordelay) ends the retries with the provider's answer β never an early retry, never an unbounded wait. - Retries are one call: one answer, one usage record, paid once.
-
circuitBreakerPolicy: { failureThreshold, window, openFor, fallback? }: that many failed calls within the window open the breaker foropenFor; while open, calls go to thefallbackprovider (its base URL and key) or fail fast withModelCallErrormodel.circuit-open. Shared per provider in the process (across processes after #121). - Timeouts and retries are the provider's β LangChain's own retries are off.
At every startup (with the production gateway) and with agboss check --models (CLI,
part of make check for job-scout), every model use is checked against what its provider says the
model supports (OpenRouter: its public model list β parameters, reasoning efforts, mandatory
reasoning, cache pricing; Jev: its known models). All problems at once, each with the setting's
key, the value, the model and what the model supports:
ConfigurationError: 2 model setting(s) do not fit their models
model.unsupported-setting agents.scout.thinking = on(effort high, budget 4000 tokens) β model moonshotai/kimi-k2.6: efforts minimal, low, medium, high, xhigh; budget yes; effort with budget no
model.unknown-model routers.main = typesafe/jev-1.4 β model typesafe/jev-1.4: jev does not list it
Codes: model.unsupported-setting (reasoning form, cache retention or parts, temperature,
max_tokens), model.unknown-model, model.no-price, model.no-provider,
model.ambiguous-provider. Nothing is substituted β not the nearest effort, not the nearest
retention, not "budget instead of both". Caching "where supported" on a model without caching is
simply none.
The startup log (app.models, printed by agboss chat) shows each use's provider, reasoning and
caching β (inherited from <provider>) when the component sets none β and the cost source.
toWireRequest is the one place a request body is finalised: the provider's request fields, the
reasoning and caching fields in its dialect, cache markers, tools sorted by name (their JSON Schemas
without $schema). Jev routes and LLM-router options are sent sorted by name too, and agent and
router prompts are normalised at load (BOM, edge blank lines, NFC) and sent as loaded β declaration
order and file encoding change neither the request nor its fingerprint (#125).
Tests never reach a provider: testWith scripts every model (Testing); a scripted answer
on a model without a price reports its cost the way a provider would. Provider code is tested with a
stub fetch (createAppDeps(bundle, { providerFetch })).