diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d17dfa22c..eaf7a4d97 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -398,6 +398,8 @@ jobs:
- uses: actions/checkout@v4
- name: Providers doc generator drift (docs/api/providers.md must match generator output)
run: python3 scripts/gen_providers_doc.py --check
+ - name: Provider boundaries (no call-level reads or provider-name branches in providers, removed names stay removed)
+ run: bash scripts/check_provider_boundaries.sh
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: ts-rs export drift (checked-in types must match generator output)
diff --git a/.gitignore b/.gitignore
index 6e6fabbd4..6567f9180 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,3 +53,6 @@ quirks-corpus/
# Generated audit artifacts — keep conclusions in Markdown, not raw output.
/docs/quality-audit/**/lcov.info
/docs/quality-audit/**/*.log
+
+# AI SDK protocol fixture recorder (scripts/aisdk-fixtures) — npm install output
+scripts/aisdk-fixtures/node_modules/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 746dfdf78..3dfb36e36 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,14 +9,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Breaking
+- Middleware wraps generation and streaming with callbacks for both operations,
+ parameter transforms and provider/model/supported-URL overrides. Explicit
+ model and provider ids take precedence over middleware overrides.
+ `NoSuchProvider` carries `provider_id`, `model_id`, `model_type` and
+ `available_providers`. Node and Python OpenAI constructors use Responses
+ by default.
+
+- Public surface (`aimux-core`, `aimux-provider-utils`): removed
+ `recording::init_recording_from_env`; made private the retry preparation
+ helpers and default constants, `composite::{add_usage,
+ build_aggregator_prompt, extract_text}`, `recording::new_call_id`,
+ `util::rfc3339_now`, two session helpers, `fetch_error_to_ai_mux_error`,
+ `extract_response_header_pairs`, the SigV4 signing internals, the logging
+ internals, `DEFAULT_MAX_JSON_RESPONSE_SIZE` and `TungsteniteConnector`.
+
- Provider results carry request and response information as the AI SDK does
(`aimux-core`). `GenerateResult` has `request: Option` and
`response: Option`; `ResponseInfo` gains `id` and holds the
timestamp, model id, headers and body. `StreamResult` has `request` and
`response: Option`. The flat `request_body` and
`response_headers` fields are removed and `response` is optional; the call
- layer fills a missing id, timestamp and model id. The user-facing results and
- their JSON are unchanged.
+ layer fills a missing id, timestamp and model id. `GenerateTextResult`
+ exposes `request: RequestInfo` and `response: ResponseInfo`, including
+ headers and body.
- The prompt a provider receives is modelled by role (`aimux-core`), as the AI
SDK's `LanguageModelV4Message`. `LanguageModelPrompt` is
@@ -48,11 +64,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(`aimux-core`). A provider emits `tool::RawToolCall` (`input` is the raw
argument text) in `GenerateContent::ToolCall(..)` and
`StreamPart::ToolCall(..)`, and `tool::ToolResult` in the `ToolResult`
- variants; these variants are newtype variants now. `StreamPart` is generic
- over the tool-call type: a provider's `do_stream` yields `StreamPart`
- (= `StreamPart`), `stream_text` and everything after it yield
- `TextStreamPart` (= `StreamPart`, parsed input with `invalid` /
- `error`). `RawToolCall` moved from `parse_tool_call` to `tool`. Wire format:
+ variants; these variants are newtype variants now. A provider's `do_stream`
+ yields `StreamPart` with raw tool-call input. `stream_text` and everything
+ after it yield `TextStreamPart` with parsed input and `invalid` / `error`.
+ Call-layer streams consume provider response-metadata events internally;
+ `TextStreamPart` has no `ResponseMetadata` variant. `RawToolCall` moved from `parse_tool_call` to `tool`. Wire format:
enum variants keep their JSON shape; a provider-layer `StreamPart::ToolCall`
has a string `input` and no `invalid` / `error`; a
`GenerateContent::ToolCall` with a non-string `input` no longer
@@ -74,8 +90,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Removed the generated `ProviderName` type in every binding (Rust enum, TS
const object, Go/Java/Kotlin consts, Swift enum, Dart consts, Python
`Literal`) and `scripts/gen_provider_names.py`. Provider names are plain
- strings: `provider("groq", ...)`. Built-in and overlay-registered names now
- share one string path. The provider list lives in
+ strings: Rust uses `create_provider("groq", PresetSettings)`; bindings retain
+ `provider("groq", ...)`, including overlay-registered names. The provider list lives in
[docs/api/providers.md](docs/api/providers.md); Rust also gains
`provider_names()`.
@@ -141,6 +157,248 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
name ends the stream with `InvalidResponseData` (previously it started a
call with an empty name). `DeltaToolCall.index` is now `Option`.
+**Rust (provider factory, RFC-0036)**
+
+- The native Decision/Jev extension from master is retained. Its typed
+ `JevConfig` / `JevProvider::new` entry point and `Provider::decision_model`
+ remain available. Decision replay now takes the caller's registry;
+ recordings contain provider identity only and keep decision capabilities
+ separately under input. Decision retries use the core call-level policy.
+
+- `OpenAIProvider::language_model(id)` (and so `create_provider("openai", ..)`
+ and the registry id `openai:`) returns the **Responses** model, as
+ `@ai-sdk/openai` does. It returned the Chat Completions model. Call
+ `.chat(id)` for Chat Completions, for example against an endpoint that has
+ no `/responses` route.
+ The bindings' `openai(...)` constructors and the C ABI's `aimux_openai_new*`
+ are unchanged: they still create the Chat Completions model.
+
+- Removed crate-root exports `provider()`, `provider_handle`,
+ `provider_from_env`, `provider_discovery`, `provider_registry_entry`,
+ `ProviderOptions`, `ProviderProfile`, `register_provider` and
+ `load_providers_from_json`. Binding compatibility lives in
+ `aimux_providers::provider`; binding APIs are unchanged. Rust by-name
+ creation is `create_provider(name, PresetSettings)`; `provider_names()`
+ lists names, and `default_providers()` supplies the map for
+ `aimux_core::provider_registry::create_provider_registry(providers, options)`
+ with `provider:model` ids by default.
+
+- Redirects are handled in the request helper layer, not in the transport.
+ An ordinary API call follows every redirect, as the AI SDK's `postToApi`
+ does under fetch's default `redirect: "follow"`. A hop that leaves the
+ request's origin keeps only `User-Agent`, the rule the AI SDK's
+ `fetchWithValidatedRedirects` applies on the server, so no credential
+ header (`authorization`, `x-api-key`, `x-goog-api-key`, `api-key`, ...)
+ reaches another origin, and dropped headers stay dropped if a later hop
+ returns. `FetchRequest.credentials` (`RequestCredentials::Include` /
+ `Omit`, after the fetch `credentials` member) is `Omit` on such a hop, and
+ `SigV4Fetch` then sends it unsigned, without `x-amz-security-token`. Every
+ followed hop goes through the request's transport again, so SigV4 signs
+ the URL it sends. A `Fetch` never follows a redirect itself:
+ `RedirectPolicy`, `FetchRequest.redirect` and `PinnedFetch::unpinned` are
+ removed. Validated downloads keep their hop-by-hop guard.
+- `Provider` follows the AI SDK's `ProviderV4`: `language_model`,
+ `embedding_model` and `image_model` are required and return
+ `NoSuchModel { model_id, model_type }` when a vendor has no such modality;
+ `transcription_model`, `speech_model`, `reranking_model` and `files` are
+ optional (`None` = not offered); video and search stay aimux extensions.
+ AI SDK model constructors return an `Arc`. Removed: `Provider::name()`,
+ `specification_version()` (from `Provider` and the nine model traits),
+ `LanguageModel::config_snapshot()` and `list_models` on `Provider`.
+ Discovery is the separate `ProviderDiscovery` trait, accessed through
+ `Provider::discovery()`; `supported_urls()` is added to `LanguageModel`.
+- AI SDK-aligned packages remove `XxxConfig`, config builders, `from_env()`,
+ `with_*` methods and `XxxProvider::new(...)`. Each package
+ has package-specific `XxxProviderSettings`, `create_xxx(settings)`
+ (validating package-specific settings, including base URLs, conflicting
+ credentials and template parameters) and a default instance `xxx()` where
+ the package provides one.
+ Models read a crate-private per-model config; there are no getters and no
+ snapshot. `Fetch` (HTTP) and package-specific WebSocket settings are
+ transport injection points. OpenAI, Anthropic and Google use fixed string
+ credentials and header maps, without a body-transform setting. The
+ OpenAI-compatible package exposes its upstream `transform_request_body`,
+ `supported_urls` and `convert_usage` hooks. Cohere and Mistral expose
+ `generate_id` settings. Package settings omit `name` and body transforms
+ when the upstream package does not offer them. Every request carries
+ `ai-sdk-/` in its user-agent.
+- For packages using the API-key loader, environment fallbacks are evaluated
+ on every request: `api_key: None` reads the package's environment variable, `Some("")` is
+ sent verbatim and never falls back to the environment, and a missing key
+ fails the call (not the factory) with `AiMuxError::LoadApiKey { env_var,
+ description }`. A missing required setting (AWS region, Azure resource name,
+ Vertex project, …) fails the call with `AiMuxError::LoadSetting { env_var,
+ name }`. Credentials and custom headers otherwise follow each package's
+ settings; the generic compatible factory sends no auth for a missing or empty
+ key. Headers merge case-insensitively in package-specific order; custom and
+ call headers can override credential headers.
+- Retry is a call-level concern only. `RetryConfig`, `retry_config()`,
+ `with_retry_config`, the `max_retries` fields on every provider config and
+ `ProviderRecord.max_retries` are deleted; the nine core operations call
+ `prepare_retries(max_retries, abort)` with the constant defaults 2 retries,
+ 2000 ms initial delay, factor 2 (jitter, `Retry-After` and the error history
+ are unchanged). Providers no longer retry anything themselves: `list_models`
+ and files uploads are single exchanges, and job-creating requests are sent
+ exactly once.
+- `body_overrides` is removed everywhere (provider configs, builders,
+ `CallOptions`, `ProviderOptions`, `config_json`, binding `ProviderConfig`).
+ OpenAI-compatible provider-level body rewrites use its upstream
+ `transform_request_body` closure on the finished JSON body. `ProviderOptions`,
+ the C ABI `config_json` and the Node `ProviderConfig` that carry `max_retries` or
+ `body_overrides` now fail with `InvalidArgument` instead of ignoring them
+ (`reject_removed_provider_options` for other shapes).
+- Recording: `RECORDING_SCHEMA = 3`. `ProviderRecord` holds identity only
+ (`provider_id`, `provider`, `model_id`); base URL, key source, profile,
+ options and retry settings are no longer recorded and schema-2 files are
+ rejected on read. `rebuild_provider(record, registry)` resolves
+ `provider_id` through the caller-supplied registry, retaining its settings.
+ If the default model method differs from the recorded one, pass the model
+ to `replay_with_model`. Recorded provider strings change with the next item.
+- `model.provider()` follows the package's fixed identity or, where offered,
+ its `settings.name` override. Defaults: `openai.chat` /
+ `openai.responses` / `openai.embedding` / `openai.image` / `openai.speech` /
+ `openai.transcription` / `openai.files`; `anthropic.messages` (a custom name
+ is used verbatim; `{name}.files` derives from it), `anthropic-aws`,
+ `googleVertex.anthropic.messages`; `google.generative-ai` (+ `.video`,
+ `.files`), `google.vertex` (+ `.video`, `.transcription`),
+ `amazon-bedrock`; `azure.chat` / `.responses` / `.embeddings` / `.image` /
+ `.transcription` / `.speech`; `xai.responses`, `huggingface.responses`,
+ `mistral.chat` / `.embedding`, `cohere.chat` / `.textEmbedding` /
+ `.reranking`, `codex.responses`, `{name}.responses` (open_responses),
+ `voyage.embedding` / `.reranking`, `elevenlabs.speech` / `.transcription`,
+ `groq.chat`, `deepseek.chat`; compat and preset providers
+ `{name}.chat` / `.embedding` / `.image`; single-modality packages
+ `luma.image`, `deepgram.transcription`, `tavily.search`, `amazon-polly.speech`,
+ `blackForestLabs.image`, `cartesia.transcription`, … . Consumers that
+ compared `provider` to `"openai"` must match the prefix (the CLI and web
+ probes filter by `model.provider()`).
+- providerOptions namespaces are canonical only: `googleVertex` and
+ `amazonBedrock` (the legacy `vertex` / `bedrock` keys are neither read nor
+ written; Bedrock still reads `anthropic` for its Anthropic models). The
+ Anthropic package reads `anthropic` merged with the first segment of a
+ custom `name` (custom wins) and writes metadata under the custom key;
+ compat providers no longer read the `openai` key; `providerOptions.deepseek`
+ is honoured (`reasoningEffort`, `thinking`).
+- Presets: `provider_registry.json` has 281 rows; Groq and DeepSeek are
+ vendor packages instead of preset rows. Former thin wrapper types are
+ deleted. The JSON is embedded and parsed once into a runtime descriptor
+ table in `preset.rs`; there is no `family` field, generated presets source
+ or per-name Rust function. Create presets or vendor packages by name via
+ `create_provider(name, PresetSettings)`.
+ Rows carry `auth: api_key | none` (`none` sends no
+ `Authorization`; `PLACEHOLDER_API_KEY` is gone), `base_url_env` and
+ template `params` (env, default, derived host maps). Only declared
+ parameters are accepted, host parameters reject `/ @ : ?`, and an
+ unexpanded placeholder is an error. Unknown names are `NoSuchProvider`;
+ nothing falls back to OpenAI. `litellm_proxy` reads `LITELLM_PROXY_BASE_URL`
+ (the old wrapper read its API-key variable as a URL). Registry validation
+ runs in the table test, and
+ `scripts/check_provider_boundaries.sh` runs in CI.
+- Groq and DeepSeek are standalone packages (`create_groq`, `create_deepseek`)
+ with their own chat models; the native OpenAI package no longer knows any
+ other vendor. Compat providers expose chat, embedding and image only (the
+ upstream set); the native OpenAI package warns on and drops `top_k`;
+ `OpenAICompatProfile` and `with_profile` are deleted in favour of dialect
+ hooks.
+- Anthropic: the base URL includes `/v1` (endpoint `{base}/messages`; only the
+ bare `https://api.anthropic.com` is rewritten); `ANTHROPIC_BASE_URL` and
+ `OPENAI_BASE_URL` supply the base URL when the corresponding setting is
+ absent; `stream` is omitted on non-streaming calls; `providerOptions.anthropic.metadata.userId` maps to `metadata.user_id`; `anthropic-beta` is
+ sent on every host. Result-level `providerMetadata` is now populated
+ (`usage`, `stopSequence`, `iterations`, `container`, `contextManagement`,
+ under `anthropic` and the custom name), `usage.raw` is the provider's own
+ usage object, and `usage.iterations` now feeds the token totals. Vertex-
+ Anthropic and `anthropic_aws` run on the Anthropic core.
+- Google: request bodies follow the AI SDK (`generationConfig` is always
+ sent, function tools use `parametersJsonSchema`, `providerOptions.google`
+ maps `thinkingConfig`, `responseModalities`, `safetySettings`,
+ `cachedContent`, …; response `modelId` comes from `modelVersion`).
+ Vertex: Gemini uses `v1beta1`, Anthropic-on-Vertex `v1`; a tuned model in
+ express mode now fails at request time instead of at construction.
+ Bedrock signs the final request bytes through `SigV4Fetch`;
+ `list_models` now calls the control-plane host
+ `bedrock.{region}.amazonaws.com` (it used `….api.amazonaws.com`).
+- Azure defaults to `api_version = "v1"` with `/v1{path}`; a dated
+ `api_version` selects the deployment URL form. xAI, Hugging Face and Azure
+ default to the Responses API. **xAI and Hugging Face no longer have a Chat
+ Completions model**: the AI SDK packages serve Responses only, so `XaiModel`
+ and the Hugging Face chat model are removed (no `chat` / `chat_completions`
+ method). To keep calling those endpoints, use
+ `create_openai_compatible` with `https://api.x.ai/v1` or
+ `https://router.huggingface.co/v1`; the xAI-specific chat response
+ handling (citations, search parameters, 200-status errors) is not available
+ on that path. The xAI Responses model now sends `top_k` and warns for
+ `frequencyPenalty` and `presencePenalty`, as upstream does. The OpenAI default `language_model` and
+ `call` use Responses, matching upstream; `chat` remains an explicit accessor. Codex is `codex.responses`, its ChatGPT-account base URL
+ defaults to `https://chatgpt.com/backend-api/codex` (it was missing
+ `/codex`), and `store: false` is a package rule. `open_responses`: `url` →
+ `base_url`. The Azure deepseek / completion / MAI models and the Foundry item
+ type are not ported.
+- Single-modality vendors (28 packages) run async jobs on package constants:
+ a fixed interval and attempt cap (previously unbounded loops now stop after
+ 6000 × 100 ms), a transient poll error spends one attempt, and the interval
+ is overridden through the package namespace (`pollIntervalMs`, luma
+ `pollIntervalMillis`) which replaces the old per-provider poll settings.
+ `cartesia` `version` and `runwayml` `poll_interval` / `timeout` settings are
+ removed (headers / constants); `google_pse` `cx` resolves settings, then
+ providerOptions, then `GOOGLE_CSE_ID` per request; DataForSEO without
+ credentials fails with `LoadApiKey`; searxng accepts an optional bearer
+ token; the user-agent suffix is gone. `VideoModel::poll_config()` stays as
+ the package-constant source.
+- Removed from `aimux-providers`: `body_merge`, `openai_legacy`, `AzureAuth`,
+ `TokenProvider`, `VertexAuth`, `BedrockAuth`, `StaticBearerConfig` and the
+ `hmac` dependency (`sha2` / `hex` are dev-dependencies).
+- Bedrock regions must be a single DNS label; invalid values fail the call
+ with `InvalidArgument` before a request is sent.
+- `ExternalProviderEntry` debug output reports only whether `api_key` is
+ present, keeping literal keys out of debug logs.
+
+**C ABI**
+
+- New error codes `AIMUX_E_LOAD_API_KEY = 18` and `AIMUX_E_LOAD_SETTING = 19`
+ (appended; retired 4 is not reused). `aimux_error_provider_code` returns the
+ environment variable that was consulted and `aimux_error_provider_message`
+ the key's description (`"OpenAI"`) or the setting name (`"region"`); no new
+ symbol. `aimux_error_model_type` carries `NoSuchModel.model_type`. These two
+ failures used to surface as `AIMUX_E_INVALID_ARGUMENT`.
+- `aimux_provider_new`, `aimux_provider_handle_new` and `config_json` resolve
+ names through the binding compatibility module (overlays, vendor packages
+ and presets); `config_json` accepts
+ `base_url`, `headers`, `organization`, `project` and `params`, and rejects
+ `max_retries` and `body_overrides` with `AIMUX_E_INVALID_ARGUMENT`. A
+ provider handle is provider + discovery. `aimux_register_providers`
+ entries reject the same two keys.
+
+**Node / Python**
+
+- Node `ProviderConfig`: `maxRetries` and `bodyOverrides` are kept in the type
+ only so that passing them throws `InvalidArgumentError`, for every native
+ constructor (`openai`, `anthropic`,
+ `google`, `cohere`, `mistral`, `xai`, `deepseek`, `bedrock`, `vertex`,
+ `anthropicAws`, `azure`, `provider`, `createProvider`), not only OpenAI.
+ `headers` now applies to all of them. New `params: Record`
+ fills a preset's template parameters. Python `config` /
+ `config_json` accept `params` and reject `max_retries` / `body_overrides`
+ the same way.
+- New error classes `LoadAPIKeyError` (`envVar`, `description`) and
+ `LoadSettingError` (`envVar`, `settingName`) in Node; `LoadAPIKeyError`
+ (`env_var`, `description`) and `LoadSettingError` (`env_var`,
+ `setting_name`) in Python. Both extend `AimuxError`, no longer
+ `InvalidArgumentError`.
+- Recorded and traced provider strings follow `"{name}.{method}"`
+ (`openai.chat`, not `openai`).
+
+**Go / Java / Kotlin / Swift / Dart**
+
+- New error codes 18 / 19 (`CodeLoadAPIKey` / `CodeLoadSetting`,
+ `LoadAPIKeyError` / `LoadSettingError`, `.loadApiKey` / `.loadSetting`),
+ with the consulted environment variable as `EnvVar` / `envVar`.
+- The call-level `body_overrides` / `bodyOverrides` field is deleted from
+ `GenerateTextOptions` (the core no longer accepts the key; leaving it would
+ have been silently ignored). The provider `config_json` helpers
+ (`ProviderConfig` in Go and Dart) drop `max_retries` / `body_overrides` and
+ gain `params`; call-level `max_retries` is unchanged.
+
## [0.5.0] - 2026-09-27
**Breaking release.** 13 PRs since 0.3.0: the cross-language error model
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 240a759ab..32e07985f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -9,8 +9,8 @@ to set up a development environment, run the tests, and submit changes.
aimux/
├── aimux-core/ # Core abstractions: LanguageModel / Provider / Message / StreamPart
├── aimux-providers/ # provider implementations + cassettes (counts: docs/api/providers.md)
-├── aimux-stream/ # SSE stream parsing
-├── aimux-provider-utils/ # HTTP utilities: retry, backoff, error parsing, API-key loading, streamed tool-call tracking
+├── aimux-stream/ # SSE decoding
+├── aimux-provider-utils/ # HTTP utilities: Fetch transport, Resolvable settings, error parsing, API-key loading, streamed tool-call tracking
├── aimux-ffi/ # C ABI (opaque handle + JSON + push callback) for non-native bindings
├── bindings/ # Node, Python, Swift, Kotlin, Flutter, Go, C — share one Rust core
├── rfc/ # Design docs (RFCs)
@@ -38,6 +38,20 @@ cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
```
+Provider changes also run the generator and boundary checks CI runs in the
+`contract-tests` job:
+
+```bash
+python3 scripts/gen_providers_doc.py --check # docs/api/providers.md
+python3 scripts/gen_ts_types.py --check # ts-rs types for the Node binding
+bash scripts/check_provider_boundaries.sh # factory-shape rules for provider packages
+```
+
+The Node and Python bindings are separate workspaces: run `cargo fmt` and
+`cargo clippy -- -D warnings` inside each, then `npm ci && npm run build &&
+npm test` (Node) or `maturin build --release`, `pip install` the wheel and
+`python -m pytest tests/` (Python).
+
## Testing
Tests run entirely on **cassette playback** — no network access and no API keys
@@ -62,10 +76,13 @@ aimux distinguishes three kinds of providers:
1. **Native protocol** — providers with their own request/response model
(OpenAI, Anthropic, Google, Bedrock, …). Implement the full `convert`/model
path and handle provider-specific differences.
-2. **OpenAI-compatible thin wrapper** — the majority. Describe differences via
- `OpenAICompatProfile` (top_k, tools, response_format, streaming usage,
- request-body post-processing) so the thin wrapper does not erase
- provider-specific behavior.
+2. **OpenAI-compatible preset** — the majority. One row of
+ `aimux-providers/src/provider_registry.json` (base URL, key variable or
+ `auth: none`, optional URL variable and template parameters) that
+ is embedded and parsed once into a runtime table. Create presets by name
+ through `provider(name, ...)`. Vendors with behavior of their own get a package on
+ the shared compatible chat model (`groq/`, `deepseek/`) instead of a
+ branch on their name.
3. **Modality-specific** — speech, image, video, transcription, etc.
Step-by-step checklists (with the generator and CI rules) live in
diff --git a/README.md b/README.md
index 3508b67e3..4e5da174f 100644
--- a/README.md
+++ b/README.md
@@ -4,12 +4,12 @@
-> **A unified LLM access layer written in Rust. One API for [327 AI providers](docs/api/providers.md).**
+> **A unified LLM access layer written in Rust. One API for [326 provider names](docs/api/providers.md).**
[](https://github.com/arcships/aimux/actions/workflows/ci.yml)
[](LICENSE)
[](https://www.rust-lang.org/)
-[](docs/api/providers.md)
+[](docs/api/providers.md)
[](bindings/)
[](https://crates.io/crates/aimux-core)
[](https://www.npmjs.com/package/@arcships/aimux)
@@ -30,13 +30,10 @@ difference: aimux is an access layer, those are orchestration layers.
## Why aimux
-- **327 providers** (as of 2026-09-24) — 251 registry-backed OpenAI-compatible
- (unified `provider(name, ...)` entry) + 76 typed providers (native protocol
- implementations such as OpenAI/Anthropic/Google/Bedrock/Vertex, local
- engines like Ollama/vLLM, and speech/image/video/search modality providers).
- Counts by category and the full list live in
- [docs/api/providers.md](docs/api/providers.md) — generated, and the single
- source of truth for provider counts (only the badge above repeats the total).
+- **326 provider names** — 281 registry-backed OpenAI-compatible presets
+ + 45 vendor packages, covering text and other modalities. Local engines
+ such as Ollama/vLLM are presets. `provider_names()` lists the Rust names;
+ the generated catalogue lives in [docs/api/providers.md](docs/api/providers.md).
- **Unified, object-safe interface** — the `LanguageModel` trait supports
`Box` so providers are interchangeable without changing call sites.
- **Full multimodal** — text, streaming, tool calling, embeddings, image,
@@ -49,10 +46,12 @@ difference: aimux is an access layer, those are orchestration layers.
- **Composite models** (new in 0.3.0) — `RouterModel` routes each call with
fallback (RFC-0021); `MoaModel` aggregates parallel reference models
mixture-of-agents style (RFC-0022). Both are plain `LanguageModel`s.
-- **Config-driven provider registry** — `provider-registry.json` describes
- each registry-backed OpenAI-compatible provider (base URL, env var, profile
- quirks: top_k, tools, response_format, streaming usage, max_tokens key);
- one unified `provider(name, ...)` entry in every binding.
+- **Config-driven provider registry** — `provider_registry.json` describes
+ each registry-backed OpenAI-compatible provider (base URL, env var, auth
+ mode, template parameters); the embedded JSON is parsed once into a runtime
+ descriptor table. Rust uses `create_provider(name, PresetSettings)` for
+ presets and vendor packages; bindings retain `provider(name, ...)`.
+ Packages follow the AI SDK shape: `XxxProviderSettings` + `create_xxx()` + a default `xxx()`.
- **Fast and small** — Rust core, release profile tuned for binary size
(`lto`, `codegen-units=1`, `panic="abort"`, `strip`, `opt-level="z"`).
- **8 language bindings** from one core: Node, Python, Swift, Kotlin, Flutter,
@@ -148,14 +147,12 @@ console.log(result.text)
```rust
use aimux_core::prelude::*;
-use aimux_providers::{OpenAIConfig, OpenAIProvider};
+use aimux_providers::openai::openai;
#[tokio::main]
async fn main() -> Result<(), AiMuxError> {
- let provider = OpenAIProvider::new(
- OpenAIConfig::new(std::env::var("OPENAI_API_KEY")?)
- );
- let model = provider.model("gpt-4o");
+ // The default provider loads OPENAI_API_KEY when a request is made.
+ let model = openai().chat("gpt-4o");
let result = generate_text(
&model,
@@ -182,8 +179,8 @@ let result = stream_text(
let mut stream = result.stream;
while let Some(part) = stream.next().await {
match part? {
- StreamPart::TextDelta { delta, .. } => print!("{}", delta),
- StreamPart::Finish { .. } => println!("\n[done]"),
+ TextStreamPart::TextDelta { delta, .. } => print!("{}", delta),
+ TextStreamPart::Finish { .. } => println!("\n[done]"),
_ => {}
}
}
@@ -220,9 +217,10 @@ use aimux_core::moa::{MoaConfig, MoaModel};
use aimux_core::router::{FallbackPolicy, RouterConfig, RouterModel, RuleRouter};
// Router: pick one model per call, fall back on failure (RFC-0021).
+let provider = aimux_providers::openai::openai();
let children: Vec = vec![
- Arc::new(provider.model("gpt-4o")) as ChildModel,
- Arc::new(provider.model("gpt-4o-mini")) as ChildModel,
+ Arc::new(provider.chat("primary-model-id")) as ChildModel,
+ Arc::new(provider.chat("secondary-model-id")) as ChildModel,
];
let routed = RouterModel::new(
children.clone(),
@@ -234,7 +232,7 @@ let routed = RouterModel::new(
// MoA: fan out to references, aggregate their answers (RFC-0022).
let moa = MoaModel::new(
children,
- Arc::new(provider.model("gpt-4o")) as ChildModel,
+ Arc::new(provider.chat("primary-model-id")) as ChildModel,
MoaConfig::default(),
);
@@ -273,35 +271,36 @@ session.close()
## Switch providers
```rust
-// OpenAI → DeepSeek: only the provider name changes (registry-backed;
-// key read from the provider's env var)
-use aimux_providers::{provider, provider_from_env};
+use aimux_core::provider_registry::create_provider_registry;
+use aimux_providers::{create_provider, default_providers, provider_names, PresetSettings};
-let model = provider("deepseek", None, "deepseek-chat", None)?;
-let model = provider_from_env("deepseek", "deepseek-chat", None)?;
+// Keys are loaded when a request is made.
+let provider = create_provider("deepseek", PresetSettings::default())?;
+let model = provider.language_model("deepseek-chat")?;
+
+// Or assemble the built-in providers and resolve a provider:model id.
+let registry = create_provider_registry(default_providers(), Default::default());
+let model = registry.language_model("deepseek:deepseek-chat")?;
+let names = provider_names();
// model usage is identical — it's all dyn LanguageModel
```
-All registry-backed OpenAI-compatible providers share one entry:
-`provider(name, ...)` in every binding. Provider names are runtime strings;
-the generated provider list is the discovery surface.
-The retired per-provider shell types (`XxxConfig`/`XxxProvider`) are gone —
-see [docs/API.md](docs/API.md#providers).
-
-> **Scope:** OpenAI-compatible → `provider(name, ...)`; others (Anthropic,
-> multimodal, local…) → their constructors. List:
-> [docs/api/providers.md](docs/api/providers.md).
+Rust by-name creation covers vendor packages and all 281 presets. Each vendor
+also has `create_xxx(XxxProviderSettings {..})` and a default instance `xxx()`.
+Bindings keep their own `provider(name, ...)` API. Runtime model listing uses
+`Provider::discovery()` and then `ProviderDiscovery::list_models()` — see
+[docs/api/rust.md](docs/api/rust.md#providers).
## Provider coverage
-Counts by category live in
-[docs/api/providers.md](docs/api/providers.md#totals) — generated from the
-registry and `lib.rs`, and the single source of truth. The rough shape of
+The generated catalogue is [docs/api/providers.md](docs/api/providers.md).
+The accepted Rust names come from `provider_names()`. The rough shape of
the roster:
-- **Native protocol** — OpenAI, Anthropic, Google, Bedrock, Vertex, Azure, Cohere, Mistral, xAI, Anthropic-AWS, Voyage, Codex, OpenRouter
-- **OpenAI-compatible (registry-backed)** — Groq, Fireworks, Together, Perplexity, Ollama Cloud, DeepSeek, Alibaba Tongyi, Zhipu, Baidu, Tencent, Moonshot, SiliconFlow…
-- **OpenAI-compatible (standalone + Vertex-hosted)** — Hugging Face, Ollama, vLLM, SGLang, Llama.cpp, LiteLLM Proxy, Vertex-hosted DeepSeek/Qwen/Llama…
+- **Native protocol** — OpenAI, Anthropic, Google, Bedrock, Vertex, Azure, Cohere, Mistral, Groq, DeepSeek, xAI, Anthropic-AWS, Voyage
+- **OpenAI-compatible (registry-backed)** — Fireworks, Together, Perplexity, OpenRouter, Ollama Cloud, Alibaba Tongyi, Zhipu, Baidu, Tencent, Moonshot, SiliconFlow…
+- **Local and Vertex-hosted presets** — Ollama, vLLM, SGLang, Llama.cpp, LiteLLM Proxy, Vertex-hosted DeepSeek/Qwen/Llama…
+- **Responses API for text** — xAI and Hugging Face (no chat-completions entry)
- **Speech / transcription** — ElevenLabs, Deepgram, AssemblyAI, AWS Polly, Cartesia, Hume, Gladia, RevAI, LMNT, Fal
- **Image / video** — Black Forest Labs, Replicate, Luma, Prodia, KlingAI, Recraft, Stability, RunwayML
- **Embeddings / rerank / search** — Voyage, Jina, Tavily, Exa, Firecrawl, Serper, SearXNG, You.com…
diff --git a/ROADMAP.md b/ROADMAP.md
index bcd4c3d8e..4ea40c032 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -26,7 +26,7 @@ aimux 是 **provider 接入与治理的运行时**:
│ ├─→ #174 Auth L1 ──→ #175 Auth L2 ← 依赖 B1
│ └─→ #167 transport-level replay ──→ #179 replay 子命令 ← 依赖 B1
├─ C 轨 FFI(现 123 个 extern "C",#166 基线 109)→ ops 协议(C1 升级为传输无关协议:op 表 / 二进制帧 / 版本协商)
-│ └─ C2 构造器转发 shim 是 B4/B5 前置
+│ └─ C2 构造器转发 shim 取消:B4/B5 的直接构造调用点随 provider 工厂切换一次迁移(调整:见 [docs/aisdk-architecture-alignment.md §0.7](docs/aisdk-architecture-alignment.md#07-与-roadmap--rfc-0036-既有承诺的关系))
├─ D 轨 8 种绑定重写为 ops 薄封装(依赖 C1)+ D8 类型镜像生成
├─ E 轨 内部清理(#164 已合入,改为 master 上独立 PR)+ 文档三层重组
│
@@ -66,30 +66,30 @@ aimux 是 **provider 接入与治理的运行时**:
| 项 | 内容 |
|---|---|
| B2–B8 | 17 个 LanguageModel 实现 → ~7 个协议;33 个 wrapper 退役;responses 家族合并(遵循 #166 修正 3/6) |
-| #174 / #175 | registry `auth` schema + `apply_auth()`;Credential 解析序 + CredentialStore + TokenRefresher |
+| #174 / #175 | registry `auth` 列作为 preset descriptor 数据,鉴权在各厂商包内以 headers 闭包 / fetch 装饰器实现;CredentialStore + TokenRefresher 随宿主回调后置(调整:见 [docs/aisdk-architecture-alignment.md §0.7](docs/aisdk-architecture-alignment.md#07-与-roadmap--rfc-0036-既有承诺的关系)) |
| L2 补齐 | `StreamPart::Raw` / `provider_metadata` 在各协议接线——投影不下的不得静默丢弃 |
| **S3** | `cargo-bloat` 审计 0.5.0 增重(Android `.so` 13.4 → 21 MB);feature gating 仅作可选小体积路径,默认全量 |
| ~~调研 RFC~~ | ~~L2 数据模型选型(AI SDK 形态 / Open Responses items / 自有),指标见 RFC-0036 §3~~ 撤销:选型已定为 AI SDK V4 形态(调整:见 [docs/aisdk-architecture-alignment.md §0.7](docs/aisdk-architecture-alignment.md#07-与-roadmap--rfc-0036-既有承诺的关系)) |
-版本语义:Rust API 大 breaking(33 wrapper 退役);C ABI 经 C2 转发不受影响。
+版本语义:Rust API、binding wire 与 C ABI 在全链路对齐切换时一次性 breaking(33 wrapper 退役、构造器替换),不经 C2 转发(调整:见 [docs/aisdk-architecture-alignment.md §0.7](docs/aisdk-architecture-alignment.md#07-与-roadmap--rfc-0036-既有承诺的关系))。
### 0.8.0 ——「ops 协议落地」(~4-5 周)
| 项 | 内容 |
|---|---|
-| C1 | 9 个导出 + `dispatch`,**同一 dispatch 同时服务 FFI 与 stdio CLI**;旧导出保留共存 |
+| C1 | 9 个导出 + `dispatch`,**同一 dispatch 同时服务 FFI 与 stdio CLI**;旧符号按切换门一次替换,不共存(调整:见 [docs/aisdk-architecture-alignment.md §0.7](docs/aisdk-architecture-alignment.md#07-与-roadmap--rfc-0036-既有承诺的关系)) |
| C3 | `aimux_error_*` 访问器 → 错误 JSON 信封(含派生 `retry_after_ms`) |
| D1–D7 | 8 种绑定迁到 ops 薄封装。D1 Kotlin(只依赖 Java artifact)与 D5 Flutter ffigen 过渡版不依赖 C1,可在 0.6/0.7 提前做 |
| D8 | 类型镜像生成:serde → JSON Schema → 各语言,`--check` 门禁扩到全部输出 |
| P | stdio 入口往返开销纳入性能门禁 |
-版本语义:新 ABI 加入,旧导出并存(#166 要求共存至少一个 minor 版本);绑定按各自节奏迁移。“共存至少一个 minor”不再作为承诺:全链路对齐切换本身没有共存期,之后 ops ABI 引入时的旧符号也按切换门一次替换;C2 转发 shim 与 C4 旧导出清理随之取消(调整:见 [docs/aisdk-architecture-alignment.md §0.7](docs/aisdk-architecture-alignment.md#07-与-roadmap--rfc-0036-既有承诺的关系))。
+版本语义:新 ABI 按切换门一次替换旧符号,不提供共存期(#166 原要求的“共存至少一个 minor”撤销);全链路对齐切换本身同样没有共存期,C2 转发 shim 与 C4 旧导出清理随之取消(调整:见 [docs/aisdk-architecture-alignment.md §0.7](docs/aisdk-architecture-alignment.md#07-与-roadmap--rfc-0036-既有承诺的关系))。
### 0.9.0 ——「治理」(~3-4 周)
| 项 | 内容 |
|---|---|
-| #167 | transport-level replay:mock 挂 HTTP 层跑真协议代码,覆盖全协议 / 全模态;`ProviderRecord` = registry 行 + protocol |
+| #167 | transport-level replay:mock 挂 HTTP 层跑真协议代码,覆盖全协议 / 全模态;录制只存身份,不保存可重建的 `ProviderRecord`(调整:见 [docs/aisdk-architecture-alignment.md §0.7](docs/aisdk-architecture-alignment.md#07-与-roadmap--rfc-0036-既有承诺的关系)) |
| 漂移检测 | #170 扩展:定期重录,与 cassette 字节级 diff |
| 能力矩阵 | provider × 能力,每格由探测或录制支撑 |
| CLI | `aimux probe / replay / diff`(#179 replay 子命令并入);#181 debug CLI 并入 |
@@ -98,7 +98,7 @@ aimux 是 **provider 接入与治理的运行时**:
| 项 | 内容 |
|---|---|
-| C4 | 8 种绑定全部迁移后删除旧导出、旧头文件、旧 FFI 测试 |
+| ~~C4~~ | ~~8 种绑定全部迁移后删除旧导出、旧头文件、旧 FFI 测试~~ 取消:旧导出在切换时随绑定一起删除,1.0 不再补删(调整:见 [docs/aisdk-architecture-alignment.md §0.7](docs/aisdk-architecture-alignment.md#07-与-roadmap--rfc-0036-既有承诺的关系)) |
| 冻结范围 | ops 协议与 L0 / L1 契约。**L2 独立版本化,不随 1.0 冻结** |
| 发布判据 | #166 ledger 全勾(B9 / #171 后半除外);错误模型 #95 与请求管线 #164 稳定一个周期;各门禁(S2 / P)连续绿 |
diff --git a/aimux-core/Cargo.toml b/aimux-core/Cargo.toml
index c48aaee7c..70ab4aeec 100644
--- a/aimux-core/Cargo.toml
+++ b/aimux-core/Cargo.toml
@@ -26,6 +26,7 @@ httpdate = { workspace = true }
rand = { workspace = true }
base64 = "0.22"
jsonschema = { workspace = true }
+regex = "1"
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "test-util"] }
diff --git a/aimux-core/src/composite.rs b/aimux-core/src/composite.rs
index d4f3b5505..e737471cb 100644
--- a/aimux-core/src/composite.rs
+++ b/aimux-core/src/composite.rs
@@ -8,9 +8,9 @@
//!
//! This module holds the pieces both share:
//! - [`ChildModel`] — the child-handle shape (`Arc`).
-//! - [`add_usage`] — per-field `Usage` accumulation.
-//! - [`extract_text`] — pull text out of a `GenerateContent` list.
-//! - [`build_aggregator_prompt`] — assemble the MoA aggregator prompt.
+//! - `add_usage` — per-field `Usage` accumulation.
+//! - `extract_text` — pull text out of a `GenerateContent` list.
+//! - `build_aggregator_prompt` — assemble the MoA aggregator prompt.
//!
//! See [`crate::trace::layer::TraceLayer`] for the established decorator pattern
//! (`inner: Arc` implementing `LanguageModel`); composite
@@ -34,7 +34,7 @@ pub type ChildModel = Arc;
/// Each usage detail field is `Option`; `None` is treated as zero so a
/// child that doesn't report a breakdown doesn't erase the other child's data.
#[must_use]
-pub fn add_usage(a: Usage, b: &Usage) -> Usage {
+pub(crate) fn add_usage(a: Usage, b: &Usage) -> Usage {
Usage {
input_tokens: InputTokenUsage {
total: opt_add(a.input_tokens.total, b.input_tokens.total),
@@ -64,7 +64,7 @@ fn opt_add(a: Option, b: Option) -> Option {
/// are kept; `Reasoning` / `ToolCall` / `Source` / `File` are dropped (MoA
/// thin version — references contribute analysis text, not tool calls).
#[must_use]
-pub fn extract_text(content: &[GenerateContent]) -> String {
+pub(crate) fn extract_text(content: &[GenerateContent]) -> String {
let mut out = String::new();
for c in content {
if let GenerateContent::Text { text, .. } = c {
@@ -83,7 +83,7 @@ pub fn extract_text(content: &[GenerateContent]) -> String {
/// The reference list may be empty — in that case the aggregator just runs the
/// original prompt (degenerates to a single-model call).
#[must_use]
-pub fn build_aggregator_prompt(
+pub(crate) fn build_aggregator_prompt(
prompt: &LanguageModelPrompt,
instructions: Option<&str>,
references: &[(String, String)],
diff --git a/aimux-core/src/decision_model.rs b/aimux-core/src/decision_model.rs
index 6166b146f..af3646461 100644
--- a/aimux-core/src/decision_model.rs
+++ b/aimux-core/src/decision_model.rs
@@ -418,21 +418,9 @@ impl DecisionResult {
#[async_trait]
pub trait DecisionModel: Send + Sync {
- fn specification_version(&self) -> &'static str {
- "v4"
- }
fn provider(&self) -> &str;
fn model_id(&self) -> &str;
fn capabilities(&self) -> DecisionCapabilities;
- fn config_snapshot(&self) -> crate::recording::ProviderRecord {
- let mut snapshot =
- crate::recording::ProviderRecord::minimal(self.provider(), self.model_id());
- snapshot.profile = Some(serde_json::json!({"decision_capabilities": self.capabilities()}));
- snapshot
- }
- fn retry_config(&self) -> crate::retry::RetryConfig {
- crate::retry::RetryConfig::default()
- }
/// Perform one provider attempt.
/// # Errors
/// Returns provider, transport or invalid response errors.
@@ -496,11 +484,7 @@ pub async fn decide(
}
let timeout = timeout::OperationTimeout::new(options.timeout.unwrap_or_default())?;
let abort_signal = options.abort_signal.clone();
- let retries = retry::prepare_retries(
- options.max_retries,
- model.retry_config(),
- abort_signal.clone(),
- );
+ let retries = retry::prepare_retries(options.max_retries, abort_signal.clone());
let context = crate::recording::recorder().map(|recorder| {
let ctx =
crate::recording::RecordingContext::new(crate::recording::new_call_id(), recorder);
@@ -510,9 +494,12 @@ pub async fn decide(
&options,
model.provider(),
model.model_id(),
+ &capabilities,
+ );
+ ctx.recorder.record_provider(
+ &ctx.call_id,
+ &crate::recording::ProviderRecord::from_model(model.provider(), model.model_id()),
);
- ctx.recorder
- .record_provider(&ctx.call_id, &model.config_snapshot());
ctx
});
let result = timeout::run(
diff --git a/aimux-core/src/decision_replay.rs b/aimux-core/src/decision_replay.rs
index 9e35f362f..e588c77c9 100644
--- a/aimux-core/src/decision_replay.rs
+++ b/aimux-core/src/decision_replay.rs
@@ -49,16 +49,9 @@ impl MockDecisionReplayModel {
"decision replay: no matching provider/model recording".into(),
)
})?;
- let capabilities = serde_json::from_value(
- first
- .provider
- .profile
- .as_ref()
- .and_then(|v| v.get("decision_capabilities"))
- .cloned()
- .unwrap_or(Value::Null),
- )
- .map_err(|e| AiMuxError::InvalidArgument(format!("decision replay capabilities: {e}")))?;
+ let capabilities = first.input.decision_capabilities.clone().ok_or_else(|| {
+ AiMuxError::InvalidArgument("decision replay: missing decision capabilities".into())
+ })?;
Ok(Self {
provider,
model_id,
diff --git a/aimux-core/src/embedding_model.rs b/aimux-core/src/embedding_model.rs
index 7f1807048..e97ff1dab 100644
--- a/aimux-core/src/embedding_model.rs
+++ b/aimux-core/src/embedding_model.rs
@@ -117,21 +117,12 @@ pub struct EmbeddingResponse {
/// `true`; otherwise they should return an error.
#[async_trait]
pub trait EmbeddingModel: Send + Sync {
- /// Specification version (always `"v4"`).
- fn specification_version(&self) -> &'static str {
- "v4"
- }
-
/// Provider name, e.g. `"openai"`.
fn provider(&self) -> &str;
/// Provider-specific model ID, e.g. `"text-embedding-3-small"`.
fn model_id(&self) -> &str;
- fn retry_config(&self) -> crate::retry::RetryConfig {
- crate::retry::RetryConfig::default()
- }
-
/// Limit of how many embeddings can be generated in a single API call.
///
/// `None` means the model has no fixed limit. The TS spec allows this to
@@ -159,11 +150,7 @@ pub async fn embed(
) -> Result {
let timeout = timeout::OperationTimeout::new(options.timeout.unwrap_or_default())?;
let abort_signal = options.abort_signal.clone();
- let retries = retry::prepare_retries(
- options.max_retries,
- model.retry_config(),
- abort_signal.clone(),
- );
+ let retries = retry::prepare_retries(options.max_retries, abort_signal.clone());
timeout::run(
retries.retry(|| model.do_embed(&options)),
abort_signal.as_ref(),
diff --git a/aimux-core/src/error.rs b/aimux-core/src/error.rs
index e39bf2be8..d9c7bfd9b 100644
--- a/aimux-core/src/error.rs
+++ b/aimux-core/src/error.rs
@@ -224,6 +224,34 @@ pub enum AiMuxError {
#[error("invalid prompt: {0}")]
InvalidPrompt(String),
+ /// No API key was passed and the fallback environment variable is unset
+ /// (the AI SDK's `LoadAPIKeyError`). Pre-HTTP: no request was made.
+ #[error(
+ "No API key found for {description}. \
+ Please provide it via the `api_key` parameter \
+ or set the `{env_var}` environment variable."
+ )]
+ LoadApiKey {
+ /// The environment variable consulted as the fallback.
+ env_var: String,
+ /// What the key is for, e.g. `"OpenAI"`.
+ description: String,
+ },
+
+ /// A required provider setting was not passed and its fallback
+ /// environment variable is unset (the AI SDK's `LoadSettingError`).
+ #[error(
+ "No value found for setting `{name}`. \
+ Please provide it via the `{name}` parameter \
+ or set the `{env_var}` environment variable."
+ )]
+ LoadSetting {
+ /// The environment variable consulted as the fallback.
+ env_var: String,
+ /// The setting's parameter name, e.g. `"region"`.
+ name: String,
+ },
+
/// The access token has expired (or was invalidated) and must be refreshed
/// by the caller. RFC-0018 subscription mode: the library maps a 401 from
/// the Codex subscription endpoint to this variant; the integrator
@@ -252,8 +280,13 @@ pub enum AiMuxError {
/// Registry-level "provider name does not resolve" (the AI SDK's
/// `NoSuchProviderError`).
- #[error("No such provider: {provider_id}")]
- NoSuchProvider { provider_id: String },
+ #[error("No such provider: {provider_id} (available providers: {})", .available_providers.join(","))]
+ NoSuchProvider {
+ provider_id: String,
+ model_id: String,
+ model_type: String,
+ available_providers: Vec,
+ },
#[error("request timed out: {0}")]
Timeout(String),
@@ -296,6 +329,18 @@ pub fn is_retryable_status(status: u16) -> bool {
}
impl AiMuxError {
+ /// Build a [`NoSuchModel`](Self::NoSuchModel) for `model_id`.
+ ///
+ /// `model_type` is the AI SDK's `modelType` (`"languageModel"`,
+ /// `"embeddingModel"`, `"imageModel"`, …).
+ #[must_use]
+ pub fn no_such_model(model_id: &str, model_type: &str) -> Self {
+ Self::NoSuchModel {
+ model_id: model_id.to_string(),
+ model_type: model_type.to_string(),
+ }
+ }
+
/// Returns `true` for a malformed individual stream frame that does not
/// prove the underlying transport has failed. Stream reducers may report
/// these errors and continue polling for later, independently framed data.
diff --git a/aimux-core/src/files_model.rs b/aimux-core/src/files_model.rs
index d2fcb1120..091488838 100644
--- a/aimux-core/src/files_model.rs
+++ b/aimux-core/src/files_model.rs
@@ -98,11 +98,6 @@ pub struct UploadFileResult {
/// et al.
#[async_trait]
pub trait Files: Send + Sync {
- /// Specification version (always `"v4"`).
- fn specification_version(&self) -> &'static str {
- "v4"
- }
-
/// Provider ID.
fn provider(&self) -> &str;
diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs
index 622219230..3a71543df 100644
--- a/aimux-core/src/generate.rs
+++ b/aimux-core/src/generate.rs
@@ -102,8 +102,6 @@ pub struct GenerateTextOptions {
pub reasoning: Option,
/// System instructions prepended to the prompt.
pub instructions: Option,
- /// Per-call request body overrides (deep-merged). See RFC-0017.
- pub body_overrides: Option,
/// Per-call retry count override. `None` = provider default, `Some(0)` = disable.
pub max_retries: Option,
/// Per-call timeout configuration (total / first-chunk / chunk idle).
@@ -159,7 +157,6 @@ impl GenerateTextOptions {
headers: self.headers,
provider_options: self.provider_options,
reasoning: self.reasoning,
- body_overrides: self.body_overrides,
max_retries: self.max_retries,
timeout: self.timeout,
session_id: self.session_id,
@@ -579,11 +576,7 @@ pub(crate) async fn generate_text_from_language_model_prompt(
let operation_timeout =
timeout::OperationTimeout::new(call_options.timeout.unwrap_or_default())?;
let abort_signal = call_options.abort_signal.clone();
- let retries = retry::prepare_retries(
- call_options.max_retries,
- model.retry_config(),
- abort_signal.clone(),
- );
+ let retries = retry::prepare_retries(call_options.max_retries, abort_signal.clone());
// 2a. RFC-0023: 关闭时零成本(M2 评审)——仅在录制开启时生成 call_id
// 并绑定 recorder 快照;传输封闭由层 A 收尾声明(P1 无层 B,barrier
@@ -599,8 +592,10 @@ pub(crate) async fn generate_text_from_language_model_prompt(
model.provider(),
model.model_id(),
);
- ctx.recorder
- .record_provider(&ctx.call_id, &model.config_snapshot());
+ ctx.recorder.record_provider(
+ &ctx.call_id,
+ &crate::recording::ProviderRecord::from_model(model.provider(), model.model_id()),
+ );
// 层 A 早发 closed(defense-in-depth):无 HTTP 的调用(mock/local)也
// 能完成 barrier;真实 HTTP 的骨架 finalized=false 仍会挡写,由层 B
// 结束再发 closed 完成。双发幂等。
@@ -941,11 +936,7 @@ pub async fn stream_text(
.map(|duration_ms| timeout::TimeoutDeadline::from_now("First chunk", duration_ms))
.transpose()?;
let abort_signal = call_options.abort_signal.clone();
- let retries = retry::prepare_retries(
- call_options.max_retries,
- model.retry_config(),
- abort_signal.clone(),
- );
+ let retries = retry::prepare_retries(call_options.max_retries, abort_signal.clone());
// 2a. RFC-0023: 关闭时零成本(M2 评审)——仅在录制开启时生成 call_id
// 并绑定 recorder 快照;传输封闭由层 A 收尾声明(P1 无层 B,barrier
@@ -961,8 +952,10 @@ pub async fn stream_text(
model.provider(),
model.model_id(),
);
- ctx.recorder
- .record_provider(&ctx.call_id, &model.config_snapshot());
+ ctx.recorder.record_provider(
+ &ctx.call_id,
+ &crate::recording::ProviderRecord::from_model(model.provider(), model.model_id()),
+ );
// 层 A 早发 closed(defense-in-depth):无 HTTP 的调用(mock/local)也
// 能完成 barrier;真实 HTTP 的骨架 finalized=false 仍会挡写,由层 B
// 结束再发 closed 完成。双发幂等。
diff --git a/aimux-core/src/image_model.rs b/aimux-core/src/image_model.rs
index 2a1ae9418..159de19df 100644
--- a/aimux-core/src/image_model.rs
+++ b/aimux-core/src/image_model.rs
@@ -181,21 +181,12 @@ pub struct ImageResponse {
/// Aligned with V4 `ImageModelV4`.
#[async_trait]
pub trait ImageModel: Send + Sync {
- /// Specification version (always `"v4"`).
- fn specification_version(&self) -> &'static str {
- "v4"
- }
-
/// Provider name, e.g. `"openai"`.
fn provider(&self) -> &str;
/// Provider-specific model ID, e.g. `"dall-e-3"`.
fn model_id(&self) -> &str;
- fn retry_config(&self) -> crate::retry::RetryConfig {
- crate::retry::RetryConfig::default()
- }
-
/// Limit of how many images can be generated in a single API call.
///
/// `None` means there is no fixed limit. The TS spec allows this to be a
@@ -203,6 +194,16 @@ pub trait ImageModel: Send + Sync {
/// `Option`.
fn max_images_per_call(&self) -> Option;
+ /// Whether image file inputs are supported; `None` means unknown.
+ fn supports_file_inputs(&self) -> Option {
+ None
+ }
+
+ /// Whether mask inputs are supported; `None` means unknown.
+ fn supports_mask_inputs(&self) -> Option {
+ None
+ }
+
/// Generate an array of images.
///
/// Naming: the `do_` prefix prevents accidental direct usage by users.
@@ -220,11 +221,7 @@ pub async fn generate_image(
) -> Result {
let timeout = timeout::OperationTimeout::new(options.timeout.unwrap_or_default())?;
let abort_signal = options.abort_signal.clone();
- let retries = retry::prepare_retries(
- options.max_retries,
- model.retry_config(),
- abort_signal.clone(),
- );
+ let retries = retry::prepare_retries(options.max_retries, abort_signal.clone());
timeout::run(
retries.retry(|| model.do_generate(&options)),
abort_signal.as_ref(),
diff --git a/aimux-core/src/image_model_middleware.rs b/aimux-core/src/image_model_middleware.rs
new file mode 100644
index 000000000..c1789aa8c
--- /dev/null
+++ b/aimux-core/src/image_model_middleware.rs
@@ -0,0 +1,137 @@
+//! Image-model middleware and wrapping, aligned with the upstream V4 hooks.
+
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use futures::future::BoxFuture;
+
+use crate::error::AiMuxError;
+use crate::image_model::{ImageCallOptions, ImageModel, ImageResult};
+
+/// Optional hooks for overriding model properties and wrapping generation.
+#[async_trait]
+pub trait ImageModelMiddleware: Send + Sync {
+ fn override_provider(&self, _model: &dyn ImageModel) -> Option {
+ None
+ }
+
+ fn override_model_id(&self, _model: &dyn ImageModel) -> Option {
+ None
+ }
+
+ fn override_max_images_per_call(&self, _model: &dyn ImageModel) -> Option {
+ None
+ }
+
+ /// `Some(None)` overrides support to unknown; `None` leaves it unchanged.
+ fn override_supports_file_inputs(&self, _model: &dyn ImageModel) -> Option> {
+ None
+ }
+
+ /// `Some(None)` overrides support to unknown; `None` leaves it unchanged.
+ fn override_supports_mask_inputs(&self, _model: &dyn ImageModel) -> Option > {
+ None
+ }
+
+ /// Transform the parameters passed to the wrapped operation.
+ ///
+ /// # Errors
+ /// Returns an error if the parameters cannot be transformed.
+ async fn transform_params(
+ &self,
+ params: ImageCallOptions,
+ _model: &dyn ImageModel,
+ ) -> Result {
+ Ok(params)
+ }
+
+ /// Wrap generation, with a callback using the transformed parameters.
+ ///
+ /// # Errors
+ /// Returns a middleware error or the underlying generation error.
+ async fn wrap_generate(
+ &self,
+ _params: &ImageCallOptions,
+ _model: &dyn ImageModel,
+ do_generate: &(dyn Fn() -> BoxFuture<'_, Result> + Send + Sync),
+ ) -> Result {
+ do_generate().await
+ }
+}
+
+struct WrappedImageModel {
+ model: Arc,
+ middleware: Arc,
+ provider: String,
+ model_id: String,
+ max_images_per_call: Option,
+ supports_file_inputs: Option,
+ supports_mask_inputs: Option,
+}
+
+/// Apply middleware in input order: the first entry is the outermost wrapper.
+#[must_use]
+pub fn wrap_image_model(
+ model: Arc,
+ middleware: &[Arc],
+ model_id: Option<&str>,
+ provider_id: Option<&str>,
+) -> Arc {
+ middleware.iter().rev().fold(model, |model, middleware| {
+ Arc::new(WrappedImageModel {
+ provider: provider_id
+ .map(str::to_string)
+ .or_else(|| middleware.override_provider(model.as_ref()))
+ .unwrap_or_else(|| model.provider().to_string()),
+ model_id: model_id
+ .map(str::to_string)
+ .or_else(|| middleware.override_model_id(model.as_ref()))
+ .unwrap_or_else(|| model.model_id().to_string()),
+ max_images_per_call: middleware
+ .override_max_images_per_call(model.as_ref())
+ .or_else(|| model.max_images_per_call()),
+ supports_file_inputs: middleware
+ .override_supports_file_inputs(model.as_ref())
+ .unwrap_or_else(|| model.supports_file_inputs()),
+ supports_mask_inputs: middleware
+ .override_supports_mask_inputs(model.as_ref())
+ .unwrap_or_else(|| model.supports_mask_inputs()),
+ model,
+ middleware: Arc::clone(middleware),
+ })
+ })
+}
+
+#[async_trait]
+impl ImageModel for WrappedImageModel {
+ fn provider(&self) -> &str {
+ &self.provider
+ }
+
+ fn model_id(&self) -> &str {
+ &self.model_id
+ }
+
+ fn max_images_per_call(&self) -> Option {
+ self.max_images_per_call
+ }
+
+ fn supports_file_inputs(&self) -> Option {
+ self.supports_file_inputs
+ }
+
+ fn supports_mask_inputs(&self) -> Option {
+ self.supports_mask_inputs
+ }
+
+ async fn do_generate(&self, options: &ImageCallOptions) -> Result {
+ let params = self
+ .middleware
+ .transform_params(options.clone(), self.model.as_ref())
+ .await?;
+ let do_generate = || self.model.do_generate(¶ms);
+ self.middleware
+ .wrap_generate(¶ms, self.model.as_ref(), &do_generate)
+ .await
+ }
+}
diff --git a/aimux-core/src/language_model.rs b/aimux-core/src/language_model.rs
index c6a37f50f..8cdcbbcbe 100644
--- a/aimux-core/src/language_model.rs
+++ b/aimux-core/src/language_model.rs
@@ -3,12 +3,31 @@
//! Every LLM provider implements this trait. Users never call `do_generate` /
//! `do_stream` directly; they call `generate_text` / `stream_text` instead.
+use std::collections::HashMap;
+
use async_trait::async_trait;
+use regex::Regex;
use crate::error::AiMuxError;
use crate::options::CallOptions;
use crate::result::{GenerateResult, StreamResult};
+/// URL patterns a language model can fetch itself, keyed by media-type
+/// pattern (`"image/*"`, `"application/pdf"`, …). Each value is a list of
+/// regular expressions matched against the full URL.
+///
+/// The empty default means the model fetches nothing itself.
+#[derive(Debug, Clone, Default)]
+pub struct SupportedUrls(pub HashMap>);
+
+impl SupportedUrls {
+ /// Whether no media type has a supported URL pattern.
+ #[must_use]
+ pub fn is_empty(&self) -> bool {
+ self.0.values().all(Vec::is_empty)
+ }
+}
+
/// The unified language model trait (provider-facing).
///
/// Aligned with Vercel AI SDK `LanguageModelV4`.
@@ -23,33 +42,25 @@ use crate::result::{GenerateResult, StreamResult};
/// rather than being silently dropped.
#[async_trait]
pub trait LanguageModel: Send + Sync {
- /// Specification version (always `"v4"`).
- fn specification_version(&self) -> &'static str {
- "v4"
- }
-
/// Provider name, e.g. `"openai"`.
fn provider(&self) -> &str;
/// Model identifier, e.g. `"gpt-4o"`.
fn model_id(&self) -> &str;
- /// Provider/model retry settings used when the caller does not override
- /// the retry count.
- fn retry_config(&self) -> crate::retry::RetryConfig {
- crate::retry::RetryConfig::default()
- }
-
/// Generate a complete (non-streaming) response.
async fn do_generate(&self, options: &CallOptions) -> Result;
/// Generate a streaming response.
async fn do_stream(&self, options: &CallOptions) -> Result;
- /// RFC-0023: 配置侧快照供录制。默认返回最小信息(provider/model_id);
- /// 各 provider 覆盖以提供 base_url/profile/api_key 来源等完整配置。
- /// 透明 decorator(TraceLayer 等)必须覆盖并转发 inner 快照。
- fn config_snapshot(&self) -> crate::recording::ProviderRecord {
- crate::recording::ProviderRecord::minimal(self.provider(), self.model_id())
+ /// URLs the model can fetch on its own, keyed by media-type pattern
+ /// (e.g. `"image/*"`), AI SDK `supportedUrls`.
+ ///
+ /// Default: empty, meaning every file part is sent as inline data.
+ /// Transparent decorators must forward the inner model's value. Core does
+ /// not consult this yet.
+ fn supported_urls(&self) -> SupportedUrls {
+ SupportedUrls::default()
}
}
diff --git a/aimux-core/src/language_model_middleware.rs b/aimux-core/src/language_model_middleware.rs
new file mode 100644
index 000000000..058566092
--- /dev/null
+++ b/aimux-core/src/language_model_middleware.rs
@@ -0,0 +1,150 @@
+//! Language-model middleware and wrapping, aligned with the upstream V4 hooks.
+
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use futures::future::BoxFuture;
+
+use crate::error::AiMuxError;
+use crate::language_model::{LanguageModel, SupportedUrls};
+use crate::options::CallOptions;
+use crate::result::{GenerateResult, StreamResult};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum LanguageModelOperation {
+ Generate,
+ Stream,
+}
+
+/// Optional hooks for transforming parameters and wrapping either operation.
+/// The supplied callbacks invoke either operation using the transformed parameters.
+#[async_trait]
+pub trait LanguageModelMiddleware: Send + Sync {
+ fn override_provider(&self, _model: &dyn LanguageModel) -> Option {
+ None
+ }
+
+ fn override_model_id(&self, _model: &dyn LanguageModel) -> Option {
+ None
+ }
+
+ fn override_supported_urls(&self, _model: &dyn LanguageModel) -> Option {
+ None
+ }
+
+ async fn transform_params(
+ &self,
+ _operation: LanguageModelOperation,
+ params: CallOptions,
+ _model: &dyn LanguageModel,
+ ) -> Result {
+ Ok(params)
+ }
+
+ async fn wrap_generate(
+ &self,
+ _params: &CallOptions,
+ _model: &dyn LanguageModel,
+ do_generate: &(dyn Fn() -> BoxFuture<'_, Result> + Send + Sync),
+ _do_stream: &(dyn Fn() -> BoxFuture<'_, Result> + Send + Sync),
+ ) -> Result {
+ do_generate().await
+ }
+
+ async fn wrap_stream(
+ &self,
+ _params: &CallOptions,
+ _model: &dyn LanguageModel,
+ _do_generate: &(
+ dyn Fn() -> BoxFuture<'_, Result> + Send + Sync
+ ),
+ do_stream: &(dyn Fn() -> BoxFuture<'_, Result> + Send + Sync),
+ ) -> Result {
+ do_stream().await
+ }
+}
+
+struct WrappedLanguageModel {
+ model: Arc,
+ middleware: Arc,
+ provider: String,
+ model_id: String,
+ supported_urls: SupportedUrls,
+}
+
+/// Apply middleware in input order: the first entry is the outermost wrapper.
+#[must_use]
+pub fn wrap_language_model(
+ model: Arc,
+ middleware: &[Arc],
+ model_id: Option<&str>,
+ provider_id: Option<&str>,
+) -> Arc {
+ middleware.iter().rev().fold(model, |model, middleware| {
+ let provider = provider_id
+ .map(str::to_string)
+ .or_else(|| middleware.override_provider(model.as_ref()))
+ .unwrap_or_else(|| model.provider().to_string());
+ let model_id = model_id
+ .map(str::to_string)
+ .or_else(|| middleware.override_model_id(model.as_ref()))
+ .unwrap_or_else(|| model.model_id().to_string());
+ let supported_urls = middleware
+ .override_supported_urls(model.as_ref())
+ .unwrap_or_else(|| model.supported_urls());
+ Arc::new(WrappedLanguageModel {
+ model,
+ middleware: Arc::clone(middleware),
+ provider,
+ model_id,
+ supported_urls,
+ })
+ })
+}
+
+#[async_trait]
+impl LanguageModel for WrappedLanguageModel {
+ fn provider(&self) -> &str {
+ &self.provider
+ }
+
+ fn model_id(&self) -> &str {
+ &self.model_id
+ }
+
+ fn supported_urls(&self) -> SupportedUrls {
+ self.supported_urls.clone()
+ }
+
+ async fn do_generate(&self, options: &CallOptions) -> Result {
+ let params = self
+ .middleware
+ .transform_params(
+ LanguageModelOperation::Generate,
+ options.clone(),
+ self.model.as_ref(),
+ )
+ .await?;
+ let do_generate = || self.model.do_generate(¶ms);
+ let do_stream = || self.model.do_stream(¶ms);
+ self.middleware
+ .wrap_generate(¶ms, self.model.as_ref(), &do_generate, &do_stream)
+ .await
+ }
+
+ async fn do_stream(&self, options: &CallOptions) -> Result {
+ let params = self
+ .middleware
+ .transform_params(
+ LanguageModelOperation::Stream,
+ options.clone(),
+ self.model.as_ref(),
+ )
+ .await?;
+ let do_generate = || self.model.do_generate(¶ms);
+ let do_stream = || self.model.do_stream(¶ms);
+ self.middleware
+ .wrap_stream(¶ms, self.model.as_ref(), &do_generate, &do_stream)
+ .await
+ }
+}
diff --git a/aimux-core/src/lib.rs b/aimux-core/src/lib.rs
index 638e63a0b..cd62b8dba 100644
--- a/aimux-core/src/lib.rs
+++ b/aimux-core/src/lib.rs
@@ -30,9 +30,11 @@ pub mod error;
pub mod files_model;
pub mod generate;
pub mod image_model;
+pub mod image_model_middleware;
pub mod json_repair;
pub mod language_model;
pub mod language_model_message;
+pub mod language_model_middleware;
pub mod math;
pub mod message;
pub mod moa;
@@ -42,6 +44,7 @@ pub mod openai_output;
pub mod options;
pub mod parse_tool_call;
pub mod provider;
+pub mod provider_registry;
pub mod recording;
pub mod replay;
pub mod reranking_model;
@@ -52,6 +55,7 @@ pub mod router;
pub mod search_model;
pub mod session;
pub mod shared;
+pub mod skills_model;
pub mod speech_model;
pub mod stream_part;
mod timeout;
@@ -84,8 +88,12 @@ pub mod prelude {
generate_text_result_to_chat_completion, stream_text, stream_text_as_openai,
};
pub use crate::image_model::{ImageCallOptions, ImageModel, ImageResult, generate_image};
- pub use crate::language_model::LanguageModel;
+ pub use crate::image_model_middleware::{ImageModelMiddleware, wrap_image_model};
+ pub use crate::language_model::{LanguageModel, SupportedUrls};
pub use crate::language_model_message::LanguageModelPrompt;
+ pub use crate::language_model_middleware::{
+ LanguageModelMiddleware, LanguageModelOperation, wrap_language_model,
+ };
pub use crate::message::{MessageContent, ModelMessage, ModelPrompt, Role};
pub use crate::moa::{MoaConfig, MoaFailMode, MoaModel};
pub use crate::model_catalogue::{
@@ -125,6 +133,7 @@ pub mod prelude {
SharedProviderMetadata, SharedProviderOptions, SharedProviderReference, Size,
provider_namespace,
};
+ pub use crate::skills_model::{SkillFile, Skills, UploadSkillCallOptions, UploadSkillResult};
pub use crate::speech_model::{SpeechCallOptions, SpeechModel, SpeechResult, generate_speech};
pub use crate::stream_part::{StreamPart, TextStreamPart};
pub use crate::tool::{
@@ -155,8 +164,10 @@ pub use files_model::Files;
pub use image_model::ImageModel;
pub use language_model::LanguageModel;
pub use provider::Provider;
+pub use provider_registry::{ProviderRegistry, ProviderRegistryOptions, create_provider_registry};
pub use reranking_model::RerankingModel;
pub use search_model::SearchModel;
+pub use skills_model::Skills;
pub use speech_model::SpeechModel;
pub use transcription_model::TranscriptionModel;
pub use video_model::VideoModel;
diff --git a/aimux-core/src/moa.rs b/aimux-core/src/moa.rs
index 129371048..6123c1924 100644
--- a/aimux-core/src/moa.rs
+++ b/aimux-core/src/moa.rs
@@ -125,11 +125,8 @@ impl MoaModel {
let child_opts =
ref_opts.for_step(format!("moa.ref[{i}]:{}/{}", m.provider(), m.model_id()));
async move {
- let retries = retry::prepare_retries(
- child_opts.max_retries,
- m.retry_config(),
- child_opts.abort_signal.clone(),
- );
+ let retries =
+ retry::prepare_retries(child_opts.max_retries, child_opts.abort_signal.clone());
retries
.retry(|| {
if let Some(context) = &child_opts.recording_context {
@@ -183,15 +180,6 @@ impl LanguageModel for MoaModel {
&self.config.model_id
}
- fn retry_config(&self) -> retry::RetryConfig {
- // Retrying the composite would rerun the entire reference fanout;
- // references and aggregator are retried independently instead.
- retry::RetryConfig {
- max_retries: 0,
- ..retry::RetryConfig::default()
- }
- }
-
async fn do_generate(&self, options: &CallOptions) -> Result {
// 1. Fan out references (non-streaming).
let (texts, ref_usage, warnings) = self.run_references_nonstream(options).await?;
@@ -206,11 +194,7 @@ impl LanguageModel for MoaModel {
agg_opts.prompt = agg_prompt;
// 3. Run the aggregator.
- let retries = retry::prepare_retries(
- agg_opts.max_retries,
- self.aggregator.retry_config(),
- agg_opts.abort_signal.clone(),
- );
+ let retries = retry::prepare_retries(agg_opts.max_retries, agg_opts.abort_signal.clone());
let agg_opts = agg_opts.for_step(format!(
"moa.aggregator:{}/{}",
self.aggregator.provider(),
@@ -248,11 +232,7 @@ impl LanguageModel for MoaModel {
// 3. Aggregator streams; we emit our own StreamStart and add reference
// usage onto its Finish. We swallow the aggregator's StreamStart
// (we've already emitted ours).
- let retries = retry::prepare_retries(
- agg_opts.max_retries,
- self.aggregator.retry_config(),
- agg_opts.abort_signal.clone(),
- );
+ let retries = retry::prepare_retries(agg_opts.max_retries, agg_opts.abort_signal.clone());
let agg_opts = agg_opts.for_step(format!(
"moa.aggregator:{}/{}",
self.aggregator.provider(),
diff --git a/aimux-core/src/model_catalogue.rs b/aimux-core/src/model_catalogue.rs
index fc045ed32..a7ef0d8e9 100644
--- a/aimux-core/src/model_catalogue.rs
+++ b/aimux-core/src/model_catalogue.rs
@@ -2,7 +2,7 @@
//!
//! Two independent data surfaces:
//! - [`RuntimeModel`] — what a provider's `/models` endpoint returns (sparse:
-//! id, owned_by, created). Produced by `Provider::list_models`.
+//! id, owned_by, created). Produced by `ProviderDiscovery::list_models`.
//! - [`ModelSpec`] — community knowledge portrait (context length, capabilities,
//! reasoning). Produced by `get_model_specs` (anya2a).
//!
@@ -219,8 +219,7 @@ pub struct ModelCost {
/// The full static portrait for a model, supplemented from community knowledge.
///
/// All fields are advisory — aimux never auto-applies them in the request path.
-/// The caller reads them to decide how to configure `GenerateTextOptions` /
-/// `bodyOverrides`.
+/// The caller reads them to decide how to configure `GenerateTextOptions`.
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS, PartialEq)]
#[ts(export)]
pub struct ModelSpec {
diff --git a/aimux-core/src/options.rs b/aimux-core/src/options.rs
index e9a6a302a..d43fbb159 100644
--- a/aimux-core/src/options.rs
+++ b/aimux-core/src/options.rs
@@ -115,11 +115,6 @@ pub struct CallOptions {
/// Anthropic `thinking` config.
pub reasoning: Option,
- /// Per-call request body overrides. Deep-merged into the provider-built
- /// request body (after any built-in vendor override) before sending.
- /// `null` values delete the corresponding key. See RFC-0017.
- pub body_overrides: Option,
-
/// Per-call retry count override. `None` uses the provider's configured
/// Core operation retry. `Some(0)` disables retries.
pub max_retries: Option,
@@ -208,7 +203,6 @@ impl CallOptions {
headers: None,
provider_options: None,
reasoning: None,
- body_overrides: None,
max_retries: None,
timeout: None,
session_id: None,
diff --git a/aimux-core/src/provider.rs b/aimux-core/src/provider.rs
index 70658318c..755833137 100644
--- a/aimux-core/src/provider.rs
+++ b/aimux-core/src/provider.rs
@@ -1,69 +1,198 @@
-//! The `Provider` trait — a factory that creates `LanguageModel` instances.
+//! The `Provider` trait — a factory that creates model instances for every
+//! modality a vendor offers.
+//!
+//! Mirrors the AI SDK's `ProviderV4`: three required model constructors
+//! (language / embedding / image), four optional ones that default to "not
+//! offered", and a `files()` accessor. Runtime model discovery lives on the
+//! separate [`ProviderDiscovery`] trait.
-use std::future::Future;
-use std::pin::Pin;
+use std::sync::Arc;
+use futures::future::BoxFuture;
+
+use crate::embedding_model::EmbeddingModel;
use crate::error::AiMuxError;
+use crate::files_model::Files;
+use crate::image_model::ImageModel;
use crate::language_model::LanguageModel;
use crate::model_catalogue::RuntimeModel;
+use crate::reranking_model::RerankingModel;
+use crate::search_model::SearchModel;
+use crate::skills_model::Skills;
+use crate::speech_model::SpeechModel;
+use crate::transcription_model::TranscriptionModel;
+use crate::video_model::VideoModel;
/// A provider factory.
///
-/// Holds API keys / config and creates `LanguageModel` instances by model name.
+/// Holds API keys / config and creates model instances by model id. The
+/// shape follows AI SDK `ProviderV4`:
+///
+/// - [`language_model`](Self::language_model),
+/// [`embedding_model`](Self::embedding_model) and
+/// [`image_model`](Self::image_model) are required. A vendor that does not
+/// offer a modality returns [`AiMuxError::NoSuchModel`] carrying the
+/// matching `model_type` (`"languageModel"`, `"embeddingModel"`,
+/// `"imageModel"`). Credential or settings errors are never reported as
+/// `NoSuchModel`.
+/// - [`transcription_model`](Self::transcription_model),
+/// [`speech_model`](Self::speech_model),
+/// [`reranking_model`](Self::reranking_model) and [`files`](Self::files)
+/// are optional: `None` means the vendor does not offer that modality.
+/// - [`video_model`](Self::video_model) and
+/// [`search_model`](Self::search_model) are aimux extensions; they are not
+/// members of `ProviderV4`.
+///
+/// A provider has no `name()`: the name it was registered under belongs to
+/// whoever holds the registry, and each model reports its own
+/// [`LanguageModel::provider`] string.
pub trait Provider: Send + Sync {
- /// Unique provider name (e.g. `"openai"`).
- fn name(&self) -> &str;
-
- /// Create a typed decision model.
+ /// Create a typed decision model (aimux extension).
/// # Errors
- /// Returns `UnsupportedFunctionality` unless the provider implements decisions.
- fn decision_model(&self, _model_id: &str) -> Result, AiMuxError> {
- Err(AiMuxError::UnsupportedFunctionality(format!(
- "provider '{}' does not provide decision models",
- self.name()
- )))
+ /// Returns `UnsupportedFunctionality` when decisions are not offered.
+ fn decision_model(&self, _id: &str) -> Result, AiMuxError> {
+ Err(AiMuxError::UnsupportedFunctionality(
+ "provider does not provide decision models".into(),
+ ))
}
- /// Create a model instance by its name string (e.g. `"gpt-4o"`).
+ /// Create a language model by its id (e.g. `"gpt-4o"`).
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AiMuxError::NoSuchModel`] with `model_type: "languageModel"`
+ /// when the vendor offers no language models; implementations may also
+ /// return lookup/auth errors for ids they cannot serve.
+ fn language_model(&self, id: &str) -> Result, AiMuxError>;
+
+ /// Create an embedding model by its id (e.g. `"text-embedding-3-large"`).
+ ///
+ /// # Errors
///
- /// Non-language-model providers (image/video/speech/search/… — e.g. Tavily,
- /// Stability, Recraft) do not implement this and get the default
- /// `Unsupported` error. Only providers that actually expose a language
- /// model override it (issue M9).
+ /// Returns [`AiMuxError::NoSuchModel`] with `model_type: "embeddingModel"`
+ /// when the vendor offers no embedding models.
+ fn embedding_model(&self, id: &str) -> Result, AiMuxError>;
+
+ /// Create an image model by its id (e.g. `"dall-e-3"`).
///
/// # Errors
///
- /// The default implementation returns `AiMuxError::UnsupportedFunctionality`;
- /// implementations return lookup/auth errors for model ids they cannot serve.
- fn language_model(&self, _model_id: &str) -> Result, AiMuxError> {
- Err(AiMuxError::UnsupportedFunctionality(format!(
- "provider '{}' does not provide language models",
- self.name()
- )))
+ /// Returns [`AiMuxError::NoSuchModel`] with `model_type: "imageModel"`
+ /// when the vendor offers no image models.
+ fn image_model(&self, id: &str) -> Result, AiMuxError>;
+
+ /// Create a transcription (STT) model; `None` when not offered.
+ fn transcription_model(
+ &self,
+ id: &str,
+ ) -> Option, AiMuxError>> {
+ let _ = id;
+ None
+ }
+
+ /// Create a speech (TTS) model; `None` when not offered.
+ fn speech_model(&self, id: &str) -> Option, AiMuxError>> {
+ let _ = id;
+ None
+ }
+
+ /// Create a reranking model; `None` when not offered.
+ fn reranking_model(&self, id: &str) -> Option, AiMuxError>> {
+ let _ = id;
+ None
+ }
+
+ /// The vendor's file-management interface; `None` when not offered.
+ fn files(&self) -> Option> {
+ None
+ }
+
+ /// The vendor's skill-management interface; `None` when not offered.
+ fn skills(&self) -> Option> {
+ None
+ }
+
+ /// Create a video-generation model; `None` when not offered.
+ ///
+ /// aimux extension — not a member of AI SDK `ProviderV4`.
+ fn video_model(&self, id: &str) -> Option, AiMuxError>> {
+ let _ = id;
+ None
}
+ /// Create a web-search model; `None` when not offered.
+ ///
+ /// aimux extension — not a member of AI SDK `ProviderV4`.
+ fn search_model(&self, id: &str) -> Option, AiMuxError>> {
+ let _ = id;
+ None
+ }
+
+ /// aimux extension: this provider's model-discovery side, when it has one
+ /// (see [`ProviderDiscovery`]). `None` for vendors without a `/models`
+ /// endpoint.
+ fn discovery(&self) -> Option<&dyn ProviderDiscovery> {
+ None
+ }
+}
+
+/// Runtime model discovery, implemented by providers that expose a
+/// model-list endpoint (RFC-0027).
+///
+/// Separate from [`Provider`] because `ProviderV4` has no equivalent and most
+/// vendors cannot list models.
+pub trait ProviderDiscovery: Send + Sync {
/// List the models this account can call on this provider, via the
- /// provider's `/models` endpoint (runtime discovery).
+ /// provider's `/models` endpoint.
///
/// Returns **only the provider's official data** (`RuntimeModel`: id,
/// owned_by, created) — no community catalogue enrichment. To supplement
/// with model specs (context length, capabilities, reasoning portrait),
/// call `get_model_specs` separately and merge in the host (RFC-0027).
- ///
- /// Default returns [`AiMuxError::UnsupportedFunctionality`] — providers that expose a
- /// model-list endpoint override this.
- ///
- /// Implemented as a `Pin>` (rather than `#[async_trait]`) so that
- /// the dozens of existing `Provider` impls need no changes — only providers
- /// that actually support `/models` override this.
- fn list_models(
+ fn list_models(&self) -> BoxFuture<'_, Result, AiMuxError>>;
+}
+
+/// A reference to a provider is a provider, so a `&'static` default instance
+/// (`openai()`, `anthropic()`, ...) can be registered as it is.
+impl Provider for &P {
+ fn decision_model(&self, id: &str) -> Result, AiMuxError> {
+ (**self).decision_model(id)
+ }
+
+ fn language_model(&self, id: &str) -> Result, AiMuxError> {
+ (**self).language_model(id)
+ }
+ fn embedding_model(&self, id: &str) -> Result, AiMuxError> {
+ (**self).embedding_model(id)
+ }
+ fn image_model(&self, id: &str) -> Result, AiMuxError> {
+ (**self).image_model(id)
+ }
+ fn transcription_model(
&self,
- ) -> Pin, AiMuxError>> + Send + '_>> {
- let name = self.name().to_string();
- Box::pin(async move {
- Err(AiMuxError::UnsupportedFunctionality(format!(
- "list_models not implemented for provider '{name}'"
- )))
- })
+ id: &str,
+ ) -> Option, AiMuxError>> {
+ (**self).transcription_model(id)
+ }
+ fn speech_model(&self, id: &str) -> Option, AiMuxError>> {
+ (**self).speech_model(id)
+ }
+ fn reranking_model(&self, id: &str) -> Option, AiMuxError>> {
+ (**self).reranking_model(id)
+ }
+ fn files(&self) -> Option> {
+ (**self).files()
+ }
+ fn skills(&self) -> Option> {
+ (**self).skills()
+ }
+ fn video_model(&self, id: &str) -> Option, AiMuxError>> {
+ (**self).video_model(id)
+ }
+ fn search_model(&self, id: &str) -> Option, AiMuxError>> {
+ (**self).search_model(id)
+ }
+ fn discovery(&self) -> Option<&dyn ProviderDiscovery> {
+ (**self).discovery()
}
}
diff --git a/aimux-core/src/provider_registry.rs b/aimux-core/src/provider_registry.rs
new file mode 100644
index 000000000..d2040efc7
--- /dev/null
+++ b/aimux-core/src/provider_registry.rs
@@ -0,0 +1,241 @@
+//! The provider registry: models addressed as `"{provider}:{model}"`.
+//!
+//! Mirrors the AI SDK's `createProviderRegistry`: the caller assembles a map
+//! of provider ids to providers and the registry resolves a combined id by
+//! splitting it at the first separator. The registry knows no provider on its
+//! own and never falls back to another one.
+
+use std::collections::BTreeMap;
+use std::sync::Arc;
+
+use crate::embedding_model::EmbeddingModel;
+use crate::error::AiMuxError;
+use crate::files_model::Files;
+use crate::image_model::ImageModel;
+use crate::image_model_middleware::{ImageModelMiddleware, wrap_image_model};
+use crate::language_model::LanguageModel;
+use crate::language_model_middleware::{LanguageModelMiddleware, wrap_language_model};
+use crate::provider::Provider;
+use crate::reranking_model::RerankingModel;
+use crate::search_model::SearchModel;
+use crate::skills_model::Skills;
+use crate::speech_model::SpeechModel;
+use crate::transcription_model::TranscriptionModel;
+use crate::video_model::VideoModel;
+
+/// Options of [`create_provider_registry`].
+#[derive(Clone)]
+pub struct ProviderRegistryOptions {
+ /// Separates the provider id from the model id. Defaults to `":"`.
+ pub separator: String,
+ /// Middleware applied to each language model in input order.
+ pub language_model_middleware: Vec>,
+ /// Middleware applied to each image model in input order.
+ pub image_model_middleware: Vec>,
+}
+
+impl Default for ProviderRegistryOptions {
+ fn default() -> Self {
+ Self {
+ separator: ":".to_string(),
+ language_model_middleware: Vec::new(),
+ image_model_middleware: Vec::new(),
+ }
+ }
+}
+
+/// A set of providers addressed by id; see [`create_provider_registry`].
+pub struct ProviderRegistry {
+ providers: BTreeMap>,
+ separator: String,
+ language_model_middleware: Vec>,
+ image_model_middleware: Vec>,
+}
+
+/// Create a registry over `providers` (the AI SDK's `createProviderRegistry`).
+#[must_use]
+pub fn create_provider_registry(
+ providers: BTreeMap>,
+ options: ProviderRegistryOptions,
+) -> ProviderRegistry {
+ ProviderRegistry {
+ providers,
+ separator: options.separator,
+ language_model_middleware: options.language_model_middleware,
+ image_model_middleware: options.image_model_middleware,
+ }
+}
+
+impl ProviderRegistry {
+ /// The provider registered as `id`.
+ ///
+ /// # Errors
+ ///
+ /// [`AiMuxError::NoSuchProvider`] for an id that is not registered.
+ pub fn provider(&self, id: &str) -> Result<&Arc, AiMuxError> {
+ self.get_provider(id, "")
+ }
+
+ fn get_provider(&self, id: &str, model_type: &str) -> Result<&Arc, AiMuxError> {
+ self.providers
+ .get(id)
+ .ok_or_else(|| AiMuxError::NoSuchProvider {
+ provider_id: id.to_string(),
+ model_id: id.to_string(),
+ model_type: model_type.to_string(),
+ available_providers: self.providers.keys().cloned().collect(),
+ })
+ }
+
+ /// The registered provider ids, in order.
+ pub fn provider_ids(&self) -> impl Iterator- {
+ self.providers.keys().map(String::as_str)
+ }
+
+ /// Split `id` at the first separator and look the provider up.
+ fn resolve<'a>(
+ &self,
+ id: &'a str,
+ model_type: &str,
+ ) -> Result<(&Arc
, &'a str), AiMuxError> {
+ let (provider_id, model_id) = id
+ .split_once(self.separator.as_str())
+ .ok_or_else(|| AiMuxError::no_such_model(id, model_type))?;
+ Ok((self.get_provider(provider_id, model_type)?, model_id))
+ }
+
+ /// The language model `"{provider}{separator}{model}"`.
+ ///
+ /// # Errors
+ ///
+ /// [`AiMuxError::NoSuchModel`] for an id without the separator,
+ /// [`AiMuxError::NoSuchProvider`] for an unregistered provider, and
+ /// whatever the provider returns for the model id.
+ pub fn language_model(&self, id: &str) -> Result, AiMuxError> {
+ let (provider, model_id) = self.resolve(id, "languageModel")?;
+ Ok(wrap_language_model(
+ provider.language_model(model_id)?,
+ &self.language_model_middleware,
+ None,
+ None,
+ ))
+ }
+
+ /// The embedding model for a combined id; errors as [`Self::language_model`].
+ ///
+ /// # Errors
+ ///
+ /// See [`Self::language_model`].
+ pub fn embedding_model(&self, id: &str) -> Result, AiMuxError> {
+ let (provider, model_id) = self.resolve(id, "embeddingModel")?;
+ provider.embedding_model(model_id)
+ }
+
+ /// The image model for a combined id; errors as [`Self::language_model`].
+ ///
+ /// # Errors
+ ///
+ /// See [`Self::language_model`].
+ pub fn image_model(&self, id: &str) -> Result, AiMuxError> {
+ let (provider, model_id) = self.resolve(id, "imageModel")?;
+ Ok(wrap_image_model(
+ provider.image_model(model_id)?,
+ &self.image_model_middleware,
+ None,
+ None,
+ ))
+ }
+
+ /// The transcription model for a combined id. A provider that offers none
+ /// is [`AiMuxError::NoSuchModel`].
+ ///
+ /// # Errors
+ ///
+ /// See [`Self::language_model`].
+ pub fn transcription_model(&self, id: &str) -> Result, AiMuxError> {
+ let (provider, model_id) = self.resolve(id, "transcriptionModel")?;
+ offered(
+ provider.transcription_model(model_id),
+ id,
+ "transcriptionModel",
+ )
+ }
+
+ /// The speech model for a combined id. A provider that offers none is
+ /// [`AiMuxError::NoSuchModel`].
+ ///
+ /// # Errors
+ ///
+ /// See [`Self::language_model`].
+ pub fn speech_model(&self, id: &str) -> Result, AiMuxError> {
+ let (provider, model_id) = self.resolve(id, "speechModel")?;
+ offered(provider.speech_model(model_id), id, "speechModel")
+ }
+
+ /// The reranking model for a combined id. A provider that offers none is
+ /// [`AiMuxError::NoSuchModel`].
+ ///
+ /// # Errors
+ ///
+ /// See [`Self::language_model`].
+ pub fn reranking_model(&self, id: &str) -> Result, AiMuxError> {
+ let (provider, model_id) = self.resolve(id, "rerankingModel")?;
+ offered(provider.reranking_model(model_id), id, "rerankingModel")
+ }
+
+ /// The video model for a combined id. A provider that offers none is
+ /// [`AiMuxError::NoSuchModel`].
+ ///
+ /// # Errors
+ ///
+ /// See [`Self::language_model`].
+ pub fn video_model(&self, id: &str) -> Result, AiMuxError> {
+ let (provider, model_id) = self.resolve(id, "videoModel")?;
+ offered(provider.video_model(model_id), id, "videoModel")
+ }
+
+ /// The search model for a combined id (an aimux extension). A provider
+ /// that offers none is [`AiMuxError::NoSuchModel`].
+ ///
+ /// # Errors
+ ///
+ /// See [`Self::language_model`].
+ pub fn search_model(&self, id: &str) -> Result, AiMuxError> {
+ let (provider, model_id) = self.resolve(id, "searchModel")?;
+ offered(provider.search_model(model_id), id, "searchModel")
+ }
+
+ /// The skills interface of a registered provider.
+ ///
+ /// # Errors
+ /// Returns an unknown provider or unsupported functionality error.
+ pub fn skills(&self, provider_id: &str) -> Result, AiMuxError> {
+ self.provider(provider_id)?.skills().ok_or_else(|| {
+ AiMuxError::UnsupportedFunctionality(format!(
+ "provider '{provider_id}' does not expose skills"
+ ))
+ })
+ }
+
+ /// The files interface of the provider `provider_id`.
+ ///
+ /// # Errors
+ ///
+ /// [`AiMuxError::NoSuchProvider`] for an unregistered provider,
+ /// [`AiMuxError::UnsupportedFunctionality`] when it exposes no files.
+ pub fn files(&self, provider_id: &str) -> Result, AiMuxError> {
+ self.provider(provider_id)?.files().ok_or_else(|| {
+ AiMuxError::UnsupportedFunctionality(format!(
+ "provider '{provider_id}' does not expose files"
+ ))
+ })
+ }
+}
+
+fn offered(
+ model: Option, AiMuxError>>,
+ id: &str,
+ model_type: &str,
+) -> Result, AiMuxError> {
+ model.unwrap_or_else(|| Err(AiMuxError::no_such_model(id, model_type)))
+}
diff --git a/aimux-core/src/recording.rs b/aimux-core/src/recording.rs
index 204ecabfb..c7282c597 100644
--- a/aimux-core/src/recording.rs
+++ b/aimux-core/src/recording.rs
@@ -29,7 +29,23 @@ use ts_rs::TS;
// ── 数据模型(三层 + call_id 关联;schema 版本)────────────────────────────
/// 录制格式版本(用于未来字段迁移与绑定层兼容)。
-pub const RECORDING_SCHEMA: u32 = 2;
+pub const RECORDING_SCHEMA: u32 = 3;
+
+/// 只接受当前 [`RECORDING_SCHEMA`]:反序列化旧版本录制时给出明确错误。
+fn deserialize_current_schema<'de, D>(deserializer: D) -> Result
+where
+ D: serde::Deserializer<'de>,
+{
+ let schema = u32::deserialize(deserializer)?;
+ if schema == RECORDING_SCHEMA {
+ Ok(schema)
+ } else {
+ Err(serde::de::Error::custom(format!(
+ "unsupported recording schema {schema}: only schema {RECORDING_SCHEMA} is \
+ readable, recordings written by older versions are no longer read"
+ )))
+ }
+}
/// Operation stored in a unified recording. Older recordings are language calls.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS)]
@@ -51,7 +67,9 @@ impl RecordingOperation {
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct Recording {
- /// 格式版本。
+ /// 格式版本。读取时只接受当前 [`RECORDING_SCHEMA`];旧 schema 的录制带有
+ /// 配置快照(base_url / profile 等),不再读取。
+ #[serde(deserialize_with = "deserialize_current_schema")]
pub schema: u32,
/// 暴露逻辑调用 ID,关联三层。
pub call_id: String,
@@ -134,12 +152,16 @@ impl Recording {
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct InputRecord {
+ /// Decision contract used for validation and deterministic offline replay.
+ /// Connection settings remain outside the recording.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub decision_capabilities: Option,
#[serde(default, skip_serializing_if = "RecordingOperation::is_language_model")]
pub operation: RecordingOperation,
/// 完整 prompt(消息数组,含 ContentPart::Image 等多模态)。
pub prompt: LanguageModelPrompt,
/// 序列化的 CallOptions(abort_signal/call_id 已 serde skip);
- /// headers/provider_options/body_overrides 已递归脱敏。
+ /// headers/provider_options 已递归脱敏。
pub options: serde_json::Value,
}
@@ -149,6 +171,7 @@ impl InputRecord {
pub fn from_call_options(options: &CallOptions) -> Self {
let value = serde_json::to_value(options).unwrap_or(serde_json::Value::Null);
Self {
+ decision_capabilities: None,
operation: RecordingOperation::LanguageModel,
prompt: options.prompt.clone(),
options: redact_json(value),
@@ -156,8 +179,12 @@ impl InputRecord {
}
#[must_use]
- pub fn from_decision_options(options: &crate::decision_model::DecisionCallOptions) -> Self {
+ pub fn from_decision_options(
+ options: &crate::decision_model::DecisionCallOptions,
+ capabilities: &crate::decision_model::DecisionCapabilities,
+ ) -> Self {
Self {
+ decision_capabilities: Some(capabilities.clone()),
operation: RecordingOperation::Decision,
prompt: Vec::new(),
options: redact_json(serde_json::to_value(options).unwrap_or_default()),
@@ -166,36 +193,39 @@ impl InputRecord {
}
/// ② 配置侧:provider 身份(请求回放重建用)。
+///
+/// 只记身份,不记配置:回放时按 `provider_id` + `model_id` 重新构建 model,
+/// 凭证与 base URL 只来自环境/registry(RFC-0036 §3.3)。
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct ProviderRecord {
- /// `model.provider()`,如 "openai"。
+ /// provider 名(registry 名),回放重建的查找键,如 "openai"、"deepseek"。
+ pub provider_id: String,
+ /// `model.provider()`,如 "openai.chat"。
pub provider: String,
/// `model.model_id()`,如 "gpt-4o"。
pub model_id: String,
- /// provider 的 base_url。
- pub base_url: Option,
- /// api_key 来源(不存明文):"env:OPENAI_API_KEY" / "explicit" / "none" / "unknown"。
- pub api_key_source: String,
- /// OpenAICompatProfile(能力差异)。
- pub profile: Option,
- /// ProviderOptions(headers/org/project/...;已脱敏)。
- pub provider_options: Option,
}
impl ProviderRecord {
- /// 最小快照(provider/model_id)——`config_snapshot` 默认实现;cover 的部分放结构方法。
+ /// 显式给出三项身份。
#[must_use]
- pub fn minimal(provider: &str, model_id: &str) -> Self {
+ pub fn new(provider_id: &str, provider: &str, model_id: &str) -> Self {
Self {
+ provider_id: provider_id.to_string(),
provider: provider.to_string(),
model_id: model_id.to_string(),
- base_url: None,
- api_key_source: "unknown".to_string(),
- profile: None,
- provider_options: None,
}
}
+
+ /// 由 model 的 `provider()` / `model_id()` 构造:`provider_id` 暂取
+ /// `provider()` 字符串的首段(`"openai.chat"` → `"openai"`);registry
+ /// 名接入后由其填写。
+ #[must_use]
+ pub fn from_model(provider: &str, model_id: &str) -> Self {
+ let provider_id = provider.split('.').next().unwrap_or_default();
+ Self::new(provider_id, provider, model_id)
+ }
}
/// ③ HTTP 侧:单次 attempt 的 wire 交换。
@@ -385,6 +415,7 @@ pub trait Recorder: Send + Sync {
_options: &crate::decision_model::DecisionCallOptions,
_provider: &str,
_model_id: &str,
+ _capabilities: &crate::decision_model::DecisionCapabilities,
) {
}
/// 录制会话归组信息(RFC-0024 P3):session_id + 会话内步号。
@@ -392,7 +423,7 @@ pub trait Recorder: Send + Sync {
/// 默认空实现(不参与录制的 Recorder 无需关心);开启录制且调用被归组
/// 时,`generate_text`/`stream_text` 入口调用它。
fn record_session(&self, _call_id: &str, _session_id: &str, _step: u32) {}
- /// 录制配置侧完整快照。
+ /// 录制配置侧身份(`ProviderRecord`:provider_id / provider / model_id)。
fn record_provider(&self, call_id: &str, snapshot: &ProviderRecord);
/// 录制单次 HTTP 交换(层 B http.rs 调用,per-attempt 一条)。
fn record_exchange(&self, call_id: &str, exchange: &HttpExchange);
@@ -517,18 +548,6 @@ pub fn init_recording(recorder: Option>) {
}
}
-/// 从环境变量初始化:`AIMUX_RECORD=1` 开启,`AIMUX_RECORD_DIR` 指定目录(默认 `./recordings`)。
-/// 未开启返回 false(静默)。
-#[must_use]
-pub fn init_recording_from_env() -> bool {
- if std::env::var("AIMUX_RECORD").as_deref() != Ok("1") {
- return false;
- }
- let dir = std::env::var("AIMUX_RECORD_DIR").unwrap_or_else(|_| "./recordings".to_string());
- init_recording(Some(Arc::new(JsonlRecorder::new(dir))));
- true
-}
-
/// 从当前全局 recorder 生成一次调用的 context(关闭时 None)。
/// 层 A 入口:先读一次,再用 `context && ctx.start()` 记录 ①+②。
pub fn context(call_id: impl Into) -> Option {
@@ -548,7 +567,7 @@ pub fn recorder() -> Option> {
static CALL_SEQ: AtomicU64 = AtomicU64::new(0);
/// 生成进程级唯一 call_id(`call-{ns}-{seq}`;与 session.rs 同构)。
-pub fn new_call_id() -> String {
+pub(crate) fn new_call_id() -> String {
let ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
@@ -589,7 +608,7 @@ pub fn is_sensitive_key(name: &str) -> bool {
}
/// 递归脱敏(JSON 中含敏感键的项值替换为 `[REDACTED]`)。
-/// 覆盖 `CallOptions.headers`/`provider_options`/`body_overrides` 任意层级。
+/// 覆盖 `CallOptions.headers`/`provider_options` 任意层级。
pub(crate) fn redact_json(value: serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
@@ -823,18 +842,19 @@ impl Recorder for JsonlRecorder {
options: &crate::decision_model::DecisionCallOptions,
provider: &str,
model_id: &str,
+ capabilities: &crate::decision_model::DecisionCapabilities,
) {
self.send_ev(RecordEvent::Input {
call_id: call_id.to_owned(),
- input: InputRecord::from_decision_options(options),
- provider: ProviderRecord::minimal(provider, model_id),
+ input: InputRecord::from_decision_options(options, capabilities),
+ provider: ProviderRecord::from_model(provider, model_id),
});
}
fn record_input(&self, call_id: &str, options: &CallOptions, provider: &str, model_id: &str) {
self.send_ev(RecordEvent::Input {
call_id: call_id.to_string(),
input: InputRecord::from_call_options(options),
- provider: ProviderRecord::minimal(provider, model_id),
+ provider: ProviderRecord::from_model(provider, model_id),
});
}
@@ -847,14 +867,9 @@ impl Recorder for JsonlRecorder {
}
fn record_provider(&self, call_id: &str, snapshot: &ProviderRecord) {
- // 核心边界统一强制脱敏 provider_options/profile(B6 评审),
- // 不依赖 provider 端自觉。
- let mut snap = snapshot.clone();
- snap.provider_options = snap.provider_options.take().map(redact_json);
- snap.profile = snap.profile.take().map(redact_json);
self.send_ev(RecordEvent::Provider {
call_id: call_id.to_string(),
- provider: snap,
+ provider: snapshot.clone(),
});
}
@@ -915,11 +930,12 @@ fn entry_or_init<'a>(
Recording::new(
call_id,
InputRecord {
+ decision_capabilities: None,
operation: Default::default(),
prompt: Vec::new(),
options: serde_json::Value::Null,
},
- ProviderRecord::minimal("", ""),
+ ProviderRecord::from_model("", ""),
)
})
}
@@ -1387,11 +1403,12 @@ impl RingInner {
Recording::new(
call_id,
InputRecord {
+ decision_capabilities: None,
operation: Default::default(),
prompt: Vec::new(),
options: serde_json::Value::Null,
},
- ProviderRecord::minimal("", ""),
+ ProviderRecord::from_model("", ""),
),
);
}
@@ -1464,12 +1481,13 @@ impl Recorder for RingRecorder {
options: &crate::decision_model::DecisionCallOptions,
provider: &str,
model_id: &str,
+ capabilities: &crate::decision_model::DecisionCapabilities,
) {
let mut inner = self.inner.lock().unwrap();
let rec = inner.entry_or_init_bounded(call_id);
- rec.input = InputRecord::from_decision_options(options);
+ rec.input = InputRecord::from_decision_options(options, capabilities);
if rec.provider.provider.is_empty() {
- rec.provider = ProviderRecord::minimal(provider, model_id);
+ rec.provider = ProviderRecord::from_model(provider, model_id);
}
}
fn record_input(&self, call_id: &str, options: &CallOptions, provider: &str, model_id: &str) {
@@ -1479,7 +1497,7 @@ impl Recorder for RingRecorder {
let rec = inner.entry_or_init_bounded(call_id);
rec.input = InputRecord::from_call_options(options);
if rec.provider.provider.is_empty() {
- rec.provider = ProviderRecord::minimal(provider, model_id);
+ rec.provider = ProviderRecord::from_model(provider, model_id);
}
}
@@ -1493,13 +1511,9 @@ impl Recorder for RingRecorder {
}
fn record_provider(&self, call_id: &str, snapshot: &ProviderRecord) {
- // 与 JsonlRecorder 一致的边界强制脱敏,不依赖 provider 端自觉。
- let mut snap = snapshot.clone();
- snap.provider_options = snap.provider_options.take().map(redact_json);
- snap.profile = snap.profile.take().map(redact_json);
let mut inner = self.inner.lock().unwrap();
// C4-6:entry_or_init_bounded 兜底建条目,保证乱序 Provider 快照不丢。
- inner.entry_or_init_bounded(call_id).provider = snap;
+ inner.entry_or_init_bounded(call_id).provider = snapshot.clone();
}
fn record_exchange(&self, call_id: &str, exchange: &HttpExchange) {
@@ -1979,11 +1993,13 @@ mod tests {
"x-goog-api-key": "gkey",
"x-amz-security-token": "sts-tok"
},
- "provider_options": { "headers": { "Cookie": "s=1" } },
- "body_overrides": {
- "api_key": "sk-secret",
- "token": "bearer-secret",
- "access_token": "oauth-secret"
+ "provider_options": {
+ "headers": { "Cookie": "s=1" },
+ "openai": {
+ "api_key": "sk-secret",
+ "token": "bearer-secret",
+ "access_token": "oauth-secret"
+ }
},
// 用量字段名含 "token" 子串,但非凭据——contains("token") 曾误伤。
"usage": {
@@ -1999,9 +2015,12 @@ mod tests {
assert_eq!(r["headers"]["x-amz-security-token"], "[REDACTED]");
assert_eq!(r["headers"]["X-Key"], "ok");
assert_eq!(r["provider_options"]["headers"]["Cookie"], "[REDACTED]");
- assert_eq!(r["body_overrides"]["api_key"], "[REDACTED]");
- assert_eq!(r["body_overrides"]["token"], "[REDACTED]");
- assert_eq!(r["body_overrides"]["access_token"], "[REDACTED]");
+ assert_eq!(r["provider_options"]["openai"]["api_key"], "[REDACTED]");
+ assert_eq!(r["provider_options"]["openai"]["token"], "[REDACTED]");
+ assert_eq!(
+ r["provider_options"]["openai"]["access_token"],
+ "[REDACTED]"
+ );
// 含 "token" 子串的用量字段不再被误脱敏(回归 contains("token"))。
assert_eq!(r["usage"]["max_output_tokens"], 4096);
assert_eq!(r["usage"]["prompt_tokens"], 10);
@@ -2014,7 +2033,7 @@ mod tests {
let rec = Recording::new(
"call-1",
InputRecord::from_call_options(&sample_options()),
- ProviderRecord::minimal("openai", "gpt-4o"),
+ ProviderRecord::from_model("openai", "gpt-4o"),
);
let json = serde_json::to_string(&rec).unwrap();
let back: Recording = serde_json::from_str(&json).unwrap();
@@ -2063,11 +2082,12 @@ mod tests {
let mut rec = Recording::new(
"call-x",
InputRecord {
+ decision_capabilities: None,
operation: Default::default(),
prompt: Vec::new(),
options: serde_json::Value::Null,
},
- ProviderRecord::minimal("openai", "gpt-4o"),
+ ProviderRecord::from_model("openai", "gpt-4o"),
);
rec.transport_closed = true;
rec.outcome = OutcomeRecord {
@@ -2142,7 +2162,7 @@ mod tests {
tx.send(RecordEvent::Input {
call_id: "c1".into(),
input: InputRecord::from_call_options(&sample_options()),
- provider: ProviderRecord::minimal("openai", "gpt-4o"),
+ provider: ProviderRecord::from_model("openai", "gpt-4o"),
})
.unwrap();
tx.send(RecordEvent::TransportClosed {
@@ -2218,7 +2238,7 @@ mod tests {
let rec = Recording::new(
"call-1",
InputRecord::from_call_options(&sample_options()),
- ProviderRecord::minimal("openai", "gpt-4o"),
+ ProviderRecord::from_model("openai", "gpt-4o"),
);
let json = serde_json::to_string(&rec).unwrap();
assert!(!json.contains("session_id"), "ungrouped must omit: {json}");
@@ -2760,14 +2780,7 @@ mod tests {
// ── C4-6:Provider 先于 Input 不丢 ─────────────────────────────────────
fn full_provider() -> ProviderRecord {
- ProviderRecord {
- provider: "openai".into(),
- model_id: "gpt-4o".into(),
- base_url: Some("https://api.openai.com".into()),
- api_key_source: "env:OPENAI_API_KEY".into(),
- profile: None,
- provider_options: None,
- }
+ ProviderRecord::new("openai", "openai.chat", "gpt-4o")
}
#[test]
@@ -2782,12 +2795,11 @@ mod tests {
ring.export_jsonl(&mut buf).unwrap();
let parsed: Recording =
serde_json::from_str(std::str::from_utf8(&buf).unwrap().trim()).unwrap();
- assert_eq!(parsed.provider.provider, "openai");
assert_eq!(
- parsed.provider.base_url.as_deref(),
- Some("https://api.openai.com"),
- "full snapshot preserved, not overwritten by minimal"
+ parsed.provider.provider, "openai.chat",
+ "explicit record preserved, not overwritten by the input-derived one"
);
+ assert_eq!(parsed.provider.provider_id, "openai");
assert_eq!(parsed.provider.model_id, "gpt-4o");
}
@@ -2803,11 +2815,8 @@ mod tests {
rec.flush();
let content = std::fs::read_to_string(rec.path()).unwrap();
let parsed: Recording = serde_json::from_str(content.trim()).unwrap();
- assert_eq!(
- parsed.provider.base_url.as_deref(),
- Some("https://api.openai.com")
- );
- assert_eq!(parsed.provider.provider, "openai");
+ assert_eq!(parsed.provider.provider_id, "openai");
+ assert_eq!(parsed.provider.provider, "openai.chat");
let _ = std::fs::remove_dir_all(&dir);
}
@@ -2848,11 +2857,12 @@ mod tests {
Recording::new(
call_id,
InputRecord {
+ decision_capabilities: None,
operation: Default::default(),
prompt: Vec::new(),
options: serde_json::Value::Null,
},
- ProviderRecord::minimal("", ""),
+ ProviderRecord::from_model("", ""),
)
}
diff --git a/aimux-core/src/replay.rs b/aimux-core/src/replay.rs
index 8d641d7a0..ebc162e09 100644
--- a/aimux-core/src/replay.rs
+++ b/aimux-core/src/replay.rs
@@ -51,10 +51,10 @@ pub trait ReplayMatcher: Send + Sync {
/// 精确匹配:provider/model_id 相同,且 prompt + 影响响应的可重放选项
/// (temperature/max_output_tokens/seed/response_format/tools/tool_choice)
-/// 以及 headers/provider_options/body_overrides 规范相同才命中。运行时字段
+/// 以及 headers/provider_options 规范相同才命中。运行时字段
/// (call_id/abort_signal/recording_context)不参与比较。
///
-/// headers/provider_options/body_overrides 用**脱敏感知比较**:录制侧这些
+/// headers/provider_options 用**脱敏感知比较**:录制侧这些
/// 字段经 `recording::redact_json` 脱敏
/// (敏感键值→`"[REDACTED]"`),脱敏值视为通配,匹配任意显式请求值;非脱敏
/// 部分仍精确比较。规则见 `redaction_aware_eq`。
@@ -96,9 +96,9 @@ impl ReplayMatcher for ExactMatcher {
/// `CallOptions` 中影响响应、可重放选项的规范键(ExactMatcher 用)。
///
/// 含 prompt + temperature/max_output_tokens/seed/response_format/tools/
-/// tool_choice + headers/provider_options/body_overrides;排除运行时字段
+/// tool_choice + headers/provider_options;排除运行时字段
/// (call_id/abort_signal/recording_context)。序列化为 `Value` 后比较,
-/// 等价于"稳定规范哈希"但无碰撞风险。headers/provider_options/body_overrides
+/// 等价于"稳定规范哈希"但无碰撞风险。headers/provider_options
/// 在比较时走脱敏感知语义(见 [`canonical_keys_match`])。
fn canonical_call_key(opts: &CallOptions) -> serde_json::Value {
serde_json::json!({
@@ -111,7 +111,6 @@ fn canonical_call_key(opts: &CallOptions) -> serde_json::Value {
"tool_choice": serde_json::to_value(&opts.tool_choice).unwrap_or_default(),
"headers": serde_json::to_value(&opts.headers).unwrap_or_default(),
"provider_options": serde_json::to_value(&opts.provider_options).unwrap_or_default(),
- "body_overrides": serde_json::to_value(&opts.body_overrides).unwrap_or_default(),
})
}
@@ -128,11 +127,10 @@ fn canonical_recording_key(rec: &Recording) -> serde_json::Value {
"tool_choice": o.get("tool_choice").cloned().unwrap_or_default(),
"headers": o.get("headers").cloned().unwrap_or_default(),
"provider_options": o.get("provider_options").cloned().unwrap_or_default(),
- "body_overrides": o.get("body_overrides").cloned().unwrap_or_default(),
})
}
-/// 脱敏感知比较(用于 headers/provider_options/body_overrides)。
+/// 脱敏感知比较(用于 headers/provider_options)。
///
/// 录制侧这些字段经 `recording::redact_json`
/// 脱敏:敏感键(authorization/api-key/apikey/key/token/cookie/...)的**值**
@@ -174,7 +172,7 @@ pub(crate) fn redaction_aware_eq(rec: &serde_json::Value, req: &serde_json::Valu
}
}
-/// 比较规范键:headers/provider_options/body_overrides 用脱敏感知比较
+/// 比较规范键:headers/provider_options 用脱敏感知比较
/// (录制侧可能已脱敏);其余字段(prompt/temperature/...)精确比较。
///
/// 注意:`max_output_tokens` 等含 "token" 子串的字段在录制侧也会被
@@ -182,7 +180,7 @@ pub(crate) fn redaction_aware_eq(rec: &serde_json::Value, req: &serde_json::Valu
/// 被脱敏为 `"[REDACTED]"` 而请求值为数值,精确比较会 miss(保守:宁可 miss
/// 不可误 hit,因为 max_output_tokens 影响响应,通配会引入伪命中)。
fn canonical_keys_match(rec_key: &serde_json::Value, call_key: &serde_json::Value) -> bool {
- const REDACTED_FIELDS: [&str; 3] = ["headers", "provider_options", "body_overrides"];
+ const REDACTED_FIELDS: [&str; 2] = ["headers", "provider_options"];
let (ro, co) = match (rec_key.as_object(), call_key.as_object()) {
(Some(a), Some(b)) => (a, b),
_ => return rec_key == call_key,
@@ -441,8 +439,9 @@ impl MockReplayModel {
///
/// # Errors
///
- /// Returns `AiMuxError::InvalidArgument` when the file cannot be read or
- /// contains no recordings, and `JsonParse` when a line is not valid JSON.
+ /// Returns `AiMuxError::InvalidArgument` when the file cannot be read,
+ /// contains no recordings, or holds a recording of an unsupported schema,
+ /// and `JsonParse` when a line is not valid JSON.
pub fn from_jsonl(path: &str) -> Result {
let content = std::fs::read_to_string(path)
.map_err(|e| AiMuxError::InvalidArgument(format!("mock replay: {e}")))?;
@@ -451,8 +450,16 @@ impl MockReplayModel {
if line.trim().is_empty() {
continue;
}
- let rec: Recording = serde_json::from_str(line)
- .map_err(|e| AiMuxError::JsonParse(format!("mock replay line {}: {e}", idx + 1)))?;
+ let rec: Recording = serde_json::from_str(line).map_err(|e| {
+ let msg = format!("mock replay line {}: {e}", idx + 1);
+ // Well-formed JSON that is not a current-schema `Recording`
+ // (including an old `schema`) is a bad argument, not a parse error.
+ if e.classify() == serde_json::error::Category::Data {
+ AiMuxError::InvalidArgument(msg)
+ } else {
+ AiMuxError::JsonParse(msg)
+ }
+ })?;
recordings.push(rec);
}
if recordings.is_empty() {
@@ -945,13 +952,11 @@ pub struct ReplayOverrides {
/// - `recording.input.options` 中的 headers/provider_options 已脱敏,
/// 重发会用脱敏后的 `[REDACTED]` 值——需要真实头时用 `overrides` 或
/// 重建 provider 时补充。
-/// - 逐消息 `provider_options`(如 anthropic cacheControl)不参与重建。
///
/// # Errors
///
/// Returns `AiMuxError::JsonParse` when the recorded call options cannot be
-/// deserialized, `AiMuxError::InvalidPrompt` for inline text file data without
-/// a user-facing representation, and propagates errors from `generate_text`.
+/// deserialized, and propagates errors from the generation pipeline.
pub async fn replay_with_model(
recording: &Recording,
model: &dyn LanguageModel,
@@ -1007,7 +1012,6 @@ fn generate_options_from_call_options(o: CallOptions) -> GenerateTextOptions {
provider_options: o.provider_options,
reasoning: o.reasoning,
instructions: None,
- body_overrides: o.body_overrides,
max_retries: o.max_retries,
timeout: o.timeout,
session_id: o.session_id,
@@ -1057,18 +1061,12 @@ mod tests {
call_id: trace_id.to_string(),
recorded_at: "2026-08-06T00:00:00Z".to_string(),
input: InputRecord {
+ decision_capabilities: None,
operation: Default::default(),
prompt: input_prompt,
options: serde_json::json!({ "temperature": 0.7 }),
},
- provider: ProviderRecord {
- provider: "openai".into(),
- model_id: "gpt-4o".into(),
- base_url: None,
- api_key_source: "none".into(),
- profile: None,
- provider_options: None,
- },
+ provider: ProviderRecord::new("openai", "openai", "gpt-4o"),
exchanges: vec![HttpExchange {
step: None,
attempt: 0,
diff --git a/aimux-core/src/reranking_model.rs b/aimux-core/src/reranking_model.rs
index a07c20514..cf677ebc6 100644
--- a/aimux-core/src/reranking_model.rs
+++ b/aimux-core/src/reranking_model.rs
@@ -124,21 +124,12 @@ pub struct RerankingResponse {
/// Aligned with V4 `RerankingModelV4`.
#[async_trait]
pub trait RerankingModel: Send + Sync {
- /// Specification version (always `"v4"`).
- fn specification_version(&self) -> &'static str {
- "v4"
- }
-
/// Provider name, e.g. `"cohere"`.
fn provider(&self) -> &str;
/// Provider-specific model ID, e.g. `"rerank-english-v3.0"`.
fn model_id(&self) -> &str;
- fn retry_config(&self) -> crate::retry::RetryConfig {
- crate::retry::RetryConfig::default()
- }
-
/// Rerank a list of documents using the query.
///
/// Naming: the `do_` prefix prevents accidental direct usage by users.
@@ -159,11 +150,7 @@ pub async fn rerank(
) -> Result {
let timeout = timeout::OperationTimeout::new(options.timeout.unwrap_or_default())?;
let abort_signal = options.abort_signal.clone();
- let retries = retry::prepare_retries(
- options.max_retries,
- model.retry_config(),
- abort_signal.clone(),
- );
+ let retries = retry::prepare_retries(options.max_retries, abort_signal.clone());
timeout::run(
retries.retry(|| model.do_rerank(&options)),
abort_signal.as_ref(),
diff --git a/aimux-core/src/retry.rs b/aimux-core/src/retry.rs
index dd97a1470..da3f99c06 100644
--- a/aimux-core/src/retry.rs
+++ b/aimux-core/src/retry.rs
@@ -8,40 +8,19 @@ use rand::Rng;
use crate::AbortSignal;
use crate::error::{AiMuxError, RetryError, RetryErrorReason};
+/// Default maximum number of retries after the initial attempt.
const DEFAULT_MAX_RETRIES: u32 = 2;
-const INITIAL_DELAY_MS: u64 = 2_000;
-const BACKOFF_FACTOR: u32 = 2;
-
-/// Default retry settings for model operations.
-///
-/// Per-call `max_retries` overrides only [`Self::max_retries`]; the configured
-/// delay and backoff factor remain in effect.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct RetryConfig {
- /// Maximum number of retries after the initial attempt.
- pub max_retries: u32,
- /// Initial delay between attempts.
- pub initial_delay: Duration,
- /// Multiplier applied to the delay after each retry.
- pub backoff_factor: u32,
-}
-
-impl Default for RetryConfig {
- fn default() -> Self {
- Self {
- max_retries: DEFAULT_MAX_RETRIES,
- initial_delay: Duration::from_millis(INITIAL_DELAY_MS),
- backoff_factor: BACKOFF_FACTOR,
- }
- }
-}
+/// Default delay before the first retry, in milliseconds.
+const DEFAULT_INITIAL_DELAY_MS: u64 = 2_000;
+/// Default multiplier applied to the delay after each retry.
+const DEFAULT_BACKOFF_FACTOR: u32 = 2;
/// A prepared operation retry function and its resolved retry count.
///
/// This is the Rust representation of the AI SDK's
/// `prepareRetries()` result: `{ maxRetries, retry }`.
#[derive(Debug, Clone)]
-pub struct PreparedRetries {
+pub(crate) struct PreparedRetries {
/// The effective maximum after applying the default.
pub max_retries: u32,
initial_delay_ms: u64,
@@ -49,21 +28,20 @@ pub struct PreparedRetries {
abort_signal: Option,
}
-/// Bind retry settings, applying a per-call retry-count override.
+/// Bind the call-level retry count, applying [`DEFAULT_MAX_RETRIES`] when the
+/// caller gave none. The delay and backoff factor are the fixed
+/// [`DEFAULT_INITIAL_DELAY_MS`] / [`DEFAULT_BACKOFF_FACTOR`] constants: retry
+/// is a call-level concern with no provider-level settings.
#[must_use]
-pub fn prepare_retries(
+pub(crate) fn prepare_retries(
max_retries: Option,
- mut config: RetryConfig,
- abort_signal: Option,
+ abort: Option,
) -> PreparedRetries {
- if let Some(max_retries) = max_retries {
- config.max_retries = max_retries;
- }
PreparedRetries {
- max_retries: config.max_retries,
- initial_delay_ms: u64::try_from(config.initial_delay.as_millis()).unwrap_or(u64::MAX),
- backoff_factor: u64::from(config.backoff_factor),
- abort_signal,
+ max_retries: max_retries.unwrap_or(DEFAULT_MAX_RETRIES),
+ initial_delay_ms: DEFAULT_INITIAL_DELAY_MS,
+ backoff_factor: u64::from(DEFAULT_BACKOFF_FACTOR),
+ abort_signal: abort,
}
}
@@ -299,14 +277,11 @@ mod tests {
#[tokio::test]
async fn prepared_retries_resolves_the_default_and_binds_abort() {
- assert_eq!(
- prepare_retries(None, RetryConfig::default(), None).max_retries,
- 2
- );
+ assert_eq!(prepare_retries(None, None).max_retries, 2);
let signal = AbortSignal::new();
signal.abort();
- let retries = prepare_retries(Some(4), RetryConfig::default(), Some(signal));
+ let retries = prepare_retries(Some(4), Some(signal));
let attempts = AtomicUsize::new(0);
let error = retries
@@ -323,20 +298,14 @@ mod tests {
}
#[test]
- fn per_call_max_preserves_the_model_delay_and_backoff() {
- let retries = prepare_retries(
- Some(4),
- RetryConfig {
- max_retries: 7,
- initial_delay: Duration::from_millis(123),
- backoff_factor: 3,
- },
- None,
- );
+ fn prepared_retries_use_the_fixed_delay_and_backoff() {
+ let retries = prepare_retries(Some(4), None);
assert_eq!(retries.max_retries, 4);
- assert_eq!(retries.initial_delay_ms, 123);
- assert_eq!(retries.backoff_factor, 3);
+ assert_eq!(retries.initial_delay_ms, DEFAULT_INITIAL_DELAY_MS);
+ assert_eq!(retries.backoff_factor, u64::from(DEFAULT_BACKOFF_FACTOR));
+ assert_eq!(prepare_retries(None, None).max_retries, DEFAULT_MAX_RETRIES);
+ assert_eq!(prepare_retries(Some(0), None).max_retries, 0);
}
#[tokio::test]
diff --git a/aimux-core/src/router.rs b/aimux-core/src/router.rs
index 41b5858db..4151dbff1 100644
--- a/aimux-core/src/router.rs
+++ b/aimux-core/src/router.rs
@@ -177,11 +177,7 @@ impl RouterModel {
if i == exclude {
continue;
}
- let retries = retry::prepare_retries(
- options.max_retries,
- m.retry_config(),
- options.abort_signal.clone(),
- );
+ let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone());
let child_options = options.for_step(self.step_label(i));
match retries
.retry(|| {
@@ -229,24 +225,11 @@ impl LanguageModel for RouterModel {
&self.config.model_id
}
- fn retry_config(&self) -> retry::RetryConfig {
- // Retrying the composite would rerun routing and previously attempted
- // children; each child is retried at its own execution boundary.
- retry::RetryConfig {
- max_retries: 0,
- ..retry::RetryConfig::default()
- }
- }
-
async fn do_generate(&self, options: &CallOptions) -> Result {
let raw = self.router.route(&options.prompt, &self.models)?;
let idx = self.check_index(raw, "router")?;
let model = &self.models[idx];
- let retries = retry::prepare_retries(
- options.max_retries,
- model.retry_config(),
- options.abort_signal.clone(),
- );
+ let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone());
let child_options = options.for_step(self.step_label(idx));
match retries
.retry(|| {
@@ -272,11 +255,7 @@ impl LanguageModel for RouterModel {
let raw = self.router.route(&options.prompt, &self.models)?;
let idx = self.check_index(raw, "router")?;
let model = &self.models[idx];
- let retries = retry::prepare_retries(
- options.max_retries,
- model.retry_config(),
- options.abort_signal.clone(),
- );
+ let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone());
let child_options = options.for_step(self.step_label(idx));
// Only setup can be retried. The returned stream is passed through, so
// failures after setup never invoke another child or duplicate output.
diff --git a/aimux-core/src/search_model.rs b/aimux-core/src/search_model.rs
index cd03f7b10..4c94c65dd 100644
--- a/aimux-core/src/search_model.rs
+++ b/aimux-core/src/search_model.rs
@@ -138,11 +138,6 @@ pub struct SearchResponse {
/// implement `do_search`; users never call it directly.
#[async_trait]
pub trait SearchModel: Send + Sync {
- /// Specification version (always `"v4"`).
- fn specification_version(&self) -> &'static str {
- "v4"
- }
-
/// Provider name, e.g. `"tavily"`.
fn provider(&self) -> &str;
@@ -150,10 +145,6 @@ pub trait SearchModel: Send + Sync {
/// `"tavily-search"`; others accept endpoint-specific names).
fn model_id(&self) -> &str;
- fn retry_config(&self) -> crate::retry::RetryConfig {
- crate::retry::RetryConfig::default()
- }
-
/// Execute a search query and return results.
///
/// Naming: the `do_` prefix prevents accidental direct usage by users.
@@ -171,11 +162,7 @@ pub async fn search(
) -> Result {
let timeout = timeout::OperationTimeout::new(options.timeout.unwrap_or_default())?;
let abort_signal = options.abort_signal.clone();
- let retries = retry::prepare_retries(
- options.max_retries,
- model.retry_config(),
- abort_signal.clone(),
- );
+ let retries = retry::prepare_retries(options.max_retries, abort_signal.clone());
timeout::run(
retries.retry(|| model.do_search(&options)),
abort_signal.as_ref(),
diff --git a/aimux-core/src/session.rs b/aimux-core/src/session.rs
index 315c00c0e..38a3869a2 100644
--- a/aimux-core/src/session.rs
+++ b/aimux-core/src/session.rs
@@ -348,7 +348,7 @@ pub fn session_inferer() -> Option>> {
/// Lazily honor `AIMUX_SESSION_INFER=1` (checked once, like RFC-0014's env
/// auto-init). No-op when the inferer is already registered programmatically
/// (explicit configuration wins) or the env var is not set.
-pub fn ensure_inferer_from_env() {
+fn ensure_inferer_from_env() {
if INFER_ENV_CHECKED.is_completed() {
return;
}
@@ -365,7 +365,7 @@ pub fn ensure_inferer_from_env() {
/// Returns `(session_id, source)` or `None` when no explicit id is present
/// and inference is off (or not registered).
#[must_use]
-pub fn resolve_session_id(
+pub(crate) fn resolve_session_id(
explicit: Option<&str>,
prompt: &LanguageModelPrompt,
) -> Option<(String, SessionSource)> {
diff --git a/aimux-core/src/shared.rs b/aimux-core/src/shared.rs
index db673e627..24cddc49d 100644
--- a/aimux-core/src/shared.rs
+++ b/aimux-core/src/shared.rs
@@ -311,3 +311,13 @@ impl From for ResponseInfo {
}
}
}
+
+impl From for crate::types::ResponseMetadata {
+ fn from(response: ResponseInfo) -> Self {
+ Self {
+ id: response.id,
+ timestamp: response.timestamp,
+ model_id: response.model_id,
+ }
+ }
+}
diff --git a/aimux-core/src/skills_model.rs b/aimux-core/src/skills_model.rs
new file mode 100644
index 000000000..deb5171c0
--- /dev/null
+++ b/aimux-core/src/skills_model.rs
@@ -0,0 +1,47 @@
+//! Provider-facing skill upload interface, aligned with SkillsV4.
+
+use async_trait::async_trait;
+use serde::{Deserialize, Serialize};
+use ts_rs::TS;
+
+use crate::error::AiMuxError;
+use crate::files_model::UploadFileData;
+use crate::shared::{
+ SharedProviderMetadata, SharedProviderOptions, SharedProviderReference, Warning,
+};
+
+#[derive(Debug, Clone, Serialize, Deserialize, TS)]
+#[ts(export)]
+pub struct SkillFile {
+ pub path: String,
+ pub data: UploadFileData,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, TS)]
+#[ts(export)]
+pub struct UploadSkillCallOptions {
+ pub files: Vec,
+ pub display_title: Option,
+ pub provider_options: Option,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, TS)]
+#[ts(export)]
+pub struct UploadSkillResult {
+ pub provider_reference: SharedProviderReference,
+ pub display_title: Option,
+ pub name: Option,
+ pub description: Option,
+ pub latest_version: Option,
+ pub provider_metadata: Option,
+ pub warnings: Vec,
+}
+
+#[async_trait]
+pub trait Skills: Send + Sync {
+ fn provider(&self) -> &str;
+ async fn upload_skill(
+ &self,
+ options: &UploadSkillCallOptions,
+ ) -> Result;
+}
diff --git a/aimux-core/src/speech_model.rs b/aimux-core/src/speech_model.rs
index 5add06e97..d83d38c8f 100644
--- a/aimux-core/src/speech_model.rs
+++ b/aimux-core/src/speech_model.rs
@@ -137,21 +137,12 @@ pub struct SpeechResponse {
/// Aligned with V4 `SpeechModelV4`.
#[async_trait]
pub trait SpeechModel: Send + Sync {
- /// Specification version (always `"v4"`).
- fn specification_version(&self) -> &'static str {
- "v4"
- }
-
/// Provider name, e.g. `"openai"`.
fn provider(&self) -> &str;
/// Provider-specific model ID, e.g. `"tts-1"`.
fn model_id(&self) -> &str;
- fn retry_config(&self) -> crate::retry::RetryConfig {
- crate::retry::RetryConfig::default()
- }
-
/// Generate speech audio from text.
///
/// Naming: the `do_` prefix prevents accidental direct usage by users.
@@ -169,11 +160,7 @@ pub async fn generate_speech(
) -> Result {
let timeout = timeout::OperationTimeout::new(options.timeout.unwrap_or_default())?;
let abort_signal = options.abort_signal.clone();
- let retries = retry::prepare_retries(
- options.max_retries,
- model.retry_config(),
- abort_signal.clone(),
- );
+ let retries = retry::prepare_retries(options.max_retries, abort_signal.clone());
timeout::run(
retries.retry(|| model.do_generate(&options)),
abort_signal.as_ref(),
diff --git a/aimux-core/src/trace/layer.rs b/aimux-core/src/trace/layer.rs
index c5a73b349..588e6dc7c 100644
--- a/aimux-core/src/trace/layer.rs
+++ b/aimux-core/src/trace/layer.rs
@@ -163,24 +163,18 @@ impl TraceLayer {
.session_id
.clone()
.or_else(|| self.default_session.clone());
- // B1: scope_key isolates by (provider, base_url, model_id) — not just
- // model_id — so two TraceLayers over the same model_id but different
- // providers (or base_urls) sharing one RingTraceStore never cross-match
- // their LCP history. `base_url` comes from the inner model's config
- // snapshot; providers that only report a minimal snapshot omit it, in
- // which case provider + model_id still isolate the scope. The default
- // `scope_salt` stays process-level (set in `new` / `with_default_session`).
+ // B1: scope_key isolates by (provider, model_id), so two TraceLayers
+ // over the same model_id but different providers sharing one
+ // RingTraceStore never cross-match their LCP history. A model's
+ // `provider()` string already carries the settings-level name, so two
+ // endpoints of one vendor stay apart when they are given different
+ // provider names. The default `scope_salt` stays process-level (set in
+ // `new` / `with_default_session`).
let provider = self.inner.provider();
let model_id = self.inner.model_id();
- let base_url = self.inner.config_snapshot().base_url;
- let mut scope_bytes = Vec::with_capacity(
- provider.len() + model_id.len() + base_url.as_deref().map_or(0, str::len),
- );
+ let mut scope_bytes = Vec::with_capacity(provider.len() + model_id.len());
scope_bytes.extend_from_slice(provider.as_bytes());
scope_bytes.extend_from_slice(model_id.as_bytes());
- if let Some(b) = &base_url {
- scope_bytes.extend_from_slice(b.as_bytes());
- }
let scope_key = hash::hash64(self.scope_salt, &scope_bytes);
let sent_at_unix_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -505,10 +499,6 @@ impl Drop for TraceRecordingStream {
#[async_trait]
impl LanguageModel for TraceLayer {
- fn specification_version(&self) -> &'static str {
- "v4"
- }
-
fn provider(&self) -> &str {
self.inner.provider()
}
@@ -517,14 +507,8 @@ impl LanguageModel for TraceLayer {
self.inner.model_id()
}
- fn retry_config(&self) -> crate::retry::RetryConfig {
- self.inner.retry_config()
- }
-
- /// RFC-0023 §3.3: transparent decorators must forward the inner snapshot
- /// (otherwise recording sees the decorator's minimal record).
- fn config_snapshot(&self) -> crate::recording::ProviderRecord {
- self.inner.config_snapshot()
+ fn supported_urls(&self) -> crate::language_model::SupportedUrls {
+ self.inner.supported_urls()
}
async fn do_generate(&self, options: &CallOptions) -> Result {
diff --git a/aimux-core/src/trace/mod.rs b/aimux-core/src/trace/mod.rs
index 606c23b2b..f8d60fde2 100644
--- a/aimux-core/src/trace/mod.rs
+++ b/aimux-core/src/trace/mod.rs
@@ -25,5 +25,5 @@ pub use store::{
};
pub use verdict::{
JudgmentInput, LcpInput, ProviderAuditSpec, ProviderFamily, SessionStats, Verdict,
- VerdictConfidence, VerdictKind, judge, matrix, quantize_down,
+ VerdictConfidence, VerdictKind, judge, matrix,
};
diff --git a/aimux-core/src/trace/verdict.rs b/aimux-core/src/trace/verdict.rs
index abca26a15..d23939e7a 100644
--- a/aimux-core/src/trace/verdict.rs
+++ b/aimux-core/src/trace/verdict.rs
@@ -337,7 +337,7 @@ pub struct JudgmentInput {
}
#[must_use]
-pub fn quantize_down(x: u64, gran: Option) -> u64 {
+fn quantize_down(x: u64, gran: Option) -> u64 {
match gran {
Some(g) if g > 0 => x / g * g,
_ => x,
diff --git a/aimux-core/src/transcription_model.rs b/aimux-core/src/transcription_model.rs
index efeb2c75b..516d345cf 100644
--- a/aimux-core/src/transcription_model.rs
+++ b/aimux-core/src/transcription_model.rs
@@ -284,21 +284,12 @@ impl std::fmt::Debug for TranscriptionStreamResult {
/// Aligned with V4 `TranscriptionModelV4`.
#[async_trait]
pub trait TranscriptionModel: Send + Sync {
- /// Specification version (always `"v4"`).
- fn specification_version(&self) -> &'static str {
- "v4"
- }
-
/// Provider name, e.g. `"openai"`.
fn provider(&self) -> &str;
/// Provider-specific model ID, e.g. `"whisper-1"`.
fn model_id(&self) -> &str;
- fn retry_config(&self) -> crate::retry::RetryConfig {
- crate::retry::RetryConfig::default()
- }
-
/// Generate a transcript.
///
/// Naming: the `do_` prefix prevents accidental direct usage by users.
@@ -334,11 +325,7 @@ pub async fn transcribe(
) -> Result {
let timeout = timeout::OperationTimeout::new(options.timeout.unwrap_or_default())?;
let abort_signal = options.abort_signal.clone();
- let retries = retry::prepare_retries(
- options.max_retries,
- model.retry_config(),
- abort_signal.clone(),
- );
+ let retries = retry::prepare_retries(options.max_retries, abort_signal.clone());
timeout::run(
retries.retry(|| model.do_generate(&options)),
abort_signal.as_ref(),
diff --git a/aimux-core/src/util.rs b/aimux-core/src/util.rs
index 7ac22001c..39e4212f7 100644
--- a/aimux-core/src/util.rs
+++ b/aimux-core/src/util.rs
@@ -19,7 +19,7 @@ pub use crate::math::{UtilError, cosine_similarity};
/// Current time as RFC 3339 UTC with millisecond precision
/// (`2026-08-05T04:52:30.123Z`).
#[must_use]
-pub fn rfc3339_now() -> String {
+pub(crate) fn rfc3339_now() -> String {
let d = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
diff --git a/aimux-core/src/video_model.rs b/aimux-core/src/video_model.rs
index 459086e03..647f3625a 100644
--- a/aimux-core/src/video_model.rs
+++ b/aimux-core/src/video_model.rs
@@ -287,23 +287,17 @@ impl Default for VideoPollConfig {
/// same operation reference.
#[async_trait]
pub trait VideoModel: Send + Sync {
- /// Specification version (always `"v4"`).
- fn specification_version(&self) -> &'static str {
- "v4"
- }
-
/// Provider name, e.g. `"fal"`.
fn provider(&self) -> &str;
/// Provider-specific model ID, e.g. `"kling-video"`.
fn model_id(&self) -> &str;
- fn retry_config(&self) -> retry::RetryConfig {
- retry::RetryConfig::default()
- }
-
- /// Poll pacing for this model. Defaults to the AI SDK values; providers
- /// with configurable polling should surface their configuration here.
+ /// Poll pacing for this model: the package's own constants. Providers have
+ /// no poll settings (the AI SDK's `pollIntervalMs` / `pollTimeoutMs` are
+ /// per-call options, here [`VideoCallOptions::poll`], which overrides this
+ /// per field). Defaults to the AI SDK values; a package whose job runs
+ /// longer or shorter returns its own constants.
fn poll_config(&self) -> VideoPollConfig {
VideoPollConfig::default()
}
@@ -381,11 +375,7 @@ async fn start_and_poll(
}
let abort_signal = options.abort_signal.clone();
- let retries = retry::prepare_retries(
- options.max_retries,
- model.retry_config(),
- abort_signal.clone(),
- );
+ let retries = retry::prepare_retries(options.max_retries, abort_signal.clone());
let mut poll = model.poll_config();
if let Some(overrides) = options.poll {
@@ -596,13 +586,6 @@ mod tests {
"scripted"
}
- fn retry_config(&self) -> crate::retry::RetryConfig {
- crate::retry::RetryConfig {
- initial_delay: Duration::from_millis(1),
- ..crate::retry::RetryConfig::default()
- }
- }
-
fn max_videos_per_call(&self) -> Option {
Some(1)
}
@@ -717,7 +700,8 @@ mod tests {
assert_eq!(result.videos.len(), 1);
}
- #[tokio::test]
+ // Paused time: the fixed 2s retry delay must not make the test wait.
+ #[tokio::test(start_paused = true)]
async fn start_retry_reuses_one_idempotency_key() {
let model = ScriptedVideoModel::new(vec![StatusStep::Complete]).with_start_failures(1);
let mut options = fast_poll_options();
@@ -898,7 +882,7 @@ mod tests {
assert_eq!(unique.len(), 3, "one key per batch: {keys:?}");
}
- #[tokio::test]
+ #[tokio::test(start_paused = true)]
async fn caller_key_is_distinct_per_batch_and_stable_across_retries_and_replays() {
let model = ScriptedVideoModel::new(vec![]).with_start_failures(1);
let mut options = fast_poll_options();
diff --git a/aimux-core/tests/error_value_golden_test.rs b/aimux-core/tests/error_value_golden_test.rs
index 7426f4758..fa62d5598 100644
--- a/aimux-core/tests/error_value_golden_test.rs
+++ b/aimux-core/tests/error_value_golden_test.rs
@@ -158,6 +158,20 @@ fn error_value_snapshots_plain_variants() {
AiMuxError::UnsupportedFunctionality("no audio".into()),
r#"{"UnsupportedFunctionality":"no audio"}"#,
),
+ (
+ AiMuxError::LoadApiKey {
+ env_var: "OPENAI_API_KEY".into(),
+ description: "OpenAI".into(),
+ },
+ r#"{"LoadApiKey":{"env_var":"OPENAI_API_KEY","description":"OpenAI"}}"#,
+ ),
+ (
+ AiMuxError::LoadSetting {
+ env_var: "AWS_REGION".into(),
+ name: "region".into(),
+ },
+ r#"{"LoadSetting":{"env_var":"AWS_REGION","name":"region"}}"#,
+ ),
(
AiMuxError::NoSuchModel {
model_id: "gpt-9".into(),
@@ -168,8 +182,11 @@ fn error_value_snapshots_plain_variants() {
(
AiMuxError::NoSuchProvider {
provider_id: "acme".into(),
+ model_id: "acme".into(),
+ model_type: "languageModel".into(),
+ available_providers: vec!["other".into()],
},
- r#"{"NoSuchProvider":{"provider_id":"acme"}}"#,
+ r#"{"NoSuchProvider":{"provider_id":"acme","model_id":"acme","model_type":"languageModel","available_providers":["other"]}}"#,
),
(
AiMuxError::Timeout("total timeout".into()),
diff --git a/aimux-ffi/aimux-error.h b/aimux-ffi/aimux-error.h
index bfe50be7c..abb1dda4c 100644
--- a/aimux-ffi/aimux-error.h
+++ b/aimux-ffi/aimux-error.h
@@ -6,7 +6,7 @@
* trailing out-parameter, which remains at its documented sentinel on failure.
*
* Every non-NULL error has one non-zero `aimux_error_code_t` and one message.
- * Codes 1..18 come from `AiMuxError`, 100..105 from
+ * Codes 1..20 come from `AiMuxError`, 100..105 from
* `RecordingError`, and 200..206 identify failures detected while crossing
* the C ABI. Higher-level bindings reconstruct their native error types from
* that code; they map all 200..206 codes to the language's existing
@@ -40,7 +40,7 @@ typedef struct aimux_error aimux_error_t;
typedef enum aimux_error_code {
AIMUX_OK = 0,
- /* AiMuxError: 1..18, except retired code 4. */
+ /* AiMuxError: 1..20, except retired code 4. */
AIMUX_E_OTHER = 1,
AIMUX_E_JSON_PARSE = 2,
AIMUX_E_INVALID_RESPONSE_DATA = 3,
@@ -60,7 +60,13 @@ typedef enum aimux_error_code {
AIMUX_E_NO_SUCH_TOOL = 15,
AIMUX_E_INVALID_TOOL_INPUT = 16,
AIMUX_E_TOOL_CALL_REPAIR = 17,
- AIMUX_E_NO_OUTPUT_GENERATED = 18,
+ /* No API key was passed and the fallback environment variable is unset
+ * (the AI SDK's LoadAPIKeyError). Pre-HTTP: no request was made. */
+ AIMUX_E_LOAD_API_KEY = 18,
+ /* A required provider setting was not passed and its fallback environment
+ * variable is unset (the AI SDK's LoadSettingError). */
+ AIMUX_E_LOAD_SETTING = 19,
+ AIMUX_E_NO_OUTPUT_GENERATED = 20,
/* RecordingError: 100..105. */
AIMUX_E_RECORDING_INIT = 100,
@@ -108,9 +114,13 @@ int32_t aimux_error_status(const aimux_error_t *error);
/** Retry hint in milliseconds (0 = retry now), or -1 when absent. */
int64_t aimux_error_retry_ms(const aimux_error_t *error);
-/** Provider's own error code, e.g. "insufficient_quota". */
+/** Provider's own error code, e.g. "insufficient_quota". Under
+ * AIMUX_E_LOAD_API_KEY / AIMUX_E_LOAD_SETTING: the environment variable that
+ * was consulted, e.g. "OPENAI_API_KEY". */
char *aimux_error_provider_code(const aimux_error_t *error);
-/** Failure text without Aimux's composed prefix. */
+/** Failure text without Aimux's composed prefix. Under AIMUX_E_LOAD_API_KEY:
+ * what the key is for ("OpenAI"); under AIMUX_E_LOAD_SETTING: the setting's
+ * parameter name ("region"). */
char *aimux_error_provider_message(const aimux_error_t *error);
/** Raw provider response body. */
char *aimux_error_response_body(const aimux_error_t *error);
diff --git a/aimux-ffi/aimux-ffi.h b/aimux-ffi/aimux-ffi.h
index 16db8b2d7..a781cacca 100644
--- a/aimux-ffi/aimux-ffi.h
+++ b/aimux-ffi/aimux-ffi.h
@@ -15,7 +15,7 @@
* Getter strings are caller-owned (`aimux_free_string`). See aimux-error.h.
*
* Each prototype below identifies its expected high-level error:
- * [AiMuxError] codes 1..17
+ * [AiMuxError] codes 1..19
* [RecordingError] codes 100..105
* [C ABI] no expected high-level code
* Every fallible call can additionally return a C ABI failure (200..206).
@@ -177,9 +177,10 @@ aimux_error_t *aimux_azure_new_with_base(const char *api_key, const char *base_u
* a C ABI failure.
* @param model_id NUL-terminated model ID.
* @param config_json Optional JSON object of ProviderOptions
- * ({"base_url": "...", "headers": {...}, "max_retries": 0,
- * "body_overrides": {...}});
- * NULL / empty / "null" for defaults.
+ * ({"base_url": "...", "headers": {...}, "organization": "...",
+ * "project": "..."}); "max_retries" (call-level) and
+ * "body_overrides" (removed) are rejected as invalid
+ * arguments. NULL / empty / "null" for defaults.
* @param out_handle Receives the model handle. AiMuxError: unknown
* provider, bad config, missing env key, invalid model id.
*/
diff --git a/aimux-ffi/src/lib.rs b/aimux-ffi/src/lib.rs
index ebb4d01f9..05094752a 100644
--- a/aimux-ffi/src/lib.rs
+++ b/aimux-ffi/src/lib.rs
@@ -12,7 +12,7 @@
//! value on failure (the out-parameter is left at its sentinel: handle 0,
//! pointer NULL). Every non-NULL error has one code from [`aimux_error_code`]
//! and one message from [`aimux_error_message`], and is released exactly once
-//! with [`aimux_error_free`]. Codes 1..17 come from `AiMuxError`, 100..105
+//! with [`aimux_error_free`]. Codes 1..20 come from `AiMuxError`, 100..105
//! from `RecordingError`, and 200..206 identify failures detected while
//! crossing the C ABI.
//!
@@ -71,18 +71,20 @@ use aimux_core::provider::Provider;
use aimux_core::recording::RecordingError;
use aimux_core::tool::ToolCall;
use aimux_core::trace::{RingTraceStore, TraceFilter, TraceLayer};
-use aimux_providers::anthropic::{AnthropicConfig, AnthropicProvider};
-use aimux_providers::anthropic_aws::{AnthropicAwsProvider, AnthropicAwsProviderConfig};
-use aimux_providers::azure::{AzureConfig, AzureProvider};
-use aimux_providers::bedrock::{BedrockProvider, BedrockProviderConfig};
-use aimux_providers::cohere::{CohereConfig, CohereProvider};
-use aimux_providers::google::{GoogleConfig, GoogleProvider};
-use aimux_providers::mistral::{MistralConfig, MistralProvider};
-use aimux_providers::openai::{OpenAIConfig, OpenAIProvider};
-use aimux_providers::tavily::{TavilyConfig, TavilyProvider};
-use aimux_providers::vertex::{VertexProvider, VertexProviderConfig};
-use aimux_providers::xai::{XAIConfig, XAIProvider};
-use aimux_providers::{ProviderOptions, provider, provider_handle};
+use aimux_providers::anthropic::{AnthropicProviderSettings, create_anthropic};
+use aimux_providers::anthropic_aws::{
+ AnthropicAwsAuth, AnthropicAwsProviderSettings, create_anthropic_aws,
+};
+use aimux_providers::azure::{AzureOpenAIProviderSettings, create_azure};
+use aimux_providers::bedrock::{AmazonBedrockProviderSettings, create_amazon_bedrock};
+use aimux_providers::cohere::{CohereProviderSettings, create_cohere};
+use aimux_providers::google::{GoogleProviderSettings, create_google};
+use aimux_providers::mistral::{MistralProviderSettings, create_mistral};
+use aimux_providers::openai::{OpenAIProviderSettings, create_openai};
+use aimux_providers::provider::{ProviderOptions, provider, provider_handle};
+use aimux_providers::tavily::{TavilyProvider, TavilyProviderSettings, create_tavily};
+use aimux_providers::vertex::{VertexProviderSettings, create_google_vertex};
+use aimux_providers::xai::{XAIProviderSettings, create_xai};
use futures::StreamExt;
use tokio::runtime::Runtime;
@@ -96,7 +98,7 @@ use tokio::runtime::Runtime;
#[derive(Clone)]
enum HandleEntry {
Language(Arc),
- Provider(Arc),
+ Provider(ProviderEntry),
Embedding(Arc),
Speech(Arc),
Image(Arc),
@@ -111,6 +113,14 @@ enum HandleEntry {
Abort(AbortSignal),
}
+/// A provider handle: the model factory plus its runtime-discovery side.
+///
+/// Discovery is accessed through `Provider::discovery()`.
+#[derive(Clone)]
+struct ProviderEntry {
+ provider: Arc,
+}
+
type Registry = HashMap;
static REGISTRY: OnceLock> = OnceLock::new();
@@ -439,7 +449,12 @@ pub const AIMUX_E_RETRY: i32 = 14;
pub const AIMUX_E_NO_SUCH_TOOL: i32 = 15;
pub const AIMUX_E_INVALID_TOOL_INPUT: i32 = 16;
pub const AIMUX_E_TOOL_CALL_REPAIR: i32 = 17;
-pub const AIMUX_E_NO_OUTPUT_GENERATED: i32 = 18;
+// Appended after 17 (4 stays retired): the AI SDK's `LoadAPIKeyError` and
+// `LoadSettingError`. Their facts travel on the provider-code / provider-message
+// getters: `env_var` and the description (key) or setting name.
+pub const AIMUX_E_LOAD_API_KEY: i32 = 18;
+pub const AIMUX_E_LOAD_SETTING: i32 = 19;
+pub const AIMUX_E_NO_OUTPUT_GENERATED: i32 = 20;
// 100..105 preserve `RecordingError` as a separate high-level type while C
// uses one code space for every returned error.
@@ -479,6 +494,8 @@ fn aimux_error_code_of(err: &AiMuxError) -> i32 {
AiMuxError::ToolCallRepair { .. } => AIMUX_E_TOOL_CALL_REPAIR,
AiMuxError::InvalidArgument(_) => AIMUX_E_INVALID_ARGUMENT,
AiMuxError::InvalidPrompt(_) => AIMUX_E_INVALID_PROMPT,
+ AiMuxError::LoadApiKey { .. } => AIMUX_E_LOAD_API_KEY,
+ AiMuxError::LoadSetting { .. } => AIMUX_E_LOAD_SETTING,
AiMuxError::TokenExpired(_) => AIMUX_E_TOKEN_EXPIRED,
AiMuxError::UnsupportedFunctionality(_) => AIMUX_E_UNSUPPORTED_FUNCTIONALITY,
AiMuxError::NoSuchModel { .. } => AIMUX_E_NO_SUCH_MODEL,
@@ -601,19 +618,35 @@ pub extern "C" fn aimux_error_retryable(err: *const aimux_error_t) -> i32 {
}
/// `AIMUX_E_API_CALL`: the provider's own error code, e.g. "insufficient_quota".
+/// `AIMUX_E_LOAD_API_KEY` / `AIMUX_E_LOAD_SETTING`: the environment variable
+/// that was consulted (`env_var`), e.g. "OPENAI_API_KEY".
#[unsafe(no_mangle)]
pub extern "C" fn aimux_error_provider_code(err: *const aimux_error_t) -> *mut c_char {
- opt_cstring(map_aimux_error(err, |e| api_call(e)?.provider_code.clone()).flatten())
+ opt_cstring(
+ map_aimux_error(err, |e| match e {
+ AiMuxError::LoadApiKey { env_var, .. } | AiMuxError::LoadSetting { env_var, .. } => {
+ Some(env_var.clone())
+ }
+ _ => api_call(e)?.provider_code.clone(),
+ })
+ .flatten(),
+ )
}
/// `AIMUX_E_API_CALL`: the failure's own text ("slow down"), without the
/// composed prefix `message` carries ("API call error: HTTP 429: slow down").
+/// `AIMUX_E_LOAD_API_KEY`: what the key is for ("OpenAI");
+/// `AIMUX_E_LOAD_SETTING`: the setting's parameter name ("region").
#[unsafe(no_mangle)]
pub extern "C" fn aimux_error_provider_message(err: *const aimux_error_t) -> *mut c_char {
opt_cstring(
- map_aimux_error(err, |e| Some(api_call(e)?.message.clone()))
- .flatten()
- .filter(|m| !m.is_empty()),
+ map_aimux_error(err, |e| match e {
+ AiMuxError::LoadApiKey { description, .. } => Some(description.clone()),
+ AiMuxError::LoadSetting { name, .. } => Some(name.clone()),
+ _ => Some(api_call(e)?.message.clone()),
+ })
+ .flatten()
+ .filter(|m| !m.is_empty()),
)
}
@@ -748,7 +781,7 @@ pub extern "C" fn aimux_error_model_type(err: *const aimux_error_t) -> *mut c_ch
pub extern "C" fn aimux_error_provider_id(err: *const aimux_error_t) -> *mut c_char {
opt_cstring(
map_aimux_error(err, |e| match e {
- AiMuxError::NoSuchProvider { provider_id } => Some(provider_id.clone()),
+ AiMuxError::NoSuchProvider { provider_id, .. } => Some(provider_id.clone()),
_ => None,
})
.flatten(),
@@ -1044,6 +1077,20 @@ fn invoke_stream_callback(callback_name: &str, f: impl FnOnce()) -> Result<(), F
// C ABI: provider constructors
// ─────────────────────────────────────────────────────────────────────────────
+/// An OpenAI provider for a key handed over the C ABI. The key is an explicit
+/// value, so an empty string is sent as given and never falls back to
+/// `OPENAI_API_KEY`.
+fn openai_provider(
+ api_key: String,
+ base_url: Option,
+) -> Result {
+ create_openai(OpenAIProviderSettings {
+ api_key: Some(api_key),
+ base_url,
+ ..Default::default()
+ })
+}
+
/// Create an OpenAI model instance. AiMuxError: invalid model id.
#[unsafe(no_mangle)]
pub extern "C" fn aimux_openai_new(
@@ -1053,8 +1100,9 @@ pub extern "C" fn aimux_openai_new(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let m = OpenAIProvider::new(OpenAIConfig::new(api_key)).language_model(&model_id)?;
- Ok(intern_model(Arc::from(m)))
+ Ok(intern_model(
+ openai_provider(api_key, None)?.language_model(&model_id)?,
+ ))
})
}
@@ -1070,12 +1118,38 @@ pub extern "C" fn aimux_openai_new_with_base(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let mut config = OpenAIConfig::new(api_key);
- if let Some(url) = parse_base_url(base_url)? {
- config = config.with_base_url(url);
- }
- let m = OpenAIProvider::new(config).language_model(&model_id)?;
- Ok(intern_model(Arc::from(m)))
+ let provider = openai_provider(api_key, parse_base_url(base_url)?)?;
+ Ok(intern_model(provider.language_model(&model_id)?))
+ })
+}
+
+/// An Anthropic provider for an explicit key handed over by the host, with an
+/// optional base URL. The key is an explicit value (`""` included), so it
+/// never falls back to `ANTHROPIC_API_KEY`.
+fn anthropic_provider(
+ api_key: String,
+ base_url: Option,
+) -> Result {
+ create_anthropic(AnthropicProviderSettings {
+ api_key: Some(api_key),
+ base_url,
+ ..Default::default()
+ })
+}
+
+/// An Anthropic-on-AWS provider for an explicit API key and region.
+fn anthropic_aws_provider(
+ api_key: String,
+ region: String,
+ base_url: Option,
+) -> Result {
+ create_anthropic_aws(AnthropicAwsProviderSettings {
+ region: Some(region),
+ auth: Some(AnthropicAwsAuth::ApiKey(
+ aimux_provider_utils::Resolvable::Value(api_key),
+ )),
+ base_url,
+ ..Default::default()
})
}
@@ -1088,8 +1162,8 @@ pub extern "C" fn aimux_anthropic_new(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let m = AnthropicProvider::new(AnthropicConfig::new(api_key)).language_model(&model_id)?;
- Ok(intern_model(Arc::from(m)))
+ let m = anthropic_provider(api_key, None)?.language_model(&model_id)?;
+ Ok(intern_model(m))
})
}
@@ -1105,12 +1179,9 @@ pub extern "C" fn aimux_anthropic_new_with_base(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let mut config = AnthropicConfig::new(api_key);
- if let Some(url) = parse_base_url(base_url)? {
- config = config.with_base_url(url);
- }
- let m = AnthropicProvider::new(config).language_model(&model_id)?;
- Ok(intern_model(Arc::from(m)))
+ let m =
+ anthropic_provider(api_key, parse_base_url(base_url)?)?.language_model(&model_id)?;
+ Ok(intern_model(m))
})
}
@@ -1126,10 +1197,8 @@ pub extern "C" fn aimux_anthropic_aws_new(
let api_key = str_arg(api_key, "api_key")?;
let region = str_arg(region, "region")?;
let model_id = str_arg(model_id, "model_id")?;
- let m =
- AnthropicAwsProvider::new(AnthropicAwsProviderConfig::with_api_key(api_key, region))
- .language_model(&model_id)?;
- Ok(intern_model(Arc::from(m)))
+ let m = anthropic_aws_provider(api_key, region, None)?.language_model(&model_id)?;
+ Ok(intern_model(m))
})
}
@@ -1146,12 +1215,9 @@ pub extern "C" fn aimux_anthropic_aws_new_with_base(
let api_key = str_arg(api_key, "api_key")?;
let region = str_arg(region, "region")?;
let model_id = str_arg(model_id, "model_id")?;
- let mut config = AnthropicAwsProviderConfig::with_api_key(api_key, region);
- if let Some(url) = parse_base_url(base_url)? {
- config = config.with_base_url(url);
- }
- let m = AnthropicAwsProvider::new(config).language_model(&model_id)?;
- Ok(intern_model(Arc::from(m)))
+ let m = anthropic_aws_provider(api_key, region, parse_base_url(base_url)?)?
+ .language_model(&model_id)?;
+ Ok(intern_model(m))
})
}
@@ -1171,14 +1237,10 @@ pub extern "C" fn aimux_azure_new(
let api_key = str_arg(api_key, "api_key")?;
let resource_name = str_arg(resource_name, "resource_name")?;
let deployment = str_arg(deployment, "deployment")?;
- let mut config = AzureConfig::new()
- .with_api_key(api_key)
- .with_resource_name(resource_name);
- if let Some(v) = opt_str_arg(api_version, "api_version")?.filter(|v| !v.is_empty()) {
- config = config.with_api_version(v);
- }
- let m = AzureProvider::new(config)?.language_model(&deployment)?;
- Ok(intern_model(Arc::from(m)))
+ let api_version = opt_str_arg(api_version, "api_version")?.filter(|v| !v.is_empty());
+ let m = azure_provider(api_key, Some(resource_name), None, api_version)?
+ .language_model(&deployment)?;
+ Ok(intern_model(m))
})
}
@@ -1196,17 +1258,129 @@ pub extern "C" fn aimux_azure_new_with_base(
let api_key = str_arg(api_key, "api_key")?;
let base_url = str_arg(base_url, "base_url")?;
let deployment = str_arg(deployment, "deployment")?;
- let mut config = AzureConfig::new()
- .with_api_key(api_key)
- .with_base_url(base_url);
- if let Some(v) = opt_str_arg(api_version, "api_version")?.filter(|v| !v.is_empty()) {
- config = config.with_api_version(v);
- }
- let m = AzureProvider::new(config)?.language_model(&deployment)?;
- Ok(intern_model(Arc::from(m)))
+ let api_version = opt_str_arg(api_version, "api_version")?.filter(|v| !v.is_empty());
+ let m = azure_provider(api_key, None, Some(base_url), api_version)?
+ .language_model(&deployment)?;
+ Ok(intern_model(m))
+ })
+}
+
+/// A Bedrock provider for explicit SigV4 credentials handed over by the host,
+/// with an optional base URL. The credentials are explicit values, so they
+/// never fall back to the environment.
+fn bedrock_provider(
+ access_key_id: String,
+ secret_access_key: String,
+ region: String,
+ base_url: Option,
+) -> Result {
+ create_amazon_bedrock(AmazonBedrockProviderSettings {
+ credential_provider: Some(aimux_provider_utils::Resolvable::Value(
+ aimux_provider_utils::AwsCredentials {
+ access_key_id,
+ secret_access_key,
+ session_token: None,
+ region: region.clone(),
+ },
+ )),
+ region: Some(region),
+ base_url,
+ ..Default::default()
+ })
+}
+
+/// A Vertex AI provider for an explicit access token, project and location,
+/// with an optional base URL.
+fn vertex_provider(
+ access_token: String,
+ project: String,
+ location: String,
+ base_url: Option,
+) -> Result {
+ create_google_vertex(VertexProviderSettings {
+ access_token: Some(aimux_provider_utils::Resolvable::Value(access_token)),
+ project: Some(project),
+ location: Some(location),
+ base_url,
+ ..Default::default()
+ })
+}
+
+/// A Google provider for an explicit key handed over by the host, with an
+/// optional base URL.
+fn google_provider(
+ api_key: String,
+ base_url: Option,
+) -> Result {
+ create_google(GoogleProviderSettings {
+ api_key: Some(api_key),
+ base_url,
+ ..Default::default()
})
}
+/// A Cohere provider for an explicit key handed over by the host, with an
+/// optional base URL.
+fn cohere_provider(
+ api_key: String,
+ base_url: Option,
+) -> Result {
+ create_cohere(CohereProviderSettings {
+ api_key: Some(api_key),
+ base_url,
+ ..Default::default()
+ })
+}
+
+/// A Mistral provider for an explicit key handed over by the host, with an
+/// optional base URL.
+fn mistral_provider(
+ api_key: String,
+ base_url: Option,
+) -> Result {
+ create_mistral(MistralProviderSettings {
+ api_key: Some(api_key),
+ base_url,
+ ..Default::default()
+ })
+}
+
+/// An xAI provider for an explicit key handed over by the host, with an
+/// optional base URL.
+fn xai_provider(
+ api_key: String,
+ base_url: Option,
+) -> Result {
+ create_xai(XAIProviderSettings {
+ api_key: Some(api_key),
+ base_url,
+ ..Default::default()
+ })
+}
+
+/// An Azure OpenAI provider for an explicit key, with either a resource name
+/// or a base URL. A dated `api_version` belongs to the deployment URL form
+/// (`{base}/deployments/{deployment}{path}?api-version=`), so passing one
+/// selects it; without one the AI SDK's v1 form is used.
+fn azure_provider(
+ api_key: String,
+ resource_name: Option,
+ base_url: Option,
+ api_version: Option,
+) -> Result {
+ let mut settings = AzureOpenAIProviderSettings {
+ api_key: Some(aimux_provider_utils::Resolvable::Value(api_key)),
+ resource_name,
+ base_url,
+ ..Default::default()
+ };
+ if let Some(version) = api_version {
+ settings.api_version = Some(version);
+ settings.use_deployment_based_urls = true;
+ }
+ create_azure(settings)
+}
+
/// Create a Bedrock model instance (AWS SigV4 credentials).
#[unsafe(no_mangle)]
pub extern "C" fn aimux_bedrock_new(
@@ -1227,13 +1401,9 @@ pub extern "C" fn aimux_bedrock_new(
model_id,
"model_id",
)?;
- let m = BedrockProvider::new(BedrockProviderConfig::new(
- access_key_id,
- secret_access_key,
- region,
- ))
- .language_model(&model_id)?;
- Ok(intern_model(Arc::from(m)))
+ let m = bedrock_provider(access_key_id, secret_access_key, region, None)?
+ .language_model(&model_id)?;
+ Ok(intern_model(m))
})
}
@@ -1258,12 +1428,14 @@ pub extern "C" fn aimux_bedrock_new_with_base(
model_id,
"model_id",
)?;
- let mut config = BedrockProviderConfig::new(access_key_id, secret_access_key, region);
- if let Some(url) = parse_base_url(base_url)? {
- config = config.with_base_url(url);
- }
- let m = BedrockProvider::new(config).language_model(&model_id)?;
- Ok(intern_model(Arc::from(m)))
+ let m = bedrock_provider(
+ access_key_id,
+ secret_access_key,
+ region,
+ parse_base_url(base_url)?,
+ )?
+ .language_model(&model_id)?;
+ Ok(intern_model(m))
})
}
@@ -1287,9 +1459,9 @@ pub extern "C" fn aimux_vertex_new(
model_id,
"model_id",
)?;
- let m = VertexProvider::new(VertexProviderConfig::new(access_token, project, location))
- .language_model(&model_id)?;
- Ok(intern_model(Arc::from(m)))
+ let m =
+ vertex_provider(access_token, project, location, None)?.language_model(&model_id)?;
+ Ok(intern_model(m))
})
}
@@ -1314,12 +1486,9 @@ pub extern "C" fn aimux_vertex_new_with_base(
model_id,
"model_id",
)?;
- let mut config = VertexProviderConfig::new(access_token, project, location);
- if let Some(url) = parse_base_url(base_url)? {
- config = config.with_base_url(url);
- }
- let m = VertexProvider::new(config).language_model(&model_id)?;
- Ok(intern_model(Arc::from(m)))
+ let m = vertex_provider(access_token, project, location, parse_base_url(base_url)?)?
+ .language_model(&model_id)?;
+ Ok(intern_model(m))
})
}
@@ -1332,8 +1501,8 @@ pub extern "C" fn aimux_cohere_new(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let m = CohereProvider::new(CohereConfig::new(api_key)).language_model(&model_id)?;
- Ok(intern_model(Arc::from(m)))
+ let m = cohere_provider(api_key, None)?.language_model(&model_id)?;
+ Ok(intern_model(m))
})
}
@@ -1347,12 +1516,8 @@ pub extern "C" fn aimux_cohere_new_with_base(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let mut config = CohereConfig::new(api_key);
- if let Some(url) = parse_base_url(base_url)? {
- config = config.with_base_url(url);
- }
- let m = CohereProvider::new(config).language_model(&model_id)?;
- Ok(intern_model(Arc::from(m)))
+ let m = cohere_provider(api_key, parse_base_url(base_url)?)?.language_model(&model_id)?;
+ Ok(intern_model(m))
})
}
@@ -1365,8 +1530,8 @@ pub extern "C" fn aimux_mistral_new(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let m = MistralProvider::new(MistralConfig::new(api_key)).language_model(&model_id)?;
- Ok(intern_model(Arc::from(m)))
+ let m = mistral_provider(api_key, None)?.language_model(&model_id)?;
+ Ok(intern_model(m))
})
}
@@ -1380,12 +1545,8 @@ pub extern "C" fn aimux_mistral_new_with_base(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let mut config = MistralConfig::new(api_key);
- if let Some(url) = parse_base_url(base_url)? {
- config = config.with_base_url(url);
- }
- let m = MistralProvider::new(config).language_model(&model_id)?;
- Ok(intern_model(Arc::from(m)))
+ let m = mistral_provider(api_key, parse_base_url(base_url)?)?.language_model(&model_id)?;
+ Ok(intern_model(m))
})
}
@@ -1398,8 +1559,8 @@ pub extern "C" fn aimux_xai_new(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let m = XAIProvider::new(XAIConfig::new(api_key)).language_model(&model_id)?;
- Ok(intern_model(Arc::from(m)))
+ let m = xai_provider(api_key, None)?.language_model(&model_id)?;
+ Ok(intern_model(m))
})
}
@@ -1413,12 +1574,8 @@ pub extern "C" fn aimux_xai_new_with_base(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let mut config = XAIConfig::new(api_key);
- if let Some(url) = parse_base_url(base_url)? {
- config = config.with_base_url(url);
- }
- let m = XAIProvider::new(config).language_model(&model_id)?;
- Ok(intern_model(Arc::from(m)))
+ let m = xai_provider(api_key, parse_base_url(base_url)?)?.language_model(&model_id)?;
+ Ok(intern_model(m))
})
}
@@ -1429,11 +1586,11 @@ pub extern "C" fn aimux_xai_new_with_base(
/// entry (replaces the retired `aimux_deepseek_new` etc.).
/// - `model_id` — model id string.
/// - `config_json` — optional JSON object of `ProviderOptions`
-/// (`{"base_url": "...", "headers": {...}, "max_retries": 0, "body_overrides": {...}}`);
-/// NULL / empty / "null" for defaults.
+/// (`{"base_url": "...", "headers": {...}, "organization": "...", "project": "..."}`);
+/// NULL / empty / "null" for defaults. `max_retries` (call-level) and
+/// `body_overrides` (removed) are rejected as invalid arguments.
///
-/// AiMuxError: unknown provider, bad config shape, missing env key, or
-/// invalid model id.
+/// AiMuxError: unknown provider, bad config shape, or invalid model id.
#[unsafe(no_mangle)]
pub extern "C" fn aimux_provider_new(
name: *const c_char,
@@ -1449,12 +1606,12 @@ pub extern "C" fn aimux_provider_new(
let key = opt_str_arg(api_key, "api_key")?;
let opts = parse_provider_options(config_json)?;
let m = provider(&name, key, &model_id, opts)?;
- Ok(intern_model(Arc::from(m)))
+ Ok(intern_model(m))
})
}
/// Convenience: create a language model by provider name, reading the API key
-/// from the provider's env var.
+/// from the provider's env var at request time.
#[unsafe(no_mangle)]
pub extern "C" fn aimux_provider_from_env(
name: *const c_char,
@@ -1464,8 +1621,8 @@ pub extern "C" fn aimux_provider_from_env(
with_out_handle(out_handle, || {
let name = str_arg(name, "name")?;
let model_id = str_arg(model_id, "model_id")?;
- let m = provider(&name, None, &model_id, None)?;
- Ok(intern_model(Arc::from(m)))
+ let m = provider_handle(&name, None, None)?.language_model(&model_id)?;
+ Ok(intern_model(m))
})
}
@@ -1493,8 +1650,10 @@ pub extern "C" fn aimux_provider_handle_new(
let name = str_arg(name, "name")?;
let key = opt_str_arg(api_key, "api_key")?;
let opts = parse_provider_options(config_json)?;
- let p = provider_handle(&name, key, opts)?;
- Ok(intern_handle(HandleEntry::Provider(Arc::from(p))))
+ let provider = provider_handle(&name, key, opts)?;
+ Ok(intern_handle(HandleEntry::Provider(ProviderEntry {
+ provider,
+ })))
})
}
@@ -1516,7 +1675,10 @@ pub extern "C" fn aimux_provider_list_models(
}
.into());
};
- run_json(p.list_models())
+ let discovery = p.provider.discovery().ok_or_else(|| {
+ AiMuxError::InvalidArgument("provider does not support model discovery".into())
+ })?;
+ run_json(discovery.list_models())
})
}
@@ -1539,8 +1701,8 @@ pub extern "C" fn aimux_provider_model(
.into());
};
let model_id = str_arg(model_id, "model_id")?;
- let m = p.language_model(&model_id)?;
- Ok(intern_model(Arc::from(m)))
+ let m = p.provider.language_model(&model_id)?;
+ Ok(intern_model(m))
})
}
@@ -2170,7 +2332,7 @@ pub extern "C" fn aimux_openai_embedding_new(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let model = OpenAIProvider::new(OpenAIConfig::new(api_key)).embedding_model(&model_id);
+ let model = openai_provider(api_key, None)?.embedding(&model_id);
Ok(intern_handle(HandleEntry::Embedding(Arc::new(model))))
})
}
@@ -2184,11 +2346,8 @@ pub extern "C" fn aimux_openai_embedding_new_with_base(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let mut config = OpenAIConfig::new(api_key);
- if let Some(url) = parse_base_url(base_url)? {
- config = config.with_base_url(url);
- }
- let model = OpenAIProvider::new(config).embedding_model(&model_id);
+ let provider = openai_provider(api_key, parse_base_url(base_url)?)?;
+ let model = provider.embedding(&model_id);
Ok(intern_handle(HandleEntry::Embedding(Arc::new(model))))
})
}
@@ -2201,7 +2360,7 @@ pub extern "C" fn aimux_cohere_embedding_new(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let model = CohereProvider::new(CohereConfig::new(api_key)).embedding_model(&model_id);
+ let model = cohere_provider(api_key, None)?.embedding(&model_id);
Ok(intern_handle(HandleEntry::Embedding(Arc::new(model))))
})
}
@@ -2215,11 +2374,7 @@ pub extern "C" fn aimux_cohere_embedding_new_with_base(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let mut config = CohereConfig::new(api_key);
- if let Some(url) = parse_base_url(base_url)? {
- config = config.with_base_url(url);
- }
- let model = CohereProvider::new(config).embedding_model(&model_id);
+ let model = cohere_provider(api_key, parse_base_url(base_url)?)?.embedding(&model_id);
Ok(intern_handle(HandleEntry::Embedding(Arc::new(model))))
})
}
@@ -2232,7 +2387,7 @@ pub extern "C" fn aimux_google_embedding_new(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let model = GoogleProvider::new(GoogleConfig::new(api_key)).embedding_model(&model_id);
+ let model = google_provider(api_key, None)?.embedding(&model_id);
Ok(intern_handle(HandleEntry::Embedding(Arc::new(model))))
})
}
@@ -2246,11 +2401,7 @@ pub extern "C" fn aimux_google_embedding_new_with_base(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let mut config = GoogleConfig::new(api_key);
- if let Some(url) = parse_base_url(base_url)? {
- config = config.with_base_url(url);
- }
- let model = GoogleProvider::new(config).embedding_model(&model_id);
+ let model = google_provider(api_key, parse_base_url(base_url)?)?.embedding(&model_id);
Ok(intern_handle(HandleEntry::Embedding(Arc::new(model))))
})
}
@@ -2296,7 +2447,7 @@ pub extern "C" fn aimux_openai_speech_new(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let model = OpenAIProvider::new(OpenAIConfig::new(api_key)).speech(&model_id);
+ let model = openai_provider(api_key, None)?.speech(&model_id);
Ok(intern_handle(HandleEntry::Speech(Arc::new(model))))
})
}
@@ -2310,11 +2461,8 @@ pub extern "C" fn aimux_openai_speech_new_with_base(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let mut config = OpenAIConfig::new(api_key);
- if let Some(url) = parse_base_url(base_url)? {
- config = config.with_base_url(url);
- }
- let model = OpenAIProvider::new(config).speech(&model_id);
+ let provider = openai_provider(api_key, parse_base_url(base_url)?)?;
+ let model = provider.speech(&model_id);
Ok(intern_handle(HandleEntry::Speech(Arc::new(model))))
})
}
@@ -2349,7 +2497,7 @@ pub extern "C" fn aimux_openai_image_new(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let model = OpenAIProvider::new(OpenAIConfig::new(api_key)).image(&model_id);
+ let model = openai_provider(api_key, None)?.image(&model_id);
Ok(intern_handle(HandleEntry::Image(Arc::new(model))))
})
}
@@ -2363,11 +2511,8 @@ pub extern "C" fn aimux_openai_image_new_with_base(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let mut config = OpenAIConfig::new(api_key);
- if let Some(url) = parse_base_url(base_url)? {
- config = config.with_base_url(url);
- }
- let model = OpenAIProvider::new(config).image(&model_id);
+ let provider = openai_provider(api_key, parse_base_url(base_url)?)?;
+ let model = provider.image(&model_id);
Ok(intern_handle(HandleEntry::Image(Arc::new(model))))
})
}
@@ -2380,7 +2525,7 @@ pub extern "C" fn aimux_google_image_new(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let model = GoogleProvider::new(GoogleConfig::new(api_key)).image(&model_id);
+ let model = google_provider(api_key, None)?.image(&model_id);
Ok(intern_handle(HandleEntry::Image(Arc::new(model))))
})
}
@@ -2394,11 +2539,7 @@ pub extern "C" fn aimux_google_image_new_with_base(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let mut config = GoogleConfig::new(api_key);
- if let Some(url) = parse_base_url(base_url)? {
- config = config.with_base_url(url);
- }
- let model = GoogleProvider::new(config).image(&model_id);
+ let model = google_provider(api_key, parse_base_url(base_url)?)?.image(&model_id);
Ok(intern_handle(HandleEntry::Image(Arc::new(model))))
})
}
@@ -2431,7 +2572,7 @@ pub extern "C" fn aimux_openai_transcription_new(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let model = OpenAIProvider::new(OpenAIConfig::new(api_key)).transcription(&model_id);
+ let model = openai_provider(api_key, None)?.transcription(&model_id);
Ok(intern_handle(HandleEntry::Transcription(Arc::new(model))))
})
}
@@ -2445,11 +2586,8 @@ pub extern "C" fn aimux_openai_transcription_new_with_base(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let mut config = OpenAIConfig::new(api_key);
- if let Some(url) = parse_base_url(base_url)? {
- config = config.with_base_url(url);
- }
- let model = OpenAIProvider::new(config).transcription(&model_id);
+ let provider = openai_provider(api_key, parse_base_url(base_url)?)?;
+ let model = provider.transcription(&model_id);
Ok(intern_handle(HandleEntry::Transcription(Arc::new(model))))
})
}
@@ -2689,7 +2827,7 @@ pub extern "C" fn aimux_openai_files_new(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let api_key = str_arg(api_key, "api_key")?;
- let files = OpenAIProvider::new(OpenAIConfig::new(api_key)).files();
+ let files = openai_provider(api_key, None)?.files();
Ok(intern_handle(HandleEntry::Files(Arc::new(files))))
})
}
@@ -2702,11 +2840,8 @@ pub extern "C" fn aimux_openai_files_new_with_base(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let api_key = str_arg(api_key, "api_key")?;
- let mut config = OpenAIConfig::new(api_key);
- if let Some(url) = parse_base_url(base_url)? {
- config = config.with_base_url(url);
- }
- let files = OpenAIProvider::new(config).files();
+ let provider = openai_provider(api_key, parse_base_url(base_url)?)?;
+ let files = provider.files();
Ok(intern_handle(HandleEntry::Files(Arc::new(files))))
})
}
@@ -2748,7 +2883,7 @@ pub extern "C" fn aimux_cohere_reranking_new(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let model = CohereProvider::new(CohereConfig::new(api_key)).reranking_model(&model_id);
+ let model = cohere_provider(api_key, None)?.reranking(&model_id);
Ok(intern_handle(HandleEntry::Reranking(Arc::new(model))))
})
}
@@ -2762,11 +2897,7 @@ pub extern "C" fn aimux_cohere_reranking_new_with_base(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let mut config = CohereConfig::new(api_key);
- if let Some(url) = parse_base_url(base_url)? {
- config = config.with_base_url(url);
- }
- let model = CohereProvider::new(config).reranking_model(&model_id);
+ let model = cohere_provider(api_key, parse_base_url(base_url)?)?.reranking(&model_id);
Ok(intern_handle(HandleEntry::Reranking(Arc::new(model))))
})
}
@@ -2805,7 +2936,7 @@ pub extern "C" fn aimux_google_video_new(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let model = GoogleProvider::new(GoogleConfig::new(api_key)).video(&model_id);
+ let model = google_provider(api_key, None)?.video(&model_id);
Ok(intern_handle(HandleEntry::Video(Arc::new(model))))
})
}
@@ -2819,11 +2950,7 @@ pub extern "C" fn aimux_google_video_new_with_base(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
- let mut config = GoogleConfig::new(api_key);
- if let Some(url) = parse_base_url(base_url)? {
- config = config.with_base_url(url);
- }
- let model = GoogleProvider::new(config).video(&model_id);
+ let model = google_provider(api_key, parse_base_url(base_url)?)?.video(&model_id);
Ok(intern_handle(HandleEntry::Video(Arc::new(model))))
})
}
@@ -2947,6 +3074,19 @@ pub extern "C" fn aimux_decide_with_abort(
// C ABI: Search
// ─────────────────────────────────────────────────────────────────────────────
+/// A Tavily provider for an explicit key handed over by the host, with an
+/// optional base URL.
+fn tavily_provider(
+ api_key: String,
+ base_url: Option,
+) -> Result {
+ create_tavily(TavilyProviderSettings {
+ api_key: Some(aimux_provider_utils::Resolvable::Value(api_key)),
+ base_url,
+ ..Default::default()
+ })
+}
+
/// Create a Tavily search model instance. `model_id` is accepted for API
/// symmetry but ignored (Tavily uses a fixed endpoint).
#[unsafe(no_mangle)]
@@ -2957,7 +3097,7 @@ pub extern "C" fn aimux_tavily_search_new(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let api_key = str_arg(api_key, "api_key")?;
- let model = TavilyProvider::new(TavilyConfig::new(api_key)).search_model();
+ let model = tavily_provider(api_key, None)?.search_model();
Ok(intern_handle(HandleEntry::Search(Arc::new(model))))
})
}
@@ -2971,11 +3111,7 @@ pub extern "C" fn aimux_tavily_search_new_with_base(
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let api_key = str_arg(api_key, "api_key")?;
- let mut config = TavilyConfig::new(api_key);
- if let Some(url) = parse_base_url(base_url)? {
- config = config.with_base_url(url);
- }
- let model = TavilyProvider::new(config).search_model();
+ let model = tavily_provider(api_key, parse_base_url(base_url)?)?.search_model();
Ok(intern_handle(HandleEntry::Search(Arc::new(model))))
})
}
@@ -3333,7 +3469,7 @@ pub extern "C" fn aimux_register_providers(config_json: *const c_char) -> *mut a
// Malformed JSON text is this layer's finding; a well-formed document
// that the registry rejects (bad schema, unknown protocol) is an AiMuxError.
serde_json::from_str::(&json).map_err(|e| wire_err("config_json", e))?;
- aimux_providers::load_providers_from_json(&json).map_err(|e| match e {
+ aimux_providers::provider::load_providers_from_json(&json).map_err(|e| match e {
// The registry reports a schema mismatch as `JsonParse`; the text
// already parsed above, so what it rejected is the shape —
// `AiMuxError::InvalidArgument`, not a provider-response parse failure.
@@ -3744,8 +3880,57 @@ mod tests {
assert_eq!(aimux_error_retryable(h), 0);
aimux_error_free(owner);
+ // LoadApiKey / LoadSetting carry the consulted environment variable on
+ // the provider-code channel and the description / setting name on the
+ // provider-message channel; every other getter answers its sentinel.
+ let owner = boxed(AiMuxError::LoadApiKey {
+ env_var: "OPENAI_API_KEY".into(),
+ description: "OpenAI".into(),
+ });
+ let h = owner;
+ assert_eq!(aimux_error_code(h), AIMUX_E_LOAD_API_KEY);
+ assert_eq!(
+ take(aimux_error_provider_code(h)).as_deref(),
+ Some("OPENAI_API_KEY")
+ );
+ assert_eq!(
+ take(aimux_error_provider_message(h)).as_deref(),
+ Some("OpenAI")
+ );
+ assert!(
+ take(aimux_error_message(h))
+ .unwrap()
+ .contains("OPENAI_API_KEY")
+ );
+ assert!(aimux_error_response_body(h).is_null());
+ assert!(aimux_error_model_id(h).is_null());
+ assert_eq!(aimux_error_status(h), -1);
+ assert_eq!(aimux_error_retryable(h), 0);
+ aimux_error_free(owner);
+
+ let owner = boxed(AiMuxError::LoadSetting {
+ env_var: "AWS_REGION".into(),
+ name: "region".into(),
+ });
+ let h = owner;
+ assert_eq!(aimux_error_code(h), AIMUX_E_LOAD_SETTING);
+ assert_eq!(
+ take(aimux_error_provider_code(h)).as_deref(),
+ Some("AWS_REGION")
+ );
+ assert_eq!(
+ take(aimux_error_provider_message(h)).as_deref(),
+ Some("region")
+ );
+ assert!(aimux_error_url(h).is_null());
+ assert_eq!(aimux_error_status(h), -1);
+ aimux_error_free(owner);
+
let owner = boxed(AiMuxError::NoSuchProvider {
provider_id: "p".into(),
+ model_id: String::new(),
+ model_type: String::new(),
+ available_providers: Vec::new(),
});
let h = owner;
assert_eq!(aimux_error_code(h), AIMUX_E_NO_SUCH_PROVIDER);
@@ -3924,7 +4109,7 @@ mod tests {
);
}
- /// Pin the full 16-variant → code mapping.
+ /// Pin the full 18-variant → code mapping.
#[test]
fn error_code_mapping_covers_all_variants() {
let s = |t: &str| t.to_string();
@@ -3981,6 +4166,20 @@ mod tests {
AIMUX_E_INVALID_ARGUMENT,
),
(AiMuxError::InvalidPrompt(s("x")), AIMUX_E_INVALID_PROMPT),
+ (
+ AiMuxError::LoadApiKey {
+ env_var: s("OPENAI_API_KEY"),
+ description: s("OpenAI"),
+ },
+ AIMUX_E_LOAD_API_KEY,
+ ),
+ (
+ AiMuxError::LoadSetting {
+ env_var: s("AWS_REGION"),
+ name: s("region"),
+ },
+ AIMUX_E_LOAD_SETTING,
+ ),
(AiMuxError::TokenExpired(s("x")), AIMUX_E_TOKEN_EXPIRED),
(
AiMuxError::UnsupportedFunctionality(s("x")),
@@ -3996,6 +4195,9 @@ mod tests {
(
AiMuxError::NoSuchProvider {
provider_id: s("x"),
+ model_id: String::new(),
+ model_type: String::new(),
+ available_providers: Vec::new(),
},
AIMUX_E_NO_SUCH_PROVIDER,
),
diff --git a/aimux-ffi/tests/common/mod.rs b/aimux-ffi/tests/common/mod.rs
index e0f76a45e..81777a24d 100644
--- a/aimux-ffi/tests/common/mod.rs
+++ b/aimux-ffi/tests/common/mod.rs
@@ -6,8 +6,8 @@ use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use aimux_ffi::{
- AIMUX_E_FFI_CALLBACK_FAILURE, AIMUX_E_FFI_NULL_POINTER, AIMUX_E_OTHER, AIMUX_E_RECORDING_INIT,
- AIMUX_E_RECORDING_WRITE, AIMUX_E_RETRY, aimux_error_code, aimux_error_free,
+ AIMUX_E_FFI_CALLBACK_FAILURE, AIMUX_E_FFI_NULL_POINTER, AIMUX_E_LOAD_SETTING, AIMUX_E_OTHER,
+ AIMUX_E_RECORDING_INIT, AIMUX_E_RECORDING_WRITE, aimux_error_code, aimux_error_free,
aimux_error_message, aimux_error_t, aimux_free_string,
};
@@ -48,7 +48,7 @@ pub fn ok(e: *mut aimux_error_t, name: &str) {
pub fn expect_aimux_error(e: *mut aimux_error_t, name: &str) -> (i32, String) {
assert!(!e.is_null(), "{name}: expected a returned error");
let code = aimux_error_code(e);
- if !(AIMUX_E_OTHER..=AIMUX_E_RETRY).contains(&code) {
+ if !(AIMUX_E_OTHER..=AIMUX_E_LOAD_SETTING).contains(&code) {
panic!(
"{name}: expected an AiMuxError code, got {code}: {}",
msg(e)
diff --git a/aimux-ffi/tests/exports_smoke_test.rs b/aimux-ffi/tests/exports_smoke_test.rs
index 5a7cf609d..2b515f81e 100644
--- a/aimux-ffi/tests/exports_smoke_test.rs
+++ b/aimux-ffi/tests/exports_smoke_test.rs
@@ -24,7 +24,7 @@
//! - Every returned error and every returned JSON string is released
//! (`aimux_error_free` / `aimux_free_string`).
//!
-//! Note: multimodal session smoke calls use the default RetryConfig
+//! Note: multimodal session smoke calls use the default retry settings
//! (2 retries) against a refused port; under `--test-threads=1` this adds
//! roughly a minute of backoff sleep — run the default parallel profile.
//!
@@ -495,7 +495,7 @@ fn constructor_exports_build_and_release_handles() {
);
// Registry-backed constructors (RFC-0017 / RFC-0027).
- let provider_opts = c(r#"{"base_url":"http://127.0.0.1:1","max_retries":0}"#);
+ let provider_opts = c(r#"{"base_url":"http://127.0.0.1:1"}"#);
ctor!(
"provider_new",
aimux_provider_new(
@@ -569,23 +569,17 @@ fn constructor_exports_build_and_release_handles() {
}
}
-/// `provider_from_env` reads the provider's env var, which the harness cannot
-/// control: a live handle or a clean missing-key error are both correct.
+/// `provider_from_env` defers reading the key until request time.
#[test]
-fn provider_from_env_handle_or_clean_failure() {
+fn provider_from_env_handle() {
let mut h = 0;
let e = aimux_provider_from_env(
c("groq").as_ptr(),
c("llama-3.3-70b-versatile").as_ptr(),
&mut h,
);
- if e.is_null() {
- assert_ne!(h, 0);
- aimux_drop_handle(h);
- } else {
- assert_eq!(h, 0);
- expect_aimux_error(e, "provider_from_env");
- }
+ expect_handle(e, h, "provider_from_env");
+ aimux_drop_handle(h);
}
// ── session class: text generation (4 exports) ──────────────────────────────
@@ -900,12 +894,12 @@ fn transcription_session_reaches_clean_terminal_error() {
#[test]
fn provider_discovery_and_catalogue_exports_fail_cleanly() {
- // Provider handle bound to the unreachable host with retries disabled.
+ // Provider handle bound to the unreachable host.
let mut provider = 0;
let e = aimux_provider_handle_new(
c("groq").as_ptr(),
c(FAKE_KEY).as_ptr(),
- c(r#"{"base_url":"http://127.0.0.1:1","max_retries":0}"#).as_ptr(),
+ c(r#"{"base_url":"http://127.0.0.1:1"}"#).as_ptr(),
&mut provider,
);
expect_handle(e, provider, "provider_handle_new");
diff --git a/aimux-provider-utils/Cargo.toml b/aimux-provider-utils/Cargo.toml
index bf427a093..fb2f67265 100644
--- a/aimux-provider-utils/Cargo.toml
+++ b/aimux-provider-utils/Cargo.toml
@@ -12,6 +12,7 @@ documentation = "https://docs.rs/aimux-provider-utils"
aimux-core = { workspace = true }
aimux-stream = { workspace = true }
+async-trait = { workspace = true }
reqwest = { workspace = true }
http = "1"
serde = { workspace = true }
@@ -25,6 +26,9 @@ futures = { workspace = true }
async-stream = { workspace = true }
url = { workspace = true }
chrono = "0.4"
+sha2 = "0.10"
+hmac = "0.12"
+hex = "0.4"
# WebSocket client (RFC-0028 realtime transcription). Optional: enabled via
# the `ws` feature so consumers without realtime needs compile it out.
diff --git a/aimux-provider-utils/src/api_key.rs b/aimux-provider-utils/src/api_key.rs
index 6257efa36..5ac0488f5 100644
--- a/aimux-provider-utils/src/api_key.rs
+++ b/aimux-provider-utils/src/api_key.rs
@@ -1,32 +1,62 @@
-//! API key loading (from parameter or environment variable).
+//! API key and setting loading (from a parameter or an environment variable).
use aimux_core::AiMuxError;
/// Load an API key from the given value or environment variable.
///
-/// If `api_key` is `Some`, returns it directly.
-/// Otherwise reads from `environment_variable_name`.
+/// A supplied value is returned exactly as given — including the empty
+/// string. An explicit key never falls back to the environment: a caller that
+/// deliberately passes `""` (a keyless local server) must not have a
+/// different credential substituted from the process environment. Only
+/// `None` reads `environment_variable_name`.
///
/// # Errors
///
-/// Returns `AiMuxError::InvalidArgument` when neither the `api_key` value nor
-/// the environment variable yields a key.
+/// Returns `AiMuxError::LoadApiKey` when `api_key` is `None` and the
+/// environment variable is unset.
pub fn load_api_key(
api_key: Option<&str>,
environment_variable_name: &str,
description: &str,
) -> Result {
- if let Some(key) = api_key
- && !key.is_empty()
- {
+ if let Some(key) = api_key {
return Ok(key.to_string());
}
- std::env::var(environment_variable_name).map_err(|_| {
- AiMuxError::InvalidArgument(format!(
- "No API key found for {description}. \
- Please provide it via the `api_key` parameter \
- or set the `{environment_variable_name}` environment variable."
- ))
+ std::env::var(environment_variable_name).map_err(|_| AiMuxError::LoadApiKey {
+ env_var: environment_variable_name.to_string(),
+ description: description.to_string(),
})
}
+
+/// Load a required setting from the given value or environment variable,
+/// with the same no-fallback rule for an explicit value as [`load_api_key`].
+///
+/// # Errors
+///
+/// Returns `AiMuxError::LoadSetting` when `value` is `None` and the
+/// environment variable is unset.
+pub fn load_setting(
+ value: Option<&str>,
+ environment_variable_name: &str,
+ setting_name: &str,
+) -> Result {
+ load_optional_setting(value, environment_variable_name).ok_or_else(|| AiMuxError::LoadSetting {
+ env_var: environment_variable_name.to_string(),
+ name: setting_name.to_string(),
+ })
+}
+
+/// Load an optional setting from the given value or environment variable.
+/// An explicit value (even `""`) wins; `None` reads the environment; an unset
+/// variable yields `None`.
+#[must_use]
+pub fn load_optional_setting(
+ value: Option<&str>,
+ environment_variable_name: &str,
+) -> Option {
+ match value {
+ Some(value) => Some(value.to_string()),
+ None => std::env::var(environment_variable_name).ok(),
+ }
+}
diff --git a/aimux-provider-utils/src/extract_response_headers.rs b/aimux-provider-utils/src/extract_response_headers.rs
index c82435746..2b559fd90 100644
--- a/aimux-provider-utils/src/extract_response_headers.rs
+++ b/aimux-provider-utils/src/extract_response_headers.rs
@@ -5,9 +5,7 @@ use std::collections::HashMap;
/// Extract response headers as ordered pairs while redacting sensitive values.
/// Recording and public error context share this one policy.
#[must_use]
-pub fn extract_response_header_pairs(
- headers: &reqwest::header::HeaderMap,
-) -> Vec<(String, String)> {
+pub(crate) fn extract_response_header_pairs(headers: &http::HeaderMap) -> Vec<(String, String)> {
headers
.iter()
.map(|(name, value)| {
@@ -23,6 +21,6 @@ pub fn extract_response_header_pairs(
/// Extract response headers while redacting sensitive values.
#[must_use]
-pub fn extract_response_headers(headers: &reqwest::header::HeaderMap) -> HashMap {
+pub fn extract_response_headers(headers: &http::HeaderMap) -> HashMap {
extract_response_header_pairs(headers).into_iter().collect()
}
diff --git a/aimux-provider-utils/src/fetch.rs b/aimux-provider-utils/src/fetch.rs
new file mode 100644
index 000000000..3538b3942
--- /dev/null
+++ b/aimux-provider-utils/src/fetch.rs
@@ -0,0 +1,429 @@
+//! Pluggable HTTP transport: the Rust shape of the AI SDK's `FetchFunction`.
+//!
+//! Every provider exchange ends in one [`Fetch::fetch`] call. A provider (or
+//! its settings) may carry a [`FetchFunction`] — a mock, a signing decorator
+//! such as [`crate::sigv4_fetch::SigV4Fetch`], a proxy-aware client — and
+//! when none is set the exchange uses [`default_fetch`], resolved *per
+//! request* (never frozen when a provider is created), so a process-wide
+//! default can change after a provider exists.
+//!
+//! This module owns the real network leaf: [`ReqwestFetch`] (one shared
+//! connection pool per tokio runtime) and [`PinnedFetch`] (a single
+//! non-redirecting hop connected only to pre-validated addresses, used by the
+//! SSRF download guard). Recording, response handlers and retry stay above
+//! this layer, in `http.rs` and `aimux-core`.
+
+use std::collections::HashMap;
+use std::fmt;
+use std::net::IpAddr;
+use std::sync::{Arc, Mutex, OnceLock};
+use std::time::Duration;
+
+use async_trait::async_trait;
+use bytes::Bytes;
+use futures::StreamExt;
+use futures::stream::BoxStream;
+use http::{HeaderMap, Method, StatusCode};
+use reqwest::Client;
+use url::Url;
+
+use aimux_core::AbortSignal;
+
+use crate::http::{ProxyConfig, global_proxy};
+
+/// One outbound HTTP request, fully materialised: body bytes are final
+/// (multipart already encoded), so a decorator can sign or inspect exactly
+/// what goes on the wire.
+#[derive(Debug, Clone)]
+pub struct FetchRequest {
+ pub method: Method,
+ pub url: Url,
+ pub headers: HeaderMap,
+ pub body: Bytes,
+ /// Caller cancellation. The helper layer already drops the in-flight
+ /// future when this fires; transports may additionally use it to cancel
+ /// work they spawned.
+ pub signal: Option,
+ /// Whole-exchange bound, when the caller wants the transport itself to
+ /// enforce one. The helper layer enforces its own response deadline and
+ /// leaves this `None`.
+ pub timeout: Option,
+ /// Whether credential decorators may authenticate this request.
+ pub credentials: RequestCredentials,
+}
+
+/// Whether a credential decorator may authenticate a request, after the
+/// `credentials` member of the WHATWG fetch `RequestInit`.
+///
+/// The helper layer sets [`RequestCredentials::Omit`] once a redirect leaves
+/// the origin the credentials belong to. Caller headers are already reduced
+/// to `User-Agent` by then; a decorator that adds credentials itself, such as
+/// [`crate::sigv4_fetch::SigV4Fetch`], must forward the request untouched.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub enum RequestCredentials {
+ /// Decorators authenticate the request.
+ #[default]
+ Include,
+ /// Decorators add no credentials.
+ Omit,
+}
+
+impl FetchRequest {
+ /// A request with no headers, no body and no bounds.
+ #[must_use]
+ pub fn new(method: Method, url: Url) -> Self {
+ Self {
+ method,
+ url,
+ headers: HeaderMap::new(),
+ body: Bytes::new(),
+ signal: None,
+ timeout: None,
+ credentials: RequestCredentials::Include,
+ }
+ }
+}
+
+/// A response whose body has not been read yet.
+pub struct FetchResponse {
+ pub status: StatusCode,
+ pub headers: HeaderMap,
+ /// The URL this response came from.
+ pub url: Url,
+ pub body: BoxStream<'static, Result>,
+}
+
+impl FetchResponse {
+ /// A response whose whole body is already in memory (mocks, tests).
+ #[must_use]
+ pub fn from_bytes(status: StatusCode, headers: HeaderMap, url: Url, body: Bytes) -> Self {
+ Self {
+ status,
+ headers,
+ url,
+ body: futures::stream::once(async move { Ok(body) }).boxed(),
+ }
+ }
+
+ /// The HTTP status.
+ #[must_use]
+ pub fn status(&self) -> StatusCode {
+ self.status
+ }
+
+ /// The response headers.
+ #[must_use]
+ pub fn headers(&self) -> &HeaderMap {
+ &self.headers
+ }
+
+ /// The declared `Content-Length`, when present and well-formed.
+ #[must_use]
+ pub fn content_length(&self) -> Option {
+ self.headers
+ .get(http::header::CONTENT_LENGTH)?
+ .to_str()
+ .ok()?
+ .parse()
+ .ok()
+ }
+
+ /// Consume the response into its body stream.
+ #[must_use]
+ pub fn bytes_stream(self) -> BoxStream<'static, Result> {
+ self.body
+ }
+}
+
+impl fmt::Debug for FetchResponse {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("FetchResponse")
+ .field("status", &self.status)
+ .field("headers", &self.headers)
+ .field("url", &self.url.as_str())
+ .finish_non_exhaustive()
+ }
+}
+
+/// A transport-level failure: no HTTP response was produced, or the body
+/// stream broke. HTTP error statuses are *not* errors here — they come back
+/// as a [`FetchResponse`] and are interpreted by the response handlers.
+#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
+pub enum FetchError {
+ /// The connection could not be established.
+ #[error("{0}")]
+ Connect(String),
+ /// The transport's own timeout fired.
+ #[error("request timed out")]
+ Timeout,
+ /// The caller's [`AbortSignal`] fired.
+ #[error("request aborted")]
+ Aborted,
+ /// The connection failed while sending the request or reading the body.
+ #[error("{0}")]
+ Io(String),
+ /// Anything else, including requests the transport refuses to build.
+ #[error("{0}")]
+ Other(String),
+}
+
+impl FetchError {
+ /// Whether an operation retry may help. Connection, timeout and I/O
+ /// failures are transient; an abort is the caller's decision and `Other`
+ /// (a malformed request, a broken transport) will fail the same way again.
+ #[must_use]
+ pub fn is_retryable(&self) -> bool {
+ matches!(self, Self::Connect(_) | Self::Timeout | Self::Io(_))
+ }
+}
+
+/// The transport contract: one request in, one response out. A transport does
+/// not follow redirects: a `3xx` is returned as it is and the helper layer
+/// (`http.rs`) decides, hop by hop, whether and with which headers to follow.
+#[async_trait]
+pub trait Fetch: Send + Sync + 'static {
+ /// Perform one HTTP exchange.
+ ///
+ /// # Errors
+ ///
+ /// Returns a [`FetchError`] when no response could be produced.
+ async fn fetch(&self, request: FetchRequest) -> Result;
+}
+
+impl fmt::Debug for dyn Fetch {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str("dyn Fetch")
+ }
+}
+
+/// A shareable transport, as carried by provider settings and requests.
+pub type FetchFunction = Arc;
+
+/// The process-wide default transport, resolved at request time.
+#[must_use]
+pub fn default_fetch() -> FetchFunction {
+ static DEFAULT: OnceLock = OnceLock::new();
+ DEFAULT.get_or_init(|| Arc::new(ReqwestFetch)).clone()
+}
+
+/// The default transport: reqwest over rustls, with a connection pool shared
+/// by every `ReqwestFetch` in the process (one pool per tokio runtime).
+///
+/// The pool has a connect timeout but no client-wide response timeout: it
+/// also serves streaming exchanges, so the non-streaming response bound lives
+/// per exchange in the helper layer and `aimux-core` owns the operation
+/// deadline. The process-wide [`ProxyConfig`] is snapshotted when a runtime's
+/// client is first built.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct ReqwestFetch;
+
+#[async_trait]
+impl Fetch for ReqwestFetch {
+ async fn fetch(&self, request: FetchRequest) -> Result {
+ let client = shared_client()?;
+ send(&client, request).await
+ }
+}
+
+/// One non-redirecting hop of a validated download. The connection is pinned
+/// to exactly the DNS answers that passed the SSRF guard (resolve overrides),
+/// defeating TTL-0 rebinding; an empty pin list is the hop on the trusted
+/// origin, which connects normally. The proxy configuration is applied so reqwest makes the per-URL
+/// routing decision itself: when a proxy carries the request the proxy
+/// resolves the target (a trusted transport, the override is unused), and any
+/// request the proxy rules send DIRECT still connects only through the
+/// validated, pinned addresses.
+///
+/// A client is built per hop: downloads are rare and each hop is one
+/// request, so there is no pool to share — and no stale-runtime pool to
+/// reuse.
+#[derive(Debug, Clone, Default)]
+pub struct PinnedFetch {
+ addresses: Vec,
+}
+
+impl PinnedFetch {
+ /// Pin the connection to these validated addresses.
+ #[must_use]
+ pub fn new(addresses: Vec) -> Self {
+ Self { addresses }
+ }
+
+ fn client(&self, url: &Url) -> Result {
+ let builder = reqwest::Client::builder()
+ .connect_timeout(Duration::from_millis(10_000))
+ .redirect(reqwest::redirect::Policy::none());
+ let builder = apply_proxy(builder, &global_proxy());
+ if self.addresses.is_empty() {
+ return builder.build().map_err(|error| {
+ FetchError::Other(format!("download client initialization failed: {error}"))
+ });
+ }
+ let host = url
+ .host_str()
+ .ok_or_else(|| FetchError::Other("download url has no host".to_string()))?;
+ let port = url.port_or_known_default().unwrap_or(80);
+ let socket_addresses: Vec<_> = self
+ .addresses
+ .iter()
+ .map(|address| std::net::SocketAddr::new(*address, port))
+ .collect();
+ builder
+ .resolve_to_addrs(host, &socket_addresses)
+ .build()
+ .map_err(|error| {
+ FetchError::Other(format!(
+ "pinned download client initialization failed: {error}"
+ ))
+ })
+ }
+}
+
+#[async_trait]
+impl Fetch for PinnedFetch {
+ async fn fetch(&self, request: FetchRequest) -> Result {
+ let client = self.client(&request.url)?;
+ send(&client, request).await
+ }
+}
+
+async fn send(client: &Client, request: FetchRequest) -> Result {
+ let mut builder = client
+ .request(request.method, request.url)
+ .headers(request.headers);
+ if !request.body.is_empty() {
+ builder = builder.body(request.body);
+ }
+ if let Some(timeout) = request.timeout {
+ builder = builder.timeout(timeout);
+ }
+ let response = builder.send().await.map_err(map_reqwest_error)?;
+ let status = response.status();
+ let headers = response.headers().clone();
+ let url = response.url().clone();
+ Ok(FetchResponse {
+ status,
+ headers,
+ url,
+ body: response
+ .bytes_stream()
+ .map(|chunk| chunk.map_err(map_reqwest_error))
+ .boxed(),
+ })
+}
+
+fn map_reqwest_error(error: reqwest::Error) -> FetchError {
+ if error.is_builder() {
+ FetchError::Other(error.to_string())
+ } else if error.is_connect() {
+ FetchError::Connect(error.to_string())
+ } else if error.is_timeout() {
+ FetchError::Timeout
+ } else {
+ FetchError::Io(error.to_string())
+ }
+}
+
+// One client (and so one connection pool) PER RUNTIME, not per process:
+// pooled connections are driven by tasks spawned onto the runtime that
+// made the request, so when that runtime shuts down its pooled
+// connections become unusable while staying checked in — and the OS can
+// recycle their ports to fresh servers, handing later requests a dead
+// connection. Production processes run a single runtime and still get
+// exactly one client. Runtimes leave no drop signal, so dead entries are
+// undetectable; the map is instead bounded by `SHARED_CLIENT_CAP` — see
+// `shared_client`.
+static SHARED: OnceLock, Client>>> = OnceLock::new();
+
+// Hosts that churn short-lived runtimes (a test binary, an FFI embedder
+// creating a runtime per call) would otherwise grow the map without bound.
+// Eviction is safe: correctness only requires never *reusing* a dead
+// runtime's pool, and an evicted live runtime simply rebuilds a fresh
+// client on its next request.
+const SHARED_CLIENT_CAP: usize = 8;
+
+/// Return the shared client for the current runtime, building it on first use.
+fn shared_client() -> Result {
+ let key = tokio::runtime::Handle::try_current().ok().map(|h| h.id());
+ let mut clients = SHARED
+ .get_or_init(|| Mutex::new(HashMap::new()))
+ .lock()
+ .expect("shared HTTP client mutex poisoned");
+ if let Some(client) = clients.get(&key) {
+ return Ok(client.clone());
+ }
+ let client = build_client(global_proxy()).map_err(|error| {
+ FetchError::Other(format!("shared HTTP client initialization failed: {error}"))
+ })?;
+ if clients.len() >= SHARED_CLIENT_CAP {
+ // Which entries are dead is unknowable, so evict them all; in-flight
+ // requests hold their own `Client` clone and are unaffected.
+ clients.clear();
+ }
+ clients.insert(key, client.clone());
+ Ok(client)
+}
+
+fn build_client(proxy: ProxyConfig) -> Result {
+ let builder = reqwest::Client::builder()
+ .redirect(reqwest::redirect::Policy::none())
+ .connect_timeout(Duration::from_millis(10_000))
+ .pool_max_idle_per_host(10)
+ .pool_idle_timeout(Some(Duration::from_secs(30)))
+ .tcp_keepalive(Some(Duration::from_secs(20)));
+ apply_proxy(builder, &proxy)
+ .build()
+ .map_err(|error| error.to_string())
+}
+
+fn apply_proxy(mut builder: reqwest::ClientBuilder, proxy: &ProxyConfig) -> reqwest::ClientBuilder {
+ let http = proxy.http_url.as_deref().or(proxy.all_url.as_deref());
+ let https = proxy.https_url.as_deref().or(proxy.all_url.as_deref());
+ if let Some(url) = http
+ && let Ok(reqwest_proxy) = reqwest::Proxy::http(url)
+ {
+ builder = apply_no_proxy(builder, reqwest_proxy, &proxy.no_proxy);
+ }
+ if let Some(url) = https
+ && let Ok(reqwest_proxy) = reqwest::Proxy::https(url)
+ {
+ builder = apply_no_proxy(builder, reqwest_proxy, &proxy.no_proxy);
+ }
+ builder
+}
+
+// Kept separate so both proxy schemes use precisely the same no-proxy rule.
+fn apply_no_proxy(
+ builder: reqwest::ClientBuilder,
+ proxy: reqwest::Proxy,
+ no_proxy: &Option,
+) -> reqwest::ClientBuilder {
+ match no_proxy.as_deref().map(reqwest::NoProxy::from_string) {
+ Some(Some(no_proxy)) => builder.proxy(proxy.no_proxy(Some(no_proxy))),
+ _ => builder.proxy(proxy),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn from_bytes_serves_the_body_once() {
+ let url = Url::parse("https://example.test/").unwrap();
+ let mut headers = HeaderMap::new();
+ headers.insert(http::header::CONTENT_LENGTH, "5".parse().unwrap());
+ let response =
+ FetchResponse::from_bytes(StatusCode::OK, headers, url, Bytes::from("hello"));
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_eq!(response.content_length(), Some(5));
+ let chunks: Vec<_> = response.bytes_stream().collect().await;
+ assert_eq!(chunks.len(), 1);
+ assert_eq!(chunks[0].as_ref().unwrap(), &Bytes::from("hello"));
+ }
+
+ #[test]
+ fn default_fetch_is_a_single_process_wide_leaf() {
+ assert!(Arc::ptr_eq(&default_fetch(), &default_fetch()));
+ }
+}
diff --git a/aimux-provider-utils/src/generate_id.rs b/aimux-provider-utils/src/generate_id.rs
new file mode 100644
index 000000000..70c0211d8
--- /dev/null
+++ b/aimux-provider-utils/src/generate_id.rs
@@ -0,0 +1,17 @@
+use std::hash::{BuildHasher, RandomState};
+use std::sync::atomic::{AtomicU64, Ordering};
+
+/// Generate a non-cryptographic, 16-character alphanumeric ID, like `generateId`.
+#[must_use]
+pub fn generate_id() -> String {
+ const ALPHABET: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
+ static COUNTER: AtomicU64 = AtomicU64::new(0);
+ let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
+ let random = RandomState::new();
+ (0..16)
+ .map(|index| {
+ let value = random.hash_one((counter, index));
+ char::from(ALPHABET[(value % ALPHABET.len() as u64) as usize])
+ })
+ .collect()
+}
diff --git a/aimux-provider-utils/src/handle_fetch_error.rs b/aimux-provider-utils/src/handle_fetch_error.rs
index 4a935e327..76b7a1160 100644
--- a/aimux-provider-utils/src/handle_fetch_error.rs
+++ b/aimux-provider-utils/src/handle_fetch_error.rs
@@ -1,6 +1,8 @@
//! Fetch error normalization.
-use aimux_core::AiMuxError;
+use aimux_core::{AiMuxError, ApiCallError};
+
+use crate::fetch::FetchError;
/// Attach request context to transport failures without changing other errors.
#[must_use]
@@ -19,3 +21,21 @@ pub fn handle_fetch_error(
other => other,
}
}
+
+/// Convert a transport failure into the public error model: an abort stays an
+/// abort; everything else is an `ApiCall` error with no status (no response
+/// arrived), retryable exactly when [`FetchError::is_retryable`] says so.
+#[must_use]
+pub(crate) fn fetch_error_to_ai_mux_error(
+ error: FetchError,
+ url: &str,
+ request_body_values: &serde_json::Value,
+) -> AiMuxError {
+ match error {
+ FetchError::Aborted => AiMuxError::Aborted("request aborted".into()),
+ other => AiMuxError::ApiCall(Box::new(ApiCallError {
+ is_retryable: other.is_retryable(),
+ ..ApiCallError::new(other.to_string(), url, request_body_values.clone())
+ })),
+ }
+}
diff --git a/aimux-provider-utils/src/headers.rs b/aimux-provider-utils/src/headers.rs
index 96822c73b..eb00f6282 100644
--- a/aimux-provider-utils/src/headers.rs
+++ b/aimux-provider-utils/src/headers.rs
@@ -1,14 +1,92 @@
//! HTTP header utilities.
+//!
+//! Provider headers are layered — provider settings, then per-call headers,
+//! then authentication — and later layers win. A layer entry of `None` means
+//! "remove this header" (the AI SDK's `undefined` header value).
use std::collections::HashMap;
-/// The SDK version (injected at build time or hardcoded for now).
-const SDK_VERSION: &str = env!("CARGO_PKG_VERSION");
+use crate::resolvable::Resolvable;
-/// Add a `User-Agent` suffix to the headers.
-///
-/// Pattern: `ai-sdk//`
-pub fn with_user_agent_suffix(headers: &mut HashMap, provider_name: &str) {
- let ua = format!("ai-sdk/{provider_name}/{SDK_VERSION}");
- headers.insert("User-Agent".to_string(), ua);
+/// A header layer: `None` removes the header from earlier layers.
+pub type HeaderMapOpt = HashMap>;
+
+/// Headers supplied directly or computed per request (token refresh, …).
+pub type HeadersFn = Resolvable;
+
+/// Append a suffix to the case-insensitive `user-agent` header.
+pub fn with_user_agent_suffix(headers: &mut HashMap, suffix: &str) {
+ let layer = headers
+ .drain()
+ .map(|(name, value)| (name, Some(value)))
+ .collect();
+ *headers = normalize_headers(layer).into_iter().collect();
+ let value = user_agent_value(headers.get("user-agent").map_or("", String::as_str), suffix);
+ headers.insert("user-agent".to_string(), value);
+}
+
+pub(crate) fn user_agent_value(current: &str, suffix: &str) -> String {
+ [current, suffix]
+ .into_iter()
+ .filter(|value| !value.is_empty())
+ .collect::>()
+ .join(" ")
+}
+
+/// Resolve provider headers and append the pinned package's suffix per request.
+#[must_use]
+pub fn with_user_agent_suffix_fn(
+ headers: HeadersFn,
+ package: &'static str,
+ version: &'static str,
+) -> HeadersFn {
+ Resolvable::from_async_fn(move || {
+ let headers = headers.clone();
+ async move {
+ let mut headers = normalize_headers(headers.resolve().await?)
+ .into_iter()
+ .collect();
+ with_user_agent_suffix(&mut headers, &format!("ai-sdk-{package}/{version}"));
+ Ok(headers
+ .into_iter()
+ .map(|(name, value)| (name, Some(value)))
+ .collect())
+ }
+ })
+}
+
+/// Merge header layers, earliest first. Names are lowercased; a later layer
+/// overrides an earlier one (never appends), including with `None`, which
+/// removes the header. Entries of one layer whose names differ only by case
+/// are applied in byte order of the original name, so the result does not
+/// depend on hash-map iteration order.
+#[must_use]
+pub fn combine_headers(layers: &[&HeaderMapOpt]) -> HeaderMapOpt {
+ let mut combined = HeaderMapOpt::new();
+ for layer in layers {
+ let mut entries: Vec<_> = layer.iter().collect();
+ entries.sort_by(|a, b| a.0.cmp(b.0));
+ for (name, value) in entries {
+ combined.insert(name.to_ascii_lowercase(), value.clone());
+ }
+ }
+ combined
+}
+
+/// Turn a header layer into the list that goes on the wire: names lowercased,
+/// `None` entries dropped, one entry per name (when names differ only by case
+/// the one that sorts last wins), sorted by name.
+#[must_use]
+pub fn normalize_headers(headers: HeaderMapOpt) -> Vec<(String, String)> {
+ let mut entries: Vec<_> = headers.into_iter().collect();
+ entries.sort_by(|a, b| a.0.cmp(&b.0));
+ let mut unique: std::collections::BTreeMap> =
+ std::collections::BTreeMap::new();
+ for (name, value) in entries {
+ unique.insert(name.to_ascii_lowercase(), value);
+ }
+ unique
+ .into_iter()
+ .filter_map(|(name, value)| value.map(|value| (name, value)))
+ .collect()
}
diff --git a/aimux-provider-utils/src/http.rs b/aimux-provider-utils/src/http.rs
index 9d8723414..554a6e88f 100644
--- a/aimux-provider-utils/src/http.rs
+++ b/aimux-provider-utils/src/http.rs
@@ -1,18 +1,17 @@
-//! One shared HTTP client and one-exchange request transport.
+//! One-exchange request transport.
//!
//! Retry and operation/stream timeouts belong to `aimux-core`. This module
-//! sends exactly one request and leaves successful/failed body interpretation
-//! to the response handlers selected by the provider.
+//! sends exactly one request through a [`Fetch`] (the request's own, or the
+//! process default) and leaves successful/failed body interpretation to the
+//! response handlers selected by the provider.
-use std::collections::HashMap;
use std::pin::Pin;
-use std::sync::{Mutex, OnceLock};
+use std::sync::OnceLock;
use std::task::{Context, Poll};
use std::time::{Duration, Instant};
use bytes::Bytes;
use futures::{Stream, StreamExt, stream::BoxStream};
-use reqwest::Client;
use serde::{Deserialize, Serialize};
use aimux_core::recording::{
@@ -20,9 +19,14 @@ use aimux_core::recording::{
};
use aimux_core::{AiMuxError, ApiCallError};
+use crate::fetch::{
+ Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse, PinnedFetch, RequestCredentials,
+ default_fetch,
+};
+use crate::handle_fetch_error::fetch_error_to_ai_mux_error;
use crate::logging::{body_logging_enabled, redact_body};
-/// Process-wide proxy configuration, fixed before the shared client is built.
+/// Process-wide proxy configuration, fixed before the first HTTP client is built.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProxyConfig {
pub http_url: Option,
@@ -32,23 +36,6 @@ pub struct ProxyConfig {
}
static GLOBAL_PROXY: OnceLock = OnceLock::new();
-// One client (and so one connection pool) PER RUNTIME, not per process:
-// pooled connections are driven by tasks spawned onto the runtime that
-// made the request, so when that runtime shuts down its pooled
-// connections become unusable while staying checked in — and the OS can
-// recycle their ports to fresh servers, handing later requests a dead
-// connection. Production processes run a single runtime and still get
-// exactly one client. Runtimes leave no drop signal, so dead entries are
-// undetectable; the map is instead bounded by `SHARED_CLIENT_CAP` — see
-// `shared_client`.
-static SHARED: OnceLock, Client>>> = OnceLock::new();
-
-// Hosts that churn short-lived runtimes (a test binary, an FFI embedder
-// creating a runtime per call) would otherwise grow the map without bound.
-// Eviction is safe: correctness only requires never *reusing* a dead
-// runtime's pool, and an evicted live runtime simply rebuilds a fresh
-// client on its next request.
-const SHARED_CLIENT_CAP: usize = 8;
/// Set proxy configuration before the first HTTP operation.
///
@@ -68,125 +55,9 @@ pub(crate) fn global_proxy() -> ProxyConfig {
GLOBAL_PROXY.get().cloned().unwrap_or_default()
}
-/// Return the single shared client. It has a connect timeout but no
-/// client-wide response timeout: the shared client also serves streaming
-/// exchanges, so the 30s non-streaming response bound lives per-exchange in
-/// `call_to_api`, and Core owns the operation deadline.
-///
-/// # Errors
-///
-/// Returns an initialization error if the shared client cannot be built.
-pub fn shared_client() -> Result {
- let key = tokio::runtime::Handle::try_current().ok().map(|h| h.id());
- let mut clients = SHARED
- .get_or_init(|| Mutex::new(HashMap::new()))
- .lock()
- .expect("shared HTTP client mutex poisoned");
- if let Some(client) = clients.get(&key) {
- return Ok(client.clone());
- }
- let client = build_client(global_proxy()).map_err(|error| {
- AiMuxError::Other(format!("shared HTTP client initialization failed: {error}"))
- })?;
- if clients.len() >= SHARED_CLIENT_CAP {
- // Which entries are dead is unknowable, so evict them all; in-flight
- // requests hold their own `Client` clone and are unaffected.
- clients.clear();
- }
- clients.insert(key, client.clone());
- Ok(client)
-}
-
-fn build_client(proxy: ProxyConfig) -> Result {
- let builder = Client::builder()
- .connect_timeout(Duration::from_millis(10_000))
- .pool_max_idle_per_host(10)
- .pool_idle_timeout(Some(Duration::from_secs(30)))
- .tcp_keepalive(Some(Duration::from_secs(20)));
- apply_proxy(builder, &proxy)
- .build()
- .map_err(|error| error.to_string())
-}
-
/// Redirect ceiling for the manual validated-download loop.
const MAX_REDIRECTS: usize = 10;
-/// Non-redirecting client for a validated hop on the trusted origin.
-/// Redirects are followed manually so every hop is re-validated; built per
-/// exchange (downloads are rare and each hop is one request, so there is no
-/// pool to share — and no stale-runtime pool to reuse).
-fn download_client() -> Result {
- let builder = Client::builder()
- .connect_timeout(Duration::from_millis(10_000))
- .redirect(reqwest::redirect::Policy::none());
- apply_proxy(builder, &global_proxy())
- .build()
- .map_err(|e| AiMuxError::Other(format!("download client initialization failed: {e}")))
-}
-
-/// Client for one validated hop off the trusted origin: the connection is
-/// pinned to exactly the DNS answers that passed the guard (resolve
-/// overrides), defeating TTL-0 rebinding. The proxy configuration is applied
-/// so reqwest makes the per-URL routing decision itself: when a proxy
-/// carries the request the proxy resolves the target (a trusted transport,
-/// the override below is unused), and any request the proxy rules send
-/// DIRECT still connects only through the validated, pinned addresses.
-fn pinned_client(url: &str, addresses: &[std::net::IpAddr]) -> Result {
- if addresses.is_empty() {
- return Err(AiMuxError::Other(
- "cannot build a pinned download client without an address".into(),
- ));
- }
- let parsed = reqwest::Url::parse(url)
- .map_err(|e| AiMuxError::Other(format!("invalid download url: {e}")))?;
- let host = parsed
- .host_str()
- .ok_or_else(|| AiMuxError::Other("download url has no host".to_string()))?
- .to_string();
- let port = parsed.port_or_known_default().unwrap_or(80);
- let socket_addresses: Vec<_> = addresses
- .iter()
- .map(|address| std::net::SocketAddr::new(*address, port))
- .collect();
- let builder = Client::builder()
- .connect_timeout(Duration::from_millis(10_000))
- .redirect(reqwest::redirect::Policy::none());
- apply_proxy(builder, &global_proxy())
- .resolve_to_addrs(&host, &socket_addresses)
- .build()
- .map_err(|e| {
- AiMuxError::Other(format!("pinned download client initialization failed: {e}"))
- })
-}
-
-fn apply_proxy(mut builder: reqwest::ClientBuilder, proxy: &ProxyConfig) -> reqwest::ClientBuilder {
- let http = proxy.http_url.as_deref().or(proxy.all_url.as_deref());
- let https = proxy.https_url.as_deref().or(proxy.all_url.as_deref());
- if let Some(url) = http
- && let Ok(reqwest_proxy) = reqwest::Proxy::http(url)
- {
- builder = apply_no_proxy(builder, reqwest_proxy, &proxy.no_proxy);
- }
- if let Some(url) = https
- && let Ok(reqwest_proxy) = reqwest::Proxy::https(url)
- {
- builder = apply_no_proxy(builder, reqwest_proxy, &proxy.no_proxy);
- }
- builder
-}
-
-// Kept separate so both proxy schemes use precisely the same no-proxy rule.
-fn apply_no_proxy(
- builder: reqwest::ClientBuilder,
- proxy: reqwest::Proxy,
- no_proxy: &Option,
-) -> reqwest::ClientBuilder {
- match no_proxy.as_deref().map(reqwest::NoProxy::from_string) {
- Some(Some(no_proxy)) => builder.proxy(proxy.no_proxy(Some(no_proxy))),
- _ => builder.proxy(proxy),
- }
-}
-
/// Explicit raw request body used only by `post_to_api`.
#[derive(Debug, Clone)]
pub enum HttpBody {
@@ -225,8 +96,7 @@ pub struct HttpRequest {
/// (e.g. Replicate `prefer: wait`) declares its own bound here. Streaming
/// exchanges are exempt regardless.
pub response_timeout: Option,
- /// Per-request override of the successful-JSON-body size cap (default
- /// [`crate::read_response_with_size_limit::DEFAULT_MAX_JSON_RESPONSE_SIZE`]).
+ /// Per-request override of the successful-JSON-body size cap (default 64 MiB).
/// Only consulted by [`crate::response_handler::create_json_response_handler`];
/// binary downloads keep the separate, larger
/// `DEFAULT_MAX_DOWNLOAD_SIZE` bound regardless of this field.
@@ -248,6 +118,11 @@ pub struct HttpRequest {
/// this value — from the first request and on every redirect hop. `None`
/// means the caller gates its own headers (e.g. BFL's host allowlist).
pub credentialed_origin: Option,
+ /// Transport for this exchange. `None` resolves [`default_fetch`] when the
+ /// request is sent. Ignored when `validate_url` is set: downloads of
+ /// provider-supplied URLs always use the SSRF guard's pinned transport,
+ /// never a provider-injected one.
+ pub fetch: Option,
}
/// The per-operation context every model's call options carry into an
@@ -353,6 +228,10 @@ pub(crate) struct PreparedRequest {
pub(crate) response_timeout: Option,
pub(crate) max_json_response_bytes: Option,
pub(crate) validation: Option,
+ pub(crate) fetch: Option,
+ /// [`RequestCredentials::Omit`] once the target is off the credentialed
+ /// origin; never switched back.
+ credentials: RequestCredentials,
}
impl HttpRequest {
@@ -361,10 +240,12 @@ impl HttpRequest {
// when the target is off the credentialed origin they never leave,
// whichever transport the exchange takes.
let mut headers = self.headers;
+ let mut credentials = RequestCredentials::Include;
if let Some(origin) = self.credentialed_origin.as_deref()
&& !crate::download_guard::same_origin(&self.url, origin)
{
crate::download_guard::retain_user_agent(&mut headers);
+ credentials = RequestCredentials::Omit;
}
PreparedRequest {
method,
@@ -380,6 +261,8 @@ impl HttpRequest {
trusted_origin: self.trusted_origin,
credentialed_origin: self.credentialed_origin,
}),
+ fetch: self.fetch,
+ credentials,
}
}
}
@@ -444,7 +327,7 @@ impl Drop for ExchangeGuard<'_> {
/// Execute exactly one HTTP exchange. Core owns retry and operation timeout.
pub(crate) async fn send_request_once(
request: &PreparedRequest,
-) -> Result {
+) -> Result {
let started = Instant::now();
let (attempt, exchange_index) = request
.recording_context
@@ -472,14 +355,7 @@ pub(crate) async fn send_request_once(
);
}
- let sent = if request.validation.is_some() {
- send_validated_redirects(request).await
- } else {
- match shared_client() {
- Ok(client) => send_one_request(&client, request).await,
- Err(error) => Err(error),
- }
- };
+ let sent = send_following_redirects(request).await;
let response = match sent {
Ok(response) => response,
Err(error) => {
@@ -491,7 +367,7 @@ pub(crate) async fn send_request_once(
let latency_ms = started.elapsed().as_millis() as u64;
tracing::debug!(
target: "aimux_provider_utils::http",
- status = response.status().as_u16(),
+ status = response.status.as_u16(),
latency_ms,
"response"
);
@@ -506,51 +382,48 @@ pub(crate) async fn send_request_once(
}
async fn send_one_request(
- client: &Client,
+ fetch: &dyn Fetch,
request: &PreparedRequest,
-) -> Result {
- if let Some(signal) = &request.abort_signal {
- if signal.is_aborted() {
- return Err(AiMuxError::from_abort_signal(signal));
- }
- tokio::select! {
- biased;
- () = signal.cancelled() => Err(AiMuxError::from_abort_signal(signal)),
- response = build_request_builder(client, request)?.send() => {
- response.map_err(|error| AiMuxError::ApiCall(Box::new(ApiCallError {
- is_retryable: true,
- ..api_call_error(request, error.to_string())
- })))
+) -> Result {
+ let mut fetch_request = build_fetch_request(request)?;
+ fetch_request.signal = request.abort_signal.clone();
+ let sent = match &request.abort_signal {
+ Some(signal) => {
+ if signal.is_aborted() {
+ return Err(AiMuxError::from_abort_signal(signal));
+ }
+ tokio::select! {
+ biased;
+ () = signal.cancelled() => return Err(AiMuxError::from_abort_signal(signal)),
+ sent = fetch.fetch(fetch_request) => sent,
}
}
- } else {
- build_request_builder(client, request)?
- .send()
- .await
- .map_err(|error| {
- AiMuxError::ApiCall(Box::new(ApiCallError {
- is_retryable: true,
- ..api_call_error(request, error.to_string())
- }))
- })
- }
+ None => fetch.fetch(fetch_request).await,
+ };
+ sent.map_err(|error| {
+ fetch_error_to_ai_mux_error(
+ error,
+ &sanitized_request_url(&request.url),
+ &crate::logging::redact_request_values(&request.body),
+ )
+ })
}
-fn is_redirect_status(status: reqwest::StatusCode) -> bool {
+fn is_redirect_status(status: http::StatusCode) -> bool {
matches!(
status,
- reqwest::StatusCode::MOVED_PERMANENTLY
- | reqwest::StatusCode::FOUND
- | reqwest::StatusCode::SEE_OTHER
- | reqwest::StatusCode::TEMPORARY_REDIRECT
- | reqwest::StatusCode::PERMANENT_REDIRECT
+ http::StatusCode::MOVED_PERMANENTLY
+ | http::StatusCode::FOUND
+ | http::StatusCode::SEE_OTHER
+ | http::StatusCode::TEMPORARY_REDIRECT
+ | http::StatusCode::PERMANENT_REDIRECT
)
}
fn redirect_error(
request: &PreparedRequest,
message: impl Into,
- status: reqwest::StatusCode,
+ status: http::StatusCode,
) -> AiMuxError {
AiMuxError::ApiCall(Box::new(ApiCallError {
status_code: Some(status.as_u16()),
@@ -558,53 +431,61 @@ fn redirect_error(
}))
}
-/// Send one validated-download exchange, following redirects manually so
-/// every hop is re-validated and its connection pinned to the DNS answers
-/// that passed the guard (see `download_guard`). The whole chain is one
-/// exchange to the caller, matching how an auto-following client behaves.
-async fn send_validated_redirects(
- request: &PreparedRequest,
-) -> Result {
- let validation = request
- .validation
- .clone()
- .expect("send_validated_redirects requires a validation config");
- let trusted_origin = validation.trusted_origin.as_deref();
- let credentialed_origin = validation.credentialed_origin.as_deref();
+/// Send one exchange, following redirects hop by hop in this layer (a
+/// transport never follows one itself). The whole chain is one exchange to
+/// the caller, matching how an auto-following client behaves.
+///
+/// - An ordinary API call follows a redirect only while it stays on the origin
+/// of the hop that issued it. A cross-origin `3xx` is returned as it is, so
+/// neither the request headers nor anything a transport decorator adds (a
+/// signature, a bearer token) reaches another origin. Every followed hop
+/// goes through the request's transport again, so a signing transport signs
+/// the URL it actually sends.
+/// - A validated download (`validation` set) re-validates every hop and pins
+/// its connection to the DNS answers that passed the guard (see
+/// `download_guard`); it follows cross-origin hops with the caller headers
+/// stripped. D26: its transport is never provider-injected.
+async fn send_following_redirects(request: &PreparedRequest) -> Result {
+ let validation = request.validation.as_ref();
+ let trusted_origin = validation.and_then(|v| v.trusted_origin.as_deref());
let mut current = request.clone();
- // First-request credential gating already ran in `prepare`; this strips
- // hop-by-hop, forwarding, and metadata-service headers for every hop.
- crate::download_guard::sanitize_download_headers(&mut current.headers);
// Headers never travel past this origin on a redirect; stripping is
// one-way, so a hop back onto it cannot restore them.
- let credential_anchor = credentialed_origin.unwrap_or(&request.url);
- let mut pinned =
- crate::download_guard::validate_download_target(¤t.url, trusted_origin).await?;
+ let credential_anchor = validation
+ .and_then(|v| v.credentialed_origin.as_deref())
+ .unwrap_or(&request.url);
+ let mut pinned = match validation {
+ Some(_) => {
+ // First-request credential gating already ran in `prepare`; this
+ // strips hop-by-hop, forwarding, and metadata-service headers.
+ crate::download_guard::sanitize_download_headers(&mut current.headers);
+ Some(
+ crate::download_guard::validate_download_target(¤t.url, trusted_origin)
+ .await?,
+ )
+ }
+ None => None,
+ };
+ // Resolved per request, so a late change of the process default is
+ // honored by providers created before it.
+ let api_fetch = request.fetch.clone().unwrap_or_else(default_fetch);
for redirect_count in 0..=MAX_REDIRECTS {
- // Empty pins mean the hop is on the trusted origin.
- let client = if pinned.is_empty() {
- download_client()?
- } else {
- pinned_client(¤t.url, &pinned)?
+ let response = match &pinned {
+ // Empty pins mean the hop is on the trusted origin.
+ Some(pins) => send_one_request(&PinnedFetch::new(pins.clone()), ¤t).await?,
+ None => send_one_request(api_fetch.as_ref(), ¤t).await?,
};
- let response = send_one_request(&client, ¤t).await?;
- let status = response.status();
+ let status = response.status;
if !is_redirect_status(status) {
return Ok(response);
}
- let Some(location) = response.headers().get(reqwest::header::LOCATION) else {
+ let Some(location) = response.headers.get(http::header::LOCATION) else {
return Ok(response);
};
- if redirect_count == MAX_REDIRECTS {
- return Err(redirect_error(request, "too many redirects", status));
- }
let location = location
.to_str()
.map(str::to_owned)
.map_err(|_| redirect_error(request, "redirect location is not valid UTF-8", status))?;
- // Dropping the unconsumed 3xx body releases its connection before a
- // potentially slow DNS check for the next hop.
- drop(response);
let base = url::Url::parse(¤t.url)
.map_err(|e| AiMuxError::InvalidArgument(format!("invalid request URL: {e}")))?;
let next = base
@@ -621,17 +502,33 @@ async fn send_validated_redirects(
));
}
let next = next.to_string();
- let hop_trusted = crate::download_guard::hop_trusted_origin(trusted_origin, ¤t.url);
- pinned = crate::download_guard::validate_download_target(&next, hop_trusted).await?;
+ if redirect_count == MAX_REDIRECTS {
+ return Err(redirect_error(request, "too many redirects", status));
+ }
+ // Dropping the unconsumed 3xx body releases its connection before a
+ // potentially slow DNS check for the next hop.
+ drop(response);
+ if pinned.is_some() {
+ let hop_trusted =
+ crate::download_guard::hop_trusted_origin(trusted_origin, ¤t.url);
+ pinned =
+ Some(crate::download_guard::validate_download_target(&next, hop_trusted).await?);
+ }
// Credentials are scoped to the credential anchor, not the previous
- // hop: once a redirect leaves it, headers cannot come back.
+ // hop: once a redirect leaves it, headers cannot come back. An API
+ // request follows every redirect, like the AI SDK's `postToApi` under
+ // fetch's default `redirect: "follow"`, but off the anchor it keeps
+ // only `User-Agent`, as `fetchWithValidatedRedirects` does: the fetch
+ // spec strips only `Authorization`, and provider keys also travel in
+ // custom headers. Signing decorators see `RequestCredentials::Omit`.
if !crate::download_guard::same_origin(&next, credential_anchor) {
crate::download_guard::retain_user_agent(&mut current.headers);
+ current.credentials = RequestCredentials::Omit;
}
- if status == reqwest::StatusCode::SEE_OTHER
+ if status == http::StatusCode::SEE_OTHER
|| (matches!(
status,
- reqwest::StatusCode::MOVED_PERMANENTLY | reqwest::StatusCode::FOUND
+ http::StatusCode::MOVED_PERMANENTLY | http::StatusCode::FOUND
) && matches!(current.method, HttpMethod::Post))
{
current.method = HttpMethod::Get;
@@ -642,28 +539,71 @@ async fn send_validated_redirects(
unreachable!("redirect loop returns on response or error")
}
-fn build_request_builder(
- client: &Client,
- request: &PreparedRequest,
-) -> Result {
- let mut builder = match request.method {
- HttpMethod::Get => client.get(&request.url),
- HttpMethod::Post => client.post(&request.url),
- };
+/// Materialise one prepared request for a [`Fetch`]: validated URL, final body
+/// bytes and an ordered-by-name header map. Headers are *inserted*, so a
+/// later entry for the same (case-insensitive) name replaces an earlier one —
+/// a request never goes out with duplicate `Authorization` headers.
+fn build_fetch_request(request: &PreparedRequest) -> Result {
+ let url = url::Url::parse(&request.url)
+ .map_err(|error| AiMuxError::InvalidArgument(format!("invalid request URL: {error}")))?;
+ let mut headers = http::HeaderMap::new();
for (name, value) in &request.headers {
- let header_name = reqwest::header::HeaderName::try_from(name)
+ let header_name = http::HeaderName::try_from(name)
.map_err(|_| AiMuxError::InvalidArgument(format!("invalid header name: {name}")))?;
- let header_value = reqwest::header::HeaderValue::try_from(value).map_err(|_| {
- AiMuxError::InvalidArgument(format!("invalid header value for {name}: {value}"))
- })?;
- builder = builder.header(header_name, header_value);
- }
- Ok(match &request.body {
- HttpBody::Json(value) => builder.json(value),
- HttpBody::Bytes(bytes, content_type) => builder
- .header(reqwest::header::CONTENT_TYPE, content_type)
- .body(bytes.clone()),
- HttpBody::Empty => builder,
+ // The value is never echoed: it is commonly a credential.
+ let header_value = http::HeaderValue::try_from(value)
+ .map_err(|_| AiMuxError::InvalidArgument(format!("invalid header value for {name}")))?;
+ headers.insert(header_name, header_value);
+ }
+ let suffix = "ai-sdk-provider-utils/5.0.51";
+ let current = request
+ .headers
+ .iter()
+ .rev()
+ .find_map(|(name, value)| {
+ name.eq_ignore_ascii_case("user-agent")
+ .then_some(value.as_str())
+ })
+ .unwrap_or_default();
+ let user_agent = crate::headers::user_agent_value(current, suffix);
+ headers.insert(
+ http::header::USER_AGENT,
+ http::HeaderValue::try_from(user_agent).map_err(|_| {
+ AiMuxError::InvalidArgument("invalid header value for user-agent".to_string())
+ })?,
+ );
+ let body = match &request.body {
+ HttpBody::Json(value) => {
+ if !headers.contains_key(http::header::CONTENT_TYPE) {
+ headers.insert(
+ http::header::CONTENT_TYPE,
+ http::HeaderValue::from_static("application/json"),
+ );
+ }
+ Bytes::from(serde_json::to_vec(value).map_err(|error| {
+ AiMuxError::InvalidArgument(format!("request body is not serializable: {error}"))
+ })?)
+ }
+ HttpBody::Bytes(bytes, content_type) => {
+ let content_type = http::HeaderValue::try_from(content_type).map_err(|_| {
+ AiMuxError::InvalidArgument(format!("invalid content type: {content_type}"))
+ })?;
+ headers.insert(http::header::CONTENT_TYPE, content_type);
+ Bytes::copy_from_slice(bytes)
+ }
+ HttpBody::Empty => Bytes::new(),
+ };
+ Ok(FetchRequest {
+ method: match request.method {
+ HttpMethod::Get => http::Method::GET,
+ HttpMethod::Post => http::Method::POST,
+ },
+ url,
+ headers,
+ body,
+ signal: None,
+ timeout: None,
+ credentials: request.credentials,
})
}
@@ -691,16 +631,15 @@ pub async fn sleep_or_abort(
}
fn observe_response(
- response: reqwest::Response,
+ response: FetchResponse,
request: &PreparedRequest,
started: Instant,
latency_ms: u64,
attempt: u32,
exchange_index: u32,
-) -> reqwest::Response {
- let status = response.status();
- let version = response.version();
- let headers = response.headers().clone();
+) -> FetchResponse {
+ let status = response.status;
+ let headers = response.headers.clone();
let recording = request.recording_context.as_ref().map(|context| {
let redacted_headers = redacted_response_headers(&headers);
let response = ResponseRecord {
@@ -742,23 +681,22 @@ fn observe_response(
}
});
let observed = ObservedBody {
- inner: response.bytes_stream().boxed(),
+ inner: response.body,
started,
chunks: 0,
done: false,
recording,
};
- let mut rebuilt = http::Response::builder()
- .status(status)
- .version(version)
- .body(reqwest::Body::wrap_stream(observed))
- .expect("status and version came from a valid reqwest response");
- *rebuilt.headers_mut() = headers;
- rebuilt.into()
+ FetchResponse {
+ status,
+ headers,
+ url: response.url,
+ body: observed.boxed(),
+ }
}
struct ObservedBody {
- inner: BoxStream<'static, Result>,
+ inner: BoxStream<'static, Result>,
started: Instant,
chunks: usize,
done: bool,
@@ -766,7 +704,7 @@ struct ObservedBody {
}
impl Stream for ObservedBody {
- type Item = Result;
+ type Item = Result;
fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> {
if self.done {
@@ -967,7 +905,7 @@ fn record_failed_exchange(
);
}
-fn redacted_response_headers(headers: &reqwest::header::HeaderMap) -> Vec<(String, String)> {
+fn redacted_response_headers(headers: &http::HeaderMap) -> Vec<(String, String)> {
crate::extract_response_headers::extract_response_header_pairs(headers)
}
diff --git a/aimux-provider-utils/src/lib.rs b/aimux-provider-utils/src/lib.rs
index 81f1606dd..556753782 100644
--- a/aimux-provider-utils/src/lib.rs
+++ b/aimux-provider-utils/src/lib.rs
@@ -2,25 +2,31 @@
//!
//! Shared utilities for provider implementations.
//!
-//! Provides one-exchange HTTP helpers, response handlers, API key loading,
-//! header management, URL utilities and the streamed tool-call tracker for
-//! the OpenAI chat-completions wire format — the Rust equivalents of
-//! `@ai-sdk/provider-utils`. Operation retry and timeout live in `aimux-core`.
+//! Provides the pluggable [`Fetch`] transport, one-exchange HTTP helpers,
+//! response handlers, API key and setting loading, [`Resolvable`] values and
+//! layered header management, URL utilities and the streamed tool-call
+//! tracker for the OpenAI chat-completions wire format — the Rust equivalents
+//! of `@ai-sdk/provider-utils`. Operation retry and timeout live in
+//! `aimux-core`.
pub mod api_key;
#[doc(hidden)]
pub use aimux_core::download_guard;
pub mod extract_response_headers;
+pub mod fetch;
+pub mod generate_id;
pub mod get_from_api;
pub mod handle_fetch_error;
pub mod headers;
pub mod http;
pub mod logging;
+pub mod media_type;
pub mod multipart;
pub mod post_to_api;
pub mod read_response_with_size_limit;
+pub mod resolvable;
pub mod response_handler;
-pub mod retry;
+pub mod sigv4_fetch;
pub mod streaming_tool_call_argument_state;
pub mod streaming_tool_call_tracker;
pub mod url;
@@ -29,16 +35,26 @@ pub mod url;
#[cfg(feature = "ws")]
pub mod ws;
-pub use api_key::load_api_key;
+pub use api_key::{load_api_key, load_optional_setting, load_setting};
pub use download_guard::same_origin;
+pub use fetch::{
+ Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse, PinnedFetch, RequestCredentials,
+ ReqwestFetch, default_fetch,
+};
+pub use generate_id::generate_id;
pub use get_from_api::get_from_api;
-pub use headers::with_user_agent_suffix;
-pub use http::{
- ExchangeContext, HttpBody, HttpRequest, ProxyConfig, init_proxy, shared_client, sleep_or_abort,
+pub use headers::{
+ HeaderMapOpt, HeadersFn, combine_headers, normalize_headers, with_user_agent_suffix,
+};
+pub use http::{ExchangeContext, HttpBody, HttpRequest, ProxyConfig, init_proxy, sleep_or_abort};
+pub use logging::{init_logging, redact_error_context};
+pub use media_type::{
+ MAX_ID3_TAG_BYTES, MediaTypeData, detect_media_type, get_top_level_media_type,
+ is_full_media_type, resolve_full_media_type,
};
-pub use logging::{body_logging_enabled, init_logging, redact_body, redact_error_context};
pub use multipart::{MultipartForm, media_type_to_extension};
pub use post_to_api::{post_form_data_to_api, post_json_to_api, post_to_api};
+pub use resolvable::Resolvable;
pub use response_handler::{
ProviderErrorParts, ResponseHandler, ResponseHandlerInput, ResponseHandlerOutput,
create_binary_response_handler, create_event_source_response_handler,
@@ -46,7 +62,7 @@ pub use response_handler::{
create_standard_json_error_response_handler, create_status_code_error_response_handler,
stream_error_api_call,
};
-pub use retry::RetryConfig;
+pub use sigv4_fetch::{AwsCredentials, SigV4Fetch};
pub use streaming_tool_call_argument_state::{
StreamingToolCallArgumentState, starts_with_structured_value,
};
diff --git a/aimux-provider-utils/src/logging.rs b/aimux-provider-utils/src/logging.rs
index 3b28790ed..8eaab9110 100644
--- a/aimux-provider-utils/src/logging.rs
+++ b/aimux-provider-utils/src/logging.rs
@@ -28,15 +28,15 @@ static INIT: Once = Once::new();
/// Default level: `warn` — only retries and failures are printed, normal
/// traffic stays silent.
-pub const DEFAULT_LEVEL: &str = "warn";
+const DEFAULT_LEVEL: &str = "warn";
/// Body log truncation limit (bytes), RFC-0014 §4.3.
-pub const BODY_LOG_LIMIT: usize = 4096;
+const BODY_LOG_LIMIT: usize = 4096;
/// Initialize the global logger (idempotent). Every entry point funnels here.
///
/// Precedence: `AIMUX_LOG` (RUST_LOG-style directives) > `AIMUX_LOG_LEVEL`
-/// (simple level name) > `level` argument > [`DEFAULT_LEVEL`].
+/// (simple level name) > `level` argument > `DEFAULT_LEVEL`.
///
/// No-ops (without registering anything) when a global subscriber is already
/// installed by the consumer — we never override the host's own logger.
@@ -66,7 +66,7 @@ pub fn init_logging(level: &str) {
/// Cheap when already initialized (`Once::is_completed` is a single atomic
/// load). Registers a subscriber only when an `AIMUX_LOG*` env var is present
/// **and** no global subscriber exists yet.
-pub fn auto_init_from_env() {
+pub(crate) fn auto_init_from_env() {
if INIT.is_completed() || tracing::dispatcher::has_been_set() {
return;
}
@@ -79,7 +79,7 @@ pub fn auto_init_from_env() {
/// Whether request/response body trace logging is enabled (`AIMUX_LOG_BODY=1`).
#[must_use]
-pub fn body_logging_enabled() -> bool {
+pub(crate) fn body_logging_enabled() -> bool {
std::env::var("AIMUX_LOG_BODY")
.map(|v| v == "1")
.unwrap_or(false)
@@ -104,7 +104,7 @@ fn build_filter(level: Option<&str>) -> EnvFilter {
EnvFilter::try_new(format!("aimux={level}")).unwrap_or_else(|_| EnvFilter::new(DEFAULT_LEVEL))
}
-/// Truncate `body` to [`BODY_LOG_LIMIT`] bytes at a UTF-8 char boundary.
+/// Truncate `body` to `BODY_LOG_LIMIT` bytes at a UTF-8 char boundary.
fn truncate(body: &str) -> &str {
if body.len() <= BODY_LOG_LIMIT {
return body;
@@ -119,9 +119,9 @@ fn truncate(body: &str) -> &str {
/// Redact a body for trace logging: JSON object keys whose lowercased name
/// match the shared recording sensitivity policy have their values replaced
/// with `[REDACTED]`. Non-JSON bodies pass through unchanged. Always truncated
-/// to [`BODY_LOG_LIMIT`].
+/// to `BODY_LOG_LIMIT`.
#[must_use]
-pub fn redact_body(body: &str) -> String {
+pub(crate) fn redact_body(body: &str) -> String {
let truncated = truncate(body);
match serde_json::from_str::(truncated) {
Ok(value) => serde_json::to_string(&redact_error_context(value))
@@ -184,7 +184,7 @@ fn redact_value(value: serde_json::Value, depth: usize) -> serde_json::Value {
/// Sanitize request values before storing them in a public `ApiCallError`.
#[must_use]
-pub fn redact_request_values(body: &crate::http::HttpBody) -> serde_json::Value {
+pub(crate) fn redact_request_values(body: &crate::http::HttpBody) -> serde_json::Value {
match body {
crate::http::HttpBody::Json(value) => redact_error_context(value.clone()),
crate::http::HttpBody::Bytes(bytes, content_type) => serde_json::json!({
diff --git a/aimux-provider-utils/src/media_type.rs b/aimux-provider-utils/src/media_type.rs
new file mode 100644
index 000000000..1127ac960
--- /dev/null
+++ b/aimux-provider-utils/src/media_type.rs
@@ -0,0 +1,210 @@
+//! Media type detection and resolution using the provider-utils signatures.
+
+use std::borrow::Cow;
+
+use aimux_core::error::AiMuxError;
+use aimux_core::language_model_message::FilePart;
+use aimux_core::shared::{FileBytes, FileData};
+
+/// Inline data accepted by the media type detector.
+#[derive(Clone, Copy, Debug)]
+pub enum MediaTypeData<'a> {
+ Bytes(&'a [u8]),
+ Base64(&'a str),
+}
+
+/// Largest ID3v2 tag (including its ten-byte header) scanned for an audio frame.
+pub const MAX_ID3_TAG_BYTES: usize = 128 * 1024;
+const DEFAULT_SNIFF_BYTES: usize = 18;
+const MAX_SIGNATURE_BYTES: usize = 12;
+
+// -1 marks a variable byte. Order is significant for overlapping containers.
+const SIGNATURES: &[(&str, &[i16])] = &[
+ ("image/gif", &[0x47, 0x49, 0x46, 0x38, 0x37, 0x61]),
+ ("image/gif", &[0x47, 0x49, 0x46, 0x38, 0x39, 0x61]),
+ ("image/png", &[0x89, 0x50, 0x4e, 0x47]),
+ ("image/jpeg", &[0xff, 0xd8]),
+ (
+ "image/webp",
+ &[
+ 0x52, 0x49, 0x46, 0x46, -1, -1, -1, -1, 0x57, 0x45, 0x42, 0x50,
+ ],
+ ),
+ ("image/bmp", &[0x42, 0x4d, -1, -1, -1, -1, 0, 0, 0, 0]),
+ ("image/tiff", &[0x49, 0x49, 0x2a, 0]),
+ ("image/tiff", &[0x4d, 0x4d, 0, 0x2a]),
+ (
+ "image/avif",
+ &[0, 0, 0, -1, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66],
+ ),
+ (
+ "image/heic",
+ &[0, 0, 0, -1, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63],
+ ),
+ ("application/pdf", &[0x25, 0x50, 0x44, 0x46]),
+ ("audio/aac", &[0xff, 0xf0]),
+ ("audio/aac", &[0xff, 0xf1]),
+ ("audio/aac", &[0xff, 0xf8]),
+ ("audio/aac", &[0xff, 0xf9]),
+ ("audio/mpeg", &[0xff, 0xfb]),
+ ("audio/mpeg", &[0xff, 0xfa]),
+ ("audio/mpeg", &[0xff, 0xf3]),
+ ("audio/mpeg", &[0xff, 0xf2]),
+ ("audio/mpeg", &[0xff, 0xe3]),
+ ("audio/mpeg", &[0xff, 0xe2]),
+ (
+ "audio/wav",
+ &[
+ 0x52, 0x49, 0x46, 0x46, -1, -1, -1, -1, 0x57, 0x41, 0x56, 0x45,
+ ],
+ ),
+ ("audio/ogg", &[0x4f, 0x67, 0x67, 0x53]),
+ ("audio/flac", &[0x66, 0x4c, 0x61, 0x43]),
+ ("audio/aac", &[0x40, 0x15, 0, 0]),
+ ("audio/webm", &[0x1a, 0x45, 0xdf, 0xa3]),
+ ("video/mp4", &[0, 0, 0, -1, 0x66, 0x74, 0x79, 0x70]),
+ ("video/webm", &[0x1a, 0x45, 0xdf, 0xa3]),
+ (
+ "video/quicktime",
+ &[0, 0, 0, 0x14, 0x66, 0x74, 0x79, 0x70, 0x71, 0x74],
+ ),
+ ("video/x-msvideo", &[0x52, 0x49, 0x46, 0x46]),
+ ("audio/mp4", &[0, 0, 0, -1, 0x66, 0x74, 0x79, 0x70]),
+];
+
+fn decode_prefix(data: MediaTypeData<'_>, max_bytes: usize) -> Result, AiMuxError> {
+ let data = match data {
+ MediaTypeData::Bytes(bytes) => {
+ return Ok(Cow::Borrowed(&bytes[..bytes.len().min(max_bytes)]));
+ }
+ MediaTypeData::Base64(data) => data,
+ };
+ // Match atob: URL alphabet, ASCII whitespace, optional padding, and
+ // noncanonical trailing bits are accepted; malformed input is an error.
+ let invalid = || AiMuxError::InvalidArgument("invalid base64 data".into());
+ let mut chars = data
+ .encode_utf16()
+ .take(max_bytes.div_ceil(3) * 4)
+ .filter(|&c| !matches!(c, 0x09 | 0x0a | 0x0c | 0x0d | 0x20))
+ .collect::>();
+ if chars.len().is_multiple_of(4) {
+ for _ in 0..2 {
+ if chars.last() == Some(&0x3d) {
+ chars.pop();
+ }
+ }
+ }
+ if chars.len() % 4 == 1 {
+ return Err(invalid());
+ }
+ let mut bytes = Vec::with_capacity(chars.len() * 3 / 4);
+ let mut value = 0u32;
+ let mut bits = 0;
+ for c in chars {
+ let digit = match c {
+ 0x41..=0x5a => c - 0x41,
+ 0x61..=0x7a => c - 0x61 + 26,
+ 0x30..=0x39 => c - 0x30 + 52,
+ 0x2b | 0x2d => 62,
+ 0x2f | 0x5f => 63,
+ _ => return Err(invalid()),
+ };
+ value = (value << 6) | u32::from(digit);
+ bits += 6;
+ if bits >= 8 {
+ bits -= 8;
+ bytes.push((value >> bits) as u8);
+ value &= (1 << bits) - 1;
+ }
+ }
+ bytes.truncate(max_bytes);
+ Ok(Cow::Owned(bytes))
+}
+
+/// Detect a known media type, optionally restricted to its top-level segment.
+///
+/// # Errors
+/// Returns an error if the inspected base64 prefix is malformed.
+pub fn detect_media_type(
+ data: MediaTypeData<'_>,
+ top_level_type: Option<&str>,
+) -> Result, AiMuxError> {
+ if top_level_type
+ .is_some_and(|kind| !matches!(kind, "image" | "audio" | "video" | "application"))
+ {
+ return Ok(None);
+ }
+ let mut bytes = decode_prefix(data, DEFAULT_SNIFF_BYTES)?;
+ let mut offset = 0;
+ if bytes.len() > 10 && bytes.starts_with(b"ID3") {
+ bytes = decode_prefix(data, MAX_ID3_TAG_BYTES + MAX_SIGNATURE_BYTES)?;
+ offset = 10
+ + ((usize::from(bytes[6] & 0x7f) << 21)
+ | (usize::from(bytes[7] & 0x7f) << 14)
+ | (usize::from(bytes[8] & 0x7f) << 7)
+ | usize::from(bytes[9] & 0x7f));
+ }
+ let bytes = &bytes[offset.min(bytes.len())..];
+ Ok(SIGNATURES.iter().find_map(|&(media_type, prefix)| {
+ let eligible = match top_level_type {
+ Some(kind) => get_top_level_media_type(media_type) == kind,
+ None => media_type != "audio/mp4",
+ };
+ (eligible
+ && bytes.len() >= prefix.len()
+ && prefix
+ .iter()
+ .zip(bytes)
+ .all(|(&expected, &actual)| expected == -1 || expected == i16::from(actual)))
+ .then_some(media_type)
+ }))
+}
+
+/// Return the segment preceding the first slash.
+#[must_use]
+pub fn get_top_level_media_type(media_type: &str) -> &str {
+ media_type
+ .split_once('/')
+ .map_or(media_type, |(kind, _)| kind)
+}
+
+/// Whether a media type has a nonempty subtype other than `*`.
+#[must_use]
+pub fn is_full_media_type(media_type: &str) -> bool {
+ media_type
+ .split_once('/')
+ .is_some_and(|(_, subtype)| !subtype.is_empty() && subtype != "*")
+}
+
+/// Resolve an inline file's partial media type, or preserve its full type.
+///
+/// # Errors
+/// Returns an error for malformed base64, or partial media types
+/// that cannot be detected or whose data is not inline.
+pub fn resolve_full_media_type(part: &FilePart) -> Result {
+ let media_type = &part.media_type;
+ let data = match &part.data {
+ FileData::Data {
+ data: FileBytes::Binary(bytes),
+ } => Some(MediaTypeData::Bytes(bytes)),
+ FileData::Data {
+ data: FileBytes::Base64(base64),
+ } => Some(MediaTypeData::Base64(base64)),
+ _ => None,
+ };
+ if is_full_media_type(media_type) {
+ return Ok(media_type.clone());
+ }
+ let reason = if let Some(data) = data {
+ if let Some(detected) = detect_media_type(data, Some(get_top_level_media_type(media_type)))?
+ {
+ return Ok(detected.into());
+ }
+ "it could not be auto-detected"
+ } else {
+ "it is not passed as inline bytes"
+ };
+ Err(AiMuxError::UnsupportedFunctionality(format!(
+ "file of media type \"{media_type}\" must specify subtype since {reason}"
+ )))
+}
diff --git a/aimux-provider-utils/src/read_response_with_size_limit.rs b/aimux-provider-utils/src/read_response_with_size_limit.rs
index bd5d1934a..794accf6f 100644
--- a/aimux-provider-utils/src/read_response_with_size_limit.rs
+++ b/aimux-provider-utils/src/read_response_with_size_limit.rs
@@ -5,6 +5,8 @@ use futures::StreamExt;
use aimux_core::{AiMuxError, ApiCallError};
+use crate::fetch::FetchResponse;
+
/// Default maximum buffered response size for binary downloads (2 GiB,
/// matching AI SDK).
pub const DEFAULT_MAX_DOWNLOAD_SIZE: usize = 2 * 1024 * 1024 * 1024;
@@ -15,7 +17,7 @@ pub const DEFAULT_MAX_DOWNLOAD_SIZE: usize = 2 * 1024 * 1024 * 1024;
/// `serde_json::Value`, and a deserialized struct — a 2 GiB cap lets a single
/// response balloon to several times that in resident memory. This bound is
/// per-request configurable via `HttpRequest::max_json_response_bytes`.
-pub const DEFAULT_MAX_JSON_RESPONSE_SIZE: usize = 64 * 1024 * 1024;
+pub(crate) const DEFAULT_MAX_JSON_RESPONSE_SIZE: usize = 64 * 1024 * 1024;
/// Read a response incrementally and fail before unbounded allocation.
///
@@ -24,7 +26,7 @@ pub const DEFAULT_MAX_JSON_RESPONSE_SIZE: usize = 64 * 1024 * 1024;
/// Returns an API-call error when the body exceeds `max_bytes` or cannot be
/// read, and an abort error when the caller cancels.
pub async fn read_response_with_size_limit(
- response: reqwest::Response,
+ response: FetchResponse,
url: &str,
request_body_values: &serde_json::Value,
max_bytes: usize,
@@ -107,7 +109,7 @@ pub async fn read_response_with_size_limit(
/// Returns an abort error when the caller cancels; never fails on read or
/// size problems.
pub(crate) async fn read_error_body_truncated(
- response: reqwest::Response,
+ response: FetchResponse,
max_bytes: usize,
abort_signal: Option<&aimux_core::AbortSignal>,
) -> Result<(Vec, bool), AiMuxError> {
diff --git a/aimux-provider-utils/src/resolvable.rs b/aimux-provider-utils/src/resolvable.rs
new file mode 100644
index 000000000..8d2cee627
--- /dev/null
+++ b/aimux-provider-utils/src/resolvable.rs
@@ -0,0 +1,157 @@
+//! A value that may be supplied directly, computed on demand, or awaited.
+//!
+//! The AI SDK's provider settings accept `string | (() => string | Promise)`
+//! for API keys and headers (`Resolvable` in `@ai-sdk/provider-utils`).
+//! This is the Rust shape of that union.
+
+use std::fmt;
+use std::sync::Arc;
+
+use futures::FutureExt;
+use futures::future::{BoxFuture, Shared};
+
+use aimux_core::AiMuxError;
+
+/// A value of `T`, or a way to obtain one when a request is made.
+///
+/// `Future` and `AsyncFn` differ in *when* they compute: a `Future` is
+/// evaluated at most once and every [`resolve`](Self::resolve) shares the
+/// cached outcome (a promise value), while an `AsyncFn` / `Fn` runs again on
+/// every resolve (a token refresher, a credential chain).
+pub enum Resolvable