Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ea41d73
R.1: remove body_overrides; report it as an unknown field (A6 item 3)
cunninghamcard-bit Oct 11, 2026
6f0aa36
node: ProviderConfig via serde deny_unknown_fields (single source of …
cunninghamcard-bit Oct 11, 2026
5ac114d
R.2: remove model-level retry_config; decisions retry on core maxRetr…
cunninghamcard-bit Oct 11, 2026
8cfdf46
R.2 tests: zero-delay retry tests in aimux-core; one real retry run i…
cunninghamcard-bit Oct 11, 2026
ff8fae7
R.3: no implicit <PROVIDER>_API_KEY; keep native default env at reque…
cunninghamcard-bit Oct 11, 2026
2c57b5f
R.4: remove config_snapshot and api_key_source; decision recordings k…
cunninghamcard-bit Oct 11, 2026
f5e72b0
R.4: drop ProviderRecord base_url/profile/provider_options (identity …
cunninghamcard-bit Oct 11, 2026
e8bfc0a
node: refuse bad ProviderConfig in from_napi_value, no error state in…
cunninghamcard-bit Oct 11, 2026
6afe38f
Merge commit 'e8bfc0aa' into HEAD
cunninghamcard-bit Oct 11, 2026
a59fdbc
jev test: end-to-end retry exhaustion for 429 and 529 too (max_retrie…
cunninghamcard-bit Oct 11, 2026
d62bd00
Merge commit 'a59fdbc8b5bb7af7cbf481ab726f590c4b896bf2' into HEAD
cunninghamcard-bit Oct 11, 2026
82dc7dc
provider_handle: test-only Handles under #[cfg(test)], drop #[serial]…
cunninghamcard-bit Oct 11, 2026
c2f2187
Merge commit '82dc7dc0' into HEAD
cunninghamcard-bit Oct 11, 2026
6afdee6
node: config errors reject the promise; take config as raw JSON
cunninghamcard-bit Oct 11, 2026
94ed020
Merge commit '6afdee6bdf4fe1d04c8e50cffcf4b43c8cb6aded' into HEAD
cunninghamcard-bit Oct 11, 2026
f6d3673
Merge commit '94ed02015a86ba568158eee409e3bfbacb1a8992' into HEAD
cunninghamcard-bit Oct 11, 2026
3fadd76
Merge commit 'f6d3673fdb4335d688c4fad351c4a12713f663a7' into HEAD
cunninghamcard-bit Oct 11, 2026
961ac53
node: tests for boolean config (rejects) and null config (resolves as…
cunninghamcard-bit Oct 11, 2026
4f7b993
Merge branch 'exp/u1R.1-remove-body-overrides' into exp/u1R.2-remove-…
cunninghamcard-bit Oct 11, 2026
6eb98b4
Merge branch 'exp/u1R.2-remove-retry-config' into exp/u1R.3-no-implic…
cunninghamcard-bit Oct 11, 2026
b1319e7
Merge branch 'exp/u1R.3-no-implicit-provider-key' into exp/u1R.4-remo…
cunninghamcard-bit Oct 11, 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
152 changes: 131 additions & 21 deletions aimux-core/src/decision_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -550,20 +550,6 @@ pub trait DecisionModel: Send + Sync {
fn provider(&self) -> &str;
fn model_id(&self) -> &str;
fn capabilities(&self) -> DecisionCapabilities;
/// The provider snapshot written into decision recordings (#226): identity
/// plus connection metadata (base URL, credential *source*, capabilities,
/// redacted options). Never carries a credential value.
fn config_snapshot(&self) -> crate::recording::ProviderRecord {
let mut snapshot =
crate::recording::ProviderRecord::from_model(self.provider(), self.model_id());
snapshot.profile = Some(serde_json::json!({"decision_capabilities": self.capabilities()}));
snapshot
}
/// Retry settings for [`decide`] (#226). A per-call `max_retries`
/// overrides only the retry count.
fn retry_config(&self) -> retry::RetryConfig {
retry::RetryConfig::default()
}
/// Perform one provider attempt.
/// # Errors
/// Returns provider, transport or invalid response errors.
Expand All @@ -575,8 +561,18 @@ pub trait DecisionModel: Send + Sync {
/// Returns invalid request, unsupported capability, provider, invalid response,
/// retry exhaustion, timeout or caller abort errors.
pub async fn decide(
model: &dyn DecisionModel,
options: DecisionCallOptions,
) -> Result<DecisionResult, AiMuxError> {
decide_with_retry_delay(model, options, None).await
}

/// [`decide`] with an optional crate-internal override of the first retry
/// delay (tests only; `None` keeps the core default).
async fn decide_with_retry_delay(
model: &dyn DecisionModel,
mut options: DecisionCallOptions,
initial_retry_delay: Option<std::time::Duration>,
) -> Result<DecisionResult, AiMuxError> {
options.validate()?;
let capabilities = model.capabilities();
Expand Down Expand Up @@ -648,11 +644,14 @@ 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_with_config(
options.max_retries,
model.retry_config(),
abort_signal.clone(),
);
let retries = retry::prepare_retries(options.max_retries, abort_signal.clone());
#[cfg(test)]
let retries = match initial_retry_delay {
Some(delay) => retries.with_initial_delay(delay),
None => retries,
};
#[cfg(not(test))]
let _ = initial_retry_delay;
let context = crate::recording::recorder().map(|recorder| {
let ctx =
crate::recording::RecordingContext::new(crate::recording::new_call_id(), recorder);
Expand All @@ -664,8 +663,10 @@ pub async fn decide(
model.model_id(),
&capabilities,
);
ctx.recorder
.record_provider(&ctx.call_id, &model.config_snapshot().redacted());
ctx.recorder.record_provider(
&ctx.call_id,
&crate::recording::ProviderRecord::from_model(model.provider(), model.model_id()),
);
ctx
});
let result = timeout::run(
Expand Down Expand Up @@ -721,3 +722,112 @@ pub async fn decide(
}
result
}

#[cfg(test)]
mod retry_tests {
//! Decision retries use only the core call-level `max_retries` (A6 item 2).
//! The first retry delay is set to zero through the crate-internal
//! [`retry::PreparedRetries::with_initial_delay`], so no test waits.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;

use super::*;
use crate::error::{ApiCallError, RetryErrorReason};

struct FailingModel {
calls: AtomicUsize,
status: u16,
retryable: bool,
}

#[async_trait]
impl DecisionModel for FailingModel {
fn provider(&self) -> &str {
"test"
}
fn model_id(&self) -> &str {
"test"
}
fn capabilities(&self) -> DecisionCapabilities {
DecisionCapabilities {
supports_images: false,
max_images: None,
supports_typed_choices: false,
rounding: DecisionRounding::default(),
probability_source: DecisionProbabilitySource::ModelEstimate,
supports_boolean: true,
supports_choice: true,
supports_score: true,
returns_distributions: false,
max_questions: None,
min_choices: None,
max_choices: None,
max_score_levels: None,
}
}
async fn do_decide(&self, _: &DecisionCallOptions) -> Result<DecisionResult, AiMuxError> {
self.calls.fetch_add(1, Ordering::SeqCst);
Err(AiMuxError::ApiCall(Box::new(ApiCallError {
status_code: Some(self.status),
is_retryable: self.retryable,
..ApiCallError::new("failure", "https://example.test", serde_json::json!({}))
})))
}
}

fn options(max_retries: Option<u32>) -> DecisionCallOptions {
let mut options: DecisionCallOptions = serde_json::from_value(serde_json::json!({
"state": "text",
"questions": [{"id": "q", "type": "boolean", "instructions": "test"}]
}))
.unwrap();
options.max_retries = max_retries;
options
}

async fn run(status: u16, retryable: bool, max_retries: Option<u32>) -> (usize, AiMuxError) {
let model = FailingModel {
calls: AtomicUsize::new(0),
status,
retryable,
};
let error = decide_with_retry_delay(&model, options(max_retries), Some(Duration::ZERO))
.await
.unwrap_err();
(model.calls.load(Ordering::SeqCst), error)
}

#[tokio::test]
async fn call_level_max_retries_sets_exact_attempts_until_exhausted() {
for status in [429, 503, 529] {
for (max_retries, attempts) in [(Some(1), 2), (Some(3), 4), (None, 3)] {
let (calls, error) = run(status, true, max_retries).await;
assert_eq!(
calls, attempts,
"status {status}, max_retries {max_retries:?}"
);
let AiMuxError::Retry(retry) = &error else {
panic!("expected retry exhaustion, got {error:?}");
};
assert_eq!(retry.reason, RetryErrorReason::MaxRetriesExceeded);
assert_eq!(retry.errors.len(), attempts);
let AiMuxError::ApiCall(last) = retry.last_error() else {
panic!("expected API error");
};
assert_eq!(last.status_code, Some(status));
}
}
}

#[tokio::test]
async fn zero_max_retries_and_non_retryable_errors_make_one_attempt() {
let (calls, error) = run(503, true, Some(0)).await;
assert_eq!(calls, 1);
assert!(matches!(&error, AiMuxError::ApiCall(e) if e.status_code == Some(503)));
for status in [401, 422, 499] {
let (calls, error) = run(status, false, Some(3)).await;
assert_eq!(calls, 1, "status {status}");
assert!(matches!(&error, AiMuxError::ApiCall(e) if e.status_code == Some(status)));
}
}
}
58 changes: 23 additions & 35 deletions aimux-core/src/recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,22 +210,6 @@ pub struct ProviderRecord {
pub provider: String,
/// `model.model_id()`,如 "gpt-4o"。
pub model_id: String,
/// Decision connection snapshot (#226, RFC-0038 录制): the provider base
/// URL. Informational only: replay takes the endpoint from the caller's
/// registry. Absent for every other operation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
/// Where the credential came from, never its value: `"env:VAR"`,
/// `"explicit"` or `"none"` (#226).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_source: Option<String>,
/// Decision capabilities and default protocol (#226).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<serde_json::Value>,
/// Connection options (headers, retries); sensitive headers are redacted
/// when recorded (#226).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_options: Option<serde_json::Value>,
}

impl ProviderRecord {
Expand All @@ -236,10 +220,6 @@ impl ProviderRecord {
provider_id: provider_id.to_string(),
provider: provider.to_string(),
model_id: model_id.to_string(),
base_url: None,
api_key_source: None,
profile: None,
provider_options: None,
}
}

Expand All @@ -251,14 +231,6 @@ impl ProviderRecord {
let provider_id = provider.split('.').next().unwrap_or_default();
Self::new(provider_id, provider, model_id)
}

/// The same record with sensitive keys in `provider_options` redacted:
/// what a recorder may write.
#[must_use]
pub fn redacted(mut self) -> Self {
self.provider_options = self.provider_options.map(redact_json);
self
}
}

/// ③ HTTP 侧:单次 attempt 的 wire 交换。
Expand Down Expand Up @@ -664,13 +636,6 @@ pub fn is_sensitive_key(name: &str) -> bool {
|| squashed.contains("apikey")
}

/// Redact sensitive keys of a provider-options snapshot (#226): what
/// [`ProviderRecord::redacted`] applies, for providers building one.
#[must_use]
pub fn redact_provider_options(value: serde_json::Value) -> serde_json::Value {
redact_json(value)
}

/// 递归脱敏(JSON 中含敏感键的项值替换为 `[REDACTED]`)。
/// 覆盖 `CallOptions.headers`/`provider_options` 任意层级。
pub(crate) fn redact_json(value: serde_json::Value) -> serde_json::Value {
Expand Down Expand Up @@ -2001,6 +1966,29 @@ mod tests {
)
}

/// Recordings written before R.4 carried #226's `baseUrl` / `profile` /
/// `providerOptions` in the provider record. `ProviderRecord` is not
/// `deny_unknown_fields`, so they still load (and replay) as identity.
#[test]
fn provider_record_ignores_removed_snapshot_fields() {
let old = serde_json::json!({
"providerId": "openai",
"provider": "openai",
"modelId": "gpt-6-luna",
"baseUrl": "https://api.openai.com/v1",
"apiKeySource": "env:OPENAI_API_KEY",
"profile": {"decision_capabilities": {}},
"providerOptions": {"headers": {"x-api-key": "[REDACTED]"}}
});
let record: ProviderRecord = serde_json::from_value(old).unwrap();
assert_eq!(record.provider_id, "openai");
assert_eq!(record.model_id, "gpt-6-luna");
assert_eq!(
serde_json::to_value(&record).unwrap(),
serde_json::json!({"providerId": "openai", "provider": "openai", "modelId": "gpt-6-luna"})
);
}

#[test]
fn gate_is_off_by_default() {
assert!(recorder().is_none());
Expand Down
50 changes: 8 additions & 42 deletions aimux-core/src/retry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,31 +15,6 @@ const DEFAULT_INITIAL_DELAY_MS: u64 = 2_000;
/// Default multiplier applied to the delay after each retry.
const DEFAULT_BACKOFF_FACTOR: u32 = 2;

/// Retry settings a decision model may carry (#226,
/// [`DecisionModel::retry_config`](crate::DecisionModel::retry_config)).
///
/// A 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(DEFAULT_INITIAL_DELAY_MS),
backoff_factor: DEFAULT_BACKOFF_FACTOR,
}
}
}

/// A prepared operation retry function and its resolved retry count.
///
/// This is the Rust representation of the AI SDK's
Expand Down Expand Up @@ -70,25 +45,16 @@ pub(crate) fn prepare_retries(
}
}

/// Bind a model's retry settings, applying a per-call retry-count override.
#[must_use]
pub(crate) fn prepare_retries_with_config(
max_retries: Option<u32>,
mut config: RetryConfig,
abort: Option<AbortSignal>,
) -> 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: abort,
impl PreparedRetries {
/// Replace the delay before the first retry. Crate-internal: only this
/// crate's tests use it (zero delay); callers set nothing but `max_retries`.
#[cfg(test)]
#[must_use]
pub(crate) fn with_initial_delay(mut self, delay: Duration) -> Self {
self.initial_delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX);
self
}
}

impl PreparedRetries {
/// Retry a complete operation using API-call retryability and response hints.
///
/// # Errors
Expand Down
45 changes: 0 additions & 45 deletions aimux-providers/src/decision_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,51 +37,6 @@ impl DecisionEndpoint {
Ok(base.trim_end_matches('/').to_string())
}

fn headers(&self) -> &crate::shared::ProviderHeaders {
match self {
Self::OpenAI(config) => &config.headers,
Self::Compatible(config) => &config.headers,
}
}

/// #226's decision connection snapshot: identity, base URL, credential
/// *source* and the connection headers/retries. The credential value is
/// never read; sensitive headers are redacted again when recorded.
pub(crate) fn snapshot(
&self,
provider: &str,
model_id: &str,
retry_config: aimux_core::retry::RetryConfig,
profile: Value,
) -> aimux_core::recording::ProviderRecord {
use crate::shared::Credential;
let headers = self.headers();
let mut record = aimux_core::recording::ProviderRecord::from_model(provider, model_id);
record.base_url = self.base_url().ok();
record.api_key_source = Some(match &headers.credential {
Credential::None => "none".into(),
Credential::Explicit(_) => "explicit".into(),
Credential::Env { var, .. } => format!("env:{var}"),
});
let mut wire_headers = serde_json::Map::new();
for (name, value) in &headers.fixed {
wire_headers.insert(name.clone(), Value::String(value.clone()));
}
for (name, value) in headers.user.iter().flatten() {
if let Some(value) = value {
wire_headers.insert(name.clone(), Value::String(value.clone()));
}
}
record.provider_options = Some(aimux_core::recording::redact_provider_options(
serde_json::json!({
"headers": wire_headers,
"max_retries": retry_config.max_retries,
}),
));
record.profile = Some(profile);
record
}

async fn request_headers(
&self,
call_headers: Option<&HashMap<String, String>>,
Expand Down
Loading
Loading