Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
e06697c
feat(providers): the remaining upstream model kinds, ported from the …
claude Oct 12, 2026
c8d773a
fix: carry provider stack repairs into model kinds
Oct 12, 2026
5438b98
Merge branch 'v4/6-vendors-follow-the-upstream-packages' into v4/7-re…
claude Oct 12, 2026
a7150bb
Merge [6/8] into [7/8]: vendor alignment and the lower layers' fixes
claude Oct 13, 2026
f0ced8e
chore(fixtures): pin @ai-sdk/provider 4.0.22, the first DecisionModel…
claude Oct 13, 2026
6ed88c1
refactor(core)!: realtime transcription streaming is an optional method
claude Oct 13, 2026
2198aab
feat(core): custom_provider forwards discovery to its fallback
claude Oct 13, 2026
73012b0
feat(core)!: speech results carry the provider's usage
claude Oct 13, 2026
907393f
refactor(core)!: decision recordings carry no capability snapshot
claude Oct 13, 2026
37255a7
Merge [6/8] into [7/8]: per-package user-agents and upstream error/he…
claude Oct 13, 2026
bbbc72a
refactor(core)!: decisions follow DecisionModelV4
claude Oct 13, 2026
c71fae0
Merge [6/8] into [7/8]: layer 6 with layers 2-5 (V4 types, operation …
claude Oct 13, 2026
e8279c4
fix: layer 7 code takes layers 2-6's types
claude Oct 13, 2026
635a3f4
test(providers): the layer 7 transcription models refuse URL audio
claude Oct 13, 2026
f94eadb
fix(core): decide runs its model call in the operation settings
claude Oct 13, 2026
efd10d6
test(kotlin): the OpenAI decision test sends the fixture's options in…
claude Oct 13, 2026
6aa327d
docs(node): regenerate index.d.ts for applyToolCallRepair's doc
claude Oct 13, 2026
1602ed8
Merge [6/8] into [7/8]: binding tests follow layers 3 and 4
claude Oct 13, 2026
5415fea
test(swift): the OpenAI decision test sends the fixture's options in …
claude Oct 13, 2026
7294e13
Merge [6/8] into [7/8]: recording schema 2, master recordings load
claude Oct 13, 2026
9fcc401
Merge [6/8] into [7/8]: CHANGELOG wording for legacy namespace keys
claude Oct 13, 2026
3bae669
docs(changelog): decision recordings carry no capability snapshot
claude Oct 13, 2026
ee60e4e
Merge [6/8] into [7/8]: rustdoc link fixes
claude Oct 13, 2026
48083ff
docs: plain code spans for the private stream_transcription methods
claude Oct 13, 2026
f80fff2
Merge [6/8] into [7/8]: the CI speed-up (#262)
claude Oct 14, 2026
22db086
Merge [6/8] into [7/8]: the CI fix (#262)
claude Oct 14, 2026
9db6427
Merge [6/8] into [7/8]: master (#205-#212, #248, #263), #262's CI fol…
claude Oct 16, 2026
e899de9
feat(anthropic): decision_model is upstream's evaluationModel
claude Oct 15, 2026
7511ff4
test(providers): a failed decision is recorded and replays as the fai…
claude Oct 15, 2026
7c3bb6a
docs(core): replay's question types and the video status key
claude Oct 15, 2026
aad72e8
Merge [6/8] into [7/8]: deferred tool results and the recording scope
claude Oct 16, 2026
31fb8fe
Merge [6/8] into [7/8]: tracker ids, binding provider tools, InputRec…
claude Oct 16, 2026
e661f11
Merge [6/8] into [7/8]: gated ts-rs exports and the ProviderName removal
claude Oct 16, 2026
de9e9a0
Merge [6/8] into [7/8]: the deferred tool result rule is generate_text's
claude Oct 16, 2026
cd55182
Merge [6/8] into [7/8]: master's #266 docs wording
claude Oct 16, 2026
dc656f8
Merge [6/8] into [7/8]: the pin fetch test fix
claude Oct 16, 2026
593b5e5
Merge [6/8] into [7/8]: RFC text for the operation settings
claude Oct 16, 2026
fa3d877
Merge [6/8] into [7/8]: #262, #246 and #247 as eric merged them
claude Oct 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 69 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -566,8 +566,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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.
recordings contain provider identity only and no capability snapshot
(see the DecisionModelV4 entry). Decision retries use the core call-level
policy.

- `OpenAIProvider::language_model(id)` (and so `create_provider("openai", ..)`
and the registry id `openai:<model>`) returns the **Responses** model, as
Expand Down Expand Up @@ -661,9 +662,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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.
older call options, or the pre-V4 decision options and results.
`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,
Expand Down Expand Up @@ -773,6 +774,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
with `InvalidArgument` before a request is sent.
- `ExternalProviderEntry` debug output reports only whether `api_key` is
present, keeping literal keys out of debug logs.
- `TranscriptionModel::do_stream` is optional, as the AI SDK's `doStream?`:
it returns `Option<BoxFuture<…>>` and is `None` by default, instead of a
default method that fails with `UnsupportedFunctionality`. Implementors
return `Some(Box::pin(…))`. `stream_transcribe` reports a model without the
method as `UnsupportedFunctionality` before any I/O
(`The {provider} model "{id}" does not support streaming transcription.`).
- `SpeechResult` has `usage: Option<JsonObject>`, the provider's usage in its
native shape, as the AI SDK's `SpeechModelV4Result.usage`. Google speech
passes its `usageMetadata` through; other vendors report `None`. Speech
model implementors set the field.
- Decision recordings no longer store a capability snapshot:
`InputRecord::decision_capabilities` is removed, and
`InputRecord::from_decision_options` and `Recorder::record_decision_input`
no longer take capabilities. `MockDecisionReplayModel` does not read the
field; replay rebuilds the model from the host registry. The recording
schema stays 2, and older recordings with the field still load.
- Decisions follow the AI SDK's `DecisionModelV4` (`@ai-sdk/provider@4.0.22`)
and `decide` (`ai@7.0.128`). Questions are a map keyed by id; Choice and
Score questions take `criteria` (option names to descriptions, ordered
levels). Answers are `{choice, probabilities?}`, `{score, probabilities?}`
(keyed `"0"`, `"1"`, …) and `{probability}`. `decide` takes `DecideOptions`
(the call options plus `max_retries`, default 2, and `timeout`) and returns
`DecideResult` (answers, usage with `total_tokens`, warnings, rounding,
provider metadata, response). It validates the input, rejects unsupported
question types before any I/O and validates the answers once after the
retries, with the upstream messages. The model call runs inside
`operation::OperationSettings::scope` with the decision's `max_retries` and
`timeout`, as `generate_text`'s does.
- `DecisionModel` has `specification_version`, `provider`, `model_id`,
`supported_question_types` and `do_decide(&DecisionCallOptions)`;
`capabilities()` and `DecisionCapabilities` are removed, and provider limits
are checked in `do_decide` before HTTP. Images and OpenAI typed choice
values move to `provider_options.<provider>`; the probability source,
confidence, OpenAI typed values, Laya abstentions and `latency_ms` move to
`provider_metadata.<provider>`. An OpenAI refusal or a vLLM skipped question
fails the decision instead of returning a partial answer. Decision
recordings hold the `DecideOptions` and the `DecideResult`; recordings made
before this change load but do not replay.
- `Provider::decision_model` returns `Option<Result<Arc<dyn DecisionModel>, _>>`,
`None` by default; the registry and `custom_provider` report `None` as
`NoSuchModel` with `model_type = "decisionModel"`. `Provider::evaluation_model`
and the `evaluation_model` module are removed: Google's `decision_model` is
the language-model adapter (`google.evaluation`), OpenAI's stays the native
Decisions API and `OpenAIProvider::evaluation` builds the Responses adapter.
`EvaluationLanguageModel` is renamed `DecisionLanguageModel`; its default
provider is `"{language provider}.decision"`.

**C ABI**

Expand All @@ -782,6 +829,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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_decide` takes `DecideOptions` JSON and returns `DecideResult` JSON.
`aimux_decision_capabilities` keeps its name and signature but returns
`{specification_version, provider, model_id, supported_question_types}`.
`aimux_provider_decision_model` on a provider without decision models
returns `AIMUX_E_NO_SUCH_MODEL` (was `AIMUX_E_UNSUPPORTED_FUNCTIONALITY`).
- `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
Expand All @@ -808,6 +860,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`InvalidArgumentError`.
- Recorded and traced provider strings follow `"{name}.{method}"`
(`openai.chat`, not `openai`).
- Decisions use the `DecideOptions` / `DecideResult` JSON. Node
`decisionCapabilities(model)` is replaced by `describeDecisionModel(model)`
and Python `decision_capabilities` by `describe_decision_model`; the native
`capabilities()` method is `describe()`. Python
`decide(model, state, questions, **options)` takes the questions as a dict
keyed by id.

**Go / Java / Kotlin / Swift / Dart**

Expand All @@ -819,6 +877,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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.
- Decisions use the `DecideOptions` / `DecideResult` JSON; the decision model's
`Capabilities()` / `capabilities()` is `Describe()` / `describe()` and
returns the model's V4 properties.

**All bindings**

Expand Down Expand Up @@ -870,6 +931,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `OpenAIProvider::tools` and `AzureOpenAIProvider::tools`: the provider tools
on the provider instance, as the AI SDK's `openai.tools` and `azure.tools`
(`openai().tools.web_search(…)`). Azure's holds its upstream subset.
- `custom_provider` forwards model discovery as it forwards files and
skills: `CustomProviderOptions::discovery`, else the fallback provider's
`discovery()`.
- `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
Expand Down
176 changes: 176 additions & 0 deletions aimux-core/src/custom_provider.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
//! `custom_provider`: a provider assembled from alias tables, with an optional
//! fallback provider.
//!
//! Mirrors the AI SDK's `customProvider` (`ai/src/registry/custom-provider.ts`).
//! A lookup consults the alias table of its modality first, then the fallback
//! provider; with neither, it is [`AiMuxError::NoSuchModel`]. There are no
//! string aliases that resolve through a global default provider: Rust has
//! none.
//!
//! The aimux discovery extension ([`Provider::discovery`]) is forwarded the
//! way upstream forwards files and skills
//! (`ai@7.0.127 src/registry/custom-provider.ts:268-283`: the configured
//! interface, else the fallback provider's): the configured
//! [`CustomProviderOptions::discovery`], else the fallback provider's.

use std::collections::HashMap;
use std::sync::Arc;

use crate::decision_model::DecisionModel;
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::provider::{Provider, ProviderDiscovery};
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 [`custom_provider`]; every field is optional.
#[derive(Default)]
pub struct CustomProviderOptions {
pub language_models: HashMap<String, Arc<dyn LanguageModel>>,
pub embedding_models: HashMap<String, Arc<dyn EmbeddingModel>>,
pub image_models: HashMap<String, Arc<dyn ImageModel>>,
pub transcription_models: HashMap<String, Arc<dyn TranscriptionModel>>,
pub speech_models: HashMap<String, Arc<dyn SpeechModel>>,
pub reranking_models: HashMap<String, Arc<dyn RerankingModel>>,
pub video_models: HashMap<String, Arc<dyn VideoModel>>,
pub search_models: HashMap<String, Arc<dyn SearchModel>>,
pub decision_models: HashMap<String, Arc<dyn DecisionModel>>,
/// A files interface for uploading files.
pub files: Option<Arc<dyn Files>>,
/// A skills interface for uploading skills.
pub skills: Option<Arc<dyn Skills>>,
/// aimux extension: a model-discovery interface. Without one, the
/// fallback provider's discovery is used.
pub discovery: Option<Arc<dyn ProviderDiscovery>>,
/// Consulted when a requested model is not in the alias tables.
pub fallback_provider: Option<Arc<dyn Provider>>,
}

/// A provider built by [`custom_provider`].
pub struct CustomProvider {
options: CustomProviderOptions,
}

/// Create a custom provider from alias tables and an optional fallback.
#[must_use]
pub fn custom_provider(options: CustomProviderOptions) -> CustomProvider {
CustomProvider { options }
}

/// The aliased model, else what the fallback offers, else `NoSuchModel`.
fn resolve<T: ?Sized>(
models: &HashMap<String, Arc<T>>,
id: &str,
model_type: &str,
fallback: impl FnOnce() -> Option<Result<Arc<T>, AiMuxError>>,
) -> Result<Arc<T>, AiMuxError> {
models
.get(id)
.map(|model| Ok(Arc::clone(model)))
.or_else(fallback)
.unwrap_or_else(|| Err(AiMuxError::no_such_model(id, model_type)))
}

impl Provider for CustomProvider {
fn language_model(&self, id: &str) -> Result<Arc<dyn LanguageModel>, AiMuxError> {
let o = &self.options;
resolve(&o.language_models, id, "languageModel", || {
o.fallback_provider.as_ref().map(|p| p.language_model(id))
})
}

fn embedding_model(&self, id: &str) -> Result<Arc<dyn EmbeddingModel>, AiMuxError> {
let o = &self.options;
resolve(&o.embedding_models, id, "embeddingModel", || {
o.fallback_provider.as_ref().map(|p| p.embedding_model(id))
})
}

fn image_model(&self, id: &str) -> Result<Arc<dyn ImageModel>, AiMuxError> {
let o = &self.options;
resolve(&o.image_models, id, "imageModel", || {
o.fallback_provider.as_ref().map(|p| p.image_model(id))
})
}

// The optional modalities are always defined here, as upstream: an id
// neither table nor fallback serves is `NoSuchModel`, never `None`.

fn transcription_model(
&self,
id: &str,
) -> Option<Result<Arc<dyn TranscriptionModel>, AiMuxError>> {
let o = &self.options;
Some(resolve(
&o.transcription_models,
id,
"transcriptionModel",
|| o.fallback_provider.as_ref()?.transcription_model(id),
))
}

fn speech_model(&self, id: &str) -> Option<Result<Arc<dyn SpeechModel>, AiMuxError>> {
let o = &self.options;
Some(resolve(&o.speech_models, id, "speechModel", || {
o.fallback_provider.as_ref()?.speech_model(id)
}))
}

fn reranking_model(&self, id: &str) -> Option<Result<Arc<dyn RerankingModel>, AiMuxError>> {
let o = &self.options;
Some(resolve(&o.reranking_models, id, "rerankingModel", || {
o.fallback_provider.as_ref()?.reranking_model(id)
}))
}

fn video_model(&self, id: &str) -> Option<Result<Arc<dyn VideoModel>, AiMuxError>> {
let o = &self.options;
Some(resolve(&o.video_models, id, "videoModel", || {
o.fallback_provider.as_ref()?.video_model(id)
}))
}

fn search_model(&self, id: &str) -> Option<Result<Arc<dyn SearchModel>, AiMuxError>> {
let o = &self.options;
Some(resolve(&o.search_models, id, "searchModel", || {
o.fallback_provider.as_ref()?.search_model(id)
}))
}

/// `ai@7.0.128 src/registry/custom-provider.ts:241-258`: the alias, else
/// the fallback provider's decision model, else `NoSuchModel`.
fn decision_model(&self, id: &str) -> Option<Result<Arc<dyn DecisionModel>, AiMuxError>> {
let o = &self.options;
Some(resolve(&o.decision_models, id, "decisionModel", || {
o.fallback_provider.as_ref()?.decision_model(id)
}))
}

fn files(&self) -> Option<Arc<dyn Files>> {
let o = &self.options;
o.files
.clone()
.or_else(|| o.fallback_provider.as_ref()?.files())
}

fn skills(&self) -> Option<Arc<dyn Skills>> {
let o = &self.options;
o.skills
.clone()
.or_else(|| o.fallback_provider.as_ref()?.skills())
}

fn discovery(&self) -> Option<&dyn ProviderDiscovery> {
let o = &self.options;
o.discovery
.as_deref()
.or_else(|| o.fallback_provider.as_ref()?.discovery())
}
}
Loading
Loading