Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
64567fd
feat(providers)!: AI SDK-shaped provider factories, source only
cunninghamcard-bit Oct 5, 2026
cb27e1b
fix(providers): factories, request layer, settings and middleware fol…
cunninghamcard-bit Oct 6, 2026
b205103
Merge the branch below: review fixes
cunninghamcard-bit Oct 6, 2026
afd9441
fix(providers): media type detection for Cohere and Mistral, Bedrock …
cunninghamcard-bit Oct 6, 2026
682051e
Merge the branch below: review fixes
cunninghamcard-bit Oct 6, 2026
12402c2
refactor(providers): vendor option schemas live in each package; drop…
cunninghamcard-bit Oct 6, 2026
72a1d9c
Merge the branch below: review fixes
cunninghamcard-bit Oct 6, 2026
f1f0683
Merge the branch below: formatting
cunninghamcard-bit Oct 6, 2026
95e4451
Merge the branch below: ported test files kept
cunninghamcard-bit Oct 6, 2026
f847795
chore: integrate current foundations and decision API into provider f…
cunninghamcard-bit Oct 7, 2026
15a8432
fix(decision): follow call-level retries and identity-only replay
cunninghamcard-bit Oct 7, 2026
0a12c6e
test(decision): create temporary recording directory without private …
cunninghamcard-bit Oct 7, 2026
94a2f39
test(decision): exercise new factory and identity-only recording surface
cunninghamcard-bit Oct 7, 2026
475de91
test(decision): mark migrated recording fixtures as schema three
cunninghamcard-bit Oct 7, 2026
bc5222f
chore(node): regenerate decision types for the updated stack
cunninghamcard-bit Oct 7, 2026
398a884
Merge branch 'codex/restack-223' into codex/restack-224
cunninghamcard-bit Oct 7, 2026
e640df1
docs(decision): align replay contract with identity-only schema
cunninghamcard-bit Oct 7, 2026
94942c4
fix(decision): keep removed model snapshot and version hooks out of f…
cunninghamcard-bit Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
1 change: 1 addition & 0 deletions aimux-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
12 changes: 6 additions & 6 deletions aimux-core/src/composite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
//!
//! This module holds the pieces both share:
//! - [`ChildModel`] — the child-handle shape (`Arc<dyn LanguageModel>`).
//! - [`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<dyn LanguageModel>` implementing `LanguageModel`); composite
Expand All @@ -34,7 +34,7 @@ pub type ChildModel = Arc<dyn LanguageModel>;
/// Each usage detail field is `Option<u32>`; `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),
Expand Down Expand Up @@ -64,7 +64,7 @@ fn opt_add(a: Option<u32>, b: Option<u32>) -> Option<u32> {
/// 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 {
Expand All @@ -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)],
Expand Down
25 changes: 6 additions & 19 deletions aimux-core/src/decision_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand All @@ -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(
Expand Down
13 changes: 3 additions & 10 deletions aimux-core/src/decision_replay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 1 addition & 14 deletions aimux-core/src/embedding_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -159,11 +150,7 @@ pub async fn embed(
) -> Result<EmbeddingResult, AiMuxError> {
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(),
Expand Down
49 changes: 47 additions & 2 deletions aimux-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String>,
},

#[error("request timed out: {0}")]
Timeout(String),
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 0 additions & 5 deletions aimux-core/src/files_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
27 changes: 10 additions & 17 deletions aimux-core/src/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,6 @@ pub struct GenerateTextOptions {
pub reasoning: Option<ReasoningEffort>,
/// System instructions prepended to the prompt.
pub instructions: Option<String>,
/// Per-call request body overrides (deep-merged). See RFC-0017.
pub body_overrides: Option<Value>,
/// Per-call retry count override. `None` = provider default, `Some(0)` = disable.
pub max_retries: Option<u32>,
/// Per-call timeout configuration (total / first-chunk / chunk idle).
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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 完成。双发幂等。
Expand Down Expand Up @@ -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
Expand All @@ -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 完成。双发幂等。
Expand Down
25 changes: 11 additions & 14 deletions aimux-core/src/image_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,28 +181,29 @@ 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
/// function of `modelId`; in Rust the provider resolves it to a plain
/// `Option<u32>`.
fn max_images_per_call(&self) -> Option<u32>;

/// Whether image file inputs are supported; `None` means unknown.
fn supports_file_inputs(&self) -> Option<bool> {
None
}

/// Whether mask inputs are supported; `None` means unknown.
fn supports_mask_inputs(&self) -> Option<bool> {
None
}

/// Generate an array of images.
///
/// Naming: the `do_` prefix prevents accidental direct usage by users.
Expand All @@ -220,11 +221,7 @@ pub async fn generate_image(
) -> Result<ImageResult, AiMuxError> {
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(),
Expand Down
Loading