diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e4861d8fa..1d31c5288 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -642,6 +642,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
with:
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 ea313a12b..e4dc50ace 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,14 +9,85 @@ 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 and `DEFAULT_MAX_JSON_RESPONSE_SIZE`.
+
+- Proxy settings belong to a transport instance, not to the process
+ (`aimux-provider-utils`). `init_proxy` and its set-once global are
+ removed; `ProxyConfig` moves to `fetch`. `ReqwestFetch::new(proxy)` is a
+ transport with its own proxy and connection pool, given to a provider as
+ its `fetch` setting, as the AI SDK leaves routing to `fetch`; the
+ WebSocket counterpart is `ws::TungsteniteConnector::new(proxy)`, given as
+ `web_socket`. `set_default_transport` replaces the default that providers
+ without `fetch`, built-in WebSocket connects and download hops read per
+ request. `PinnedFetch::new` takes the hop's `ProxyConfig`.
+ `aimux_init_proxy` (C ABI unchanged) and the Node and Python `initProxy` /
+ `init_proxy` now replace the default transport for later requests, every
+ time they are called, instead of taking effect only before the first
+ request.
+
+- Removed items the AI SDK has no counterpart for: `aimux_core::ModelId`
+ (and `model_id`, the Node `ModelId` type), the hidden
+ `aimux_provider_utils::download_guard` re-export (use
+ `aimux_core::download_guard`; `same_origin` stays re-exported), the
+ deprecated panicking converters `convert_prompt_to_anthropic`,
+ `convert_prompt_to_anthropic_full`, `convert_prompt_to_openai_messages`
+ and `convert_prompt_to_openai_messages_with_mode` (use the `_fallible`
+ forms), and the `ResponsesSystemMessageMode` alias (use
+ `SystemMessageMode`).
+
+- `aimux_provider_utils::create_standard_json_error_response_handler` is
+ removed. Failed-response handlers live in the provider modules, as the AI
+ SDK keeps `groqFailedResponseHandler` and DeepSeek's error schema in their
+ packages; build one with `create_json_error_response_handler`. The Groq
+ handler now reads only `error.type` as the provider code, and DeepSeek's
+ prefers `error.code` (a string or a number) over `error.type`.
+
+- `LanguageModel::supported_urls` and
+ `LanguageModelMiddleware::override_supported_urls` are async, as the AI
+ SDK's `supportedUrls` and `overrideSupportedUrls` may be a `PromiseLike`.
+ A wrapped model reads the override, else the inner model, when asked
+ instead of once at wrap time. The OpenAI-compatible `SupportedUrlsFn`
+ setting returns a `BoxFuture<'static, SupportedUrls>`.
+
+- The user operations download as the AI SDK's do, through a transport the
+ caller can replace (`aimux-core`, new public `download` module).
+ `generate_text` and `stream_text` read the model's `supported_urls()`
+ before the call and download each prompt file URL it does not cover (user
+ `FileUrl` parts and `file` items of tool outputs; not `data:` URLs), which
+ then reaches the provider as inline data; they used to pass every URL
+ through. `GenerateTextOptions::download` takes a `DownloadFunction` (the AI
+ SDK's `experimental_download`) instead. `AudioInput::Url` is new:
+ `transcribe` downloads it with `TranscriptionCallOptions::download`, and a
+ transcription model given one returns `AudioInput::url_error()`.
+ `generate_video` downloads every video returned as a URL with
+ `VideoCallOptions::download` and returns it as `VideoData::Binary` (it
+ returned the URL before). Without a download each operation uses
+ `create_download(None)`, the guarded download (public HTTP(S) URLs only,
+ pinned hops, no provider credentials, 2 GiB limit), so a Vertex `gs://`
+ video result needs an injected download.
+
- 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
@@ -139,11 +210,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
@@ -230,8 +301,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()`.
@@ -244,9 +315,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`generateVideo` `poll` option (`aimux-core`, `aimux-providers`).
`VideoModel::poll_config()` and `VideoPollConfig` are removed; unset
`VideoCallOptions::poll` fields use the AI SDK defaults (5000 ms interval,
- 600000 ms timeout). `RunwaymlConfig` loses `poll_interval`, `timeout`,
- `with_poll_interval` and `with_timeout` (RunwayML used 2 s / 300 s); pass
- `poll` per call instead. Each status check, its retries included, now races
+ 600000 ms timeout). RunwayML no longer paces its own polling (it used
+ 2 s / 300 s, first as settings, then as package constants); pass `poll` per
+ call instead. Each status check, its retries included, now races
the time left in the poll budget and a hanging check fails with `Timeout`
"Video generation timed out after {timeoutMs}ms." as upstream.
@@ -406,6 +477,265 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`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.
+ `Provider` gains `specification_version()`, returning `"v4"` like
+ `ProviderV4` and the model traits.
+ AI SDK model constructors return an `Arc`. Removed: `Provider::name()`,
+ `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` stays 2, and recordings written by aimux 0.5
+ and earlier (schemas 1 and 2) still load; schema 3 and above are refused.
+ `ProviderRecord` holds identity only (`provider_id`, `provider`,
+ `model_id`); base URL, key source, profile, options and retry settings are
+ no longer recorded, and when read from an older recording they are
+ ignored. What this version cannot replay from an older recording fails at
+ replay time with an error that names it: its empty `provider_id`
+ (`ProviderRecord::registry_id`) for a rebuild from a registry, a prompt in
+ the older format (`InputRecord::unreadable_prompt`, loaded as empty),
+ older call options, or decision capabilities that only the older
+ `provider.profile` holds. `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: `googleVertex` and `amazonBedrock` are the
+ canonical keys; the legacy `vertex` / `bedrock` keys are still read, and
+ provider metadata is written under both, as the upstream packages do
+ (Bedrock also 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 282 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`, held in a `OnceLock` without leaking memory:
+ `PresetDescriptor`, `ParamSpec` and `DeriveSpec` own their data (`String`,
+ `Vec`) instead of `&'static` slices, and lose `Copy`; 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` unless the row's optional key variable, such as
+ `VLLM_API_KEY`, is set; `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.
+- Decisions (#226) come from the providers: `OpenAIProvider::decision` and
+ `Provider::decision_model` on OpenAI, and `decision_model` on the official
+ runtime presets (ollama, llamacpp, localai, laya, vllm, sglang,
+ cloudflare). `laya` is a registry row. Decision recordings store the
+ provider identity only, and `rebuild_decision_provider` takes the
+ caller's `ProviderRegistry` instead of an API key.
+- 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.
+- 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 = 19` and `AIMUX_E_LOAD_SETTING = 20`
+ (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 19 / 20 (`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.
+
**All bindings**
- Typed provider tools in Java, Kotlin, Swift, Python and Flutter carry
@@ -428,8 +758,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`ToolApprovalResponse` are decoded. Tool-call
repair reads the top-level `tool_calls` only.
+### Added
+
+- `aimux_core::wrap_provider`, the AI SDK's `wrapProvider`: wraps every
+ language model of a provider with language-model middleware and, when
+ given, every image model with image-model middleware; all other members
+ are the provider's own.
+- C ABI: `void aimux_drop_handle_ptr(void *handle)`, `aimux_drop_handle` with
+ the handle passed as a pointer-sized value (NULL is a no-op), for a
+ finalizer that can only register a `void (*)(void *)` callback.
+- `aimux_providers::provider_api_key_env(name)`: the environment variable a
+ built-in provider reads its key from when `create_provider` gets none, and
+ `provider_default_base_url(name)`, the endpoint it calls when given no base
+ URL (`None` when that is built from settings).
+
### Fixed
+- `default_providers()`, `openai()` and `anthropic()` no longer panic on an
+ invalid `OPENAI_BASE_URL` / `ANTHROPIC_BASE_URL`. The variable is still
+ read when the provider is created, as the AI SDK does; an invalid value now
+ fails each call of that provider with `InvalidArgument` naming the
+ variable. An invalid explicit `base_url` still fails the factory.
+- Anthropic Messages streams honor `include_raw_chunks`: each SSE chunk is
+ sent as `StreamPart::Raw` before the parts made from it, a chunk that is
+ not JSON as `Raw` with `null`, as the AI SDK does.
+- OpenAI Responses: reasoning parts of one assistant message that share an
+ `itemId` become one input item, as the AI SDK does: one `item_reference`
+ with `store`, otherwise one `reasoning` item whose summary collects the
+ parts and whose `encrypted_content` is the last one given. Each part used
+ to become its own item, which the API rejects as a duplicate id.
+- Recordings and logs redact Prodia's `X-Prodia-Key` and any header ending in
+ `subscription-key` (Azure's `Ocp-Apim-Subscription-Key`), with or without
+ separators; both were stored in clear text.
+- Flutter: the `NativeFinalizer` that releases a garbage-collected `Model` or
+ `ProviderHandle` calls `aimux_drop_handle_ptr`. It called
+ `aimux_drop_handle(uint64_t)` through a `void (*)(void *)` pointer, which on
+ 32-bit ARM (armeabi-v7a) passes a garbage high word and leaks the handle.
+- Python: every blocking native call (`generate_text`, `generate_object`,
+ `consume_stream_text`, `generate_text_as_openai`,
+ `ProviderHandle.list_models`, `get_model_specs`, the multimodal calls and
+ the recording flushes) releases the GIL while it waits, so other Python
+ threads keep running.
+- aimux-web: an `env:VAR` key reference is accepted only for the variable the
+ provider's key is registered under (for example `env:OPENAI_API_KEY` for
+ `openai`); any other name is refused before it is read, on the calls,
+ cache-probe and replay paths. It read any variable of the console process.
+- aimux-web: a call or cache probe whose `base_url` is not the provider's
+ default endpoint gets only a key carried in the request (plaintext, loopback
+ binds only): the Settings key and the environment are not read, `env:` is
+ refused, and with no key the call goes out without one. Providers that read
+ other credentials from the environment (Bedrock, Vertex, Codex, Polly,
+ DataForSEO, Anthropic on AWS) refuse a custom `base_url`. A client could
+ point `base_url` at its own host and receive the console's key.
+- Recordings: a call that lost a recording event (the JSONL recorder's queue
+ was full or its writer gone) or was marked inconsistent (a duplicate
+ attempt, an exchange update that matches no exchange) is written with
+ `complete: false`, by the JSONL and the ring recorder, whether its last
+ event or a flush writes it. Such calls used to be marked complete.
+- `aimux-replay` replays a decision recording live for any provider with a
+ decision model (OpenAI, the runtime presets, Jev), rebuilt from the same
+ registry as language recordings: the built-in providers, the recorded one
+ with `--api-key` when given. It accepted only Jev.
+- aimux-web: a call at a custom `base_url` gets its own cache-probe trace
+ scope, named after the provider and that endpoint, so two endpoints of one
+ provider and model no longer match each other's prefix history. The trace
+ scope isolates by provider name and model id; `TraceLayer::with_scope_name`
+ names a scope for a host that calls one provider at several endpoints.
- Gated aimux-core's ts-rs exports behind `ts-export`, so plain `cargo test`
no longer rewrites tracked TypeScript types. `scripts/gen_ts_types.py`
enables exports and rejects ungated export attributes with `--check`.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 959bcfa67..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 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 ba5627084..d12a9f0c3 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
-> **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 [327 provider names](docs/api/providers.md).**
[](https://github.com/arcships/aimux/actions/workflows/ci.yml)
[](LICENSE)
@@ -32,13 +32,10 @@ For planned milestones and their current status, see the [roadmap](ROADMAP.md).
## 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).
+- **327 provider names** — 282 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,
@@ -51,10 +48,12 @@ For planned milestones and their current status, see the [roadmap](ROADMAP.md).
- **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,
@@ -150,14 +149,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,
@@ -222,9 +219,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(),
@@ -236,7 +234,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(),
);
@@ -275,35 +273,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 282 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 1added1d2..1ff8fa7fd 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -35,7 +35,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)+ 文档三层重组
│
@@ -75,12 +75,12 @@ 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 周)
@@ -92,13 +92,13 @@ aimux 是 **provider 接入与治理的运行时**:
| 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 × 能力,每格由探测或录制支撑;后置到全链路对齐迁移之后(见 §6.1) |
| CLI | `aimux probe / replay / diff`(#179 replay 子命令并入);#181 debug CLI 并入 |
@@ -107,7 +107,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 9530e33c6..9e0043176 100644
--- a/aimux-core/Cargo.toml
+++ b/aimux-core/Cargo.toml
@@ -32,6 +32,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 a11e5712f..9d3098404 100644
--- a/aimux-core/src/composite.rs
+++ b/aimux-core/src/composite.rs
@@ -10,9 +10,9 @@
//! - [`ChildModel`] — the child-handle shape (`Arc`).
//! - `step_context` — the recording context of one child step.
//! - `child_max_retries` — the operation's retry count for a child call.
-//! - [`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
@@ -43,7 +43,8 @@ pub(crate) fn step_context(step: impl Into) -> Option
/// The retry count of a child call: the `max_retries` of the operation the
/// composite runs in ([`OperationSettings::current`]). `None` outside an
-/// operation, or when the operation sets none, leaves the child's default.
+/// operation, or when the operation sets none, retries twice, the AI SDK
+/// default.
pub(crate) fn child_max_retries() -> Option {
OperationSettings::current().and_then(|settings| settings.max_retries)
}
@@ -54,7 +55,7 @@ pub(crate) fn child_max_retries() -> Option {
/// 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),
@@ -84,7 +85,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 {
@@ -103,7 +104,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 36a867904..f7c3289ec 100644
--- a/aimux-core/src/decision_model.rs
+++ b/aimux-core/src/decision_model.rs
@@ -503,21 +503,13 @@ impl DecisionResult {
#[async_trait]
pub trait DecisionModel: Send + Sync {
+ /// Specification version (always `"v4"`).
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.
@@ -602,11 +594,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);
@@ -615,9 +603,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 1b3c0ddb8..90bfdcd8e 100644
--- a/aimux-core/src/decision_replay.rs
+++ b/aimux-core/src/decision_replay.rs
@@ -51,16 +51,13 @@ 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 (recordings written by aimux \
+ 0.5 or earlier keep them in provider.profile, which this version does not read)"
+ .into(),
+ )
+ })?;
Ok(Self {
provider,
model_id,
diff --git a/aimux-core/src/download.rs b/aimux-core/src/download.rs
index cfa7d0f45..17699b879 100644
--- a/aimux-core/src/download.rs
+++ b/aimux-core/src/download.rs
@@ -1,8 +1,31 @@
-//! Resolve generated file URLs before exposing their file data.
+//! Downloads made by the user operations, as the AI SDK's `download`
+//! options (ai@7.0.127 `util/download/`).
+//!
+//! - `transcribe` and `generate_video` take a [`Download`], which fetches
+//! one URL (`transcribe/transcribe.ts:56`, `:109-117`;
+//! `generate-video/generate-video.ts:136`, `:253-261`).
+//! - `generate_text` and `stream_text` take a [`DownloadFunction`] for the
+//! file URLs of the prompt (`util/download/download-function.ts`;
+//! `prompt/convert-to-language-model-prompt.ts:36-55`).
+//!
+//! An operation given neither uses the guarded download ([`create_download`]):
+//! every hop is validated and its connection pinned to the validated
+//! addresses ([`crate::download_guard`]), and the provider's fetch and
+//! credentials are never used for these URLs. A caller that needs another
+//! transport (offline replay, a proxy, an authenticated store) passes its
+//! own.
+//!
+//! The URLs of generated files in a text result are always fetched with the
+//! guarded download: upstream's `resolveGeneratedFileData` takes no download
+//! option either (`generate-text/resolve-generated-file-data.ts:14-41`).
+use std::fmt;
+use std::future::Future;
use std::net::SocketAddr;
+use std::sync::Arc;
use futures::StreamExt;
+use futures::future::BoxFuture;
use crate::abort_signal::AbortSignal;
use crate::download_guard::{validate_download_target, validate_download_url};
@@ -12,6 +35,177 @@ use crate::shared::{FileBytes, GeneratedFileData};
const DEFAULT_MAX_DOWNLOAD_SIZE: usize = 2 * 1024 * 1024 * 1024;
const MAX_DOWNLOAD_REDIRECTS: usize = 10;
+/// A downloaded file: its bytes and the media type the response reported
+/// (`{ data, mediaType }`).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DownloadedFile {
+ /// The file's bytes.
+ pub data: Vec,
+ /// The response's `Content-Type`, when it had one.
+ pub media_type: Option,
+}
+
+/// What a [`Download`] is asked to fetch (`{ url, abortSignal }`).
+#[derive(Debug, Clone)]
+pub struct DownloadRequest {
+ /// The URL to fetch.
+ pub url: String,
+ /// The operation's abort signal.
+ pub abort_signal: Option,
+}
+
+type DownloadCallback =
+ dyn Fn(DownloadRequest) -> BoxFuture<'static, Result> + Send + Sync;
+
+/// Fetches one URL: the `download` option of `transcribe` and
+/// `generate_video` (ai@7.0.127 `transcribe/transcribe.ts:109-117`,
+/// `generate-video/generate-video.ts:253-261`). [`create_download`] builds
+/// the default one.
+#[derive(Clone)]
+pub struct Download(Arc);
+
+impl Download {
+ /// Wrap a download callback.
+ #[must_use]
+ pub fn new(download: F) -> Self
+ where
+ F: Fn(DownloadRequest) -> Fut + Send + Sync + 'static,
+ Fut: Future