From cef1608f3d8861b47f93c82ddd7a766bae705621 Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Wed, 7 Oct 2026 01:16:32 +0800 Subject: [PATCH 01/10] refactor(providers)!: remove the by-name provider entries; bindings go through create_provider What: aimux_providers::provider (provider, provider_handle, register_provider, is_external_provider, load_providers_from_json, ExternalProviderEntry, ProviderProfile, ProviderOptions and the JSON overlay registration) is deleted. The ffi exports aimux_provider_new, aimux_provider_from_env and aimux_provider_handle_new build their provider through create_provider with PresetSettings; aimux_register_providers is removed from the C ABI and header. The Node and Python by-name functions keep their names and parameters and use the same path; their registerProviders / register_providers entries are removed. Unknown keys in the by-name config are now rejected. Deletes presets_test.rs and the Node provider_config test, which pinned the old registry. Why: one path for provider construction, the per-package factories plus create_provider and the registry; upstream has no by-name overlay. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- aimux-ffi/aimux-ffi.h | 15 +- aimux-ffi/src/lib.rs | 96 ++-- aimux-ffi/tests/error_detail_test.rs | 18 +- aimux-ffi/tests/exports_smoke_test.rs | 24 +- aimux-providers/src/lib.rs | 3 - aimux-providers/src/provider.rs | 524 ------------------ aimux-providers/tests/presets_test.rs | 230 -------- .../node/__test__/provider_config.test.ts | 245 -------- bindings/node/index.d.ts | 11 - bindings/node/index.js | 1 - bindings/node/src/lib.rs | 123 ++-- bindings/python/python/aimux/__init__.py | 2 - bindings/python/src/lib.rs | 112 ++-- docs/api/c.md | 1 - 14 files changed, 183 insertions(+), 1222 deletions(-) delete mode 100644 aimux-providers/src/provider.rs delete mode 100644 aimux-providers/tests/presets_test.rs delete mode 100644 bindings/node/__test__/provider_config.test.ts diff --git a/aimux-ffi/aimux-ffi.h b/aimux-ffi/aimux-ffi.h index e15e291d5..5a8240328 100644 --- a/aimux-ffi/aimux-ffi.h +++ b/aimux-ffi/aimux-ffi.h @@ -176,11 +176,11 @@ aimux_error_t *aimux_azure_new_with_base(const char *api_key, const char *base_u * var from the registry entry. Non-NULL but non-UTF-8 is * a C ABI failure. * @param model_id NUL-terminated model ID. - * @param config_json Optional JSON object of ProviderOptions + * @param config_json Optional JSON object of settings * ({"base_url": "...", "headers": {...}, "organization": "...", - * "project": "..."}); "max_retries" (call-level) and - * "body_overrides" (removed) are rejected as invalid - * arguments. NULL / empty / "null" for defaults. + * "project": "...", "params": {...}}); any other key + * ("max_retries" is call-level, "body_overrides" is gone) + * is an invalid argument. NULL / empty / "null" for defaults. * @param out_handle Receives the model handle. AiMuxError: unknown * provider, bad config, missing env key, invalid model id. */ @@ -696,13 +696,6 @@ void aimux_recording_flush(void); AIMUX_E_RECORDING_FLUSH_TIMEOUT (5) — no writer ack within 30s */ aimux_error_t *aimux_recording_try_flush(void); -/* [AiMuxError] Register external OpenAI-compatible providers from a JSON config - string (RFC-0020). config_json is { "providers": [ { "name", "base_url", - ... } ] }. Entries override same-named built-ins or add new ones. Malformed - JSON text is a C ABI failure; a well-formed document the registry - rejects (bad schema, unknown protocol) is AiMuxError AIMUX_E_INVALID_ARGUMENT. */ -aimux_error_t *aimux_register_providers(const char *config_json); - /* [AiMuxError] Set the global proxy configuration (M6, RFC-0016). Must be called before the first generate_text / stream_text call; a no-op if the shared HTTP client is already initialised. config_json is a serialized diff --git a/aimux-ffi/src/lib.rs b/aimux-ffi/src/lib.rs index a6cc00d02..be9b437e3 100644 --- a/aimux-ffi/src/lib.rs +++ b/aimux-ffi/src/lib.rs @@ -81,10 +81,10 @@ use aimux_providers::cohere::{CohereProviderSettings, create_cohere}; use aimux_providers::google::{GoogleProviderSettings, create_google}; use aimux_providers::mistral::{MistralProviderSettings, create_mistral}; use aimux_providers::openai::{OpenAIProviderSettings, create_openai}; -use aimux_providers::provider::{ProviderOptions, provider, provider_handle}; use aimux_providers::tavily::{TavilyProvider, TavilyProviderSettings, create_tavily}; use aimux_providers::vertex::{VertexProviderSettings, create_google_vertex}; use aimux_providers::xai::{XAIProviderSettings, create_xai}; +use aimux_providers::{PresetSettings, create_provider}; use futures::StreamExt; use tokio::runtime::Runtime; @@ -973,17 +973,52 @@ fn parse_opts_arg(opts_json: *const c_char) -> FfiResult { } } -/// Parse the optional `config_json` argument (`ProviderOptions`); NULL, -/// empty or "null" means unset. -fn parse_provider_options(config_json: *const c_char) -> FfiResult> { - match opt_str_arg(config_json, "config_json")? { +/// The optional `config_json` of the by-name constructors, as the settings of +/// the selected factory. NULL, empty or "null" means no settings. An unknown +/// key (`max_retries`, `body_overrides`, ...) is `InvalidArgument`. +fn parse_provider_settings( + api_key: Option, + config_json: *const c_char, +) -> FfiResult { + #[derive(serde::Deserialize, Default)] + #[serde(deny_unknown_fields)] + struct Config { + base_url: Option, + headers: Option>, + organization: Option, + project: Option, + params: Option>, + } + let config = match opt_str_arg(config_json, "config_json")? { Some(s) if !s.trim().is_empty() && s.trim() != "null" => { - serde_json::from_str::(&s) - .map(Some) - .map_err(|e| wire_err("config_json", e)) + serde_json::from_str::(&s).map_err(|e| wire_err("config_json", e))? + } + _ => Config::default(), + }; + // Organization and project become their headers, below the caller's own. + let mut headers = aimux_provider_utils::HeaderMapOpt::new(); + for (name, value) in [ + ("OpenAI-Organization", config.organization), + ("OpenAI-Project", config.project), + ] { + if let Some(value) = value { + headers.insert(name.to_string(), Some(value)); } - _ => Ok(None), } + headers.extend( + config + .headers + .into_iter() + .flatten() + .map(|(k, v)| (k, Some(v))), + ); + Ok(PresetSettings { + api_key: api_key.map(aimux_provider_utils::Resolvable::Value), + base_url: config.base_url, + headers: (!headers.is_empty()).then_some(headers), + params: config.params.unwrap_or_default(), + ..PresetSettings::default() + }) } /// Read a config-style JSON C string and normalize it for lenient @@ -1584,10 +1619,10 @@ pub extern "C" fn aimux_xai_new_with_base( /// - `api_key` — may be NULL to read the provider's env var from the registry /// entry (replaces the retired `aimux_deepseek_new` etc.). /// - `model_id` — model id string. -/// - `config_json` — optional JSON object of `ProviderOptions` -/// (`{"base_url": "...", "headers": {...}, "organization": "...", "project": "..."}`); -/// NULL / empty / "null" for defaults. `max_retries` (call-level) and -/// `body_overrides` (removed) are rejected as invalid arguments. +/// - `config_json` — optional JSON object of settings +/// (`{"base_url": "...", "headers": {...}, "organization": "...", "project": "...", "params": {...}}`); +/// NULL / empty / "null" for defaults. Any other key (`max_retries` is a +/// call-level option, `body_overrides` is gone) is an invalid argument. /// /// AiMuxError: unknown provider, bad config shape, or invalid model id. #[unsafe(no_mangle)] @@ -1603,8 +1638,8 @@ pub extern "C" fn aimux_provider_new( let model_id = str_arg(model_id, "model_id")?; // NULL => the registry entry's env var; a non-NULL key must be UTF-8. let key = opt_str_arg(api_key, "api_key")?; - let opts = parse_provider_options(config_json)?; - let m = provider(&name, key, &model_id, opts)?; + let settings = parse_provider_settings(key, config_json)?; + let m = create_provider(&name, settings)?.language_model(&model_id)?; Ok(intern_model(m)) }) } @@ -1620,7 +1655,7 @@ pub extern "C" fn aimux_provider_from_env( with_out_handle(out_handle, || { let name = str_arg(name, "name")?; let model_id = str_arg(model_id, "model_id")?; - let m = provider_handle(&name, None, None)?.language_model(&model_id)?; + let m = create_provider(&name, PresetSettings::default())?.language_model(&model_id)?; Ok(intern_model(m)) }) } @@ -1636,7 +1671,7 @@ pub extern "C" fn aimux_provider_from_env( /// discovery) and `aimux_provider_model` (build a model from a discovered id). /// /// `api_key = null` reads the provider's env var from the registry entry. -/// `config_json` is an optional `ProviderOptions` JSON string (same as +/// `config_json` is an optional settings JSON string (same as /// `aimux_provider_new`). #[unsafe(no_mangle)] pub extern "C" fn aimux_provider_handle_new( @@ -1648,8 +1683,8 @@ pub extern "C" fn aimux_provider_handle_new( with_out_handle(out_handle, || { let name = str_arg(name, "name")?; let key = opt_str_arg(api_key, "api_key")?; - let opts = parse_provider_options(config_json)?; - let provider = provider_handle(&name, key, opts)?; + let settings = parse_provider_settings(key, config_json)?; + let provider = create_provider(&name, settings)?; Ok(intern_handle(HandleEntry::Provider(ProviderEntry { provider, }))) @@ -3359,29 +3394,6 @@ pub extern "C" fn aimux_recording_try_flush() -> *mut aimux_error_t { }) } -/// Register external OpenAI-compatible providers from a JSON config string -/// (RFC-0020). `config_json` is `{ "providers": [ { "name": ..., "base_url": -/// ..., ... }, ... ] }`. Entries override same-named built-ins or add new -/// ones. Malformed JSON text is `InvalidWireJson`; a well-formed document the -/// registry rejects is `AiMuxError::InvalidArgument`. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_register_providers(config_json: *const c_char) -> *mut aimux_error_t { - no_result(|| { - let json = str_arg(config_json, "config_json")?; - // Malformed JSON text is this layer's finding; a well-formed document - // that the registry rejects (bad schema, unknown protocol) is an AiMuxError. - serde_json::from_str::(&json).map_err(|e| wire_err("config_json", e))?; - aimux_providers::provider::load_providers_from_json(&json).map_err(|e| match e { - // The registry reports a schema mismatch as `JsonParse`; the text - // already parsed above, so what it rejected is the shape — - // `AiMuxError::InvalidArgument`, not a provider-response parse failure. - AiMuxError::JsonParse(m) => AiMuxError::InvalidArgument(format!("config_json: {m}")), - e => e, - })?; - Ok(()) - }) -} - /// Set the global proxy configuration (M6, RFC-0016). Must be called before /// the first `aimux_generate_text` / `aimux_stream_text` call; a no-op if the /// shared HTTP client is already initialised. diff --git a/aimux-ffi/tests/error_detail_test.rs b/aimux-ffi/tests/error_detail_test.rs index 915b8aa11..1def90c72 100644 --- a/aimux-ffi/tests/error_detail_test.rs +++ b/aimux-ffi/tests/error_detail_test.rs @@ -17,8 +17,8 @@ use aimux_ffi::{ aimux_consume_stream_text, aimux_drop_handle, aimux_error_t, aimux_generate_object, aimux_generate_text, aimux_google_image_new, aimux_google_video_new, aimux_init_proxy, aimux_openai_embedding_new, aimux_openai_files_new, aimux_openai_image_new, aimux_openai_new, - aimux_openai_speech_new, aimux_openai_transcription_new, aimux_provider_new, - aimux_register_providers, aimux_stream_text, aimux_tavily_search_new, + aimux_openai_speech_new, aimux_openai_transcription_new, aimux_provider_new, aimux_stream_text, + aimux_tavily_search_new, }; use common::{c, expect_aimux_error, expect_ffi_error, ok}; @@ -187,20 +187,6 @@ fn azure_null_deployment_is_an_ffi_error() { ); } -/// `register_providers`: malformed text is a C ABI failure; a well-formed -/// document the registry rejects is AiMuxError::InvalidArgument. -#[test] -fn register_providers_wire_vs_schema() { - let e = aimux_register_providers(c("{not json").as_ptr()); - assert!( - expect_ffi_error(e, "register_providers (wire)").starts_with("config_json: invalid JSON:") - ); - let e = aimux_register_providers(c(r#"{"providers": 42}"#).as_ptr()); - let (code, m) = expect_aimux_error(e, "register_providers (schema)"); - assert_eq!(code, AIMUX_E_INVALID_ARGUMENT); - assert!(m.contains("config_json:"), "{m}"); -} - // Keep abort helpers linked (smoke). #[test] fn abort_signal_new_drop_smoke() { diff --git a/aimux-ffi/tests/exports_smoke_test.rs b/aimux-ffi/tests/exports_smoke_test.rs index f4a5f037b..3d3e08d33 100644 --- a/aimux-ffi/tests/exports_smoke_test.rs +++ b/aimux-ffi/tests/exports_smoke_test.rs @@ -30,7 +30,7 @@ //! //! ## Coverage //! -//! All 123 `#[unsafe(no_mangle)]` exports in `src/lib.rs` are exercised (the +//! All 122 `#[unsafe(no_mangle)]` exports in `src/lib.rs` are exercised (the //! constructor and utility classes in full; the session class one //! representative call per export). [`header_and_exports_agree`] pins the //! count against the two headers. @@ -69,8 +69,8 @@ use aimux_ffi::{ aimux_openai_transcription_new, aimux_openai_transcription_new_with_base, aimux_provider_handle_new, aimux_provider_list_models, aimux_provider_model, aimux_provider_new, aimux_recording_flush, aimux_recording_stop, aimux_recording_try_flush, - aimux_register_providers, aimux_rerank, aimux_router_new, aimux_search, aimux_session_calls, - aimux_session_infer_init, aimux_session_store_init, aimux_speech_generate, aimux_stream_text, + aimux_rerank, aimux_router_new, aimux_search, aimux_session_calls, aimux_session_infer_init, + aimux_session_store_init, aimux_speech_generate, aimux_stream_text, aimux_stream_text_as_openai, aimux_stream_text_as_openai_with_abort, aimux_stream_text_with_abort, aimux_tavily_search_new, aimux_tavily_search_new_with_base, aimux_tool_call_repair_context, aimux_trace_aggregate, aimux_trace_clear, @@ -157,7 +157,7 @@ fn header_and_exports_agree() { exports.sort(); assert_eq!( exports.len(), - 123, + 122, "export count changed; update the headers" ); @@ -951,15 +951,15 @@ fn utility_exports_return_clean_values() { // aimux_drop_handle(0) is documented as a safe no-op. aimux_drop_handle(0); - // register_providers: valid overlay then a malformed config. - let valid = c( - r#"{"providers":[{"name":"ffi-smoke-provider","base_url":"http://127.0.0.1:1/v1","protocol":"openai_compat"}]}"#, - ); - ok( - aimux_register_providers(valid.as_ptr()), - "register_providers (valid)", + // A malformed provider config. + let mut unused = 0; + let e = aimux_provider_new( + c("groq").as_ptr(), + ptr::null(), + c("m").as_ptr(), + c("{not json").as_ptr(), + &mut unused, ); - let e = aimux_register_providers(c("{not json").as_ptr()); // Malformed JSON text is this C layer's finding. AiMuxError-only payload // getters answer their sentinels for its unified FFI code. assert!(!e.is_null()); diff --git a/aimux-providers/src/lib.rs b/aimux-providers/src/lib.rs index b99d90ae7..5e2507615 100644 --- a/aimux-providers/src/lib.rs +++ b/aimux-providers/src/lib.rs @@ -143,9 +143,6 @@ macro_rules! __optional_model { }; } -// Registry-backed provider construction: the embedded `provider_registry.json` -// is parsed once into `preset` descriptors and used by the by-name entry points. -pub mod provider; pub mod replay; pub use preset::{AuthMode, PresetDescriptor, PresetEntry, PresetSettings}; pub use replay::rebuild_provider; diff --git a/aimux-providers/src/provider.rs b/aimux-providers/src/provider.rs deleted file mode 100644 index 1a870a102..000000000 --- a/aimux-providers/src/provider.rs +++ /dev/null @@ -1,524 +0,0 @@ -//! By-name creation for the bindings. -//! -//! The Rust surface for creating providers is the factories, -//! [`create_provider`](crate::create_provider) and the registry. The FFI and -//! the Node / Python bindings still expose an older shape (a provider name -//! plus a JSON options object, and providers registered at runtime from a -//! JSON document); this module is that shape, implemented on -//! `create_provider`. It is not re-exported from the crate root and goes away -//! when the bindings mirror the Rust surface. - -use std::collections::HashMap; -use std::sync::{Arc, OnceLock, RwLock}; - -use serde::Deserialize; -use serde_json::Value; - -use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; -use aimux_provider_utils::{Resolvable, load_api_key, validate_base_url}; - -use crate::openai_compatible::config::{BaseUrl, ChatDialect}; -use crate::openai_compatible::{Assembly, ChatProfile, OpenAICompatibleProvider}; -use crate::preset::PresetSettings; -use crate::shared::Credential; - -/// Chat capabilities of an external provider entry, expressed as data -/// (RFC-0020). The `Default` impl and the per-field `#[serde(default = -/// "default_true")]` both yield `true` for the three `supports_*` flags, -/// matching the OpenAI-compatible baseline of the registry presets - so a -/// profile that is omitted entirely (or present but with individual fields -/// missing) stays at full capability. -#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] -pub struct ProviderProfile { - #[serde(default = "default_true")] - pub supports_top_k: bool, - #[serde(default = "default_true")] - pub supports_tools: bool, - #[serde(default = "default_true")] - pub supports_response_format: bool, - /// Streaming usage rides in `chunk[key].usage` instead of `chunk.usage`. - pub stream_usage_key: Option, - /// `"max_tokens"` or `"max_completion_tokens"`: the only max-token key the - /// endpoint accepts. Any other value is rejected at registration. - pub max_tokens_key: Option, -} - -impl Default for ProviderProfile { - fn default() -> Self { - Self { - supports_top_k: true, - supports_tools: true, - supports_response_format: true, - stream_usage_key: None, - max_tokens_key: None, - } - } -} - -fn default_true() -> bool { - true -} - -impl ProviderProfile { - /// The chat behavior this profile describes. - fn chat_profile(&self) -> Result { - let max_tokens_key = match self.max_tokens_key.as_deref() { - None => None, - Some("max_tokens") => Some("max_tokens"), - Some("max_completion_tokens") => Some("max_completion_tokens"), - Some(other) => { - return Err(AiMuxError::InvalidArgument(format!( - "profile.max_tokens_key must be \"max_tokens\" or \"max_completion_tokens\", \ - got {other:?}" - ))); - } - }; - let mut dialect = ChatDialect::baseline(); - dialect.supports_top_k = self.supports_top_k; - dialect.supports_tools = self.supports_tools; - dialect.supports_response_format = self.supports_response_format; - dialect.stream_usage_key = self.stream_usage_key.clone(); - dialect.max_tokens_key = max_tokens_key; - Ok(ChatProfile { - include_usage: true, - supports_structured_outputs: true, - dialect, - }) - } -} - -/// Per-call construction options for [`provider`] (overrides the registry entry). -/// -/// Deserializing a JSON object that carries `max_retries` or `body_overrides` -/// fails: retry is a call-level option (`CallOptions::max_retries`) and -/// request-body overrides are gone, so neither is silently ignored here. The -/// FFI `config_json` and the Node/Python configs reach this check and report it -/// as `InvalidArgument`. -#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] -#[serde(try_from = "ProviderOptionsWire")] -pub struct ProviderOptions { - /// Override the registry base URL. - pub base_url: Option, - /// Extra headers merged into every request. - pub headers: Option>, - /// OpenAI organization ID (`OpenAI-Organization` header). - pub organization: Option, - /// OpenAI project ID (`OpenAI-Project` header). - pub project: Option, - /// Values of the preset's template parameters (`account_id`, `region`, - /// `project`, ...); only the ones the registry row declares are accepted. - pub params: Option>, -} - -/// Wire shape of [`ProviderOptions`]: the same keys plus the two removed ones, -/// kept only so their presence can be reported instead of dropped. -#[derive(Deserialize)] -struct ProviderOptionsWire { - base_url: Option, - headers: Option>, - organization: Option, - project: Option, - params: Option>, - max_retries: Option, - body_overrides: Option, -} - -impl TryFrom for ProviderOptions { - type Error = String; - - fn try_from(wire: ProviderOptionsWire) -> Result { - if wire.max_retries.is_some() { - return Err(MAX_RETRIES_REJECTED.to_string()); - } - if wire.body_overrides.is_some() { - return Err(BODY_OVERRIDES_REJECTED.to_string()); - } - Ok(Self { - base_url: wire.base_url, - headers: wire.headers, - organization: wire.organization, - project: wire.project, - params: wire.params, - }) - } -} - -const MAX_RETRIES_REJECTED: &str = "`max_retries` is a call-level option: pass it in the call \ - options (`maxRetries`), not in the provider configuration"; - -const BODY_OVERRIDES_REJECTED: &str = "`body_overrides` is no longer supported: request-body \ - overrides were removed from the provider configuration and the call options"; - -/// Report a provider configuration that still carries `max_retries` or -/// `body_overrides`, for configuration shapes that are not [`ProviderOptions`] -/// JSON (the Node and Python `ProviderConfig`). Pass whether each key was -/// given. -/// -/// # Errors -/// -/// Returns [`AiMuxError::InvalidArgument`] naming the first removed key that -/// was given. -pub fn reject_removed_provider_options( - max_retries: bool, - body_overrides: bool, -) -> Result<(), AiMuxError> { - if max_retries { - return Err(AiMuxError::InvalidArgument(MAX_RETRIES_REJECTED.into())); - } - if body_overrides { - return Err(AiMuxError::InvalidArgument(BODY_OVERRIDES_REJECTED.into())); - } - Ok(()) -} - -/// A language model by provider name: [`provider_handle`] then -/// `language_model(model_id)`. -/// -/// # Errors -/// -/// As [`provider_handle`], plus whatever the provider returns for the id. -pub fn provider( - name: impl AsRef, - api_key: Option, - model_id: &str, - options: Option, -) -> Result, AiMuxError> { - let p = provider_handle(name, api_key, options)?; - p.language_model(model_id) -} - -// ── Runtime overlay layer (RFC-0020) ───────────────────────────────────────── - -/// A provider entry registered at runtime via [`register_provider`] / -/// [`load_providers_from_json`]. Overrides a same-named built-in entry (whole -/// replacement, not deep merge) or adds a new one. -/// -/// Only OpenAI-compatible providers can be registered this way - native -/// protocols (anthropic/google/bedrock...) are code implementations and cannot -/// be described by config data. The JSON form accepts exactly these fields; -/// `max_retries` and `body_overrides` (removed) and any unknown field are -/// `InvalidArgument`. -#[derive(Clone, Deserialize)] -pub struct ExternalProviderEntry { - /// Provider name used for `provider("name", ...)` lookup. Required. - pub name: String, - /// Human-readable name. Defaults to `name` if absent. - pub display: Option, - /// API base URL. Required, must be a valid `http(s)://` URL. - pub base_url: String, - /// Env var name to read the API key from. Optional. - pub env_var: Option, - /// `"env:VAR_NAME"` reference (recommended) or a literal key string - /// (supported but discouraged). Optional. - pub api_key: Option, - /// Protocol kind. Only `"openai_compat"` is accepted; other values error. - #[serde(default = "default_openai_compat")] - pub protocol: String, - /// Provider capability profile. All fields optional, defaults to full. - #[serde(default)] - pub profile: ProviderProfile, - // --- Fields equivalent to ProviderOptions (provider-level config) --- - /// Extra headers merged into every request. - pub headers: Option>, - /// OpenAI organization ID (`OpenAI-Organization` header). - pub organization: Option, - /// OpenAI project ID (`OpenAI-Project` header). - pub project: Option, - /// Free-form note for the user; the library ignores this. - pub comment: Option, -} - -impl std::fmt::Debug for ExternalProviderEntry { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ExternalProviderEntry") - .field("name", &self.name) - .field("display", &self.display) - .field("base_url", &self.base_url) - .field("env_var", &self.env_var) - .field("api_key", &self.api_key.is_some()) - .field("protocol", &self.protocol) - .field("profile", &self.profile) - .field("headers", &self.headers.as_ref().map(HashMap::len)) - .field("organization", &self.organization) - .field("project", &self.project) - .field("comment", &self.comment) - .finish() - } -} - -fn default_openai_compat() -> String { - "openai_compat".to_string() -} - -#[derive(Deserialize)] -struct ProvidersConfig { - providers: Vec, -} - -/// The keys of an external entry's JSON object. -const EXTERNAL_ENTRY_KEYS: [&str; 11] = [ - "name", - "display", - "base_url", - "env_var", - "api_key", - "protocol", - "profile", - "headers", - "organization", - "project", - "comment", -]; - -static OVERLAYS: OnceLock>> = OnceLock::new(); - -fn overlays() -> &'static RwLock> { - OVERLAYS.get_or_init(|| RwLock::new(HashMap::new())) -} - -/// Register (or replace) an external provider entry. -/// -/// Validation failures (empty name, non-`http(s)://` base_url, templated -/// base_url, unsupported protocol, unusable profile) return -/// [`AiMuxError::InvalidArgument`] - they never panic. -/// -/// # Errors -/// -/// Returns [`AiMuxError::InvalidArgument`] when entry validation fails (empty -/// name, non-`http(s)://` base URL, unsupported protocol). -pub fn register_provider(entry: ExternalProviderEntry) -> Result<(), AiMuxError> { - validate_external_entry(&entry)?; - let mut overlays = overlays().write().unwrap(); - overlays.insert(entry.name.clone(), entry); - Ok(()) -} - -/// Whether `name` was registered at runtime via [`register_provider`] / -/// [`load_providers_from_json`] (RFC-0020 overlay). Used by the replay path -/// to recognize externally-registered OpenAI-compatible providers. -#[must_use] -pub fn is_external_provider(name: &str) -> bool { - overlays().read().unwrap().contains_key(name) -} - -/// Load and register multiple external providers from a JSON string -/// (`{ "providers": [ ... ] }`). Useful for binding-layer pass-through. -/// -/// # Errors -/// -/// Returns `AiMuxError::JsonParse` for malformed JSON or a document of the -/// wrong shape, `InvalidArgument` for an entry carrying a removed -/// (`max_retries`, `body_overrides`) or unknown field, and propagates each -/// entry's `register_provider` validation error. -pub fn load_providers_from_json(json: &str) -> Result<(), AiMuxError> { - let value: Value = serde_json::from_str(json).map_err(|e| { - AiMuxError::JsonParse(format!("failed to parse external providers config: {e}")) - })?; - if let Some(entries) = value.get("providers").and_then(Value::as_array) { - for entry in entries.iter().filter_map(Value::as_object) { - let name = entry - .get("name") - .and_then(Value::as_str) - .unwrap_or(""); - reject_removed_provider_options( - entry.contains_key("max_retries"), - entry.contains_key("body_overrides"), - ) - .map_err(|e| AiMuxError::InvalidArgument(format!("external provider '{name}': {e}")))?; - if let Some(unknown) = entry - .keys() - .find(|key| !EXTERNAL_ENTRY_KEYS.contains(&key.as_str())) - { - return Err(AiMuxError::InvalidArgument(format!( - "external provider '{name}' has an unknown field `{unknown}`; accepted \ - fields: {}", - EXTERNAL_ENTRY_KEYS.join(", ") - ))); - } - } - } - let config: ProvidersConfig = serde_json::from_value(value).map_err(|e| { - AiMuxError::JsonParse(format!("failed to parse external providers config: {e}")) - })?; - for entry in config.providers { - register_provider(entry)?; - } - Ok(()) -} - -/// Validate an external entry before inserting it into the overlay. -fn validate_external_entry(entry: &ExternalProviderEntry) -> Result<(), AiMuxError> { - if entry.name.trim().is_empty() { - return Err(AiMuxError::InvalidArgument( - "external provider entry missing `name`".into(), - )); - } - if entry.base_url.trim().is_empty() { - return Err(AiMuxError::InvalidArgument(format!( - "external provider '{}' missing `base_url`", - entry.name - ))); - } - if !(entry.base_url.starts_with("https://") || entry.base_url.starts_with("http://")) { - return Err(AiMuxError::InvalidArgument(format!( - "external provider '{}' base_url must start with http(s)://, got {:?}", - entry.name, entry.base_url - ))); - } - if entry.base_url.contains(['{', '}', '<', '>']) { - return Err(AiMuxError::InvalidArgument(format!( - "external provider '{}' base_url {:?} has a placeholder; an external entry takes a \ - concrete URL", - entry.name, entry.base_url - ))); - } - if entry.protocol != "openai_compat" { - return Err(AiMuxError::InvalidArgument(format!( - "external provider '{}' has unsupported protocol {:?}; only \"openai_compat\" is supported", - entry.name, entry.protocol - ))); - } - entry.profile.chat_profile().map_err(|e| { - AiMuxError::InvalidArgument(format!("external provider '{}': {e}", entry.name)) - })?; - Ok(()) -} - -// ── By-name creation for the bindings ──────────────────────────────────────── - -/// Create a provider by name for a binding: a runtime-registered external -/// entry first, otherwise [`create_provider`](crate::create_provider) (the -/// vendor packages and the registry rows). -/// -/// # Errors -/// -/// [`AiMuxError::NoSuchProvider`] for an unknown name, `InvalidArgument` for -/// invalid options. -pub fn provider_handle( - name: impl AsRef, - api_key: Option, - options: Option, -) -> Result, AiMuxError> { - let name = name.as_ref(); - let external = overlays().read().unwrap().get(name).cloned(); - match external { - Some(entry) => Ok(Arc::new(build_external(&entry, api_key, options)?)), - None => crate::create_provider(name, preset_settings(api_key, options)), - } -} - -/// `PresetSettings` from the by-name arguments. Organization and project -/// become their headers, below the caller's own headers. -fn preset_settings(api_key: Option, options: Option) -> PresetSettings { - let mut settings = PresetSettings { - api_key: api_key.map(Resolvable::Value), - ..PresetSettings::default() - }; - if let Some(options) = options { - let mut headers = aimux_provider_utils::HeaderMapOpt::new(); - if let Some(org) = options.organization { - headers.insert("OpenAI-Organization".to_string(), Some(org)); - } - if let Some(project) = options.project { - headers.insert("OpenAI-Project".to_string(), Some(project)); - } - for (name, value) in options.headers.into_iter().flatten() { - headers.insert(name, Some(value)); - } - settings.base_url = options.base_url; - settings.headers = (!headers.is_empty()).then_some(headers); - settings.params = options.params.unwrap_or_default(); - } - settings -} - -/// An external entry as a compatible provider: its profile, its URL (the -/// per-call override wins), its headers and its key, which is resolved now. -fn build_external( - entry: &ExternalProviderEntry, - api_key: Option, - options: Option, -) -> Result { - let display = entry.display.clone().unwrap_or_else(|| entry.name.clone()); - let options = options.unwrap_or_default(); - if let Some(params) = &options.params - && !params.is_empty() - { - return Err(AiMuxError::InvalidArgument(format!( - "external provider '{}' has no template parameters; `params` does not apply", - entry.name - ))); - } - - let key = resolve_external_key(entry, &display, api_key)?; - - let mut fixed_headers = Vec::new(); - if let Some(org) = options.organization.or_else(|| entry.organization.clone()) { - fixed_headers.push(("OpenAI-Organization".to_string(), org)); - } - if let Some(project) = options.project.or_else(|| entry.project.clone()) { - fixed_headers.push(("OpenAI-Project".to_string(), project)); - } - let headers = options - .headers - .or_else(|| entry.headers.clone()) - .map(|headers| headers.into_iter().map(|(k, v)| (k, Some(v))).collect()); - let base_url = validate_base_url(options.base_url.as_deref().unwrap_or(&entry.base_url))?; - - OpenAICompatibleProvider::assemble(Assembly { - name: entry.name.clone(), - base_url: BaseUrl::Fixed(base_url), - credential: Credential::Explicit(Resolvable::Value(key)), - fixed_headers, - headers, - query_params: None, - fetch: None, - transform_request_body: None, - profile: entry.profile.chat_profile()?, - }) -} - -/// Resolve the api key of an external entry. -/// -/// Priority: explicit `api_key` parameter > entry-level `api_key` field -/// (supports `"env:VAR"` references, resolved against the environment) > -/// entry `env_var` (read from the environment via [`load_api_key`]). -fn resolve_external_key( - entry: &ExternalProviderEntry, - display: &str, - api_key: Option, -) -> Result { - if let Some(key) = api_key { - return Ok(key); - } - if let Some(entry_key) = &entry.api_key - && !entry_key.is_empty() - { - // Entry-level key: "env:VAR" → read env; otherwise treat as literal. - if let Some(var) = entry_key.strip_prefix("env:") { - if var.is_empty() { - return Err(AiMuxError::InvalidArgument(format!( - "external provider '{}' has malformed api_key reference {:?} (empty var name)", - entry.name, entry_key - ))); - } - let val = std::env::var(var).map_err(|_| { - AiMuxError::InvalidArgument(format!( - "external provider '{}' references env var `{var}` via api_key, but it is not set", - entry.name - )) - })?; - return Ok(val); - } - return Ok(entry_key.clone()); - } - match entry.env_var.as_deref().filter(|var| !var.is_empty()) { - Some(var) => load_api_key(None, var, display), - None => Err(AiMuxError::InvalidArgument(format!( - "provider '{}' has no api_key parameter, entry-level api_key, or env_var to read from", - entry.name - ))), - } -} diff --git a/aimux-providers/tests/presets_test.rs b/aimux-providers/tests/presets_test.rs deleted file mode 100644 index aba8f4eaa..000000000 --- a/aimux-providers/tests/presets_test.rs +++ /dev/null @@ -1,230 +0,0 @@ -//! Runtime registry validation and by-name preset behavior. - -use std::collections::{BTreeMap, BTreeSet, VecDeque}; -use std::sync::{Arc, Mutex}; - -use async_trait::async_trait; -use bytes::Bytes; -use reqwest::StatusCode; -use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue}; -use serde_json::json; -use serial_test::serial; - -use aimux_core::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::LanguageModelMessage; -use aimux_core::options::CallOptions; -use aimux_provider_utils::{Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse}; -use aimux_providers::preset; -use aimux_providers::{PresetSettings, create_provider}; - -// ── injected transport ─────────────────────────────────────────────────────── - -/// One request as the transport saw it. -#[derive(Clone, Debug)] -struct Seen { - url: String, - /// Lower-cased names (the `http` crate normalizes them). - headers: BTreeMap, -} - -#[derive(Default)] -struct MockFetch { - canned: Mutex>>, - seen: Mutex>, -} - -impl MockFetch { - fn new(canned: Vec>) -> Arc { - Arc::new(Self { - canned: Mutex::new(canned.into()), - seen: Mutex::default(), - }) - } - - fn seen(&self) -> Vec { - self.seen.lock().unwrap().clone() - } - - fn transport(self: &Arc) -> FetchFunction { - self.clone() - } -} - -#[async_trait] -impl Fetch for MockFetch { - async fn fetch(&self, request: FetchRequest) -> Result { - self.seen.lock().unwrap().push(Seen { - url: request.url.to_string(), - headers: request - .headers - .iter() - .map(|(name, value)| { - ( - name.as_str().to_string(), - value.to_str().expect("ASCII header").to_string(), - ) - }) - .collect(), - }); - let canned = self - .canned - .lock() - .unwrap() - .pop_front() - .ok_or_else(|| FetchError::Other("no canned response left".into()))?; - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - Ok(FetchResponse::from_bytes( - StatusCode::OK, - headers, - request.url, - Bytes::from(canned), - )) - } -} - -/// Temporarily change an environment variable; used by serial tests. -struct EnvVar { - name: &'static str, - saved: Option, -} - -impl EnvVar { - fn set(name: &'static str, value: Option<&str>) -> Self { - let saved = std::env::var(name).ok(); - unsafe { - match value { - Some(value) => std::env::set_var(name, value), - None => std::env::remove_var(name), - } - } - Self { name, saved } - } -} - -impl Drop for EnvVar { - fn drop(&mut self) { - unsafe { - match &self.saved { - Some(value) => std::env::set_var(self.name, value), - None => std::env::remove_var(self.name), - } - } - } -} - -// ── helpers ────────────────────────────────────────────────────────────────── - -fn chat_ok() -> Vec { - serde_json::to_vec(&json!({ - "id": "chatcmpl-1", "object": "chat.completion", "created": 1, "model": "m", - "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}], - "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} - })).unwrap() -} - -fn hello() -> CallOptions { - CallOptions::new(vec![LanguageModelMessage::user_text("hi")]) -} - -#[serial] -#[test] -fn every_registry_row_loads_and_creates_without_environment() { - let entries: Vec<_> = preset::entries().collect(); - assert_eq!(entries.len(), 281); - assert!( - preset::names() - .collect::>() - .windows(2) - .all(|pair| pair[0] < pair[1]) - ); - let mut variables = BTreeSet::new(); - for entry in &entries { - let d = entry.descriptor; - variables.extend((!d.env_var.is_empty()).then_some(d.env_var)); - variables.extend(d.base_url_env); - variables.extend(d.params.iter().flat_map(|param| param.env.iter().copied())); - } - let _guards: Vec<_> = variables - .into_iter() - .map(|var| EnvVar::set(var, None)) - .collect(); - for entry in entries { - preset::create(entry.descriptor.name, PresetSettings::default()) - .unwrap_or_else(|error| panic!("{}: {error}", entry.descriptor.name)); - } -} - -#[tokio::test] -async fn a_keyless_row_sends_no_authorization() { - let mock = MockFetch::new(vec![chat_ok()]); - let preset = preset::create( - "ollama", - PresetSettings { - base_url: Some("http://127.0.0.1:9/v1".into()), - fetch: Some(mock.transport()), - ..Default::default() - }, - ) - .unwrap(); - preset.chat("m").do_generate(&hello()).await.unwrap(); - let seen = mock.seen(); - assert_eq!(seen.len(), 1); - assert!(!seen[0].headers.contains_key("authorization")); - assert_eq!(seen[0].url, "http://127.0.0.1:9/v1/chat/completions"); -} - -#[test] -fn an_unknown_name_has_no_fallback() { - assert!(preset::lookup("no-such-preset").is_none()); - assert!( - matches!(create_provider("no-such-preset", PresetSettings::default()), - Err(AiMuxError::NoSuchProvider { provider_id, .. }) if provider_id == "no-such-preset") - ); -} - -#[tokio::test] -async fn a_template_parameter_must_be_a_plain_host_segment() { - let mock = MockFetch::new(vec![chat_ok()]); - let result = preset::create( - "neon", - PresetSettings { - params: [("branch_host".into(), "user@host/path".into())].into(), - fetch: Some(mock.transport()), - ..Default::default() - }, - ); - assert!( - matches!(result, Err(AiMuxError::InvalidArgument(message)) if message.contains("branch_host")) - ); - assert!(mock.seen().is_empty()); -} - -/// The built-in table holds the vendor packages and every registry row, and a -/// registry over it resolves both the same way. -#[test] -fn default_providers_serve_packages_and_registry_rows_through_one_registry() { - let providers = aimux_providers::default_providers(); - assert!( - providers.len() > 281, - "packages and rows: {}", - providers.len() - ); - let registry = aimux_core::create_provider_registry(providers, Default::default()); - - let model = registry.language_model("anthropic:claude-x").unwrap(); - assert_eq!(model.provider(), "anthropic.messages"); - let model = registry.language_model("abacus:some/model:v1").unwrap(); - assert_eq!(model.provider(), "abacus.chat"); - assert_eq!(model.model_id(), "some/model:v1"); - // A vendor package wins over the registry row of the same name. - let model = registry.language_model("groq:m").unwrap(); - assert_eq!(model.provider(), "groq.chat"); - - let error = registry.language_model("nope:m").err().unwrap(); - assert!( - matches!(error, AiMuxError::NoSuchProvider { .. }), - "{error:?}" - ); -} diff --git a/bindings/node/__test__/provider_config.test.ts b/bindings/node/__test__/provider_config.test.ts deleted file mode 100644 index e8c112183..000000000 --- a/bindings/node/__test__/provider_config.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -// provider_config.test.ts — Node e2e tests for the provider factory config -// (headers) and the call-level maxRetries, plus the removed bodyOverrides and -// the provider-level maxRetries, which are rejected instead of ignored. -// -// These verify the full chain: JS → napi → Rust → HTTP mock. - -import test from 'ava' -import { createServer, type Server } from 'node:http' -import { - anthropic, - anthropicAws, - azure, - bedrock, - cohere, - createProvider, - deepseek, - google, - mistral, - openai, - provider, - vertex, - xai, - InvalidArgumentError, -} from '../src/native.ts' - -function startMockServer(handler: (req: any, res: any) => void): Promise<{ server: Server; url: string }> { - return new Promise((resolve) => { - const server = createServer(handler) - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as any - resolve({ server, url: `http://127.0.0.1:${addr.port}` }) - }) - }) -} - -function closeServer(server: Server): Promise { - return new Promise((resolve) => server.close(() => resolve())) -} - -function readBody(req: any): Promise { - return new Promise((resolve) => { - let body = '' - req.on('data', (chunk: string) => { body += chunk }) - req.on('end', () => resolve(JSON.parse(body))) - }) -} - -const RESPONSES_RESPONSE = JSON.stringify({ - id: 'resp-test', - model: 'm', - created_at: 1, - status: 'completed', - output: [{ - type: 'message', - id: 'msg-test', - role: 'assistant', - status: 'completed', - content: [{ type: 'output_text', text: 'Done.', annotations: [] }], - }], - usage: { input_tokens: 10, output_tokens: 1, total_tokens: 11 }, -}) - -// ── removed: bodyOverrides ─────────────────────────────────────────────────── - -test('per-call body_overrides no longer reaches the request body', async (t) => { - let requestBody: any = null - const { server, url } = await startMockServer(async (req, res) => { - requestBody = await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(RESPONSES_RESPONSE) - }) - - try { - const model = await openai('test-key', 'm', url) - const opts = JSON.stringify({ - temperature: 0.9, - body_overrides: { temperature: 0.1, enable_thinking: false }, - }) - await model.generateText(JSON.stringify('Hello'), opts) - - t.is(requestBody.temperature, 0.9, 'the standard option stands') - t.true(requestBody.enable_thinking === undefined, 'the override is not applied') - } finally { - await closeServer(server) - } -}) - -test('factory config bodyOverrides is rejected, not ignored', async (t) => { - const err = await t.throwsAsync(() => - openai('test-key', 'm', { - baseUrl: 'http://127.0.0.1:1', - bodyOverrides: '{"X-Relay-Tag":"my-team"}', - }), - ) - t.true(err instanceof Error) - t.regex(err!.message, /body_overrides/) -}) - -test('factory config maxRetries is rejected, not ignored', async (t) => { - const err = await t.throwsAsync(() => - openai('test-key', 'm', { baseUrl: 'http://127.0.0.1:1', maxRetries: 0 }), - ) - t.true(err instanceof Error) - t.regex(err!.message, /max_retries/) -}) - -// Every native constructor takes the same config and rejects the two removed -// keys; none of them drops a key silently. -const NATIVE_CONSTRUCTORS: Array<[string, (config: object) => Promise]> = [ - ['openai', (c) => openai('k', 'm', c)], - ['anthropic', (c) => anthropic('k', 'm', c)], - ['google', (c) => google('k', 'm', c)], - ['cohere', (c) => cohere('k', 'm', c)], - ['mistral', (c) => mistral('k', 'm', c)], - ['xai', (c) => xai('k', 'm', c)], - ['deepseek', (c) => deepseek('k', 'm', c)], - ['bedrock', (c) => bedrock('AKIA', 'secret', 'us-east-1', 'm', c)], - ['vertex', (c) => vertex('token', 'proj', 'us-central1', 'm', c)], - ['anthropicAws', (c) => anthropicAws('k', 'us-east-1', 'm', c)], - ['azure', (c) => azure('k', 'res', 'dep', null, c)], - ['provider', (c) => provider('groq', 'k', 'm', c)], - ['createProvider', (c) => createProvider('groq', 'k', c)], -] - -for (const [name, construct] of NATIVE_CONSTRUCTORS) { - test(`${name}: maxRetries / bodyOverrides in the config are InvalidArgumentError`, async (t) => { - const maxRetries = await t.throwsAsync(() => construct({ maxRetries: 1 })) - t.true(maxRetries instanceof InvalidArgumentError) - t.regex(maxRetries!.message, /max_retries/) - const overrides = await t.throwsAsync(() => construct({ bodyOverrides: '{"a":1}' })) - t.true(overrides instanceof InvalidArgumentError) - t.regex(overrides!.message, /body_overrides/) - }) - - test(`${name}: a plain config is accepted`, async (t) => { - await t.notThrowsAsync(() => construct({ baseUrl: 'http://127.0.0.1:1' })) - }) -} - -// ── params: preset template parameters ─────────────────────────────────────── - -test('params fills a preset template parameter', async (t) => { - const saved = process.env.CLOUDFLARE_ACCOUNT_ID - delete process.env.CLOUDFLARE_ACCOUNT_ID - try { - // Without the parameter the preset cannot build its base URL. - const missing = await t.throwsAsync(() => provider('cloudflare_workers_ai', 'k', 'm')) - t.true(missing instanceof InvalidArgumentError) - t.regex(missing!.message, /account_id/) - - await t.notThrowsAsync(() => - provider('cloudflare_workers_ai', 'k', 'm', { params: { account_id: 'acct123' } }), - ) - await t.notThrowsAsync(() => - createProvider('cloudflare_workers_ai', 'k', { params: { account_id: 'acct123' } }), - ) - } finally { - if (saved !== undefined) process.env.CLOUDFLARE_ACCOUNT_ID = saved - } -}) - -test('params the preset does not declare are rejected', async (t) => { - const err = await t.throwsAsync(() => - provider('groq', 'k', 'm', { params: { account_id: 'acct123' } }), - ) - t.true(err instanceof InvalidArgumentError) - t.regex(err!.message, /no template parameter `account_id`/) -}) - -test('params values that could change the URL are rejected', async (t) => { - const err = await t.throwsAsync(() => - provider('cloudflare_workers_ai', 'k', 'm', { params: { account_id: 'a/b' } }), - ) - t.true(err instanceof InvalidArgumentError) -}) - -test('factory config headers are sent on every request', async (t) => { - let headers: any = null - const { server, url } = await startMockServer(async (req, res) => { - headers = req.headers - // consume body - await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(RESPONSES_RESPONSE) - }) - - try { - const model = await openai('test-key', 'm', { - baseUrl: url, - headers: JSON.stringify({ 'X-Custom-Header': 'custom-value' }), - }) - await model.generateText(JSON.stringify('Hello')) - - t.is(headers['x-custom-header'], 'custom-value') - } finally { - await closeServer(server) - } -}) - -// ── backward compatibility ─────────────────────────────────────────────────── - -test('factory accepts bare string baseUrl (backward compatible)', async (t) => { - const { server, url } = await startMockServer(async (req, res) => { - t.is(req.url, '/responses') - await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(RESPONSES_RESPONSE) - }) - - try { - // 3rd param is a plain string (old API) - const model = await openai('test-key', 'm', url) - const result = JSON.parse(await model.generateText(JSON.stringify('Hello'))) - t.is(result.text, 'Done.') - } finally { - await closeServer(server) - } -}) - -// ── maxRetries ──────────────────────────────────────────────────────────────── - -test('maxRetries: 0 disables retries (single request on 500)', async (t) => { - let requestCount = 0 - const { server, url } = await startMockServer(async (req, res) => { - requestCount++ - await readBody(req) - res.writeHead(500, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ error: { message: 'Internal server error', type: 'server_error' } })) - }) - - try { - const model = await openai('test-key', 'm', url) - const opts = JSON.stringify({ max_retries: 0 }) - - await t.throwsAsync( - async () => model.generateText(JSON.stringify('Hello'), opts), - { message: /500|Internal|server_error/i }, - ) - - // With retries disabled, exactly 1 request should have been made. - t.is(requestCount, 1, 'should make exactly 1 request when maxRetries=0') - } finally { - await closeServer(server) - } -}) diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index d267fdbe7..f2ba260ee 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -519,17 +519,6 @@ export declare function recordingStop(): void */ export declare function recordingTryFlush(): AimuxResult -/** - * Register external OpenAI-compatible providers from a JSON config string - * (RFC-0020). Entries override same-named built-ins or add new ones. - * - * `configJson` shape: `{ "providers": [ { "name": "...", "base_url": "...", ... } ] }`. - * Malformed JSON text throws a plain `Error` (napi `InvalidArg`); a - * well-formed document the registry rejects (bad base_url scheme, empty - * name, unsupported protocol, wrong shape) throws `InvalidArgumentError`. - */ -export declare function registerProviders(configJson: string): AimuxResult - export declare function router(models: Array, configJson?: string | undefined | null): AimuxResult /** diff --git a/bindings/node/index.js b/bindings/node/index.js index 2b7bd0556..909bb21d7 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -750,7 +750,6 @@ module.exports.provider = nativeBinding.provider module.exports.recordingFlush = nativeBinding.recordingFlush module.exports.recordingStop = nativeBinding.recordingStop module.exports.recordingTryFlush = nativeBinding.recordingTryFlush -module.exports.registerProviders = nativeBinding.registerProviders module.exports.router = nativeBinding.router module.exports.sessionCalls = nativeBinding.sessionCalls module.exports.startTranscriptionSession = nativeBinding.startTranscriptionSession diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 8df951969..75361df60 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -32,7 +32,7 @@ use aimux_core::message::ModelPrompt; use aimux_core::openai_output::OpenAiStreamOptions; use aimux_core::parse_tool_call::{ToolCallRepairReply, tool_call_repair_inputs}; use aimux_core::tool::ToolCall; -use aimux_providers::provider as providers; +use aimux_providers::{PresetSettings, create_provider as create_named_provider}; use napi::bindgen_prelude::*; use napi_derive::napi; @@ -640,11 +640,18 @@ pub struct ProviderConfig { impl ProviderConfig { /// Fail on the two removed keys instead of dropping them silently. fn reject_removed_keys(&self) -> MResult<()> { - providers::reject_removed_provider_options( - self.max_retries.is_some(), - self.body_overrides.is_some(), - ) - .map_err(|e| AiMuxBindingError::from(&e)) + let removed = if self.max_retries.is_some() { + "`max_retries` is a call-level option: pass it in the call options (`maxRetries`), \ + not in the provider configuration" + } else if self.body_overrides.is_some() { + "`body_overrides` is no longer supported: request-body overrides were removed from \ + the provider configuration and the call options" + } else { + return Ok(()); + }; + Err(AiMuxBindingError::from(&AiMuxError::InvalidArgument( + removed.into(), + ))) } } @@ -730,33 +737,6 @@ pub fn init_session_infer(enabled: bool) { aimux_core::session::init_session_infer(enabled); } -// ───────────────────────────────────────────────────────────────────────────── -// External provider config (RFC-0020) -// ───────────────────────────────────────────────────────────────────────────── - -/// Register external OpenAI-compatible providers from a JSON config string -/// (RFC-0020). Entries override same-named built-ins or add new ones. -/// -/// `configJson` shape: `{ "providers": [ { "name": "...", "base_url": "...", ... } ] }`. -/// Malformed JSON text throws a plain `Error` (napi `InvalidArg`); a -/// well-formed document the registry rejects (bad base_url scheme, empty -/// name, unsupported protocol, wrong shape) throws `InvalidArgumentError`. -#[napi] -pub fn register_providers(config_json: String) -> error::AimuxResult<()> { - AimuxResult((|| -> error::MResult<()> { - // Malformed text is the binding's finding; the registry reports a - // schema mismatch as `JsonParse`, which is core's InvalidArgument - // here (the text already parsed) — JsonParse is for provider responses. - let _: serde_json::Value = parse_wire_json("config_json", &config_json)?; - providers::load_providers_from_json(&config_json).map_err(|e| match e { - AiMuxError::JsonParse(m) => crate::error::AiMuxBindingError::from( - &AiMuxError::InvalidArgument(format!("config_json: {m}")), - ), - e => crate::error::AiMuxBindingError::from(&e), - }) - })()) -} - /// Set the global proxy configuration (M6, RFC-0016). Must be called before /// the first `generateText` / `streamText` call; a no-op if the shared HTTP /// client is already initialised (the shared client is lazily built on first @@ -1061,8 +1041,9 @@ pub async fn deepseek( ) -> AimuxResult { AimuxResult({ let __r: crate::error::MResult = async { - let options = provider_options_from_config(config)?; - let model = providers::provider("deepseek", Some(api_key), &model_id, options) + let settings = named_settings(Some(api_key), config)?; + let model = create_named_provider("deepseek", settings) + .and_then(|p| p.language_model(&model_id)) .map_err(|e| AiMuxBindingError::from(&e))?; Ok(Model { inner: model, @@ -1391,11 +1372,9 @@ pub async fn provider( ) -> AimuxResult { AimuxResult({ let __r: crate::error::MResult = async { - let options = match config { - Some(cfg) => provider_options_from_config(Some(Either::B(cfg)))?, - None => None, - }; - let model = providers::provider(&name, api_key, &model_id, options) + let settings = named_settings(api_key, config.map(Either::B))?; + let model = create_named_provider(&name, settings) + .and_then(|p| p.language_model(&model_id)) .map_err(|e| AiMuxBindingError::from(&e))?; Ok(Model { inner: model, @@ -1492,12 +1471,9 @@ pub async fn create_provider( ) -> AimuxResult { AimuxResult({ let __r: crate::error::MResult = async { - let options = match config { - Some(cfg) => provider_options_from_config(Some(Either::B(cfg)))?, - None => None, - }; - let inner = providers::provider_handle(&name, api_key, options) - .map_err(|e| AiMuxBindingError::from(&e))?; + let settings = named_settings(api_key, config.map(Either::B))?; + let inner = + create_named_provider(&name, settings).map_err(|e| AiMuxBindingError::from(&e))?; Ok(ProviderHandle { inner }) } .await; @@ -1522,36 +1498,35 @@ pub async fn get_model_specs(source_url: Option) -> AimuxResult }) } -/// Build `ProviderOptions` from a Node `ProviderConfig` (3rd factory arg). -fn provider_options_from_config( +/// The settings of a by-name provider from a Node `ProviderConfig` (3rd +/// factory arg). Organization and project become their headers, below the +/// caller's own. +fn named_settings( + api_key: Option, config: Option>, -) -> MResult> { - let opts = match config { - None => None, - Some(Either::A(url)) => Some(providers::ProviderOptions { - base_url: Some(url), - ..Default::default() - }), - Some(Either::B(cfg)) => { - cfg.reject_removed_keys()?; - let mut o = providers::ProviderOptions::default(); - if let Some(url) = cfg.base_url { - o.base_url = Some(url); - } - if let Some(ref json_str) = cfg.headers { - o.headers = Some(parse_wire_json("config.headers", json_str)?); - } - if let Some(org) = cfg.organization { - o.organization = Some(org); - } - if let Some(proj) = cfg.project { - o.project = Some(proj); - } - o.params = cfg.params; - Some(o) - } +) -> MResult { + let params = match &config { + Some(Either::B(cfg)) => cfg.params.clone().unwrap_or_default(), + _ => Default::default(), }; - Ok(opts) + let native = native_config(config)?; + let mut headers = aimux_provider_utils::HeaderMapOpt::new(); + for (name, value) in [ + ("OpenAI-Organization", native.organization), + ("OpenAI-Project", native.project), + ] { + if let Some(value) = value { + headers.insert(name.to_string(), Some(value)); + } + } + headers.extend(native.headers.into_iter().flatten()); + Ok(PresetSettings { + api_key: api_key.map(aimux_provider_utils::Resolvable::Value), + base_url: native.base_url, + headers: (!headers.is_empty()).then_some(headers), + params, + ..PresetSettings::default() + }) } // ───────────────────────────────────────────────────────────────────────────── diff --git a/bindings/python/python/aimux/__init__.py b/bindings/python/python/aimux/__init__.py index 431b80533..b76bb6679 100644 --- a/bindings/python/python/aimux/__init__.py +++ b/bindings/python/python/aimux/__init__.py @@ -61,7 +61,6 @@ recording_flush, recording_try_flush, mock_replay, - register_providers, init_proxy, router, moa, @@ -139,7 +138,6 @@ "recording_flush", "recording_try_flush", "mock_replay", - "register_providers", "init_proxy", "router", "moa", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 484bbf4d4..a603d1982 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -482,17 +482,14 @@ fn anthropic(api_key: &str, model_id: &str, base_url: Option<&str>) -> PyResult< #[pyfunction] #[pyo3(signature = (api_key, model_id, base_url=None))] fn deepseek(api_key: &str, model_id: &str, base_url: Option<&str>) -> PyResult { - let options = base_url.map(|url| aimux_providers::provider::ProviderOptions { - base_url: Some(url.to_string()), + let settings = aimux_providers::PresetSettings { + api_key: Some(aimux_provider_utils::Resolvable::Value(api_key.to_string())), + base_url: base_url.map(str::to_string), ..Default::default() - }); - let model = aimux_providers::provider::provider( - "deepseek", - Some(api_key.to_string()), - model_id, - options, - ) - .map_err(|e| to_py_err(&e))?; + }; + let model = aimux_providers::create_provider("deepseek", settings) + .and_then(|p| p.language_model(model_id)) + .map_err(|e| to_py_err(&e))?; Ok(Model { inner: Arc::from(model), trace_store: None, @@ -728,12 +725,58 @@ fn azure( }) } +/// The settings of a by-name provider: the optional `config_json` object, with +/// the `base_url` parameter winning over its field. Organization and project +/// become their headers, below the caller's own. +fn named_settings( + api_key: Option, + base_url: Option<&str>, + config_json: Option<&str>, +) -> PyResult { + #[derive(serde::Deserialize, Default)] + #[serde(deny_unknown_fields)] + struct Config { + base_url: Option, + headers: Option>, + organization: Option, + project: Option, + params: Option>, + } + let config: Config = match config_json { + Some(s) if !s.trim().is_empty() && s.trim() != "null" => wire_json("config_json", s)?, + _ => Config::default(), + }; + let mut headers = aimux_provider_utils::HeaderMapOpt::new(); + for (name, value) in [ + ("OpenAI-Organization", config.organization), + ("OpenAI-Project", config.project), + ] { + if let Some(value) = value { + headers.insert(name.to_string(), Some(value)); + } + } + headers.extend( + config + .headers + .into_iter() + .flatten() + .map(|(k, v)| (k, Some(v))), + ); + Ok(aimux_providers::PresetSettings { + api_key: api_key.map(aimux_provider_utils::Resolvable::Value), + base_url: base_url.map(str::to_string).or(config.base_url), + headers: (!headers.is_empty()).then_some(headers), + params: config.params.unwrap_or_default(), + ..Default::default() + }) +} + /// Create a language model from the built-in registry by provider name /// (RFC-0017 phase 4). `api_key=None` reads the provider's env var. -/// `config_json` is a serialized `ProviderOptions` object (`base_url` / -/// `headers` / `organization` / `project` / `params`; `max_retries` and -/// `body_overrides` are rejected as invalid arguments); the `base_url` -/// parameter wins over the JSON field. +/// `config_json` is a JSON object (`base_url` / `headers` / `organization` / +/// `project` / `params`; any other key, `max_retries` and `body_overrides` +/// included, is rejected as an invalid argument); the `base_url` parameter +/// wins over the JSON field. #[pyfunction] #[pyo3(signature = (name, api_key, model_id, base_url=None, config_json=None))] fn provider( @@ -743,14 +786,9 @@ fn provider( base_url: Option<&str>, config_json: Option<&str>, ) -> PyResult { - let mut options: Option = match config_json { - Some(s) if !s.trim().is_empty() && s.trim() != "null" => Some(wire_json("config_json", s)?), - _ => None, - }; - if let Some(url) = base_url { - options.get_or_insert_with(Default::default).base_url = Some(url.to_string()); - } - let model = aimux_providers::provider::provider(name, api_key, model_id, options) + let settings = named_settings(api_key, base_url, config_json)?; + let model = aimux_providers::create_provider(name, settings) + .and_then(|p| p.language_model(model_id)) .map_err(|e| to_py_err(&e))?; Ok(Model { inner: Arc::from(model), @@ -811,15 +849,8 @@ fn create_provider( base_url: Option<&str>, config_json: Option<&str>, ) -> PyResult { - let mut options: Option = match config_json { - Some(s) if !s.trim().is_empty() && s.trim() != "null" => Some(wire_json("config_json", s)?), - _ => None, - }; - if let Some(url) = base_url { - options.get_or_insert_with(Default::default).base_url = Some(url.to_string()); - } - let inner = aimux_providers::provider::provider_handle(name, api_key, options) - .map_err(|e| to_py_err(&e))?; + let settings = named_settings(api_key, base_url, config_json)?; + let inner = aimux_providers::create_provider(name, settings).map_err(|e| to_py_err(&e))?; Ok(ProviderHandle { inner }) } @@ -853,24 +884,6 @@ fn init_logging(level: &str) { aimux_providers::init_logging(level); } -/// Register external OpenAI-compatible providers from a JSON config string -/// (RFC-0020). Entries override same-named built-ins or add new ones. -/// -/// `config_json` shape: `{ "providers": [ { "name": "...", "base_url": "...", ... } ] }`. -/// Malformed JSON text raises `ValueError`; a well-formed document the -/// registry rejects (bad base_url scheme, empty name, unsupported protocol, -/// wrong shape) raises `InvalidArgumentError`. -#[pyfunction] -fn register_providers(config_json: &str) -> PyResult<()> { - let _: serde_json::Value = wire_json("config_json", config_json)?; - aimux_providers::provider::load_providers_from_json(config_json).map_err(|e| match e { - AiMuxError::JsonParse(m) => { - to_py_err(&AiMuxError::InvalidArgument(format!("config_json: {m}"))) - } - e => to_py_err(&e), - }) -} - /// Set the global proxy configuration (M6, RFC-0016). Must be called before the /// first `generate_text` / `stream_text` call; a no-op if the shared HTTP /// client is already initialised. @@ -1227,7 +1240,6 @@ fn aimux(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(start_transcription_session, m)?)?; } m.add_function(wrap_pyfunction!(moa, m)?)?; - m.add_function(wrap_pyfunction!(register_providers, m)?)?; m.add_function(wrap_pyfunction!(init_proxy, m)?)?; m.add_function(wrap_pyfunction!(openai, m)?)?; m.add_function(wrap_pyfunction!(anthropic, m)?)?; diff --git a/docs/api/c.md b/docs/api/c.md index 491fa7c3c..42b34a745 100644 --- a/docs/api/c.md +++ b/docs/api/c.md @@ -483,7 +483,6 @@ Pass `"session_id": "..."` inside `opts_json` of generate/stream to group a call | Function | Description | |------|------| | `aimux_init_logging(level)` | [C ABI] `"off"…"trace"`, NULL = `"warn"` | -| `aimux_register_providers(config_json)` | [AiMuxError] External OpenAI-compatible providers (RFC-0020) | | `aimux_init_proxy(config_json)` | [AiMuxError] Global proxy (before first call) | | `aimux_router_new(const uint64_t *handles, size_t len, config_json, uint64_t *out_handle)` | [AiMuxError] RouterModel (RFC-0021) | | `aimux_moa_new(const uint64_t *reference_handles, size_t ref_len, aggregator, config_json, uint64_t *out_handle)` | [AiMuxError] MoaModel (RFC-0022) | From 55487cb42e60575d9734a0b24cbc9dad8b725b80 Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Wed, 7 Oct 2026 01:22:48 +0800 Subject: [PATCH 02/10] refactor(core)!: call-layer tool result carries upstream's structured output What: ContentPart::ToolResult has tool_call_id, tool_name, output (ToolResultOutput: text, json, execution-denied, error-text, error-json, content) and provider_options; the free JSON result and the is_error, preliminary and dynamic flags are gone, and the conversion to the provider prompt copies the output unchanged. Response messages build the output from the generate-level tool result. The web wire type, its TypeScript consumer, the Node and Python test suites and the Python wrapper follow. A repaired tool call that still fails parsing reports that parse error directly, as upstream does; only a failing repair function is wrapped. tool_input_test and tool_choice_test are now ports of parse-tool-call.test.ts and prepare-tool-choice.test.ts; their cases without an upstream counterpart are deleted. Why: upstream's ModelMessage tool result uses the same output union as the provider layer; one shape for one thing. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- aimux-core/src/content.rs | 40 +------ aimux-core/src/language_model_message.rs | 56 +--------- aimux-core/src/parse_tool_call.rs | 4 +- aimux-core/src/response_messages.rs | 36 ++++++- aimux-core/tests/tool_choice_test.rs | 101 ++++-------------- aimux-core/tests/tool_input_test.rs | 86 ++------------- aimux-providers/tests/e2e_test.rs | 14 ++- bindings/node/__test__/e2e.test.ts | 3 +- .../node/__test__/reasoning_replay.test.ts | 10 +- .../node/__test__/roundtrip-fields.test.ts | 2 +- bindings/node/src/types/AiMuxError.ts | 12 +-- bindings/node/src/types/ContentPart.ts | 30 +----- bindings/node/src/types/GenerateTextResult.ts | 34 +++--- bindings/node/src/types/StreamPart.ts | 2 +- .../src/types/StreamTextResultAggregated.ts | 32 +++--- bindings/node/src/types/TextStreamPart.ts | 12 +-- bindings/python/python/aimux/wrapper.py | 7 +- bindings/python/tests/test_e2e.py | 4 +- bindings/python/tests/test_wrapper.py | 6 +- tools/aimux-web/src/wire.rs | 26 ++--- tools/aimux-web/web/src/agent/engine.ts | 8 +- .../src/types/generated/WireContentPart.ts | 6 +- 22 files changed, 173 insertions(+), 358 deletions(-) diff --git a/aimux-core/src/content.rs b/aimux-core/src/content.rs index 25681a97c..aa82b1202 100644 --- a/aimux-core/src/content.rs +++ b/aimux-core/src/content.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use ts_rs::TS; +use crate::language_model_message::ToolResultOutput; use crate::shared::SharedProviderOptions; /// A part of a multi-part message. @@ -141,31 +142,11 @@ pub enum ContentPart { provider_options: Option, }, - /// The result of executing a tool call. + /// The result of executing a tool call (upstream `ToolResultPart`). ToolResult { tool_call_id: String, - /// The tool's output (usually a JSON value or plain text). - /// - /// Accepts the legacy `output` field name (used by the Vercel AI SDK - /// and the 0.1.0 TypeScript bindings) as an alias during deserialization, - /// so multi-part tool messages constructed either way round-trip - /// correctly. Serialization always emits `result`. - #[serde(alias = "output")] - result: Value, - /// The name of the tool that produced this result (optional on the - /// user-input side; providers that need it can look it up from the - /// preceding tool call). - #[serde(default, skip_serializing_if = "Option::is_none")] - tool_name: Option, - /// Whether the result is an error or error message. - #[serde(default, skip_serializing_if = "Option::is_none")] - is_error: Option, - /// Whether the result is preliminary (replaces prior, e.g. image previews). - #[serde(default, skip_serializing_if = "Option::is_none")] - preliminary: Option, - /// Whether the tool is dynamic (defined at runtime, e.g. MCP tools). - #[serde(default, skip_serializing_if = "Option::is_none")] - dynamic: Option, + tool_name: String, + output: ToolResultOutput, /// Provider-specific options for this part (e.g. /// `anthropic.cacheControl`). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -197,19 +178,6 @@ impl ContentPart { } } - /// Convenience constructor for a tool-result part (no provider options). - pub fn tool_result(tool_call_id: impl Into, result: Value) -> Self { - ContentPart::ToolResult { - tool_call_id: tool_call_id.into(), - result, - tool_name: None, - is_error: None, - preliminary: None, - dynamic: None, - provider_options: None, - } - } - /// Convenience constructor for a reasoning/thinking part (no provider /// options, no signature). pub fn reasoning(text: impl Into) -> Self { diff --git a/aimux-core/src/language_model_message.rs b/aimux-core/src/language_model_message.rs index 2f8f920ee..5a858decf 100644 --- a/aimux-core/src/language_model_message.rs +++ b/aimux-core/src/language_model_message.rs @@ -11,7 +11,6 @@ use crate::error::AiMuxError; use crate::message::{MessageContent, ModelMessage, Role}; use crate::shared::{FileBytes, FileData, GeneratedFileData, SharedProviderOptions}; use std::borrow::Cow; -use std::collections::HashMap; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -229,10 +228,7 @@ impl LanguageModelMessage { /// Map a user-facing part onto the widest provider part (the assistant union); /// the role then narrows it. -fn to_assistant_part( - part: &ContentPart, - tool_names: &HashMap, -) -> Result { +fn to_assistant_part(part: &ContentPart) -> Result { let file = |data, media_type: &String, filename: &Option, po: &Option<_>| { AssistantPart::File(FilePart { data, @@ -338,42 +334,13 @@ fn to_assistant_part( }), ContentPart::ToolResult { tool_call_id, - result, tool_name, - is_error, + output, provider_options, - .. } => AssistantPart::ToolResult(ToolResultPart { tool_call_id: tool_call_id.clone(), - tool_name: tool_name - .as_ref() - .or_else(|| tool_names.get(tool_call_id)) - .cloned() - .ok_or_else(|| { - AiMuxError::InvalidPrompt(format!( - "tool result {tool_call_id} has no tool name or preceding tool call" - )) - })?, - output: if *is_error == Some(true) { - ToolResultOutput::ErrorText { - value: match result { - Value::Null => "unknown error".into(), - Value::String(value) => value.clone(), - _ => result.to_string(), - }, - provider_options: None, - } - } else if let Value::String(value) = result { - ToolResultOutput::Text { - value: value.clone(), - provider_options: None, - } - } else { - ToolResultOutput::Json { - value: result.clone(), - provider_options: None, - } - }, + tool_name: tool_name.clone(), + output: output.clone(), provider_options: provider_options.clone(), }), ContentPart::Custom { @@ -423,7 +390,6 @@ pub fn convert_to_language_model_prompt( }); } - let mut tool_names = HashMap::new(); for msg in messages { let bad = |what: &str| AiMuxError::InvalidPrompt(format!("{:?} message: {what}", msg.role)); if msg.role == Role::System { @@ -440,18 +406,6 @@ pub fn convert_to_language_model_prompt( MessageContent::Text(text) => Cow::Owned(vec![ContentPart::text(text)]), MessageContent::Parts(parts) => Cow::Borrowed(parts), }; - if msg.role == Role::Assistant { - for part in parts.iter() { - if let ContentPart::ToolCall { - tool_call_id, - tool_name, - .. - } = part - { - tool_names.insert(tool_call_id.clone(), tool_name.clone()); - } - } - } let parts = parts .iter() .filter(|part| { @@ -464,7 +418,7 @@ pub fn convert_to_language_model_prompt( if text.is_empty() && (msg.role == Role::User || (msg.role == Role::Assistant && provider_options.is_none()))) }) - .map(|part| to_assistant_part(part, &tool_names)) + .map(to_assistant_part) .collect::, _>>()?; let provider_options = None; result.push(match msg.role { diff --git a/aimux-core/src/parse_tool_call.rs b/aimux-core/src/parse_tool_call.rs index 053245b6e..ba7f62b9f 100644 --- a/aimux-core/src/parse_tool_call.rs +++ b/aimux-core/src/parse_tool_call.rs @@ -161,7 +161,9 @@ fn apply_repair_outcome( let cause = match outcome { RepairOutcome::Repaired(repaired) => match parse_and_validate_tool_call(&repaired, tools) { Ok((input, dynamic)) => return parsed_tool_call(repaired, input, dynamic), - Err(repaired_error) => repaired_error, + // Upstream rethrows the parse error of the repaired call as is; + // only a failing repair function is wrapped. + Err(repaired_error) => return invalid_tool_call(tool_call, repaired_error), }, RepairOutcome::Unchanged => return invalid_tool_call(tool_call, original_error), RepairOutcome::Failed(repair_error) => repair_error, diff --git a/aimux-core/src/response_messages.rs b/aimux-core/src/response_messages.rs index c3239b6e3..6d89336f5 100644 --- a/aimux-core/src/response_messages.rs +++ b/aimux-core/src/response_messages.rs @@ -10,6 +10,7 @@ use base64::Engine; use serde_json::Value; use crate::content::ContentPart; +use crate::language_model_message::ToolResultOutput; use crate::message::{MessageContent, ModelMessage, Role}; use crate::result::{ GenerateContent, GeneratedFile, ReasoningFileOutput, ReasoningOutput, ReasoningPart, Source, @@ -254,11 +255,8 @@ impl ResponseMessageBuilder { }, GenerateContent::ToolResult(result) => ContentPart::ToolResult { tool_call_id: result.tool_call_id.clone(), - tool_name: Some(result.tool_name.clone()), - result: result.result.clone(), - is_error: result.is_error, - preliminary: result.preliminary, - dynamic: result.dynamic, + tool_name: result.tool_name.clone(), + output: tool_model_output(&result.result, result.is_error == Some(true)), provider_options: result.provider_metadata.clone(), }, GenerateContent::Source(_) => continue, @@ -280,6 +278,34 @@ impl ResponseMessageBuilder { } } +/// TS: `createToolModelOutput` without a `toModelOutput` hook: errors become +/// `error-text` with the error message, other strings `text`, the rest `json`. +fn tool_model_output(result: &Value, is_error: bool) -> ToolResultOutput { + let provider_options = None; + match (result, is_error) { + (Value::String(value), false) => ToolResultOutput::Text { + value: value.clone(), + provider_options, + }, + (_, false) => ToolResultOutput::Json { + value: result.clone(), + provider_options, + }, + (Value::Null, true) => ToolResultOutput::ErrorText { + value: "unknown error".into(), + provider_options, + }, + (Value::String(value), true) => ToolResultOutput::ErrorText { + value: value.clone(), + provider_options, + }, + (_, true) => ToolResultOutput::ErrorText { + value: result.to_string(), + provider_options, + }, + } +} + fn file_base64(data: &FileBytes) -> String { match data { FileBytes::Base64(data) => data.clone(), diff --git a/aimux-core/tests/tool_choice_test.rs b/aimux-core/tests/tool_choice_test.rs index c7a38463f..4db0092bd 100644 --- a/aimux-core/tests/tool_choice_test.rs +++ b/aimux-core/tests/tool_choice_test.rs @@ -1,97 +1,36 @@ -//! Wire-format tests for `ToolChoice`. -//! -//! The serialized shape aligns with Vercel AI SDK `toolChoice`: -//! `"auto" | "none" | "required" | { "type": "tool", "toolName": "..." }`. -//! These tests lock the cross-language wire contract so refactors on the -//! Rust side cannot silently break the bindings. +//! Ported from upstream `ai/src/prompt/prepare-tool-choice.test.ts`: the user +//! facing `toolChoice` values (`"auto" | "none" | "required" | { type: "tool", +//! toolName }`) and their wire shape. use aimux_core::tool::ToolChoice; use serde_json::json; -#[test] -fn serialize_unit_variants_as_bare_strings() { - assert_eq!( - serde_json::to_value(ToolChoice::Auto).unwrap(), - json!("auto") - ); - assert_eq!( - serde_json::to_value(ToolChoice::None).unwrap(), - json!("none") - ); - assert_eq!( - serde_json::to_value(ToolChoice::Required).unwrap(), - json!("required") - ); +fn assert_wire(choice: ToolChoice, wire: serde_json::Value) { + assert_eq!(serde_json::to_value(&choice).unwrap(), wire); + assert_eq!(serde_json::from_value::(wire).unwrap(), choice); } +/// TS: returns auto when tool choice is not provided #[test] -fn serialize_tool_variant_as_tagged_object_with_camelcase_field() { - let tc = ToolChoice::Tool { - tool_name: "get_weather".into(), - }; - assert_eq!( - serde_json::to_value(tc).unwrap(), - json!({ "type": "tool", "toolName": "get_weather" }) - ); +fn defaults_to_auto() { + assert_eq!(ToolChoice::default(), ToolChoice::Auto); } +/// TS: handles string tool choice: auto / none / required #[test] -fn deserialize_bare_strings() { - assert_eq!( - serde_json::from_str::("\"auto\"").unwrap(), - ToolChoice::Auto - ); - assert_eq!( - serde_json::from_str::("\"none\"").unwrap(), - ToolChoice::None - ); - assert_eq!( - serde_json::from_str::("\"required\"").unwrap(), - ToolChoice::Required - ); +fn string_tool_choices() { + assert_wire(ToolChoice::Auto, json!("auto")); + assert_wire(ToolChoice::None, json!("none")); + assert_wire(ToolChoice::Required, json!("required")); } +/// TS: handles object tool choice #[test] -fn deserialize_tool_object() { - let v = json!({ "type": "tool", "toolName": "get_weather" }); - assert_eq!( - serde_json::from_value::(v).unwrap(), +fn object_tool_choice() { + assert_wire( ToolChoice::Tool { - tool_name: "get_weather".into() - } - ); -} - -#[test] -fn round_trip_all_variants() { - let cases = [ - ToolChoice::Auto, - ToolChoice::None, - ToolChoice::Required, - ToolChoice::Tool { - tool_name: "search".into(), + tool_name: "tool2".into(), }, - ]; - for tc in cases { - let j = serde_json::to_string(&tc).unwrap(); - let back: ToolChoice = serde_json::from_str(&j).unwrap(); - assert_eq!(back, tc, "round-trip failed for {j}"); - } -} - -#[test] -fn reject_unknown_string() { - assert!(serde_json::from_str::("\"always\"").is_err()); -} - -#[test] -fn reject_tool_object_missing_tool_name() { - let v = json!({ "type": "tool" }); - assert!(serde_json::from_value::(v).is_err()); -} - -#[test] -fn reject_unknown_tool_type() { - let v = json!({ "type": "function", "toolName": "x" }); - assert!(serde_json::from_value::(v).is_err()); + json!({ "type": "tool", "toolName": "tool2" }), + ); } diff --git a/aimux-core/tests/tool_input_test.rs b/aimux-core/tests/tool_input_test.rs index 3360f1bca..b36cda348 100644 --- a/aimux-core/tests/tool_input_test.rs +++ b/aimux-core/tests/tool_input_test.rs @@ -1,3 +1,5 @@ +//! Ported from upstream `ai/src/generate-text/parse-tool-call.test.ts`. + use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -21,6 +23,7 @@ fn raw(name: &str, input: &str) -> RawToolCall { } } +/// TS: should successfully parse a valid tool call #[tokio::test] async fn parses_and_validates_exact_json() { let mut tool_call = raw("weather", r#"{"city":"Singapore","days":3}"#); @@ -33,6 +36,7 @@ async fn parses_and_validates_exact_json() { assert!(call.error.is_none()); } +/// TS: should successfully process empty tool calls for tools that have no inputSchema #[tokio::test] async fn validates_empty_input_as_an_empty_object() { let no_arg_tool = FunctionTool::new( @@ -52,47 +56,7 @@ async fn validates_empty_input_as_an_empty_object() { assert_eq!(call.invalid, None); } -#[tokio::test] -async fn parses_a_json_string_without_confusing_it_with_the_raw_carrier() { - let echo_tool = FunctionTool::new("echo", json!({ "type": "string" })); - let call = parse_tool_call( - raw("echo", r#""hello""#), - Some(&[echo_tool.into()]), - None, - &[], - None, - ) - .await; - - assert_eq!(call.input, json!("hello")); - assert_eq!(call.invalid, None); -} - -#[tokio::test] -async fn does_not_apply_partial_json_repair_to_final_tool_calls() { - for input in [ - r#"{"city":"Singapore"#, - r#"{"city":"Singapore",}"#, - r#"{"city":"Singapore"},"days":3}"#, - ] { - let call = parse_tool_call( - raw("weather", input), - Some(&[weather_tool()]), - None, - &[], - None, - ) - .await; - - assert_eq!(call.input, json!(input)); - assert_eq!(call.invalid, Some(true)); - assert!(matches!( - call.error, - Some(AiMuxError::InvalidToolInput { .. }) - )); - } -} - +/// TS: should throw InvalidToolInputError when args are invalid #[tokio::test] async fn preserves_parsed_input_when_schema_validation_fails() { let input = r#"{"city":7}"#; @@ -113,6 +77,7 @@ async fn preserves_parsed_input_when_schema_validation_fails() { )); } +/// TS: should throw NoSuchToolError when tool is not found #[tokio::test] async fn unknown_tool_is_an_invalid_dynamic_call_with_available_tools() { let call = parse_tool_call( @@ -137,6 +102,7 @@ async fn unknown_tool_is_an_invalid_dynamic_call_with_available_tools() { )); } +/// TS: should invoke repairTool when provided and use its result / should pass instructions to repairToolCall #[tokio::test] async fn repair_runs_once_and_the_replacement_is_fully_revalidated() { let calls = Arc::new(AtomicUsize::new(0)); @@ -169,40 +135,7 @@ async fn repair_runs_once_and_the_replacement_is_fully_revalidated() { assert_eq!(call.invalid, None); } -#[tokio::test] -async fn an_invalid_repair_is_not_repaired_again() { - let calls = Arc::new(AtomicUsize::new(0)); - let repair_calls = Arc::clone(&calls); - let repair = ToolCallRepair::new(move |context| { - repair_calls.fetch_add(1, Ordering::SeqCst); - async move { - Ok(Some(RawToolCall { - input: r#"{"city":7}"#.into(), - ..context.tool_call - })) - } - }); - - let call = parse_tool_call( - raw("weather", "{"), - Some(&[weather_tool()]), - Some(&repair), - &[], - None, - ) - .await; - - assert_eq!(calls.load(Ordering::SeqCst), 1); - assert_eq!(call.input, json!("{")); - assert_eq!(call.invalid, Some(true)); - assert!(matches!( - call.error, - Some(AiMuxError::ToolCallRepair { original_error, cause }) - if matches!(*original_error, AiMuxError::InvalidToolInput { ref tool_input, .. } if tool_input == "{") - && matches!(*cause, AiMuxError::InvalidToolInput { ref tool_input, .. } if tool_input == r#"{"city":7}"#) - )); -} - +/// TS: should throw NoSuchToolError when tools is null #[tokio::test] async fn missing_tools_bypasses_repair_like_ai_sdk() { let calls = Arc::new(AtomicUsize::new(0)); @@ -224,6 +157,7 @@ async fn missing_tools_bypasses_repair_like_ai_sdk() { )); } +/// TS: should re-throw error if tool call repair returns null #[tokio::test] async fn repair_returning_none_keeps_the_original_failure() { let repair = ToolCallRepair::new(|_| async { Ok(None) }); @@ -245,6 +179,7 @@ async fn repair_returning_none_keeps_the_original_failure() { )); } +/// TS: should throw ToolCallRepairError if repairToolCall throws #[tokio::test] async fn repair_failure_keeps_both_typed_errors() { let repair = ToolCallRepair::new(|context| async move { @@ -269,6 +204,7 @@ async fn repair_failure_keeps_both_typed_errors() { )); } +/// TS: should successfully parse a valid provider-executed dynamic tool call #[tokio::test] async fn provider_executed_dynamic_calls_do_not_require_a_local_tool() { let mut tool_call = raw("provider_search", r#"{"query":"rust"}"#); diff --git a/aimux-providers/tests/e2e_test.rs b/aimux-providers/tests/e2e_test.rs index e2301cbbd..5e023300e 100644 --- a/aimux-providers/tests/e2e_test.rs +++ b/aimux-providers/tests/e2e_test.rs @@ -9,6 +9,7 @@ use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; +use aimux_core::language_model_message::ToolResultOutput; use aimux_core::message::{MessageContent, ModelMessage, Role}; use aimux_core::stream_part::TextStreamPart; use aimux_core::tool::{FunctionTool, Tool, ToolCall, ToolChoice}; @@ -607,10 +608,15 @@ async fn e2e_openai_tool_call_round_trip() { }, ModelMessage { role: Role::Tool, - content: MessageContent::Parts(vec![ContentPart::tool_result( - "call_abc", - json!({"temperature": 22, "condition": "sunny"}), - )]), + content: MessageContent::Parts(vec![ContentPart::ToolResult { + tool_call_id: "call_abc".into(), + tool_name: "get_weather".into(), + output: ToolResultOutput::Json { + value: json!({"temperature": 22, "condition": "sunny"}), + provider_options: None, + }, + provider_options: None, + }]), }, ]; diff --git a/bindings/node/__test__/e2e.test.ts b/bindings/node/__test__/e2e.test.ts index ad174d0e8..ef37d473e 100644 --- a/bindings/node/__test__/e2e.test.ts +++ b/bindings/node/__test__/e2e.test.ts @@ -418,7 +418,8 @@ test('e2e: OpenAI tool-call full round-trip (ToolCall → ToolResult → final t content: [{ type: 'tool_result', tool_call_id: 'call_abc', - result: { temperature: 22, condition: 'sunny' }, + tool_name: 'get_weather', + output: { type: 'json', value: { temperature: 22, condition: 'sunny' } }, }], }, ] diff --git a/bindings/node/__test__/reasoning_replay.test.ts b/bindings/node/__test__/reasoning_replay.test.ts index 7498b0dad..c87960a3a 100644 --- a/bindings/node/__test__/reasoning_replay.test.ts +++ b/bindings/node/__test__/reasoning_replay.test.ts @@ -52,9 +52,9 @@ const CHAT_RESPONSE = JSON.stringify({ usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 }, }) -// ── Issue 1: tool role ContentPart[] with the legacy `output` field ───────── +// ── Issue 1: tool role ContentPart[] with a structured `output` ───────────── -test('issue 1: tool_result with legacy `output` field is accepted and carries tool_call_id', async (t) => { +test('issue 1: tool_result with a structured `output` is accepted and carries tool_call_id', async (t) => { let requestBody: any = null const { server, url } = await startMockServer(async (req, res) => { requestBody = await readBody(req) @@ -65,8 +65,7 @@ test('issue 1: tool_result with legacy `output` field is accepted and carries to try { const model = await openai('test-key', 'deepseek-v4-flash', url) - // The exact shape from the user's failing case: a tool message whose - // ContentPart uses the legacy `output` field (Vercel AI SDK / 0.1.0 TS). + // A tool message whose ContentPart carries the AI SDK `ToolResultPart` shape. const messages = [ { role: 'user', content: 'write a file' }, { @@ -83,7 +82,8 @@ test('issue 1: tool_result with legacy `output` field is accepted and carries to content: [{ type: 'tool_result', tool_call_id: 'tc1', - output: 'Successfully wrote to /tmp/test.txt', + tool_name: 'write_file', + output: { type: 'text', value: 'Successfully wrote to /tmp/test.txt' }, }], }, ] diff --git a/bindings/node/__test__/roundtrip-fields.test.ts b/bindings/node/__test__/roundtrip-fields.test.ts index a2237a1cf..538deab70 100644 --- a/bindings/node/__test__/roundtrip-fields.test.ts +++ b/bindings/node/__test__/roundtrip-fields.test.ts @@ -125,7 +125,7 @@ test('roundtrip: google toolName reaches functionResponse.name', async (t) => { }, { role: 'tool', - content: [{ type: 'tool_result', tool_call_id: 'call-1', tool_name: 'weather', result: { temp: 70 } }], + content: [{ type: 'tool_result', tool_call_id: 'call-1', tool_name: 'weather', output: { type: 'json', value: { temp: 70 } } }], }, ] await model.generateText(JSON.stringify(prompt)) diff --git a/bindings/node/src/types/AiMuxError.ts b/bindings/node/src/types/AiMuxError.ts index 0a44a0a3e..f468763c1 100644 --- a/bindings/node/src/types/AiMuxError.ts +++ b/bindings/node/src/types/AiMuxError.ts @@ -9,28 +9,28 @@ import type { RetryError } from "./RetryError"; * failure came from. `ApiCallError` is boxed only to keep the Rust enum * compact; serde and every binding still observe the same object shape. */ -export type AiMuxError = { "ApiCall": ApiCallError } | { "Retry": RetryError } | { "JsonParse": string } | { "NoOutputGenerated": string } | { "InvalidResponseData": string } | { "ToolCallNotFoundForApproval": { tool_call_id: string, approval_id: string, } } | { "NoSuchTool": { tool_name: string, available_tools?: Array | null, +export type AiMuxError = { "ApiCall": ApiCallError } | { "Retry": RetryError } | { "JsonParse": string } | { "NoOutputGenerated": string } | { "InvalidResponseData": string } | { "ToolCallNotFoundForApproval": { tool_call_id: string, approval_id: string, } } | { "NoSuchTool": { tool_name: string, available_tools?: Array | null, /** * Original argument text, when supplied by the provider. Absent in * older serialized errors and errors constructed without a call. */ -tool_input?: string | null, } } | { "InvalidToolInput": { tool_name: string, tool_input: string, cause: string, } } | { "ToolCallRepair": { original_error: AiMuxError, cause: AiMuxError, } } | { "InvalidArgument": string } | { "InvalidPrompt": string } | { "LoadApiKey": { +tool_input?: string | null, } } | { "InvalidToolInput": { tool_name: string, tool_input: string, cause: string, } } | { "ToolCallRepair": { original_error: AiMuxError, cause: AiMuxError, } } | { "InvalidArgument": string } | { "InvalidPrompt": string } | { "LoadApiKey": { /** * The environment variable consulted as the fallback. */ -env_var: string, +env_var: string, /** * What the key is for, e.g. `"OpenAI"`. */ -description: string, } } | { "LoadSetting": { +description: string, } } | { "LoadSetting": { /** * The environment variable consulted as the fallback. */ -env_var: string, +env_var: string, /** * The setting's parameter name, e.g. `"region"`. */ -name: string, } } | { "TokenExpired": string } | { "UnsupportedFunctionality": string } | { "NoSuchModel": { model_id: string, +name: string, } } | { "TokenExpired": string } | { "UnsupportedFunctionality": string } | { "NoSuchModel": { model_id: string, /** * What kind of model was requested (`"languageModel"`, * `"imageModel"`, …), the AI SDK's `modelType`. diff --git a/bindings/node/src/types/ContentPart.ts b/bindings/node/src/types/ContentPart.ts index fd965cc5e..b64f10087 100644 --- a/bindings/node/src/types/ContentPart.ts +++ b/bindings/node/src/types/ContentPart.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { GeneratedFileData } from "./GeneratedFileData"; +import type { ToolResultOutput } from "./ToolResultOutput"; import type { JsonValue } from "./serde_json/JsonValue"; /** @@ -32,34 +33,7 @@ provider_executed?: boolean | null, * Provider-specific options for this part (e.g. * `anthropic.cacheControl`). */ -provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "tool_result", tool_call_id: string, -/** - * The tool's output (usually a JSON value or plain text). - * - * Accepts the legacy `output` field name (used by the Vercel AI SDK - * and the 0.1.0 TypeScript bindings) as an alias during deserialization, - * so multi-part tool messages constructed either way round-trip - * correctly. Serialization always emits `result`. - */ -result: JsonValue, -/** - * The name of the tool that produced this result (optional on the - * user-input side; providers that need it can look it up from the - * preceding tool call). - */ -tool_name?: string | null, -/** - * Whether the result is an error or error message. - */ -is_error?: boolean | null, -/** - * Whether the result is preliminary (replaces prior, e.g. image previews). - */ -preliminary?: boolean | null, -/** - * Whether the tool is dynamic (defined at runtime, e.g. MCP tools). - */ -dynamic?: boolean | null, +provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "tool_result", tool_call_id: string, tool_name: string, output: ToolResultOutput, /** * Provider-specific options for this part (e.g. * `anthropic.cacheControl`). diff --git a/bindings/node/src/types/GenerateTextResult.ts b/bindings/node/src/types/GenerateTextResult.ts index 98b9ac920..1a5b0ef09 100644 --- a/bindings/node/src/types/GenerateTextResult.ts +++ b/bindings/node/src/types/GenerateTextResult.ts @@ -18,74 +18,74 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * Result of `generate_text` (user-facing). */ -export type GenerateTextResult = { +export type GenerateTextResult = { /** * Ordered generated content with parsed tool calls. */ -content?: Array>, +content?: Array>, /** * The generated text (concatenated from all text content parts). */ -text: string, +text: string, /** * Tool calls requested by the model. */ -tool_calls: Array, +tool_calls: Array, /** * Why generation stopped. */ -finish_reason: FinishReason, +finish_reason: FinishReason, /** * Token usage. */ -usage: Usage, +usage: Usage, /** * Warnings from the provider. */ -warnings: Array, +warnings: Array, /** * Raw provider result (for advanced use). */ -raw: GenerateResult, +raw: GenerateResult, /** * Reasoning / thinking segments from the model. */ -reasoning: Array, +reasoning: Array, /** * Concatenated reasoning text (convenience for `reasoning.iter().map(text).join("")`). */ -reasoning_text: string, +reasoning_text: string, /** * Sources / citations (search-preview models). */ -sources: Array, +sources: Array, /** * Files generated by the model. */ -files: Array, +files: Array, /** * Assistant messages ready to append to the prompt for the next turn * (solves the multi-turn "manually build assistant message" footgun). */ -response_messages: Array, +response_messages: Array, /** * The raw provider-specific finish reason string (e.g. "stop", * "end_turn", "safety"). Useful when `finish_reason.unified` is `Other`. */ -raw_finish_reason: string | null, +raw_finish_reason: string | null, /** * Provider-specific metadata (e.g. Anthropic cache info). Mirrored from * `raw.provider_metadata` for top-level convenience. */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** * Request information from the provider. */ -request: RequestInfo, +request: RequestInfo, /** * Response metadata, headers, and body from the provider. */ -response: ResponseInfo, +response: ResponseInfo, /** * Total token usage across all steps. In single-step mode (aimux's * default), `total_usage` equals `usage`. Provided for AI SDK parity. diff --git a/bindings/node/src/types/StreamPart.ts b/bindings/node/src/types/StreamPart.ts index 03c3f862f..a17cf1e48 100644 --- a/bindings/node/src/types/StreamPart.ts +++ b/bindings/node/src/types/StreamPart.ts @@ -30,7 +30,7 @@ dynamic?: boolean | null, /** * Optional title for the tool call. */ -title?: string | null, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolCall": RawToolCall } | { "ToolResult": ToolResult } | { "File": GeneratedFile } | { "ReasoningFile": GeneratedFile } | { "Custom": { kind: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolApprovalRequest": RawToolApprovalRequest } | { "ReasoningStart": { id: string, +title?: string | null, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolCall": RawToolCall } | { "ToolResult": ToolResult } | { "File": GeneratedFile } | { "ReasoningFile": GeneratedFile } | { "Custom": { kind: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolApprovalRequest": RawToolApprovalRequest } | { "ReasoningStart": { id: string, /** * Provider-specific metadata (e.g. xAI `itemId`). */ diff --git a/bindings/node/src/types/StreamTextResultAggregated.ts b/bindings/node/src/types/StreamTextResultAggregated.ts index bf8b91600..fb3fb902a 100644 --- a/bindings/node/src/types/StreamTextResultAggregated.ts +++ b/bindings/node/src/types/StreamTextResultAggregated.ts @@ -19,68 +19,68 @@ import type { JsonValue } from "./serde_json/JsonValue"; * `GenerateTextResult`'s user-facing fields (without `raw`, since streaming * has no `GenerateResult` equivalent). */ -export type StreamTextResultAggregated = { +export type StreamTextResultAggregated = { /** * Ordered content from the last generation step. */ -content?: Array>, +content?: Array>, /** * The generated text (concatenated `TextDelta`). */ -text: string, +text: string, /** * Reasoning / thinking segments. */ -reasoning: Array, +reasoning: Array, /** * Concatenated reasoning text. */ -reasoning_text: string, +reasoning_text: string, /** * Tool calls requested by the model. */ -tool_calls: Array, +tool_calls: Array, /** * Sources / citations. */ -sources: Array, +sources: Array, /** * Files generated by the model. */ -files: Array, +files: Array, /** * Why generation stopped. */ -finish_reason: FinishReason, +finish_reason: FinishReason, /** * Raw provider-specific finish reason string. */ -raw_finish_reason: string | null, +raw_finish_reason: string | null, /** * Token usage. */ -usage: Usage, +usage: Usage, /** * Total token usage across all steps. In single-step mode (aimux's * default), `total_usage` equals `usage`. Provided for AI SDK parity. */ -total_usage: Usage, +total_usage: Usage, /** * Warnings from the provider. */ -warnings: Array, +warnings: Array, /** * Provider-specific metadata from the Finish chunk. */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** * Request information from the provider. */ -request: RequestInfo, +request: RequestInfo, /** * Response metadata and headers from the last step. */ -response: ResponseInfo, +response: ResponseInfo, /** * Assistant messages ready to append for the next turn. */ diff --git a/bindings/node/src/types/TextStreamPart.ts b/bindings/node/src/types/TextStreamPart.ts index 7457a4b09..ed76d8312 100644 --- a/bindings/node/src/types/TextStreamPart.ts +++ b/bindings/node/src/types/TextStreamPart.ts @@ -15,27 +15,27 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * A public call-layer stream chunk, with parsed tool calls. */ -export type TextStreamPart = { "TextStart": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TextDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TextEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "StreamStart": { warnings: Array, } } | { "Finish": { finish_reason: FinishReason, usage: Usage, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "FinishStep": { finish_reason: FinishReason, usage: Usage, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, response: ResponseInfo, } } | { "Error": { error: AiMuxError, } } | { "ToolInputStart": { id: string, tool_name: string, +export type TextStreamPart = { "TextStart": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TextDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TextEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "StreamStart": { warnings: Array, } } | { "Finish": { finish_reason: FinishReason, usage: Usage, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "FinishStep": { finish_reason: FinishReason, usage: Usage, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, response: ResponseInfo, } } | { "Error": { error: AiMuxError, } } | { "ToolInputStart": { id: string, tool_name: string, /** * Whether the tool call will be executed by the provider. */ -provider_executed?: boolean | null, +provider_executed?: boolean | null, /** * Whether the tool is dynamic (defined at runtime, e.g. MCP tools). */ -dynamic?: boolean | null, +dynamic?: boolean | null, /** * Optional title for the tool call. */ -title?: string | null, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolCall": ToolCall } | { "ToolResult": ToolResult } | { "File": GeneratedFile } | { "ReasoningFile": ReasoningFileOutput } | { "Custom": { kind: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolApprovalRequest": ToolApprovalRequestOutput } | { "ReasoningStart": { id: string, +title?: string | null, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolCall": ToolCall } | { "ToolResult": ToolResult } | { "File": GeneratedFile } | { "ReasoningFile": ReasoningFileOutput } | { "Custom": { kind: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolApprovalRequest": ToolApprovalRequestOutput } | { "ReasoningStart": { id: string, /** * Provider-specific metadata (e.g. xAI `itemId`). */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ReasoningDelta": { id: string, delta: string, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ReasoningDelta": { id: string, delta: string, /** * Provider-specific metadata (e.g. xAI `itemId`). */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ReasoningEnd": { id: string, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ReasoningEnd": { id: string, /** * Provider-specific metadata (e.g. xAI `itemId`, `reasoningEncryptedContent`). */ diff --git a/bindings/python/python/aimux/wrapper.py b/bindings/python/python/aimux/wrapper.py index e2e42d3d9..e8ecfa95a 100644 --- a/bindings/python/python/aimux/wrapper.py +++ b/bindings/python/python/aimux/wrapper.py @@ -864,11 +864,8 @@ class _ToolCallContentPart(BaseModel): class _ToolResultContentPart(BaseModel): type: Literal["tool_result"] = "tool_result" tool_call_id: str - result: Any - tool_name: Optional[str] = None - is_error: Optional[bool] = None - preliminary: Optional[bool] = None - dynamic: Optional[bool] = None + tool_name: str + output: Any provider_options: Optional[Any] = None diff --git a/bindings/python/tests/test_e2e.py b/bindings/python/tests/test_e2e.py index 6a6157868..4cd739061 100644 --- a/bindings/python/tests/test_e2e.py +++ b/bindings/python/tests/test_e2e.py @@ -495,7 +495,9 @@ def test_tool_call_round_trip(self): ]}, {"role": "tool", "content": [ {"type": "tool_result", "tool_call_id": "call_abc", - "result": {"temperature": 22, "condition": "sunny"}}, + "tool_name": "get_weather", + "output": {"type": "json", + "value": {"temperature": 22, "condition": "sunny"}}}, ]}, ] diff --git a/bindings/python/tests/test_wrapper.py b/bindings/python/tests/test_wrapper.py index 19caf5613..3eb348ce3 100644 --- a/bindings/python/tests/test_wrapper.py +++ b/bindings/python/tests/test_wrapper.py @@ -344,7 +344,11 @@ def test_round_trip_with_typed_content_parts(self): { "type": "tool_result", "tool_call_id": "call_abc", - "result": {"temperature": 22, "condition": "sunny"}, + "tool_name": "get_weather", + "output": { + "type": "json", + "value": {"temperature": 22, "condition": "sunny"}, + }, } ], ), diff --git a/tools/aimux-web/src/wire.rs b/tools/aimux-web/src/wire.rs index e9457b0d2..1cdfd834f 100644 --- a/tools/aimux-web/src/wire.rs +++ b/tools/aimux-web/src/wire.rs @@ -11,6 +11,7 @@ use ts_rs::TS; use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::generate::GenerateTextOptions; +use aimux_core::language_model_message::ToolResultOutput; use aimux_core::message::{MessageContent, ModelMessage, ModelPrompt, Role}; use aimux_core::tool::{FunctionTool, Tool}; @@ -125,9 +126,10 @@ pub enum WireContentPart { }, ToolResult { tool_call_id: String, - result: Value, - #[serde(default, skip_serializing_if = "Option::is_none")] - is_error: Option, + tool_name: String, + /// The core type, imported by the frontend through the `@aimux/types` alias. + #[ts(type = "import(\"@aimux/types/ToolResultOutput\").ToolResultOutput")] + output: ToolResultOutput, }, } @@ -181,15 +183,12 @@ fn to_content_part(p: &WireContentPart) -> ContentPart { }, WireContentPart::ToolResult { tool_call_id, - result, - is_error, + tool_name, + output, } => ContentPart::ToolResult { tool_call_id: tool_call_id.clone(), - result: result.clone(), - tool_name: None, - is_error: *is_error, - preliminary: None, - dynamic: None, + tool_name: tool_name.clone(), + output: output.clone(), provider_options: None, }, } @@ -443,8 +442,11 @@ mod tests { role: "tool".into(), content: vec![WireContentPart::ToolResult { tool_call_id: "c1".into(), - result: serde_json::json!({"value": 2}), - is_error: None, + tool_name: "calc".into(), + output: ToolResultOutput::Json { + value: serde_json::json!({"value": 2}), + provider_options: None, + }, }], }, ]; diff --git a/tools/aimux-web/web/src/agent/engine.ts b/tools/aimux-web/web/src/agent/engine.ts index 41f7170c0..4c2828e37 100644 --- a/tools/aimux-web/web/src/agent/engine.ts +++ b/tools/aimux-web/web/src/agent/engine.ts @@ -165,8 +165,8 @@ export async function runAgent( toolResults.push({ type: 'tool_result', tool_call_id: tc.id, - result: out.result, - is_error: false, + tool_name: tc.name, + output: { type: typeof out.result === 'string' ? 'text' : 'json', value: out.result }, }) } catch (e) { tc.result = String(e) @@ -174,8 +174,8 @@ export async function runAgent( toolResults.push({ type: 'tool_result', tool_call_id: tc.id, - result: String(e), - is_error: true, + tool_name: tc.name, + output: { type: 'error-text', value: String(e) }, }) } finally { tc.executing = false diff --git a/tools/aimux-web/web/src/types/generated/WireContentPart.ts b/tools/aimux-web/web/src/types/generated/WireContentPart.ts index c3a3fd32d..610984a4e 100644 --- a/tools/aimux-web/web/src/types/generated/WireContentPart.ts +++ b/tools/aimux-web/web/src/types/generated/WireContentPart.ts @@ -4,4 +4,8 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * A content part in the wire format. */ -export type WireContentPart = { "type": "text", text: string, } | { "type": "tool_call", tool_call_id: string, tool_name: string, input: JsonValue, provider_executed?: boolean | null, } | { "type": "tool_result", tool_call_id: string, result: JsonValue, is_error?: boolean | null, }; +export type WireContentPart = { "type": "text", text: string, } | { "type": "tool_call", tool_call_id: string, tool_name: string, input: JsonValue, provider_executed?: boolean | null, } | { "type": "tool_result", tool_call_id: string, tool_name: string, +/** + * The core type, imported by the frontend through the `@aimux/types` alias. + */ +output: import("@aimux/types/ToolResultOutput").ToolResultOutput, }; From c7f21fcf6c867b22b436bcf5106acca4794932a6 Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Wed, 7 Oct 2026 16:23:42 +0800 Subject: [PATCH 03/10] refactor!: public JSON equals the AI SDK JSON Every public type in aimux-core serializes camelCase fields, unions carry upstream's type tags, and optional fields are omitted instead of null. The generated TypeScript types, the web wire mirror and the FFI, CLI and replay readers follow. Vendor request/response types are unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 ++ aimux-core/src/content.rs | 27 +++- aimux-core/src/embedding_model.rs | 22 +++ aimux-core/src/error.rs | 149 +++++++++++++++++- aimux-core/src/evaluation_model.rs | 40 ++++- aimux-core/src/files_model.rs | 17 ++ aimux-core/src/generate.rs | 58 ++++++- aimux-core/src/image_model.rs | 46 +++++- aimux-core/src/language_model_message.rs | 52 +++++- aimux-core/src/message.rs | 1 + aimux-core/src/moa.rs | 1 + aimux-core/src/model_catalogue.rs | 36 ++++- aimux-core/src/model_id.rs | 1 + aimux-core/src/options.rs | 63 +++++++- aimux-core/src/parse_tool_call.rs | 55 ++++--- aimux-core/src/recording.rs | 74 ++++++--- aimux-core/src/replay.rs | 18 +-- aimux-core/src/reranking_model.rs | 35 ++++ aimux-core/src/result.rs | 48 +++++- aimux-core/src/search_model.rs | 44 ++++++ aimux-core/src/session.rs | 2 + aimux-core/src/shared.rs | 31 ++++ aimux-core/src/skills_model.rs | 17 ++ aimux-core/src/speech_model.rs | 37 +++++ aimux-core/src/stream_part.rs | 48 ++++++ aimux-core/src/tool.rs | 28 +++- aimux-core/src/trace/fingerprint.rs | 3 +- aimux-core/src/trace/record.rs | 30 +++- aimux-core/src/trace/store.rs | 24 +++ aimux-core/src/trace/verdict.rs | 6 + aimux-core/src/transcription_model.rs | 79 ++++++++++ aimux-core/src/types.rs | 43 ++++- aimux-core/src/video_model.rs | 62 +++++++- aimux-core/tests/error_value_golden_test.rs | 86 ++++------ aimux-ffi/src/lib.rs | 44 +++--- aimux-ffi/tests/exports_smoke_test.rs | 34 ++-- aimux-ffi/tests/provider_handle_test.rs | 2 +- aimux-providers/src/catalogue.rs | 1 + .../tests/google_evaluation_test.rs | 4 +- .../tests/mistral_upstream_e2e_test.rs | 8 +- .../tests/openai_upstream_e2e_test.rs | 54 ++++--- .../tests/vertex_upstream_e2e_test.rs | 4 +- .../tests/xai_transcription_test.rs | 67 ++++---- .../tests/xai_upstream_e2e_test.rs | 4 +- bindings/node/src/types/AiMuxError.ts | 28 +--- bindings/node/src/types/ApiCallError.ts | 14 +- bindings/node/src/types/AudioChunk.ts | 2 +- bindings/node/src/types/AudioData.ts | 2 +- bindings/node/src/types/AudioInput.ts | 2 +- bindings/node/src/types/CallOptions.ts | 36 ++--- bindings/node/src/types/ContentPart.ts | 10 +- bindings/node/src/types/CustomPart.ts | 2 +- .../node/src/types/EmbeddingCallOptions.ts | 8 +- bindings/node/src/types/EmbeddingResponse.ts | 4 +- bindings/node/src/types/EmbeddingResult.ts | 6 +- bindings/node/src/types/EvaluationAnswer.ts | 4 +- .../node/src/types/EvaluationCallOptions.ts | 2 +- bindings/node/src/types/EvaluationQuestion.ts | 2 +- bindings/node/src/types/EvaluationResult.ts | 2 +- bindings/node/src/types/EvaluationRounding.ts | 2 +- bindings/node/src/types/EvaluationUsage.ts | 2 +- bindings/node/src/types/FileBytes.ts | 2 +- bindings/node/src/types/FileData.ts | 2 +- bindings/node/src/types/FilePart.ts | 2 +- bindings/node/src/types/Fingerprint.ts | 6 +- bindings/node/src/types/FinishReason.ts | 2 +- bindings/node/src/types/FunctionTool.ts | 10 +- bindings/node/src/types/GenerateContent.ts | 2 +- .../node/src/types/GenerateObjectResult.ts | 8 +- bindings/node/src/types/GenerateResult.ts | 8 +- .../node/src/types/GenerateTextOptions.ts | 14 +- bindings/node/src/types/GenerateTextResult.ts | 14 +- bindings/node/src/types/GeneratedFile.ts | 2 +- bindings/node/src/types/GeneratedFileData.ts | 2 +- bindings/node/src/types/HttpExchange.ts | 6 +- bindings/node/src/types/HttpRecord.ts | 2 +- bindings/node/src/types/ImageCallOptions.ts | 20 +-- bindings/node/src/types/ImageFile.ts | 8 +- bindings/node/src/types/ImageFileData.ts | 2 +- bindings/node/src/types/ImageOutputs.ts | 2 +- bindings/node/src/types/ImageResponse.ts | 6 +- bindings/node/src/types/ImageResult.ts | 4 +- bindings/node/src/types/ImageUsage.ts | 6 +- bindings/node/src/types/InputAudioFormat.ts | 4 +- bindings/node/src/types/InputTokenUsage.ts | 8 +- .../node/src/types/LanguageModelMessage.ts | 2 +- .../src/types/LanguageModelReasoningPart.ts | 2 +- bindings/node/src/types/ModelCapabilities.ts | 4 +- bindings/node/src/types/ModelCost.ts | 2 +- bindings/node/src/types/ModelLimits.ts | 6 +- bindings/node/src/types/ModelSpec.ts | 6 +- bindings/node/src/types/OutcomeRecord.ts | 6 +- bindings/node/src/types/OutputTokenUsage.ts | 6 +- bindings/node/src/types/PrefixBreak.ts | 2 +- bindings/node/src/types/ProviderAuditSpec.ts | 6 +- bindings/node/src/types/ProviderRecord.ts | 4 +- .../node/src/types/RawToolApprovalRequest.ts | 2 +- bindings/node/src/types/RawToolCall.ts | 2 +- .../node/src/types/ReasoningFileOutput.ts | 2 +- bindings/node/src/types/ReasoningFilePart.ts | 2 +- bindings/node/src/types/ReasoningOutput.ts | 2 +- bindings/node/src/types/ReasoningPart.ts | 2 +- bindings/node/src/types/ReasoningSpec.ts | 12 +- bindings/node/src/types/Recording.ts | 10 +- bindings/node/src/types/RequestCacheHints.ts | 2 +- bindings/node/src/types/RequestInfo.ts | 2 +- .../node/src/types/RerankingCallOptions.ts | 10 +- bindings/node/src/types/RerankingDocuments.ts | 2 +- bindings/node/src/types/RerankingRank.ts | 2 +- bindings/node/src/types/RerankingResponse.ts | 10 +- bindings/node/src/types/RerankingResult.ts | 6 +- bindings/node/src/types/ResponseFormat.ts | 2 +- bindings/node/src/types/ResponseInfo.ts | 10 +- bindings/node/src/types/ResponseMetadata.ts | 2 +- bindings/node/src/types/ResponseRecord.ts | 4 +- bindings/node/src/types/RuntimeModel.ts | 4 +- bindings/node/src/types/SearchCallOptions.ts | 18 +-- bindings/node/src/types/SearchResponse.ts | 4 +- bindings/node/src/types/SearchResult.ts | 6 +- bindings/node/src/types/SearchResultItem.ts | 12 +- bindings/node/src/types/SessionCall.ts | 4 +- bindings/node/src/types/SessionChainView.ts | 2 +- bindings/node/src/types/SessionStepStat.ts | 8 +- bindings/node/src/types/SessionView.ts | 2 +- bindings/node/src/types/Source.ts | 2 +- bindings/node/src/types/SpeechCallOptions.ts | 18 +-- bindings/node/src/types/SpeechRequest.ts | 2 +- bindings/node/src/types/SpeechResponse.ts | 8 +- bindings/node/src/types/SpeechResult.ts | 4 +- bindings/node/src/types/StreamPart.ts | 14 +- bindings/node/src/types/StreamResponseInfo.ts | 2 +- .../src/types/StreamTextResultAggregated.ts | 14 +- bindings/node/src/types/TextContent.ts | 2 +- bindings/node/src/types/TextPart.ts | 2 +- bindings/node/src/types/TextStreamPart.ts | 14 +- .../node/src/types/TimeoutConfiguration.ts | 8 +- bindings/node/src/types/TimingRecord.ts | 2 +- .../src/types/ToolApprovalRequestOutput.ts | 2 +- .../src/types/ToolApprovalResponsePart.ts | 2 +- bindings/node/src/types/ToolCall.ts | 14 +- bindings/node/src/types/ToolCallPart.ts | 2 +- .../node/src/types/ToolCallRepairReply.ts | 4 +- bindings/node/src/types/ToolResult.ts | 10 +- bindings/node/src/types/ToolResultContent.ts | 2 +- bindings/node/src/types/ToolResultOutput.ts | 2 +- bindings/node/src/types/ToolResultPart.ts | 2 +- bindings/node/src/types/TraceFilter.ts | 2 +- bindings/node/src/types/TraceRecord.ts | 18 +-- bindings/node/src/types/TraceStats.ts | 2 +- .../src/types/TranscriptionCallOptions.ts | 10 +- .../node/src/types/TranscriptionRequest.ts | 2 +- .../node/src/types/TranscriptionResponse.ts | 8 +- .../node/src/types/TranscriptionResult.ts | 8 +- .../node/src/types/TranscriptionSegment.ts | 4 +- .../node/src/types/TranscriptionStreamPart.ts | 2 +- .../node/src/types/UploadFileCallOptions.ts | 6 +- bindings/node/src/types/UploadFileData.ts | 2 +- bindings/node/src/types/UploadFileResult.ts | 8 +- .../node/src/types/UploadSkillCallOptions.ts | 2 +- bindings/node/src/types/UploadSkillResult.ts | 2 +- bindings/node/src/types/Usage.ts | 4 +- bindings/node/src/types/UsageSnapshot.ts | 4 +- bindings/node/src/types/Verdict.ts | 4 +- bindings/node/src/types/VideoCallOptions.ts | 30 ++-- bindings/node/src/types/VideoData.ts | 2 +- bindings/node/src/types/VideoFile.ts | 8 +- bindings/node/src/types/VideoFileData.ts | 2 +- bindings/node/src/types/VideoFrameImage.ts | 2 +- bindings/node/src/types/VideoFrameType.ts | 2 +- bindings/node/src/types/VideoPollOptions.ts | 4 +- bindings/node/src/types/VideoResponse.ts | 6 +- bindings/node/src/types/VideoResult.ts | 2 +- bindings/node/src/types/Warning.ts | 2 +- tools/aimux-cli/src/probe/offline.rs | 30 ++-- tools/aimux-cli/src/probe/session.rs | 20 +-- tools/aimux-web/examples/gen_demo_fixtures.rs | 24 +-- tools/aimux-web/examples/gen_fixture.rs | 4 +- tools/aimux-web/src/api/calls.rs | 4 +- tools/aimux-web/src/api/replay.rs | 8 +- tools/aimux-web/src/api/sessions.rs | 2 +- tools/aimux-web/src/wire.rs | 67 +++++--- tools/aimux-web/web/src/agent/engine.ts | 60 ++++--- tools/aimux-web/web/src/api/client.ts | 18 +-- .../aimux-web/web/src/components/StepCard.vue | 2 +- .../src/types/generated/WireCallRequest.ts | 10 +- .../src/types/generated/WireCallResponse.ts | 2 +- .../src/types/generated/WireContentPart.ts | 2 +- .../web/src/types/generated/WireMeta.ts | 2 +- .../web/src/types/generated/WireOptions.ts | 6 +- .../web/src/types/generated/WireTool.ts | 2 +- tools/aimux-web/web/src/views/Agent.vue | 2 +- tools/aimux-web/web/src/views/Playground.vue | 28 ++-- tools/aimux-web/web/src/views/Replay.vue | 16 +- tools/aimux-web/web/src/views/Sessions.vue | 18 +-- tools/aimux-web/web/src/views/Traces.vue | 42 ++--- 195 files changed, 1800 insertions(+), 827 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42a8b05b0..5979423e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking +- aimux's public JSON equals the AI SDK's JSON. Struct fields serialize + camelCase (`toolCallId`, `providerOptions`, `inputTokens`); unions carry the + AI SDK's tag and strings (`{"type":"text-delta"}`, `"tool-call"`, + `"reasoning-file"`, Source `sourceType`, Warning/FileData/ResponseFormat/ + stream parts no longer externally tagged); byte payloads are untagged + (array of numbers or base64 string); optional fields are omitted instead of + `null` and generate as `field?: T`; `AiMuxError` is tagged by `name` + (`AI_APICallError`, `AI_RetryError`, ...). Vendor request and response types + and the OpenAI chat-completion output keep their vendor wire formats. + Persisted recordings and trace records change with the same rule. + - 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. diff --git a/aimux-core/src/content.rs b/aimux-core/src/content.rs index aa82b1202..398108ade 100644 --- a/aimux-core/src/content.rs +++ b/aimux-core/src/content.rs @@ -11,13 +11,18 @@ use crate::shared::SharedProviderOptions; /// /// Shared between `ModelMessage` (user-facing) and `LanguageModelPrompt` (provider-facing). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[serde(tag = "type", rename_all = "snake_case")] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] #[ts(export)] pub enum ContentPart { /// A text segment. Text { text: String, /// Provider-specific options for this part (e.g. `openai.promptCacheBreakpoint`). + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -25,6 +30,7 @@ pub enum ContentPart { /// Provider-specific custom content. Custom { kind: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -33,6 +39,7 @@ pub enum ContentPart { ReasoningFile { data: crate::shared::GeneratedFileData, media_type: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -41,12 +48,16 @@ pub enum ContentPart { ToolApprovalRequest { approval_id: String, tool_call_id: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] reason: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] is_automatic: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] signature: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] input_schema_input: Option, }, @@ -55,6 +66,7 @@ pub enum ContentPart { Image { image: Vec, media_type: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -63,8 +75,10 @@ pub enum ContentPart { File { data: Vec, media_type: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] filename: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -78,8 +92,10 @@ pub enum ContentPart { FileBase64 { data: String, media_type: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] filename: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -90,6 +106,7 @@ pub enum ContentPart { FileUrl { url: String, media_type: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -102,8 +119,10 @@ pub enum ContentPart { FileReference { media_type: String, reference: Value, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] filename: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -117,10 +136,13 @@ pub enum ContentPart { /// `redacted_thinking` block is emitted). Reasoning { text: String, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] signature: Option, /// Provider-specific options for this part (e.g. /// `anthropic.signature`, `anthropic.redactedData`, /// `anthropic.cacheControl`). + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -134,10 +156,12 @@ pub enum ContentPart { /// Whether the tool call is executed by the provider rather than by /// the client. This is part of the prompt contract because providers /// need it to replay server tool calls on a later turn. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_executed: Option, /// Provider-specific options for this part (e.g. /// `anthropic.cacheControl`). + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -149,6 +173,7 @@ pub enum ContentPart { output: ToolResultOutput, /// Provider-specific options for this part (e.g. /// `anthropic.cacheControl`). + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, diff --git a/aimux-core/src/embedding_model.rs b/aimux-core/src/embedding_model.rs index e97ff1dab..44d74a405 100644 --- a/aimux-core/src/embedding_model.rs +++ b/aimux-core/src/embedding_model.rs @@ -25,6 +25,7 @@ pub type Embedding = Vec; /// /// Aligned with V4 `EmbeddingModelV4CallOptions`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct EmbeddingCallOptions { /// List of text values to generate embeddings for. @@ -36,15 +37,23 @@ pub struct EmbeddingCallOptions { pub abort_signal: Option, /// Per-call retry override. `None` uses the model default. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_retries: Option, /// Per-call operation timeout. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout: Option, /// Additional provider-specific options, keyed by provider name. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_options: Option, /// Additional HTTP headers to send with the request. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option, } @@ -64,6 +73,7 @@ impl EmbeddingCallOptions { /// Token usage for an embedding call. Embeddings only report input tokens. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct EmbeddingUsage { /// Number of input tokens consumed. @@ -74,18 +84,25 @@ pub struct EmbeddingUsage { /// /// Aligned with V4 `EmbeddingModelV4Result`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct EmbeddingResult { /// Generated embeddings, in the same order as the input `values`. pub embeddings: Vec, /// Token usage (input tokens only). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub usage: Option, /// Additional provider-specific metadata. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_metadata: Option, /// Optional response information for debugging. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub response: Option, /// Warnings for the call, e.g. unsupported settings. @@ -94,11 +111,16 @@ pub struct EmbeddingResult { /// Debugging response info specific to embedding calls. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct EmbeddingResponse { /// Response headers. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option, /// The response body (opaque JSON). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub body: Option, } diff --git a/aimux-core/src/error.rs b/aimux-core/src/error.rs index d9c7bfd9b..1f2a1371c 100644 --- a/aimux-core/src/error.rs +++ b/aimux-core/src/error.rs @@ -29,6 +29,7 @@ use ts_rs::TS; /// This keeps transport and response failures self-contained and matches the /// AI SDK's `APICallError` contract. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ApiCallError { /// Sanitized request URL. Required for every API-derived failure. @@ -39,12 +40,14 @@ pub struct ApiCallError { /// (`APICallError.statusCode`). Always the *observed* status: the HTTP /// layer fills it for every response-derived error; errors built without /// an HTTP exchange leave it `None`. - #[serde(default)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub status_code: Option, /// Provider's machine-readable code (OpenAI's `code`/`type`, /// e.g. `"rate_limit_exceeded"` vs `"insufficient_quota"`). Never an HTTP /// status. Our normalized take on `APICallError.data`. - #[serde(default)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_code: Option, /// Human-readable failure text. Provider text is verbatim when available; /// locally detected transport/parse failures include their source detail. @@ -53,13 +56,16 @@ pub struct ApiCallError { /// The raw response body, verbatim (`APICallError.responseBody`) — the /// lossless evidence when `message`/`provider_code` are extractions. /// `None` when the error did not come from a response body. - #[serde(default)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub response_body: Option, /// Sanitized response headers. - #[serde(default)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub response_headers: Option>, /// Parsed provider error data. - #[serde(default)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub data: Option, /// Whether retrying can help (`APICallError.isRetryable`) — stored at /// construction, exactly like the AI SDK: the response path computes it @@ -103,6 +109,7 @@ pub enum RetryErrorReason { /// Complete error history for a retried model operation. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct RetryError { pub reason: RetryErrorReason, @@ -164,7 +171,8 @@ impl std::fmt::Display for ApiCallError { /// failure came from. `ApiCallError` is boxed only to keep the Rust enum /// compact; serde and every binding still observe the same object shape. #[derive(Debug, Clone, Serialize, Deserialize, TS, Error)] -#[ts(export)] +#[serde(from = "AiMuxErrorWire", into = "AiMuxErrorWire")] +#[ts(export, as = "AiMuxErrorWire")] pub enum AiMuxError { #[error("API call error: {0}")] ApiCall(Box), @@ -195,10 +203,12 @@ pub enum AiMuxError { #[error("Model tried to call unavailable tool '{tool_name}'. {}", no_such_tool_availability(.available_tools))] NoSuchTool { tool_name: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] available_tools: Option>, /// Original argument text, when supplied by the provider. Absent in /// older serialized errors and errors constructed without a call. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] tool_input: Option, }, @@ -298,6 +308,133 @@ pub enum AiMuxError { Other(String), } +/// The JSON form of [`AiMuxError`]: internally tagged by `name`, with the AI +/// SDK's error names (`AI_APICallError`, ...). `AiMuxError` serializes through +/// this mirror because its tuple variants cannot carry an internal tag. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(tag = "name", rename_all_fields = "camelCase")] +pub enum AiMuxErrorWire { + #[serde(rename = "AI_APICallError")] + ApiCall(Box), + #[serde(rename = "AI_RetryError")] + Retry(RetryError), + #[serde(rename = "AI_JSONParseError")] + JsonParse { message: String }, + #[serde(rename = "AI_NoOutputGeneratedError")] + NoOutputGenerated { message: String }, + #[serde(rename = "AI_InvalidResponseDataError")] + InvalidResponseData { message: String }, + #[serde(rename = "AI_ToolCallNotFoundForApprovalError")] + ToolCallNotFoundForApproval { + tool_call_id: String, + approval_id: String, + }, + #[serde(rename = "AI_NoSuchToolError")] + NoSuchTool { + tool_name: String, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] + available_tools: Option>, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] + tool_input: Option, + }, + #[serde(rename = "AI_InvalidToolInputError")] + InvalidToolInput { + tool_name: String, + tool_input: String, + cause: String, + }, + #[serde(rename = "AI_ToolCallRepairError")] + ToolCallRepair { + original_error: Box, + cause: Box, + }, + #[serde(rename = "AI_InvalidArgumentError")] + InvalidArgument { message: String }, + #[serde(rename = "AI_InvalidPromptError")] + InvalidPrompt { message: String }, + #[serde(rename = "AI_LoadAPIKeyError")] + LoadApiKey { + env_var: String, + description: String, + }, + #[serde(rename = "AI_LoadSettingError")] + LoadSetting { + env_var: String, + /// `name` is the union's tag key, so the setting's name is `settingName`. + #[serde(rename = "settingName")] + name: String, + }, + #[serde(rename = "AI_TokenExpiredError")] + TokenExpired { message: String }, + #[serde(rename = "AI_UnsupportedFunctionalityError")] + UnsupportedFunctionality { message: String }, + #[serde(rename = "AI_NoSuchModelError")] + NoSuchModel { + model_id: String, + #[serde(default)] + model_type: String, + }, + #[serde(rename = "AI_NoSuchProviderError")] + NoSuchProvider { + provider_id: String, + model_id: String, + model_type: String, + available_providers: Vec, + }, + #[serde(rename = "TimeoutError")] + Timeout { message: String }, + #[serde(rename = "AbortError")] + Aborted { message: String }, + #[serde(rename = "AI_Error")] + Other { message: String }, +} + +/// Maps each `AiMuxError` variant to its `AiMuxErrorWire` twin and back: +/// tuple variants carry their payload as `message`, struct variants keep +/// their fields. +macro_rules! wire_variants { + ( + newtype: $($nv:ident),* ; + message: $($mv:ident),* ; + fields: $($fv:ident { $($f:ident),* }),* + ) => { + impl From for AiMuxErrorWire { + fn from(e: AiMuxError) -> Self { + match e { + $(AiMuxError::$nv(v) => Self::$nv(v),)* + $(AiMuxError::$mv(message) => Self::$mv { message },)* + $(AiMuxError::$fv { $($f),* } => Self::$fv { $($f),* },)* + } + } + } + impl From for AiMuxError { + fn from(e: AiMuxErrorWire) -> Self { + match e { + $(AiMuxErrorWire::$nv(v) => Self::$nv(v),)* + $(AiMuxErrorWire::$mv { message } => Self::$mv(message),)* + $(AiMuxErrorWire::$fv { $($f),* } => Self::$fv { $($f),* },)* + } + } + } + }; +} + +wire_variants! { + newtype: ApiCall, Retry; + message: JsonParse, NoOutputGenerated, InvalidResponseData, InvalidArgument, + InvalidPrompt, TokenExpired, UnsupportedFunctionality, Timeout, Aborted, Other; + fields: ToolCallNotFoundForApproval { tool_call_id, approval_id }, + NoSuchTool { tool_name, available_tools, tool_input }, + InvalidToolInput { tool_name, tool_input, cause }, + ToolCallRepair { original_error, cause }, + LoadApiKey { env_var, description }, + LoadSetting { env_var, name }, + NoSuchModel { model_id, model_type }, + NoSuchProvider { provider_id, model_id, model_type, available_providers } +} + fn no_such_tool_availability(available_tools: &Option>) -> String { match available_tools { Some(tools) => format!("Available tools: {}.", tools.join(", ")), diff --git a/aimux-core/src/evaluation_model.rs b/aimux-core/src/evaluation_model.rs index 0def4b696..07a1f5e68 100644 --- a/aimux-core/src/evaluation_model.rs +++ b/aimux-core/src/evaluation_model.rs @@ -56,6 +56,7 @@ impl EvaluationQuestionType { /// The optional descriptions of a Boolean question's outcomes. An absent key /// and an explicit `null` differ: `Some(None)` is `null` (no description). #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct BooleanCriteria { #[serde( @@ -86,7 +87,11 @@ where /// A judgment to make about the shared state (`EvaluationModelV4Question`). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[serde(tag = "type", rename_all = "lowercase")] +#[serde( + tag = "type", + rename_all = "lowercase", + rename_all_fields = "camelCase" +)] #[ts(export)] pub enum EvaluationQuestion { Choice { @@ -103,6 +108,7 @@ pub enum EvaluationQuestion { }, Boolean { instructions: EvaluationInput, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] criteria: Option, }, @@ -123,6 +129,7 @@ impl EvaluationQuestion { /// Options passed to [`EvaluationModel::do_evaluate`] /// (`EvaluationModelV4CallOptions`). #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct EvaluationCallOptions { /// One shared state, even when the value is an array. @@ -134,13 +141,21 @@ pub struct EvaluationCallOptions { #[serde(skip)] #[ts(skip)] pub abort_signal: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_options: Option, } /// The answer to one question (`EvaluationModelV4Answer`). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[serde(tag = "type", rename_all = "lowercase")] +#[serde( + tag = "type", + rename_all = "lowercase", + rename_all_fields = "camelCase" +)] #[ts(export)] pub enum EvaluationAnswer { Choice { @@ -148,6 +163,7 @@ pub enum EvaluationAnswer { /// exists. choice: String, /// Complete distribution over the question's options, when available. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] probabilities: Option>, }, @@ -156,6 +172,7 @@ pub enum EvaluationAnswer { score: f64, /// Complete distribution keyed by zero-based level indices as /// strings; when supplied, `score` is its probability-weighted mean. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] probabilities: Option>, }, @@ -168,31 +185,50 @@ pub enum EvaluationAnswer { /// Decimal places the provider rounded its output to; omit for full precision. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct EvaluationRounding { + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub probability_decimals: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub score_decimals: Option, } /// Token usage of an evaluation. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct EvaluationUsage { + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub input_tokens: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub output_tokens: Option, } /// The result of [`EvaluationModel::do_evaluate`] (`EvaluationModelV4Result`). #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct EvaluationResult { /// Exactly one answer per question, under the original question ids. #[ts(type = "Record")] pub answers: IndexMap, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub rounding: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub usage: Option, pub warnings: Vec, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_metadata: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub response: Option, } diff --git a/aimux-core/src/files_model.rs b/aimux-core/src/files_model.rs index 091488838..9aeb7e64b 100644 --- a/aimux-core/src/files_model.rs +++ b/aimux-core/src/files_model.rs @@ -22,6 +22,11 @@ use crate::shared::{ /// `SharedV4FileData` (URLs and provider references are not valid uploads), so /// this is a dedicated two-variant enum rather than the full [`crate::shared::FileData`]. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde( + tag = "type", + rename_all = "lowercase", + rename_all_fields = "camelCase" +)] #[ts(export)] pub enum UploadFileData { /// Raw bytes (`Uint8Array`) or a base64-encoded string. @@ -34,6 +39,7 @@ pub enum UploadFileData { /// /// Aligned with V4 `FilesV4UploadFileCallOptions`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct UploadFileCallOptions { /// The file data (raw bytes/base64 or inline text). @@ -43,9 +49,13 @@ pub struct UploadFileCallOptions { pub media_type: String, /// The filename of the file. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub filename: Option, /// Additional provider-specific options, keyed by provider name. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_options: Option, /// Abort signal for cancelling the upload operation. @@ -71,6 +81,7 @@ impl UploadFileCallOptions { /// /// Aligned with V4 `FilesV4UploadFileResult`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct UploadFileResult { /// A provider reference mapping provider names to provider-specific file @@ -79,12 +90,18 @@ pub struct UploadFileResult { /// The IANA media type of the uploaded file, if available from the /// provider. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub media_type: Option, /// The filename of the uploaded file, if available from the provider. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub filename: Option, /// Additional provider-specific metadata, keyed by provider name. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_metadata: Option, /// Warnings from the provider. diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs index 3a71543df..0df13af5d 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -82,35 +82,70 @@ async fn resolve_file( /// Unlike `CallOptions` (provider-facing), this does not include `prompt` /// (passed separately) and defaults are more ergonomic. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct GenerateTextOptions { + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_output_tokens: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub temperature: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub stop_sequences: Option>, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub top_p: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub top_k: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub presence_penalty: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub frequency_penalty: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub response_format: Option, - #[ts(type = "number | null")] + #[ts(optional, type = "number")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub seed: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub tools: Option>, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_choice: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option>, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_options: Option, /// Top-level reasoning effort. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning: Option, /// System instructions prepended to the prompt. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub instructions: Option, /// Per-call retry count override. `None` = provider default, `Some(0)` = disable. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_retries: Option, /// Per-call timeout configuration (total / first-chunk / chunk idle). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout: Option, /// Session identifier, for grouping consecutive calls into a session /// (observability, see RFC-0024). Orthogonal to RFC-0019 session-affinity /// headers. When `None` and the optional session inferer is enabled, one /// may be inferred from prompt-prefix continuation. - #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub session_id: Option, /// Abort signal for cancelling the call. /// @@ -120,6 +155,8 @@ pub struct GenerateTextOptions { #[ts(skip)] pub abort_signal: Option, /// Emit raw provider stream chunks as `StreamPart::Raw` (debugging aid). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub include_raw_chunks: Option, /// Optional one-shot repair callback for unknown, malformed, or /// schema-invalid tool calls. @@ -174,6 +211,7 @@ impl GenerateTextOptions { /// Result of `generate_text` (user-facing). #[derive(Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct GenerateTextResult { /// Ordered generated content with parsed tool calls. @@ -210,11 +248,13 @@ pub struct GenerateTextResult { pub response_messages: Vec, /// The raw provider-specific finish reason string (e.g. "stop", /// "end_turn", "safety"). Useful when `finish_reason.unified` is `Other`. - #[serde(default)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub raw_finish_reason: Option, /// Provider-specific metadata (e.g. Anthropic cache info). Mirrored from /// `raw.provider_metadata` for top-level convenience. - #[serde(default)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_metadata: Option, /// Request information from the provider. #[serde(default)] @@ -231,6 +271,7 @@ pub struct GenerateTextResult { /// Result of `generate_object` (user-facing, M12). The parsed JSON object plus /// convenience fields from the underlying `generate_text` call. #[derive(Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct GenerateObjectResult { /// The parsed JSON object returned by the model. @@ -238,17 +279,20 @@ pub struct GenerateObjectResult { /// Why generation stopped. pub finish_reason: FinishReason, /// Raw provider-specific finish reason string. - #[serde(default)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub raw_finish_reason: Option, /// Token usage. pub usage: Usage, /// Warnings from the provider. pub warnings: Vec, /// Concatenated reasoning text (if the model produced reasoning/thinking). - #[serde(default)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning: Option, /// Provider-specific metadata (e.g. Anthropic cache info). - #[serde(default)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_metadata: Option, /// Response metadata (id, timestamp, model_id). #[serde(default)] diff --git a/aimux-core/src/image_model.rs b/aimux-core/src/image_model.rs index 159de19df..8bb910d1e 100644 --- a/aimux-core/src/image_model.rs +++ b/aimux-core/src/image_model.rs @@ -17,6 +17,11 @@ use crate::{AbortSignal, retry, timeout}; /// /// Aligned with V4 `ImageModelV4File`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde( + tag = "type", + rename_all = "lowercase", + rename_all_fields = "camelCase" +)] #[ts(export)] pub enum ImageFile { /// Inline file data (base64 or binary) with an explicit media type. @@ -35,6 +40,7 @@ pub enum ImageFile { /// File payload for an [`ImageFile::File`]: base64 string or raw bytes. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(untagged)] #[ts(export)] pub enum ImageFileData { /// Base64-encoded string. @@ -51,6 +57,7 @@ pub enum ImageFileData { /// upstream API returns base64, return [`ImageOutputs::Base64`]; if binary, /// return [`ImageOutputs::Binary`]. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(untagged)] #[ts(export)] pub enum ImageOutputs { /// Images as base64-encoded strings. @@ -63,13 +70,20 @@ pub enum ImageOutputs { /// /// Aligned with V4 `ImageModelV4Usage`. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ImageUsage { /// Number of input (prompt) tokens used. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub input_tokens: Option, /// Number of output tokens used, if reported. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub output_tokens: Option, /// Total tokens as reported by the provider. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub total_tokens: Option, } @@ -77,10 +91,13 @@ pub struct ImageUsage { /// /// Aligned with V4 `ImageModelV4CallOptions`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ImageCallOptions { /// Prompt for the image generation. `None` for operations (e.g. upscaling) /// that do not require a prompt. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub prompt: Option, /// Number of images to generate. @@ -88,20 +105,29 @@ pub struct ImageCallOptions { /// Size of the images, in `{width}x{height}` format. /// `None` uses the provider's default size. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub size: Option, /// Aspect ratio of the images, in `{width}:{height}` format. /// `None` uses the provider's default aspect ratio. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub aspect_ratio: Option, /// Seed for deterministic generation. `None` uses the provider's default. - #[ts(type = "number | null")] + #[ts(optional, type = "number")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub seed: Option, /// Images for image editing or variation generation. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub files: Option>, /// Mask image for inpainting operations. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub mask: Option, /// Additional provider-specific options, keyed by provider name. @@ -113,12 +139,18 @@ pub struct ImageCallOptions { pub abort_signal: Option, /// Per-call retry override. `None` uses the model default. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_retries: Option, /// Per-call operation timeout. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout: Option, /// Additional HTTP headers to send with the request. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option, } @@ -146,6 +178,7 @@ impl ImageCallOptions { /// /// Aligned with V4 `ImageModelV4Result`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ImageResult { /// Generated images (base64 strings or binary data). @@ -155,24 +188,35 @@ pub struct ImageResult { pub warnings: Vec, /// Additional provider-specific metadata, keyed by provider name. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_metadata: Option, /// Response information for telemetry and debugging. pub response: ImageResponse, /// Optional token usage for the call (if the provider reports it). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub usage: Option, } /// Response information for an image generation call. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ImageResponse { /// Timestamp for the start of the generated response (ISO 8601 string). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub timestamp: Option, /// The ID of the model that was used to generate the response. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, /// Response headers. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option, } diff --git a/aimux-core/src/language_model_message.rs b/aimux-core/src/language_model_message.rs index 5a858decf..fbaef20ef 100644 --- a/aimux-core/src/language_model_message.rs +++ b/aimux-core/src/language_model_message.rs @@ -24,110 +24,144 @@ pub type LanguageModelPrompt = Vec; /// `provider_options` is message-level (e.g. `anthropic.cacheControl`), /// mirroring `LanguageModelV4Message.providerOptions`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[serde(tag = "role", rename_all = "snake_case")] +#[serde( + tag = "role", + rename_all = "lowercase", + rename_all_fields = "camelCase" +)] #[ts(export)] pub enum LanguageModelMessage { System { content: String, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, User { content: Vec, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, Assistant { content: Vec, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, Tool { content: Vec, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, } /// Text content part. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct TextPart { pub text: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_options: Option, } /// File content part (the only file part; `data` is a tagged `FileData`). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct FilePart { pub data: FileData, pub media_type: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub filename: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_options: Option, } /// Reasoning / thinking content part. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] #[ts(rename = "LanguageModelReasoningPart")] pub struct ReasoningPart { pub text: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_options: Option, } /// Tool call content part. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ToolCallPart { pub tool_call_id: String, pub tool_name: String, pub input: Value, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_executed: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_options: Option, } /// Tool result content part. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ToolResultPart { pub tool_call_id: String, pub tool_name: String, pub output: ToolResultOutput, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_options: Option, } /// The provider-facing result of a tool call. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[serde(tag = "type", rename_all = "kebab-case")] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] #[ts(export)] pub enum ToolResultOutput { Text { value: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, Json { value: Value, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, ExecutionDenied { + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] reason: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, ErrorText { value: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, ErrorJson { value: Value, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -138,12 +172,17 @@ pub enum ToolResultOutput { /// Content within a tool result. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[serde(tag = "type", rename_all = "kebab-case")] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] #[ts(export)] pub enum ToolResultContent { Text(TextPart), File(FilePart), Custom { + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -151,31 +190,38 @@ pub enum ToolResultContent { /// A file generated as part of reasoning. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ReasoningFilePart { pub data: GeneratedFileData, pub media_type: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_options: Option, } /// Provider-specific content identified by its provider and kind. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct CustomPart { pub kind: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_options: Option, } /// The user's decision for a provider-executed tool approval request. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ToolApprovalResponsePart { pub approval_id: String, pub approved: bool, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub reason: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_options: Option, } diff --git a/aimux-core/src/message.rs b/aimux-core/src/message.rs index 00ee5622c..48c9ff3e1 100644 --- a/aimux-core/src/message.rs +++ b/aimux-core/src/message.rs @@ -34,6 +34,7 @@ pub enum MessageContent { /// A single user-facing chat message. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ModelMessage { pub role: Role, diff --git a/aimux-core/src/moa.rs b/aimux-core/src/moa.rs index 6123c1924..3a3f08c37 100644 --- a/aimux-core/src/moa.rs +++ b/aimux-core/src/moa.rs @@ -34,6 +34,7 @@ pub enum MoaFailMode { } #[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct MoaConfig { /// `provider()` value (default `"moa"`). #[serde(default = "default_moa_provider")] diff --git a/aimux-core/src/model_catalogue.rs b/aimux-core/src/model_catalogue.rs index a7ef0d8e9..3eadfc4ab 100644 --- a/aimux-core/src/model_catalogue.rs +++ b/aimux-core/src/model_catalogue.rs @@ -24,16 +24,18 @@ use ts_rs::TS; /// comes from [`ModelSpec`] (community knowledge); the host merges the two as /// needed (RFC-0027). aimux itself does not combine them. #[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct RuntimeModel { /// Model identifier, e.g. `"gpt-4o"`. pub id: String, /// `owned_by` field from OpenAI-compatible `/models` responses. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub owned_by: Option, /// `created` unix timestamp from OpenAI-compatible `/models` responses. + #[ts(optional, type = "number")] #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(type = "number | null")] pub created: Option, } @@ -93,24 +95,26 @@ pub enum Modality { /// Token / request limits for a model. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS, PartialEq)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ModelLimits { /// Maximum total context window (input + output), in tokens. + #[ts(optional, type = "number")] #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(type = "number | null")] pub context: Option, /// Maximum output tokens. + #[ts(optional, type = "number")] #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(type = "number | null")] pub output: Option, /// Maximum input tokens (when distinct from `context`). + #[ts(optional, type = "number")] #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(type = "number | null")] pub input: Option, } /// Input/output modality sets. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS, PartialEq)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ModelModalities { #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -121,13 +125,14 @@ pub struct ModelModalities { /// Model-level capability flags (more precise than provider-level `supports_*`). #[derive(Debug, Clone, Default, Serialize, Deserialize, TS, PartialEq)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ModelCapabilities { /// Supports function/tool calling. #[serde(default)] pub tool_call: bool, /// Supports structured / JSON-schema output. - #[serde(default, rename = "structured_output")] + #[serde(default)] pub structured_output: bool, /// Accepts a `temperature` parameter. #[serde(default)] @@ -172,6 +177,7 @@ pub enum ReasoningVisibility { /// Reasoning / thinking portrait for a model (advisory). #[derive(Debug, Clone, Default, Serialize, Deserialize, TS, PartialEq)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ReasoningSpec { /// Whether the model family supports reasoning at all. @@ -181,37 +187,45 @@ pub struct ReasoningSpec { #[serde(default)] pub default_enabled: bool, /// Primary control mode. - #[serde(default)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub mode: Option, /// Default effort level (when `mode` is Effort/Mixed). + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub effort_default: Option, /// Supported effort levels. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub effort_options: Vec, /// Minimum token budget (when `mode` is Budget/Mixed). + #[ts(optional, type = "number")] #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(type = "number | null")] pub budget_min: Option, /// Interleaved reasoning (thinking blocks between tool calls). #[serde(default)] pub interleaved: bool, /// Output visibility. - #[serde(default)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub visibility: Option, } /// Pricing (advisory, read-only metadata). Units follow the source catalog /// (anya2a uses USD per 1M tokens). #[derive(Debug, Clone, Default, Serialize, Deserialize, TS, PartialEq)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ModelCost { + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub input: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub output: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_read: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_write: Option, } @@ -221,8 +235,10 @@ pub struct ModelCost { /// All fields are advisory — aimux never auto-applies them in the request path. /// The caller reads them to decide how to configure `GenerateTextOptions`. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS, PartialEq)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ModelSpec { + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub display_name: Option, #[serde(default)] @@ -233,16 +249,20 @@ pub struct ModelSpec { pub modalities: ModelModalities, #[serde(default)] pub capabilities: ModelCapabilities, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub cost: Option, #[serde(default)] pub source: CatalogueSource, /// Provider name (normalized to aimux registry spelling). + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, /// Verbatim source entry, preserved for forward compatibility / debugging. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub raw: Option, } diff --git a/aimux-core/src/model_id.rs b/aimux-core/src/model_id.rs index a319c1635..932828f2c 100644 --- a/aimux-core/src/model_id.rs +++ b/aimux-core/src/model_id.rs @@ -6,6 +6,7 @@ use ts_rs::TS; /// A model identifier in `"provider/model-name"` format, e.g. `"openai/gpt-4o"`. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ModelId { provider: String, diff --git a/aimux-core/src/options.rs b/aimux-core/src/options.rs index d43fbb159..ceb9e53a3 100644 --- a/aimux-core/src/options.rs +++ b/aimux-core/src/options.rs @@ -16,14 +16,25 @@ use crate::types::ReasoningEffort; /// How the model should format its response. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde( + tag = "type", + rename_all = "lowercase", + rename_all_fields = "camelCase" +)] #[ts(export)] pub enum ResponseFormat { /// Plain text (default). Text, /// JSON output, optionally constrained to a schema. Json { + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] schema: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] name: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] description: Option, }, } @@ -35,6 +46,7 @@ pub enum ResponseFormat { /// corresponding limit. A `total` timeout also covers retry backoff and the /// whole streamed response. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct TimeoutConfiguration { /// Overall timeout for the entire call (including retries and, for @@ -42,21 +54,25 @@ pub struct TimeoutConfiguration { // `number`, not the `bigint` ts-rs infers from u64: the JS bindings pass // options through `JSON.stringify`, which throws on BigInt. Milliseconds // cannot reach 2^53 (~285k years), so precision is never at stake. - #[ts(type = "number | null")] + #[ts(optional, type = "number")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub total_ms: Option, /// Timeout for one generation step, including that step's attempts and /// retry backoff, in milliseconds. Aimux currently has one step. - #[ts(type = "number | null")] + #[ts(optional, type = "number")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub step_ms: Option, /// Timeout waiting for the first stream chunk (streaming only). /// /// Counted from operation start, so it also bounds stream establishment /// and any retries before the first semantic output: it is the /// user-perceived time-to-first-output budget, not a per-attempt timer. - #[ts(type = "number | null")] + #[ts(optional, type = "number")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub first_chunk_ms: Option, /// Maximum idle time between consecutive stream chunks (streaming only). - #[ts(type = "number | null")] + #[ts(optional, type = "number")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub chunk_ms: Option, } @@ -66,61 +82,93 @@ pub struct TimeoutConfiguration { /// `GenerateTextOptions` (user-facing) which is converted to `CallOptions` /// by the `generate_text` / `stream_text` functions. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct CallOptions { /// The standardized prompt (message array). Required. pub prompt: LanguageModelPrompt, /// Maximum tokens to generate. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_output_tokens: Option, /// Sampling temperature. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub temperature: Option, /// Stop sequences. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub stop_sequences: Option>, /// Nucleus sampling `top_p`. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub top_p: Option, /// Top-k sampling. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub top_k: Option, /// Presence penalty. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub presence_penalty: Option, /// Frequency penalty. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub frequency_penalty: Option, /// Response format (text or JSON). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub response_format: Option, /// Seed for reproducibility. - #[ts(type = "number | null")] + #[ts(optional, type = "number")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub seed: Option, /// Tools available to the model (function tools and/or provider-defined tools). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub tools: Option>, /// How the model should choose tools. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_choice: Option, /// Extra HTTP headers. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option>, /// Provider-specific options (keyed by provider name). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_options: Option, /// Top-level reasoning effort. Maps to OpenAI `reasoning_effort` and /// Anthropic `thinking` config. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning: Option, /// Per-call retry count override. `None` uses the provider's configured /// Core operation retry. `Some(0)` disables retries. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_retries: Option, /// Per-call timeout configuration (total / first-chunk / chunk idle). /// `None` = no timeouts (provider defaults still apply at the HTTP layer). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout: Option, /// Session identifier, for grouping consecutive calls into a session @@ -129,7 +177,8 @@ pub struct CallOptions { /// Orthogonal to RFC-0019 session-affinity headers: this field is for /// local grouping, while session headers in `headers` are for upstream /// routing — both may share an id value but travel different paths. - #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub session_id: Option, /// Abort signal for cancelling the call. @@ -159,6 +208,8 @@ pub struct CallOptions { /// Currently honored by the OpenAI-compatible family (openai / azure / /// openai-compatible registry providers); other provider families ignore /// it for now (RFC-0016 M2). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub include_raw_chunks: Option, } diff --git a/aimux-core/src/parse_tool_call.rs b/aimux-core/src/parse_tool_call.rs index ba7f62b9f..a3843c3e8 100644 --- a/aimux-core/src/parse_tool_call.rs +++ b/aimux-core/src/parse_tool_call.rs @@ -317,10 +317,15 @@ fn invalid_tool_call(tool_call: RawToolCall, error: AiMuxError) -> ToolCall { /// The bindings cannot carry a [`ToolCallRepair`] closure, so a host instead /// reads the invalid call off the result, repairs it in its own language, and /// hands the answer back as this value. Wire shape: -/// `{"type":"repaired","tool_call":{…}}`, `{"type":"unchanged"}`, or +/// `{"type":"repaired","toolCall":{…}}`, `{"type":"unchanged"}`, or /// `{"type":"failed","message":"…"}`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] #[ts(export)] pub enum ToolCallRepairReply { /// Re-validate this replacement call, exactly as the closure path does @@ -435,9 +440,9 @@ pub fn tool_call_repair_inputs( /// Build the repair argument a host needs, as JSON, or `None` when the call /// was made without a tool set. /// -/// Mirrors the AI SDK `repairToolCall` argument: `{tool_call, error, -/// input_schema, tools, messages, instructions}`. `tool_call` is the -/// provider-facing call (raw argument *text*), `input_schema` the schema of +/// Mirrors the AI SDK `repairToolCall` argument: `{toolCall, error, +/// inputSchema, tools, messages, instructions}`. `toolCall` is the +/// provider-facing call (raw argument *text*), `inputSchema` the schema of /// the named tool (an empty-object schema when the name does not resolve), /// and `error` the serialized failure the call already carries. /// @@ -468,9 +473,9 @@ pub fn tool_call_repair_context( error, }; Ok(Some(serde_json::json!({ - "tool_call": context.tool_call, + "toolCall": context.tool_call, "error": context.error, - "input_schema": context.input_schema(&context.tool_call.tool_name), + "inputSchema": context.input_schema(&context.tool_call.tool_name), "tools": context.tools, "messages": context.messages, "instructions": context.instructions, @@ -502,7 +507,7 @@ pub fn apply_tool_call_repair( /// `GenerateObjectResult`, or `StreamTextResultAggregated`, returning the /// patched result. /// -/// Both `tool_calls[]` and the matching `response_messages[]` tool-call part +/// Both `toolCalls[]` and the matching `responseMessages[]` tool-call part /// are rewritten, the latter under the same rule `to_response_messages` uses /// (malformed primitive input is not replayed as a prompt input). A /// `GenerateObjectResult` is recognised by its nested `raw` result; `object` @@ -516,7 +521,7 @@ pub fn apply_tool_call_repair( /// # Errors /// /// [`AiMuxError::InvalidArgument`] when `tools` is `None`, when the document -/// has no `tool_calls` array, when `tool_call_id` is missing or ambiguous, +/// has no `toolCalls` array, when `toolCallId` is missing or ambiguous, /// when the matched call is not invalid, or when a replacement id conflicts /// with another call. pub fn apply_tool_call_repair_to_result( @@ -527,43 +532,43 @@ pub fn apply_tool_call_repair_to_result( ) -> Result { let mut patched = result.clone(); // `GenerateObjectResult` carries the whole text result under `raw`. - let target = if patched.get("tool_calls").is_some() { + let target = if patched.get("toolCalls").is_some() { &mut patched } else { patched.get_mut("raw").ok_or_else(|| { - AiMuxError::InvalidArgument("result: neither tool_calls nor raw".to_string()) + AiMuxError::InvalidArgument("result: neither toolCalls nor raw".to_string()) })? }; let calls = target - .get_mut("tool_calls") + .get_mut("toolCalls") .and_then(Value::as_array_mut) - .ok_or_else(|| AiMuxError::InvalidArgument("result: no tool_calls array".to_string()))?; - // Both `tool_calls` and the transcript are keyed by id here, so a + .ok_or_else(|| AiMuxError::InvalidArgument("result: no toolCalls array".to_string()))?; + // Both `toolCalls` and the transcript are keyed by id here, so a // duplicated id cannot be patched unambiguously; refuse rather than // rewrite the first entry twice. let target_index = calls .iter() - .position(|call| call.get("tool_call_id").and_then(Value::as_str) == Some(tool_call_id)) + .position(|call| call.get("toolCallId").and_then(Value::as_str) == Some(tool_call_id)) .ok_or_else(|| { AiMuxError::InvalidArgument(format!("result: no tool call '{tool_call_id}'")) })?; if calls[target_index + 1..] .iter() - .any(|call| call.get("tool_call_id").and_then(Value::as_str) == Some(tool_call_id)) + .any(|call| call.get("toolCallId").and_then(Value::as_str) == Some(tool_call_id)) { return Err(AiMuxError::InvalidArgument(format!( "result: tool call id '{tool_call_id}' is not unique" ))); } let original: ToolCall = serde_json::from_value(calls[target_index].clone()) - .map_err(|error| AiMuxError::InvalidArgument(format!("result.tool_calls: {error}")))?; + .map_err(|error| AiMuxError::InvalidArgument(format!("result.toolCalls: {error}")))?; let repaired = apply_tool_call_repair(&original, tools, reply)?; if repaired.tool_call_id != tool_call_id && calls.iter().enumerate().any(|(index, call)| { index != target_index - && call.get("tool_call_id").and_then(Value::as_str) + && call.get("toolCallId").and_then(Value::as_str) == Some(repaired.tool_call_id.as_str()) }) { @@ -575,20 +580,20 @@ pub fn apply_tool_call_repair_to_result( calls[target_index] = serde_json::to_value(&repaired) .map_err(|error| AiMuxError::InvalidArgument(format!("tool call: {error}")))?; - // The replayed transcript must agree with `tool_calls`, or the next turn + // The replayed transcript must agree with `toolCalls`, or the next turn // sends the model the unrepaired arguments. let replay_input = crate::response_messages::response_tool_call_input(&repaired.input, repaired.invalid); for part in target - .get_mut("response_messages") + .get_mut("responseMessages") .and_then(Value::as_array_mut) .into_iter() .flatten() .filter_map(|message| message.get_mut("content")?.as_array_mut()) .flatten() { - if part.get("type").and_then(Value::as_str) != Some("tool_call") - || part.get("tool_call_id").and_then(Value::as_str) != Some(tool_call_id) + if part.get("type").and_then(Value::as_str) != Some("tool-call") + || part.get("toolCallId").and_then(Value::as_str) != Some(tool_call_id) { continue; } @@ -596,12 +601,12 @@ pub fn apply_tool_call_repair_to_result( continue; }; // A repair may rename the call; the transcript must keep pointing at - // the same entry as `tool_calls`. + // the same entry as `toolCalls`. part.insert( - "tool_call_id".to_string(), + "toolCallId".to_string(), repaired.tool_call_id.clone().into(), ); - part.insert("tool_name".to_string(), repaired.tool_name.clone().into()); + part.insert("toolName".to_string(), repaired.tool_name.clone().into()); part.insert("input".to_string(), replay_input.clone()); } diff --git a/aimux-core/src/recording.rs b/aimux-core/src/recording.rs index 6e551b3e3..2423e241d 100644 --- a/aimux-core/src/recording.rs +++ b/aimux-core/src/recording.rs @@ -49,6 +49,7 @@ where /// 一次完整调用的录制记录(三层 + call_id 关联)。 #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct Recording { /// 格式版本。读取时只接受当前 [`RECORDING_SCHEMA`];旧 schema 的录制带有 @@ -83,9 +84,11 @@ pub struct Recording { /// 会话归组(RFC-0024 P3):所在会话 id。None = 未归组(无 session_id 且 /// 推断关闭)。由 `Recorder::record_session` 填充,写入 InputRecord 之前 /// 或之后均可(writer 端按 call_id 合并)。 + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub session_id: Option, /// 会话内步号(0 起,由 SessionStore 分配)。与 `session_id` 同生命周期。 + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub step: Option, } @@ -128,6 +131,7 @@ impl Recording { /// ① 输入侧:完整调用参数,足以重建 generate_text 调用。 #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct InputRecord { /// 完整 prompt(消息数组,含 ContentPart::Image 等多模态)。 @@ -154,6 +158,7 @@ impl InputRecord { /// 只记身份,不记配置:回放时按 `provider_id` + `model_id` 重新构建 model, /// 凭证与 base URL 只来自环境/registry(RFC-0036 §3.3)。 #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ProviderRecord { /// provider 名(registry 名),回放重建的查找键,如 "openai"、"deepseek"。 @@ -187,10 +192,12 @@ impl ProviderRecord { /// ③ HTTP 侧:单次 attempt 的 wire 交换。 #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct HttpExchange { /// Composite step this exchange belongs to (e.g. `router[0]:openai/gpt-4o` /// or `moa.ref[1]:...`). `None` for a plain, non-composite operation. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub step: Option, /// Core operation attempt, starting at 1. Attempt numbers are unique @@ -202,8 +209,12 @@ pub struct HttpExchange { pub exchange_index: u32, pub request: HttpRecord, /// None = 请求失败未获响应。 + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub response: Option, pub timing: TimingRecord, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, /// 流式:该 exchange 是否已终结(收到 response 补全)。非流式恒 true。 #[serde(default = "default_finalized")] @@ -215,6 +226,7 @@ fn default_finalized() -> bool { } #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct HttpRecord { pub method: String, @@ -222,30 +234,39 @@ pub struct HttpRecord { /// 敏感头(authorization/cookie/含 api-key/key/x-amz-security-token 等)已脱敏为 "\[REDACTED\]"。 pub headers: Vec<(String, String)>, /// 明文(脱敏后);None = 无 body。 + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub body: Option, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ResponseRecord { pub status: u16, pub headers: Vec<(String, String)>, /// 非流式:完整 JSON;流式:原始 SSE 拼接文本(上限截断)。 + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub body: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub stream_chunks: Option, /// 首字节延迟(流式)。 + #[ts(optional, type = "number")] #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(type = "number | null")] pub ttfb_ms: Option, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct TimingRecord { /// ms 级延迟,TS 用 number(远小于 2^53,避免 bigint)。 #[ts(type = "number")] pub latency_ms: u64, - #[ts(type = "number | null")] + #[ts(optional, type = "number")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub ttfb_ms: Option, } @@ -268,16 +289,24 @@ pub enum OutcomeStatus { /// 最终结果摘要。 #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct OutcomeRecord { pub status: OutcomeStatus, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub finish_reason: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, /// Lossless structured domain error. In particular, `RetryError.errors` /// keeps the complete attempt history rather than only its display text. - #[serde(default)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub error_value: Option, /// 序列化的 Usage。 + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub usage: Option, } @@ -1853,8 +1882,8 @@ mod tests { opts.abort_signal = None; let rec = InputRecord::from_call_options(&opts); let v = &rec.options; - assert!(v.get("call_id").is_none()); - assert!(v.get("abort_signal").is_none()); + assert!(v.get("callId").is_none()); + assert!(v.get("abortSignal").is_none()); } #[test] @@ -1875,7 +1904,7 @@ mod tests { assert!(is_sensitive_key(key), "{key} must be redacted"); } // Usage counters carry no credentials and must stay loggable. - for key in ["max_tokens", "input_tokens", "output_tokens", "model"] { + for key in ["maxTokens", "inputTokens", "outputTokens", "model"] { assert!(!is_sensitive_key(key), "{key} must stay visible"); } } @@ -1889,7 +1918,7 @@ mod tests { "x-goog-api-key": "gkey", "x-amz-security-token": "sts-tok" }, - "provider_options": { + "providerOptions": { "headers": { "Cookie": "s=1" }, "openai": { "api_key": "sk-secret", @@ -1899,9 +1928,9 @@ mod tests { }, // 用量字段名含 "token" 子串,但非凭据——contains("token") 曾误伤。 "usage": { - "max_output_tokens": 4096, - "prompt_tokens": 10, - "completion_tokens": 20 + "maxOutputTokens": 4096, + "inputTokens": 10, + "outputTokens": 20 }, "temperature": 0.5, }); @@ -1910,17 +1939,14 @@ mod tests { assert_eq!(r["headers"]["x-goog-api-key"], "[REDACTED]"); assert_eq!(r["headers"]["x-amz-security-token"], "[REDACTED]"); assert_eq!(r["headers"]["X-Key"], "ok"); - assert_eq!(r["provider_options"]["headers"]["Cookie"], "[REDACTED]"); - assert_eq!(r["provider_options"]["openai"]["api_key"], "[REDACTED]"); - assert_eq!(r["provider_options"]["openai"]["token"], "[REDACTED]"); - assert_eq!( - r["provider_options"]["openai"]["access_token"], - "[REDACTED]" - ); + assert_eq!(r["providerOptions"]["headers"]["Cookie"], "[REDACTED]"); + assert_eq!(r["providerOptions"]["openai"]["api_key"], "[REDACTED]"); + assert_eq!(r["providerOptions"]["openai"]["token"], "[REDACTED]"); + assert_eq!(r["providerOptions"]["openai"]["access_token"], "[REDACTED]"); // 含 "token" 子串的用量字段不再被误脱敏(回归 contains("token"))。 - assert_eq!(r["usage"]["max_output_tokens"], 4096); - assert_eq!(r["usage"]["prompt_tokens"], 10); - assert_eq!(r["usage"]["completion_tokens"], 20); + assert_eq!(r["usage"]["maxOutputTokens"], 4096); + assert_eq!(r["usage"]["inputTokens"], 10); + assert_eq!(r["usage"]["outputTokens"], 20); assert_eq!(r["temperature"], 0.5); } @@ -2117,7 +2143,7 @@ mod tests { assert_eq!(parsed.session_id.as_deref(), Some("sess-abc")); assert_eq!(parsed.step, Some(3)); // 序列化时 null 省略:只有归组的调用才带这两个字段。 - assert!(content.contains("\"session_id\":\"sess-abc\"")); + assert!(content.contains("\"sessionId\":\"sess-abc\"")); assert!(content.contains("\"step\":3")); } @@ -2130,7 +2156,7 @@ mod tests { ProviderRecord::from_model("openai", "gpt-4o"), ); let json = serde_json::to_string(&rec).unwrap(); - assert!(!json.contains("session_id"), "ungrouped must omit: {json}"); + assert!(!json.contains("sessionId"), "ungrouped must omit: {json}"); assert!(!json.contains("\"step\""), "ungrouped must omit: {json}"); // 旧 jsonl(无 session 字段)仍能反序列化(serde default)。 @@ -2138,7 +2164,7 @@ mod tests { let mut r = serde_json::to_value(&rec).unwrap(); r.as_object_mut() .unwrap() - .insert("transport_closed".into(), serde_json::json!(true)); + .insert("transportClosed".into(), serde_json::json!(true)); r }) .unwrap(); @@ -2625,7 +2651,7 @@ mod tests { rec.record_transport_closed("c"); assert!(rec.try_flush().is_ok()); let content = std::fs::read_to_string(rec.path()).unwrap(); - assert!(content.contains("\"call_id\":\"c\"")); + assert!(content.contains("\"callId\":\"c\"")); let _ = std::fs::remove_dir_all(&dir); // 非法目录 → new 降级(tx=None)→ try_flush 返回 WriterGone。 diff --git a/aimux-core/src/replay.rs b/aimux-core/src/replay.rs index 29cf0861f..c84e4bb8b 100644 --- a/aimux-core/src/replay.rs +++ b/aimux-core/src/replay.rs @@ -101,13 +101,13 @@ fn canonical_call_key(opts: &CallOptions) -> serde_json::Value { serde_json::json!({ "prompt": serde_json::to_value(&opts.prompt).unwrap_or_default(), "temperature": opts.temperature, - "max_output_tokens": opts.max_output_tokens, + "maxOutputTokens": opts.max_output_tokens, "seed": opts.seed, - "response_format": serde_json::to_value(&opts.response_format).unwrap_or_default(), + "responseFormat": serde_json::to_value(&opts.response_format).unwrap_or_default(), "tools": serde_json::to_value(&opts.tools).unwrap_or_default(), - "tool_choice": serde_json::to_value(&opts.tool_choice).unwrap_or_default(), + "toolChoice": serde_json::to_value(&opts.tool_choice).unwrap_or_default(), "headers": serde_json::to_value(&opts.headers).unwrap_or_default(), - "provider_options": serde_json::to_value(&opts.provider_options).unwrap_or_default(), + "providerOptions": serde_json::to_value(&opts.provider_options).unwrap_or_default(), }) } @@ -117,13 +117,13 @@ fn canonical_recording_key(rec: &Recording) -> serde_json::Value { serde_json::json!({ "prompt": serde_json::to_value(&rec.input.prompt).unwrap_or_default(), "temperature": o.get("temperature").cloned().unwrap_or_default(), - "max_output_tokens": o.get("max_output_tokens").cloned().unwrap_or_default(), + "maxOutputTokens": o.get("maxOutputTokens").cloned().unwrap_or_default(), "seed": o.get("seed").cloned().unwrap_or_default(), - "response_format": o.get("response_format").cloned().unwrap_or_default(), + "responseFormat": o.get("responseFormat").cloned().unwrap_or_default(), "tools": o.get("tools").cloned().unwrap_or_default(), - "tool_choice": o.get("tool_choice").cloned().unwrap_or_default(), + "toolChoice": o.get("toolChoice").cloned().unwrap_or_default(), "headers": o.get("headers").cloned().unwrap_or_default(), - "provider_options": o.get("provider_options").cloned().unwrap_or_default(), + "providerOptions": o.get("providerOptions").cloned().unwrap_or_default(), }) } @@ -177,7 +177,7 @@ fn redaction_aware_eq(rec: &serde_json::Value, req: &serde_json::Value) -> bool /// 被脱敏为 `"[REDACTED]"` 而请求值为数值,精确比较会 miss(保守:宁可 miss /// 不可误 hit,因为 max_output_tokens 影响响应,通配会引入伪命中)。 fn canonical_keys_match(rec_key: &serde_json::Value, call_key: &serde_json::Value) -> bool { - const REDACTED_FIELDS: [&str; 2] = ["headers", "provider_options"]; + const REDACTED_FIELDS: [&str; 2] = ["headers", "providerOptions"]; let (ro, co) = match (rec_key.as_object(), call_key.as_object()) { (Some(a), Some(b)) => (a, b), _ => return rec_key == call_key, diff --git a/aimux-core/src/reranking_model.rs b/aimux-core/src/reranking_model.rs index cf677ebc6..e386c242a 100644 --- a/aimux-core/src/reranking_model.rs +++ b/aimux-core/src/reranking_model.rs @@ -15,6 +15,11 @@ use crate::{AbortSignal, retry, timeout}; /// /// Aligned with V4 `RerankingModelV4CallOptions.documents`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde( + tag = "type", + rename_all = "lowercase", + rename_all_fields = "camelCase" +)] #[ts(export)] pub enum RerankingDocuments { /// A list of plain-text documents. @@ -27,6 +32,7 @@ pub enum RerankingDocuments { /// /// Aligned with V4 `RerankingModelV4CallOptions`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct RerankingCallOptions { /// Documents to rerank. @@ -36,6 +42,8 @@ pub struct RerankingCallOptions { pub query: String, /// Optional limit: return only the top `n` documents. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub top_n: Option, /// Abort signal for cancelling the operation. @@ -44,15 +52,23 @@ pub struct RerankingCallOptions { pub abort_signal: Option, /// Per-call retry override. `None` uses the model default. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_retries: Option, /// Per-call operation timeout. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout: Option, /// Additional provider-specific options, keyed by provider name. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_options: Option, /// Additional HTTP headers to send with the request. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option, } @@ -74,6 +90,7 @@ impl RerankingCallOptions { /// A single reranked entry: the original index and its relevance score. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct RerankingRank { /// The index of the document in the original list (before reranking). @@ -86,6 +103,7 @@ pub struct RerankingRank { /// /// Aligned with V4 `RerankingModelV4Result`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct RerankingResult { /// Ordered list of reranked documents, sorted by descending relevance @@ -94,28 +112,45 @@ pub struct RerankingResult { pub ranking: Vec, /// Additional provider-specific metadata, keyed by provider name. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_metadata: Option, /// Warnings for the call, e.g. unsupported settings. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub warnings: Option>, /// Optional response information for debugging. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub response: Option, } /// Optional response information for a reranking call. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct RerankingResponse { /// ID for the generated response, if the provider sends one. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub id: Option, /// Timestamp for the start of the generated response (ISO 8601 string). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub timestamp: Option, /// The ID of the model that was used to generate the response. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, /// Response headers. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option, /// Response body (opaque JSON). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub body: Option, } diff --git a/aimux-core/src/result.rs b/aimux-core/src/result.rs index 5f0d17c8b..a3cd58f55 100644 --- a/aimux-core/src/result.rs +++ b/aimux-core/src/result.rs @@ -15,11 +15,17 @@ use crate::types::{FinishReason, ProviderMetadata, Usage, Warning}; /// A content item in the generation result. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] #[ts(export)] pub enum GenerateContent { /// Generated text. Text { text: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, @@ -37,6 +43,7 @@ pub enum GenerateContent, }, @@ -60,25 +67,31 @@ struct TextContentBinding; /// Provider approval request for a provider-executed call. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct RawToolApprovalRequest { pub approval_id: String, pub tool_call_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub provider_metadata: Option, } /// Approval request exposed by text generation. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ToolApprovalRequestOutput { pub approval_id: String, pub tool_call: crate::tool::ToolCall, #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub reason: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub is_automatic: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub signature: Option, } @@ -86,15 +99,21 @@ pub struct ToolApprovalRequestOutput { /// payload of both `GenerateContent::Source` and `StreamPart::Source`, and the /// element of the text results' `sources`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde( + tag = "sourceType", + rename_all = "lowercase", + rename_all_fields = "camelCase" +)] #[ts(export)] -#[serde(tag = "source_type", rename_all = "lowercase")] pub enum Source { Url { id: String, url: String, #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] title: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] provider_metadata: Option, }, Document { @@ -102,8 +121,10 @@ pub enum Source { media_type: String, title: String, #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] filename: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] provider_metadata: Option, }, } @@ -113,11 +134,13 @@ pub enum Source { /// or URL. The payload of both `GenerateContent::File` and /// `StreamPart::File`, and the element of the text results' `files`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct GeneratedFile { pub data: GeneratedFileData, pub media_type: String, #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub provider_metadata: Option, } @@ -127,26 +150,33 @@ pub struct GeneratedFile { /// (`providerMetadata.anthropic.signature`). The payload of /// `GenerateContent::Reasoning` and the element of the text results' `reasoning`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ReasoningOutput { pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub provider_metadata: Option, } /// Text or a generated file produced during reasoning. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[serde(untagged)] +#[serde(tag = "type")] #[ts(export)] pub enum ReasoningPart { + #[serde(rename = "reasoning")] Text(ReasoningOutput), + #[serde(rename = "reasoning-file")] File(ReasoningFileOutput), } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ReasoningFileOutput { pub file: GeneratedFile, #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub provider_metadata: Option, } @@ -154,6 +184,7 @@ pub struct ReasoningFileOutput { /// `GenerateTextResult`'s user-facing fields (without `raw`, since streaming /// has no `GenerateResult` equivalent). #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct StreamTextResultAggregated { /// Ordered content from the last generation step. @@ -179,7 +210,8 @@ pub struct StreamTextResultAggregated { /// Why generation stopped. pub finish_reason: crate::types::FinishReason, /// Raw provider-specific finish reason string. - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub raw_finish_reason: Option, /// Token usage. #[serde(default)] @@ -192,7 +224,8 @@ pub struct StreamTextResultAggregated { #[serde(default)] pub warnings: Vec, /// Provider-specific metadata from the Finish chunk. - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub provider_metadata: Option, /// Request information from the provider. #[serde(default)] @@ -207,6 +240,7 @@ pub struct StreamTextResultAggregated { /// Result of `LanguageModel::do_generate` (non-streaming). #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct GenerateResult { /// Generated content items (text, tool calls, etc.). @@ -218,10 +252,16 @@ pub struct GenerateResult { /// Warnings issued by the provider. pub warnings: Vec, /// Provider-specific metadata. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub provider_metadata: Option, /// Optional request information for telemetry and debugging. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub request: Option, /// Optional response metadata, headers, and body. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub response: Option, } diff --git a/aimux-core/src/search_model.rs b/aimux-core/src/search_model.rs index 4c94c65dd..4f1ee9b71 100644 --- a/aimux-core/src/search_model.rs +++ b/aimux-core/src/search_model.rs @@ -16,28 +16,39 @@ use crate::{AbortSignal, retry, timeout}; /// Options passed to [`SearchModel::do_search`]. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct SearchCallOptions { /// The search query string. pub query: String, /// Maximum number of results to return. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_results: Option, /// Whether to include raw page content in results. /// /// Provider support varies; providers that cannot honor this should /// issue a warning rather than erroring. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub include_raw_content: Option, /// Optional time range filter (e.g. `"day"`, `"week"`, `"month"`, /// `"year"`). Provider support varies. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub time_range: Option, /// Optional list of domains to include in results. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub include_domains: Option>, /// Optional list of domains to exclude from results. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub exclude_domains: Option>, /// Abort signal for cancelling the operation. @@ -46,15 +57,23 @@ pub struct SearchCallOptions { pub abort_signal: Option, /// Per-call retry override. `None` uses the model default. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_retries: Option, /// Per-call operation timeout. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout: Option, /// Additional provider-specific options, keyed by provider name. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_options: Option, /// Additional HTTP headers to send with the request. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option, } @@ -79,30 +98,44 @@ impl SearchCallOptions { /// A single search result item. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct SearchResultItem { /// The title of the result (e.g. page title). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, /// The URL of the result. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub url: Option, /// A snippet/summary of the result content. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub content: Option, /// Raw page content (when `include_raw_content` is requested and /// supported by the provider). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub raw_content: Option, /// A relevance score (0.0–1.0) if the provider returns one. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub score: Option, /// Provider-specific metadata for this result. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_metadata: Option, } /// The result of [`SearchModel::do_search`]. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct SearchResult { /// Ordered list of search results. @@ -110,25 +143,36 @@ pub struct SearchResult { /// An optional direct answer / summary (some providers return /// an AI-generated answer alongside results). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub answer: Option, /// Additional provider-specific metadata. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_metadata: Option, /// Warnings for the call. pub warnings: Vec, /// Optional response information for debugging. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub response: Option, } /// Optional response information for a search call. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct SearchResponse { /// Response headers. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option, /// The response body (opaque JSON). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub body: Option, } diff --git a/aimux-core/src/session.rs b/aimux-core/src/session.rs index 38a3869a2..6b37d46dc 100644 --- a/aimux-core/src/session.rs +++ b/aimux-core/src/session.rs @@ -44,6 +44,7 @@ pub enum SessionSource { /// A single call within a session (an index entry only — call content lives /// in Recording (RFC-0023) / TraceRecord (RFC-0015)). #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct SessionCall { /// Call-level unique id. This is the association key for the future @@ -58,6 +59,7 @@ pub struct SessionCall { /// Aggregated view of one session (session_id → ordered calls). #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct SessionView { pub session_id: String, diff --git a/aimux-core/src/shared.rs b/aimux-core/src/shared.rs index 24cddc49d..72a44781c 100644 --- a/aimux-core/src/shared.rs +++ b/aimux-core/src/shared.rs @@ -82,6 +82,7 @@ pub type SharedProviderReference = HashMap; /// the upstream API returns base64, return [`FileBytes::Base64`]; if it /// returns binary, return [`FileBytes::Binary`]. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(untagged)] #[ts(export)] pub enum FileBytes { /// Raw binary bytes. @@ -94,6 +95,11 @@ pub enum FileBytes { /// /// Aligned with V4 `SharedV4FileData`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde( + tag = "type", + rename_all = "lowercase", + rename_all_fields = "camelCase" +)] #[ts(export)] pub enum FileData { /// Raw bytes (`Uint8Array`) or a base64-encoded string. @@ -101,6 +107,7 @@ pub enum FileData { /// A URL that points to the file. Url { url: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] original_url: Option, }, @@ -112,6 +119,11 @@ pub enum FileData { /// Data or a URL returned for a generated file. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde( + tag = "type", + rename_all = "lowercase", + rename_all_fields = "camelCase" +)] #[ts(export)] pub enum GeneratedFileData { Data { @@ -119,6 +131,7 @@ pub enum GeneratedFileData { }, Url { url: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] original_url: Option, }, @@ -130,6 +143,7 @@ pub enum GeneratedFileData { /// `resolution` options. Aligned with the TS template-literal type /// `` `${number}x${number}` ``. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct Size { width: u32, @@ -194,6 +208,7 @@ impl std::str::FromStr for Size { /// `aspectRatio` options. Aligned with the TS template-literal type /// `` `${number}:${number}` ``. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct AspectRatio { width: u32, @@ -269,18 +284,28 @@ fn parse_pair(s: &str, sep: char) -> Option<(u32, u32)> { /// `ResponseInfo` directly, while those that mark it optional use /// `Option`. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ResponseInfo { /// ID of the generated response, when supplied by the provider. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub id: Option, /// Timestamp for the start of the generated response (ISO 8601 string). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub timestamp: Option, /// The ID of the model that was used to generate the response. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, /// Response headers. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option, /// The response body (opaque JSON). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub body: Option, } @@ -288,16 +313,22 @@ pub struct ResponseInfo { /// /// Aligned with the `request?` object that appears on some V4 model results. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct RequestInfo { /// The request body that was sent (opaque JSON). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub body: Option, } /// Response information returned when starting a language-model stream. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct StreamResponseInfo { + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option, } diff --git a/aimux-core/src/skills_model.rs b/aimux-core/src/skills_model.rs index deb5171c0..c913fb17c 100644 --- a/aimux-core/src/skills_model.rs +++ b/aimux-core/src/skills_model.rs @@ -11,6 +11,7 @@ use crate::shared::{ }; #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct SkillFile { pub path: String, @@ -18,21 +19,37 @@ pub struct SkillFile { } #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct UploadSkillCallOptions { pub files: Vec, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub display_title: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_options: Option, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct UploadSkillResult { pub provider_reference: SharedProviderReference, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub display_title: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub latest_version: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_metadata: Option, pub warnings: Vec, } diff --git a/aimux-core/src/speech_model.rs b/aimux-core/src/speech_model.rs index d83d38c8f..30512a840 100644 --- a/aimux-core/src/speech_model.rs +++ b/aimux-core/src/speech_model.rs @@ -16,6 +16,7 @@ use crate::{AbortSignal, retry, timeout}; /// The TS result type is `string | Uint8Array`. Providers should return data /// without unnecessary conversion. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(untagged)] #[ts(export)] pub enum AudioData { /// Audio as a base64-encoded string. @@ -28,6 +29,7 @@ pub enum AudioData { /// /// Aligned with V4 `SpeechModelV4CallOptions`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct SpeechCallOptions { /// Text to convert to speech. @@ -35,23 +37,35 @@ pub struct SpeechCallOptions { /// The voice to use (provider-specific voice ID, name, or other /// identifier). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub voice: Option, /// The desired output format for the audio, e.g. `"mp3"`, `"wav"`. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub output_format: Option, /// Instructions for the speech generation, e.g. /// `"Speak in a slow and steady tone"`. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub instructions: Option, /// The speed of the speech generation. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub speed: Option, /// The language for speech generation (ISO 639-1 code, e.g. `"en"`, or /// `"auto"` for automatic detection). Provider support varies. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, /// Additional provider-specific options, keyed by provider name. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_options: Option, /// Abort signal for cancelling the operation. @@ -60,12 +74,18 @@ pub struct SpeechCallOptions { pub abort_signal: Option, /// Per-call retry override. `None` uses the model default. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_retries: Option, /// Per-call operation timeout. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout: Option, /// Additional HTTP headers to send with the request. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option, } @@ -92,6 +112,7 @@ impl SpeechCallOptions { /// /// Aligned with V4 `SpeechModelV4Result`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct SpeechResult { /// Generated audio (base64 string or binary data). @@ -101,34 +122,50 @@ pub struct SpeechResult { pub warnings: Vec, /// Optional request information for telemetry and debugging. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub request: Option, /// Response information for telemetry and debugging. pub response: SpeechResponse, /// Additional provider-specific metadata, keyed by provider name. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_metadata: Option, } /// Optional request information for a speech call. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct SpeechRequest { /// Response body (HTTP providers only). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub body: Option, } /// Response information for a speech call. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct SpeechResponse { /// Timestamp for the start of the generated response (ISO 8601 string). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub timestamp: Option, /// The ID of the model that was used to generate the response. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, /// Response headers. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option, /// Response body (opaque JSON). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub body: Option, } diff --git a/aimux-core/src/stream_part.rs b/aimux-core/src/stream_part.rs index 16c5bbb16..1e226b2bb 100644 --- a/aimux-core/src/stream_part.rs +++ b/aimux-core/src/stream_part.rs @@ -20,12 +20,18 @@ use crate::types::{FinishReason, ProviderMetadata, ResponseMetadata, Usage, Warn /// [`StreamPart`] (raw argument text); `stream_text` emits [`TextStreamPart`] /// (parsed and validated [`ToolCall`]). #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] #[ts(export, concrete(C = RawToolCall, A = RawToolApprovalRequest, R = GeneratedFile))] pub enum StreamPart { // ── P0: text ── /// Start of a text segment. TextStart { id: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, @@ -33,12 +39,14 @@ pub enum StreamPart, }, /// End of a text segment. TextEnd { id: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, @@ -52,6 +60,8 @@ pub enum StreamPart, }, /// An error occurred mid-stream. @@ -65,14 +75,18 @@ pub enum StreamPart, /// Whether the tool is dynamic (defined at runtime, e.g. MCP tools). + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] dynamic: Option, /// Optional title for the tool call. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] title: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, @@ -80,12 +94,14 @@ pub enum StreamPart, }, /// End of a tool call's input streaming. ToolInputEnd { id: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, @@ -103,6 +119,7 @@ pub enum StreamPart, }, @@ -113,6 +130,8 @@ pub enum StreamPart, }, ReasoningDelta { @@ -120,12 +139,16 @@ pub enum StreamPart, }, ReasoningEnd { id: String, /// Provider-specific metadata (e.g. xAI `itemId`, `reasoningEncryptedContent`). #[allow(unused)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, @@ -144,12 +167,18 @@ pub enum StreamPart, }, @@ -157,12 +186,14 @@ pub enum TextStreamPart { TextDelta { id: String, delta: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, /// End of a text segment. TextEnd { id: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, @@ -176,12 +207,16 @@ pub enum TextStreamPart { Finish { finish_reason: FinishReason, usage: Usage, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, /// A completed model call, including its response metadata. FinishStep { finish_reason: FinishReason, usage: Usage, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, response: crate::shared::ResponseInfo, }, @@ -196,14 +231,18 @@ pub enum TextStreamPart { id: String, tool_name: String, /// Whether the tool call will be executed by the provider. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_executed: Option, /// Whether the tool is dynamic (defined at runtime, e.g. MCP tools). + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] dynamic: Option, /// Optional title for the tool call. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] title: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, @@ -211,12 +250,14 @@ pub enum TextStreamPart { ToolInputDelta { id: String, delta: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, /// End of a tool call's input streaming. ToolInputEnd { id: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, @@ -234,6 +275,7 @@ pub enum TextStreamPart { ReasoningFile(ReasoningFileOutput), Custom { kind: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, @@ -244,6 +286,8 @@ pub enum TextStreamPart { id: String, /// Provider-specific metadata (e.g. xAI `itemId`). #[allow(unused)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, ReasoningDelta { @@ -251,12 +295,16 @@ pub enum TextStreamPart { delta: String, /// Provider-specific metadata (e.g. xAI `itemId`). #[allow(unused)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, ReasoningEnd { id: String, /// Provider-specific metadata (e.g. xAI `itemId`, `reasoningEncryptedContent`). #[allow(unused)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, diff --git a/aimux-core/src/tool.rs b/aimux-core/src/tool.rs index 2c95fb05c..e345a8fcf 100644 --- a/aimux-core/src/tool.rs +++ b/aimux-core/src/tool.rs @@ -10,31 +10,37 @@ use crate::types::ProviderMetadata; /// A tool definition passed to the model in `CallOptions.tools`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct FunctionTool { /// Unique tool name. pub name: String, /// Human-readable description. - #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, /// JSON Schema describing the tool's parameters. pub input_schema: Value, /// Whether the tool supports strict schema enforcement (OpenAI strict mode). - #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub strict: Option, /// Provider-specific options, keyed by provider name /// (e.g. `{"anthropic": {"eagerInputStreaming": true}}`). Aligned with the /// V4 `providerOptions` field on function tools. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_options: Option, /// Example inputs for the tool (V4 `inputExamples`), used by some providers /// to emit `input_examples` in the request body. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub input_examples: Option>, } /// An example input for a function tool. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct FunctionToolInputExample { pub input: Map, @@ -77,6 +83,7 @@ impl FunctionTool { /// schemas are defined by the provider, and some are executed on the provider's /// servers (e.g. web search, code execution). #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ProviderTool { /// The ID of the tool, following the format `.`. @@ -92,7 +99,7 @@ pub struct ProviderTool { /// provider-defined tool. Mirrors the V4 /// `Array`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(tag = "type", rename_all = "snake_case")] +#[serde(tag = "type", rename_all = "lowercase")] #[ts(export)] pub enum Tool { /// A user-defined function tool. @@ -112,21 +119,26 @@ impl From for Tool { /// The wire shape matches [`ToolCall`] field for field, except that `input` /// is the provider's raw argument *text* rather than a parsed value. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct RawToolCall { pub tool_call_id: String, pub tool_name: String, pub input: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_executed: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub dynamic: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_metadata: Option, } /// A tool call requested by the model. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ToolCall { /// Provider-assigned call id. @@ -137,19 +149,24 @@ pub struct ToolCall { /// provider input was not valid JSON. pub input: Value, /// Whether the tool call will be executed by the provider. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_executed: Option, /// Whether the tool is dynamic (defined at runtime, e.g. MCP tools). + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub dynamic: Option, /// Additional provider-specific metadata associated with this call. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_metadata: Option, /// Set when lookup, JSON parsing, or schema validation still failed after /// the optional repair attempt. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub invalid: Option, /// Typed failure associated with an invalid tool call. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, } @@ -157,6 +174,7 @@ pub struct ToolCall { /// A tool result produced by a provider-executed tool (e.g. xAI `file_search`, /// web search), carried next to the [`RawToolCall`] it answers. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ToolResult { /// Must match the corresponding call's `tool_call_id`. @@ -165,15 +183,19 @@ pub struct ToolResult { /// The tool's output (usually a JSON-serializable value or plain text). pub result: Value, /// Whether the result is an error or error message. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub is_error: Option, /// Whether the result is preliminary (replaces prior, e.g. image previews). + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub preliminary: Option, /// Whether the tool is dynamic (defined at runtime, e.g. MCP tools). + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub dynamic: Option, /// Additional provider-specific metadata for the tool result. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_metadata: Option, } diff --git a/aimux-core/src/trace/fingerprint.rs b/aimux-core/src/trace/fingerprint.rs index 516fc511f..04652aee1 100644 --- a/aimux-core/src/trace/fingerprint.rs +++ b/aimux-core/src/trace/fingerprint.rs @@ -126,6 +126,7 @@ fn denoise_at(value: &Value, depth: usize) -> Value { /// Serialized-wire fingerprint stored in a `TraceRecord` (hex strings — /// `u128` is not JSON-serializable). #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct Fingerprint { /// 128-bit hash of the whole denoised body (hex). @@ -240,6 +241,6 @@ mod tests { assert_eq!(f.body_hash.len(), 32); // 128-bit hex assert!(f.block_hashes.iter().all(|h| h.len() == 32)); let json = serde_json::to_string(&f).unwrap(); - assert!(json.contains("\"body_hash\":\"")); + assert!(json.contains("\"bodyHash\":\"")); } } diff --git a/aimux-core/src/trace/record.rs b/aimux-core/src/trace/record.rs index a41834d3e..d6c3986ee 100644 --- a/aimux-core/src/trace/record.rs +++ b/aimux-core/src/trace/record.rs @@ -13,23 +13,40 @@ use super::verdict::Verdict; /// Token usage snapshot (7 flat fields + raw passthrough). #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct UsageSnapshot { + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub input_total: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub input_no_cache: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_read: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_write: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub output_total: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub output_text: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub output_reasoning: Option, /// Raw provider usage payload (opaque passthrough; numbers only — no /// prompt text). + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub raw: Option, } /// Request-side cache hints (best-effort, e.g. Anthropic `cache_control`). #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct RequestCacheHints { /// Whether any message requested `cache_control` (write) this call. @@ -38,13 +55,18 @@ pub struct RequestCacheHints { /// One probed call. Plaintext never persists — fingerprints only. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct TraceRecord { pub provider: String, pub model: String, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub request_id: Option, /// From `CallOptions.session_id` (RFC-0024) — explicit first, inference /// fallback; may also be the layer's default session. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub session_id: Option, /// Call-level unique id (association key for Recording / replay, /// RFC-0023). @@ -60,23 +82,29 @@ pub struct TraceRecord { /// Client-side LCP token upper bound (block upper bound, byte-proxy /// len/4). `None` when no history matched. Consumed by `TraceStats` /// aggregation. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub lcp_token_upper: Option, /// Time to first streamed token (ms). Non-streaming: `None`. - #[ts(type = "number | null")] + #[ts(optional, type = "number")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub ttft_ms: Option, pub fingerprint: Fingerprint, pub usage: UsageSnapshot, /// Response-side cache headers (e.g. `x-openrouter-cache-status`). + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub response_cache_headers: Option>, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub request_cache_hints: Option, /// Audit verdict (`None` when no auditor is attached — the default). + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub verdict: Option, /// Error string when the call failed (still recorded: failures are part /// of the session). + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, /// Internal scope key (index only; never crosses the wire contract). diff --git a/aimux-core/src/trace/store.rs b/aimux-core/src/trace/store.rs index 09685cd73..2a329a22e 100644 --- a/aimux-core/src/trace/store.rs +++ b/aimux-core/src/trace/store.rs @@ -280,17 +280,27 @@ impl TraceStore { /// Query filter (RFC-0015 §5.3). #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct TraceFilter { + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub session_id: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub since_unix_ms: Option, } /// Aggregated statistics (RFC-0015 §5.2 — the two hit rates are reported /// side by side, never merged into a single number). #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct TraceStats { pub provider: String, @@ -299,16 +309,25 @@ pub struct TraceStats { pub input_tokens_total: u64, pub claimed_cache_read_total: u64, pub claimed_cache_write_total: u64, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub reported_hit_rate: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub client_upper_bound_hit_rate: Option, pub verdict_counts: BTreeMap, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub ttft_p50_ms: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub ttft_p95_ms: Option, pub errors: u64, } /// Session chain view (append-only order + prefix stability, RFC-0015 §5.3). #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct SessionChainView { pub session_id: String, @@ -319,6 +338,7 @@ pub struct SessionChainView { /// One step of a session's cache-hit trajectory (RFC-0024 §4.3). #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct SessionStepStat { /// Step within the session (0-based, session order). @@ -327,18 +347,22 @@ pub struct SessionStepStat { pub call_id: String, /// Reported hit rate for this step (`cache_read / input_total`); `None` /// when the call carried no input/cache-read usage. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub hit_rate: Option, /// Audit verdict (present only when an auditor is attached). + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub verdict: Option, /// Error string for failed calls (failures are part of the session). + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, } /// A prefix break between two consecutive records of a session. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct PrefixBreak { pub at_record_id: String, diff --git a/aimux-core/src/trace/verdict.rs b/aimux-core/src/trace/verdict.rs index d23939e7a..029dcecc6 100644 --- a/aimux-core/src/trace/verdict.rs +++ b/aimux-core/src/trace/verdict.rs @@ -26,6 +26,7 @@ pub enum VerdictConfidence { /// A verdict for one call. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct Verdict { pub kind: VerdictKind, @@ -59,11 +60,14 @@ impl Verdict { /// Provider audit parameters (RFC-0015 §7 matrix — the part that lives in /// core; display logic belongs to the CLI). #[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ProviderAuditSpec { /// Provider family for family-specific invariants. pub family: ProviderFamily, /// Token quantization granularity; `None` = no quantization. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub gran: Option, /// Minimum cache threshold (tokens): below it `claimed` must be 0. pub threshold: u64, @@ -74,6 +78,8 @@ pub struct ProviderAuditSpec { pub model56plus: bool, /// Claimed-hit ceiling: above this multiple of the client upper bound /// the verdict is overclaim (W) even in shared mode. `None` = use U+τ. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub shared_ceiling_mult: Option, } diff --git a/aimux-core/src/transcription_model.rs b/aimux-core/src/transcription_model.rs index 516d345cf..bac827ae4 100644 --- a/aimux-core/src/transcription_model.rs +++ b/aimux-core/src/transcription_model.rs @@ -22,6 +22,7 @@ use crate::{AbortSignal, retry, timeout}; /// Audio input: raw bytes or a base64-encoded string. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(untagged)] #[ts(export)] pub enum AudioInput { /// Raw binary bytes. @@ -32,6 +33,7 @@ pub enum AudioInput { /// A chunk of audio in a streaming transcription request. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(untagged)] #[ts(export)] pub enum AudioChunk { /// Raw binary bytes. @@ -42,6 +44,7 @@ pub enum AudioChunk { /// A transcript segment with timing information. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct TranscriptionSegment { /// The text content of this segment. @@ -56,6 +59,7 @@ pub struct TranscriptionSegment { /// /// Aligned with V4 `TranscriptionModelV4CallOptions`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct TranscriptionCallOptions { /// Audio data to transcribe (raw bytes or a base64-encoded string). @@ -65,6 +69,8 @@ pub struct TranscriptionCallOptions { pub media_type: String, /// Additional provider-specific options, keyed by provider name. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_options: Option, /// Abort signal for cancelling the operation. @@ -73,12 +79,18 @@ pub struct TranscriptionCallOptions { pub abort_signal: Option, /// Per-call retry override. `None` uses the model default. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_retries: Option, /// Per-call operation timeout. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout: Option, /// Additional HTTP headers to send with the request. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option, } @@ -101,6 +113,7 @@ impl TranscriptionCallOptions { /// /// Aligned with V4 `TranscriptionModelV4Result`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct TranscriptionResult { /// The complete transcribed text from the audio. @@ -110,53 +123,76 @@ pub struct TranscriptionResult { pub segments: Vec, /// The detected language (ISO 639-1 code, e.g. `"en"`), if detected. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, /// The total duration of the audio in seconds, if determined. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub duration_in_seconds: Option, /// Warnings for the call, e.g. unsupported settings. pub warnings: Vec, /// Optional request information for telemetry and debugging. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub request: Option, /// Response information for telemetry and debugging. pub response: TranscriptionResponse, /// Additional provider-specific metadata, keyed by provider name. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_metadata: Option, } /// Optional request information for a transcription call. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct TranscriptionRequest { /// Raw request HTTP body that was sent (JSON stringified). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub body: Option, } /// Response information for a transcription call. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct TranscriptionResponse { /// Timestamp for the start of the generated response (ISO 8601 string). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub timestamp: Option, /// The ID of the model that was used to generate the response. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, /// Response headers. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option, /// Response body (opaque JSON). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub body: Option, } /// The input audio format for a streaming transcription request. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct InputAudioFormat { /// Audio format type, e.g. `"audio/pcm"`, `"audio/pcmu"`, `"audio/pcma"`. pub format_type: String, /// Sample rate in Hz. Only applicable for formats that require a rate. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub rate: Option, } @@ -208,47 +244,90 @@ impl std::fmt::Debug for TranscriptionStreamOptions { /// /// Aligned with V4 `TranscriptionModelV4StreamPart`. #[derive(Debug, Serialize, Deserialize, TS)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] #[ts(export)] pub enum TranscriptionStreamPart { /// Stream start event, carrying warnings. StreamStart { warnings: Vec }, /// Append-only transcript delta. TranscriptDelta { + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] id: Option, delta: String, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, /// Non-final transcript text (may be revised by later parts). TranscriptPartial { + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] id: Option, text: String, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] start_second: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] duration_in_seconds: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] channel_index: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, /// Final transcript text for a provider-defined segment or utterance. TranscriptFinal { + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] id: Option, text: String, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] start_second: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] end_second: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] channel_index: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, /// Response metadata, emitted once available. ResponseMetadata { + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] timestamp: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] model_id: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] headers: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] body: Option, }, /// Metadata available after the stream finishes. Finish { text: String, segments: Vec, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] language: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] duration_in_seconds: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, /// Raw provider chunk (only when `include_raw_chunks` is `true`). diff --git a/aimux-core/src/types.rs b/aimux-core/src/types.rs index 0f9e54730..d6c0a5505 100644 --- a/aimux-core/src/types.rs +++ b/aimux-core/src/types.rs @@ -6,11 +6,14 @@ use ts_rs::TS; /// Unified finish reason (why the model stopped generating). #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct FinishReason { /// The unified reason. pub unified: FinishReasonUnified, /// The raw provider-specific reason string. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub raw: Option, } @@ -29,43 +32,55 @@ pub enum FinishReasonUnified { /// Token usage statistics. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct Usage { pub input_tokens: InputTokenUsage, pub output_tokens: OutputTokenUsage, /// Raw usage information from the provider (opaque, provider-specific). + #[ts(optional, type = "Record")] #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(type = "Record | null")] pub raw: Option>, } /// Input token usage detail. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct InputTokenUsage { /// Total tokens. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub total: Option, /// Tokens that were not served from cache. - #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub no_cache: Option, /// Tokens served from cache (read). - #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_read: Option, /// Tokens written to cache. - #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_write: Option, } /// Output token usage detail. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct OutputTokenUsage { + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub total: Option, /// Text (non-reasoning) tokens (output side). - #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub text: Option, /// Reasoning tokens (output side). - #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning: Option, } @@ -139,16 +154,25 @@ impl std::str::FromStr for ReasoningEffort { /// A warning issued by the provider (e.g. unsupported parameter). #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde( + tag = "type", + rename_all = "lowercase", + rename_all_fields = "camelCase" +)] #[ts(export)] pub enum Warning { /// A feature is not supported by this provider. Unsupported { feature: String, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] details: Option, }, /// A feature has compatibility issues. Compatibility { feature: String, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] details: Option, }, /// A deprecated setting was used. @@ -159,10 +183,17 @@ pub enum Warning { /// Metadata about the API response. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct ResponseMetadata { + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub id: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub timestamp: Option, + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, } diff --git a/aimux-core/src/video_model.rs b/aimux-core/src/video_model.rs index 647f3625a..eaeb413f7 100644 --- a/aimux-core/src/video_model.rs +++ b/aimux-core/src/video_model.rs @@ -19,6 +19,11 @@ use crate::{AbortSignal, retry, timeout}; /// /// Aligned with V4 `VideoModelV4File`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde( + tag = "type", + rename_all = "lowercase", + rename_all_fields = "camelCase" +)] #[ts(export)] pub enum VideoFile { /// Inline file data (base64 or binary) with an explicit media type. @@ -33,12 +38,15 @@ pub enum VideoFile { /// The URL of the video or image file. url: String, /// The media type of the referenced file, when known. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] media_type: Option, }, } /// File payload for a [`VideoFile::File`]: base64 string or raw bytes. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(untagged)] #[ts(export)] pub enum VideoFileData { /// Base64-encoded string. @@ -51,6 +59,7 @@ pub enum VideoFileData { /// /// Aligned with V4 `VideoModelV4FrameType`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] #[ts(export)] pub enum VideoFrameType { /// The starting frame the model animates from. @@ -63,6 +72,7 @@ pub enum VideoFrameType { /// /// Aligned with V4 `VideoModelV4FrameImage`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct VideoFrameImage { /// The image file used for this frame. @@ -76,6 +86,11 @@ pub struct VideoFrameImage { /// Aligned with V4 `VideoModelV4VideoData`. Most providers return URLs due to /// large file sizes. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde( + tag = "type", + rename_all = "lowercase", + rename_all_fields = "camelCase" +)] #[ts(export)] pub enum VideoData { /// Video available as a URL (most common). @@ -90,9 +105,12 @@ pub enum VideoData { /// /// Aligned with V4 `VideoModelV4CallOptions`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct VideoCallOptions { /// Text prompt for the video generation. `None` when not required. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub prompt: Option, /// Number of videos to generate. Default `1`; most models only support @@ -103,31 +121,48 @@ pub struct VideoCallOptions { pub n: u32, /// Aspect ratio, in `{width}:{height}` format (e.g. `"16:9"`). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub aspect_ratio: Option, /// Resolution, in `{width}x{height}` format (e.g. `"1280x720"`). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub resolution: Option, /// Duration of the video in seconds. Typically 3–10 seconds. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub duration: Option, /// Frames per second. Common values: 24, 30, 60. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub fps: Option, /// Seed for deterministic generation. `None` uses a random seed. - #[ts(type = "number | null")] + #[ts(optional, type = "number")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub seed: Option, /// Input image for image-to-video generation (the starting frame). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub image: Option, /// Role-tagged image inputs for first-last-frame generation. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub frame_images: Option>, /// Reference inputs for reference-to-video generation (images or videos). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub input_references: Option>, /// Whether the model should generate audio alongside the video. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub generate_audio: Option, /// Additional provider-specific options, keyed by provider name. @@ -140,16 +175,24 @@ pub struct VideoCallOptions { pub abort_signal: Option, /// Per-call retry override. `None` uses the model default. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_retries: Option, /// Per-call poll pacing override for the start/status flow. Unset fields /// fall back to the model's [`VideoModel::poll_config`]. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub poll: Option, /// Per-call operation timeout. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout: Option, /// Additional HTTP headers to send with the request. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option, } @@ -184,15 +227,18 @@ impl VideoCallOptions { /// Per-call poll pacing for video generation (AI SDK `generateVideo` `poll`). #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct VideoPollOptions { /// Delay between consecutive status checks, in milliseconds. // `number`, not the `bigint` ts-rs infers from u64: the JS bindings pass // options through `JSON.stringify`, which throws on BigInt. - #[ts(type = "number | null")] + #[ts(optional, type = "number")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub interval_ms: Option, /// Maximum total time to wait for completion, in milliseconds. - #[ts(type = "number | null")] + #[ts(optional, type = "number")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout_ms: Option, } @@ -200,6 +246,7 @@ pub struct VideoPollOptions { /// /// Aligned with V4 `VideoModelV4Result`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct VideoResult { /// Generated videos as URLs, base64 strings, or binary data. @@ -209,6 +256,8 @@ pub struct VideoResult { pub warnings: Vec, /// Additional provider-specific metadata, keyed by provider name. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_metadata: Option, /// Response information for telemetry and debugging. @@ -217,13 +266,20 @@ pub struct VideoResult { /// Response information for a video generation call. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct VideoResponse { /// Timestamp for the start of the generated response (ISO 8601 string). + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub timestamp: Option, /// The ID of the model that was used to generate the response. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, /// Response headers. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option, } diff --git a/aimux-core/tests/error_value_golden_test.rs b/aimux-core/tests/error_value_golden_test.rs index fa62d5598..b86beef36 100644 --- a/aimux-core/tests/error_value_golden_test.rs +++ b/aimux-core/tests/error_value_golden_test.rs @@ -1,5 +1,6 @@ -//! Golden snapshots of the `error_value` payload — the externally-tagged serde -//! JSON that PR #91 ships across the FFI boundary to all eight languages. +//! Golden snapshots of the `error_value` payload — the `name`-tagged serde +//! JSON (the AI SDK's error names) that ships across the FFI boundary to all +//! eight languages. //! //! These assert the *exact* JSON for every variant. The payload is a public //! cross-language contract: a field rename, a variant rename or a shape change @@ -32,7 +33,7 @@ fn error_value_snapshots_api_call_shapes() { is_retryable: true, ..api_error("boom") })), - r#"{"ApiCall":{"url":"https://example.test/v1","request_body_values":{},"status_code":500,"provider_code":"server_error","message":"boom","response_body":null,"response_headers":null,"data":null,"is_retryable":true}}"#, + r#"{"name":"AI_APICallError","url":"https://example.test/v1","requestBodyValues":{},"statusCode":500,"providerCode":"server_error","message":"boom","isRetryable":true}"#, ); golden( &AiMuxError::ApiCall(Box::new(ApiCallError { @@ -42,7 +43,7 @@ fn error_value_snapshots_api_call_shapes() { is_retryable: true, ..api_error("boom") })), - r#"{"ApiCall":{"url":"https://example.test/v1","request_body_values":{},"status_code":500,"provider_code":"server_error","message":"boom","response_body":"{\"error\":{\"message\":\"boom\",\"type\":\"server_error\"}}","response_headers":null,"data":null,"is_retryable":true}}"#, + r#"{"name":"AI_APICallError","url":"https://example.test/v1","requestBodyValues":{},"statusCode":500,"providerCode":"server_error","message":"boom","responseBody":"{\"error\":{\"message\":\"boom\",\"type\":\"server_error\"}}","isRetryable":true}"#, ); // A transport failure (no response arrived): no status, retryable — // exactly the AI SDK's handleFetchError shape. @@ -51,7 +52,7 @@ fn error_value_snapshots_api_call_shapes() { is_retryable: true, ..api_error("connection reset") })), - r#"{"ApiCall":{"url":"https://example.test/v1","request_body_values":{},"status_code":null,"provider_code":null,"message":"connection reset","response_body":null,"response_headers":null,"data":null,"is_retryable":true}}"#, + r#"{"name":"AI_APICallError","url":"https://example.test/v1","requestBodyValues":{},"message":"connection reset","isRetryable":true}"#, ); // A 429 is an ApiCall error whose classification is the status field — // there is no RateLimited variant; the hint remains in response headers. @@ -66,11 +67,11 @@ fn error_value_snapshots_api_call_shapes() { is_retryable: true, ..api_error("slow down") })), - r#"{"ApiCall":{"url":"https://example.test/v1","request_body_values":{},"status_code":429,"provider_code":"rate_limit_exceeded","message":"slow down","response_body":null,"response_headers":{"retry-after-ms":"2500"},"data":null,"is_retryable":true}}"#, + r#"{"name":"AI_APICallError","url":"https://example.test/v1","requestBodyValues":{},"statusCode":429,"providerCode":"rate_limit_exceeded","message":"slow down","responseHeaders":{"retry-after-ms":"2500"},"isRetryable":true}"#, ); golden( &AiMuxError::TokenExpired("expired".into()), - r#"{"TokenExpired":"expired"}"#, + r#"{"name":"AI_TokenExpiredError","message":"expired"}"#, ); } @@ -90,7 +91,7 @@ fn error_value_snapshot_retry_history() { })), ], }), - r#"{"Retry":{"reason":"maxRetriesExceeded","errors":[{"ApiCall":{"url":"https://example.test/v1","request_body_values":{},"status_code":null,"provider_code":null,"message":"first","response_body":null,"response_headers":null,"data":null,"is_retryable":true}},{"ApiCall":{"url":"https://example.test/v1","request_body_values":{},"status_code":null,"provider_code":null,"message":"second","response_body":null,"response_headers":null,"data":null,"is_retryable":true}}]}}"#, + r#"{"name":"AI_RetryError","reason":"maxRetriesExceeded","errors":[{"name":"AI_APICallError","url":"https://example.test/v1","requestBodyValues":{},"message":"first","isRetryable":true},{"name":"AI_APICallError","url":"https://example.test/v1","requestBodyValues":{},"message":"second","isRetryable":true}]}"#, ); } @@ -99,11 +100,11 @@ fn error_value_snapshots_plain_variants() { for (err, expected) in [ ( AiMuxError::JsonParse("bad json".into()), - r#"{"JsonParse":"bad json"}"#, + r#"{"name":"AI_JSONParseError","message":"bad json"}"#, ), ( AiMuxError::InvalidResponseData("eof".into()), - r#"{"InvalidResponseData":"eof"}"#, + r#"{"name":"AI_InvalidResponseDataError","message":"eof"}"#, ), // NoSuchTool is pinned in both shapes: `skip_serializing_if` makes // the wire payload vary with `available_tools`. @@ -113,7 +114,7 @@ fn error_value_snapshots_plain_variants() { available_tools: Some(vec!["weather".into(), "search".into()]), tool_input: Some(r#""hello""#.into()), }, - r#"{"NoSuchTool":{"tool_name":"weathr","available_tools":["weather","search"],"tool_input":"\"hello\""}}"#, + r#"{"name":"AI_NoSuchToolError","toolName":"weathr","availableTools":["weather","search"],"toolInput":"\"hello\""}"#, ), ( AiMuxError::NoSuchTool { @@ -121,7 +122,7 @@ fn error_value_snapshots_plain_variants() { available_tools: None, tool_input: None, }, - r#"{"NoSuchTool":{"tool_name":"weathr"}}"#, + r#"{"name":"AI_NoSuchToolError","toolName":"weathr"}"#, ), ( AiMuxError::InvalidToolInput { @@ -129,7 +130,7 @@ fn error_value_snapshots_plain_variants() { tool_input: "{".into(), cause: "JSON parsing failed".into(), }, - r#"{"InvalidToolInput":{"tool_name":"weather","tool_input":"{","cause":"JSON parsing failed"}}"#, + r#"{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{","cause":"JSON parsing failed"}"#, ), ( AiMuxError::ToolCallRepair { @@ -140,44 +141,44 @@ fn error_value_snapshots_plain_variants() { }), cause: Box::new(AiMuxError::Other("repair model failed".into())), }, - r#"{"ToolCallRepair":{"original_error":{"NoSuchTool":{"tool_name":"weathr"}},"cause":{"Other":"repair model failed"}}}"#, + r#"{"name":"AI_ToolCallRepairError","originalError":{"name":"AI_NoSuchToolError","toolName":"weathr"},"cause":{"name":"AI_Error","message":"repair model failed"}}"#, ), ( AiMuxError::InvalidArgument("bad arg".into()), - r#"{"InvalidArgument":"bad arg"}"#, + r#"{"name":"AI_InvalidArgumentError","message":"bad arg"}"#, ), ( AiMuxError::InvalidPrompt("bad prompt".into()), - r#"{"InvalidPrompt":"bad prompt"}"#, + r#"{"name":"AI_InvalidPromptError","message":"bad prompt"}"#, ), ( AiMuxError::TokenExpired("expired".into()), - r#"{"TokenExpired":"expired"}"#, + r#"{"name":"AI_TokenExpiredError","message":"expired"}"#, ), ( AiMuxError::UnsupportedFunctionality("no audio".into()), - r#"{"UnsupportedFunctionality":"no audio"}"#, + r#"{"name":"AI_UnsupportedFunctionalityError","message":"no audio"}"#, ), ( AiMuxError::LoadApiKey { env_var: "OPENAI_API_KEY".into(), description: "OpenAI".into(), }, - r#"{"LoadApiKey":{"env_var":"OPENAI_API_KEY","description":"OpenAI"}}"#, + r#"{"name":"AI_LoadAPIKeyError","envVar":"OPENAI_API_KEY","description":"OpenAI"}"#, ), ( AiMuxError::LoadSetting { env_var: "AWS_REGION".into(), name: "region".into(), }, - r#"{"LoadSetting":{"env_var":"AWS_REGION","name":"region"}}"#, + r#"{"name":"AI_LoadSettingError","envVar":"AWS_REGION","settingName":"region"}"#, ), ( AiMuxError::NoSuchModel { model_id: "gpt-9".into(), model_type: "languageModel".into(), }, - r#"{"NoSuchModel":{"model_id":"gpt-9","model_type":"languageModel"}}"#, + r#"{"name":"AI_NoSuchModelError","modelId":"gpt-9","modelType":"languageModel"}"#, ), ( AiMuxError::NoSuchProvider { @@ -186,52 +187,31 @@ fn error_value_snapshots_plain_variants() { model_type: "languageModel".into(), available_providers: vec!["other".into()], }, - r#"{"NoSuchProvider":{"provider_id":"acme","model_id":"acme","model_type":"languageModel","available_providers":["other"]}}"#, + r#"{"name":"AI_NoSuchProviderError","providerId":"acme","modelId":"acme","modelType":"languageModel","availableProviders":["other"]}"#, ), ( AiMuxError::Timeout("total timeout".into()), - r#"{"Timeout":"total timeout"}"#, + r#"{"name":"TimeoutError","message":"total timeout"}"#, ), ( AiMuxError::Aborted("request aborted".into()), - r#"{"Aborted":"request aborted"}"#, + r#"{"name":"AbortError","message":"request aborted"}"#, + ), + ( + AiMuxError::Other("misc".into()), + r#"{"name":"AI_Error","message":"misc"}"#, ), - (AiMuxError::Other("misc".into()), r#"{"Other":"misc"}"#), ] { golden(&err, expected); } } -/// Request context is required. Payloads from the pre-context schema are -/// deliberately rejected instead of silently fabricating an empty URL/body. +/// Request context is required: a payload without `url` / `requestBodyValues` +/// is rejected instead of silently fabricating an empty URL/body. #[test] fn api_call_requires_request_context() { - let old = r#"{"ApiCall":{"message":"boom"}}"#; - assert!(serde_json::from_str::(old).is_err()); - - // The removed variants no longer deserialize — a deliberate breaking - // change pinned here so it cannot happen silently a second time. - assert!( - serde_json::from_str::(r#"{"RateLimited":{"retry_after_ms":5000}}"#).is_err() - ); - assert!(serde_json::from_str::(r#"{"Auth":"bad key"}"#).is_err()); - assert!(serde_json::from_str::(r#"{"Http":"connection reset"}"#).is_err()); - assert!(serde_json::from_str::(r#"{"Provider":{"message":"boom"}}"#).is_err()); - // NoSuchModel went from a plain string to a struct. - assert!(serde_json::from_str::(r#"{"NoSuchModel":"gpt-9"}"#).is_err()); - // The renamed variants' OLD wire names no longer deserialize: - // Json/Stream/Unsupported/UnknownProvider became - // JsonParse/InvalidResponseData/UnsupportedFunctionality/NoSuchProvider. - assert!(serde_json::from_str::(r#"{"Json":"bad json"}"#).is_err()); - assert!(serde_json::from_str::(r#"{"Stream":"eof"}"#).is_err()); - assert!(serde_json::from_str::(r#"{"Unsupported":"no audio"}"#).is_err()); - assert!(serde_json::from_str::(r#"{"UnknownProvider":"acme"}"#).is_err()); - assert!( - serde_json::from_str::( - r#"{"UnknownProvider":{"provider_id":"acme","available":[]}}"# - ) - .is_err() - ); + let incomplete = r#"{"name":"AI_APICallError","message":"boom","isRetryable":false}"#; + assert!(serde_json::from_str::(incomplete).is_err()); } /// The status lives in the field and *only* there. `Display` composes the diff --git a/aimux-ffi/src/lib.rs b/aimux-ffi/src/lib.rs index be9b437e3..9c8fea7e3 100644 --- a/aimux-ffi/src/lib.rs +++ b/aimux-ffi/src/lib.rs @@ -975,13 +975,13 @@ fn parse_opts_arg(opts_json: *const c_char) -> FfiResult { /// The optional `config_json` of the by-name constructors, as the settings of /// the selected factory. NULL, empty or "null" means no settings. An unknown -/// key (`max_retries`, `body_overrides`, ...) is `InvalidArgument`. +/// key (`maxRetries`, `bodyOverrides`, ...) is `InvalidArgument`. fn parse_provider_settings( api_key: Option, config_json: *const c_char, ) -> FfiResult { #[derive(serde::Deserialize, Default)] - #[serde(deny_unknown_fields)] + #[serde(deny_unknown_fields, rename_all = "camelCase")] struct Config { base_url: Option, headers: Option>, @@ -1620,9 +1620,9 @@ pub extern "C" fn aimux_xai_new_with_base( /// entry (replaces the retired `aimux_deepseek_new` etc.). /// - `model_id` — model id string. /// - `config_json` — optional JSON object of settings -/// (`{"base_url": "...", "headers": {...}, "organization": "...", "project": "...", "params": {...}}`); -/// NULL / empty / "null" for defaults. Any other key (`max_retries` is a -/// call-level option, `body_overrides` is gone) is an invalid argument. +/// (`{"baseUrl": "...", "headers": {...}, "organization": "...", "project": "...", "params": {...}}`); +/// NULL / empty / "null" for defaults. Any other key (`maxRetries` is a +/// call-level option, `bodyOverrides` is gone) is an invalid argument. /// /// AiMuxError: unknown provider, bad config shape, or invalid model id. #[unsafe(no_mangle)] @@ -1694,7 +1694,7 @@ pub extern "C" fn aimux_provider_handle_new( /// List models on a provider handle (RFC-0027 runtime discovery). /// /// `handle` is from `aimux_provider_handle_new`. Writes a JSON array of -/// sparse `RuntimeModel` (id / owned_by / created) — **no community +/// sparse `RuntimeModel` (id / ownedBy / created) — **no community /// enrichment**. To supplement with model specs, call `aimux_get_model_specs` /// separately and merge in the host. #[unsafe(no_mangle)] @@ -1789,8 +1789,8 @@ pub extern "C" fn aimux_generate_text( /// Generate a structured JSON object from the model (M12, RFC-0016). /// /// Same signature as [`aimux_generate_text`]; writes the serialized -/// `GenerateObjectResult`. The caller passes `response_format: { "Json": { -/// ... } }` via `opts_json` to control the schema; the function applies JSON +/// `GenerateObjectResult`. The caller passes `responseFormat: { "type": "json", +/// ... }` via `opts_json` to control the schema; the function applies JSON /// repair before parsing. #[unsafe(no_mangle)] pub extern "C" fn aimux_generate_object( @@ -1932,13 +1932,13 @@ pub extern "C" fn aimux_stream_text_with_abort( /// Build the repair argument for one invalid tool call (RFC-0035). /// -/// `tool_call_json` is one entry of `GenerateTextResult.tool_calls` with +/// `tool_call_json` is one entry of `GenerateTextResult.toolCalls` with /// `"invalid": true`. `prompt_json` and `opts_json` are **the same two /// strings the call was generated with** ([`aimux_generate_text`] / /// [`aimux_stream_text`]); messages, instructions, and the tool set are /// derived from them here, so no host repeats that derivation. /// -/// Writes `{tool_call, error, input_schema, tools, messages, instructions}` — +/// Writes `{toolCall, error, inputSchema, tools, messages, instructions}` — /// the AI SDK `repairToolCall` argument — to `*out_json`, or the JSON literal /// `null` when `opts_json` carried no tools: as in the AI SDK, a call made /// without a tool set is never repaired, and the host skips it. `null` is a @@ -1970,7 +1970,7 @@ pub extern "C" fn aimux_tool_call_repair_context( /// Resolve one invalid tool call against a host's repair reply (RFC-0035). /// /// `opts_json` is the same string the call was generated with; the tool set -/// comes from it. `reply_json` is `{"type":"repaired","tool_call":{…}}`, +/// comes from it. `reply_json` is `{"type":"repaired","toolCall":{…}}`, /// `{"type":"unchanged"}`, or `{"type":"failed","message":"…"}`. Writes the /// resulting `ToolCall` — valid, or invalid with the AI SDK's nested /// `ToolCallRepairError` — to `*out_json`. Same purity as @@ -2002,7 +2002,7 @@ pub extern "C" fn aimux_apply_tool_call_repair( /// /// `result_json` is a serialized `GenerateTextResult` or /// `GenerateObjectResult`; `opts_json` is the same string the call was -/// generated with. Both `tool_calls` and the matching `response_messages` +/// generated with. Both `toolCalls` and the matching `responseMessages` /// tool-call part are rewritten. The OpenAI-shaped /// `ChatCompletion` is not supported — it carries no `invalid`/`error`, so /// repair is driven from the native result, which @@ -2038,7 +2038,7 @@ pub extern "C" fn aimux_apply_tool_call_repair_to_result( /// /// The conversion half of [`aimux_generate_text_as_openai`], for hosts that /// repair a native result themselves (RFC-0035) and then want the OpenAI -/// shape: tool calls come from `result_json`'s `tool_calls`, so a patched +/// shape: tool calls come from `result_json`'s `toolCalls`, so a patched /// result converts to a completion that reflects the repair. `handle` only /// supplies the model id fallback for `ChatCompletion.model`; no runtime work, /// so this is safe from inside a stream callback. @@ -2658,9 +2658,10 @@ pub extern "C" fn aimux_transcription_generate( // ───────────────────────────────────────────────────────────────────────────── /// JSON options for `aimux_transcription_session_new` (all fields optional): -/// `{ "input_audio_format": { "format_type": "audio/pcm", "rate": 24000 }, -/// "provider_options": { … }, "headers": { … }, "include_raw_chunks": false }`. +/// `{ "inputAudioFormat": { "formatType": "audio/pcm", "rate": 24000 }, +/// "providerOptions": { … }, "headers": { … }, "includeRawChunks": false }`. #[derive(serde::Deserialize, Default)] +#[serde(rename_all = "camelCase")] struct TranscriptionSessionFfiOptions { input_audio_format: Option, provider_options: Option, @@ -3540,9 +3541,9 @@ pub extern "C" fn aimux_router_new( /// `reference_handles` is an array of `ref_len` existing model handles (may be /// 0 — MoaModel then degrades to running just the aggregator). `aggregator` /// is a single existing model handle. `config_json` is a serialized `MoaConfig` -/// (all fields optional): `{ "provider_name": "moa", "model_id": "moa", -/// "aggregator_instructions": null, "strip_reference_tools": true, -/// "fail_mode": "best_effort" | "fail_fast" }`. +/// (all fields optional): `{ "providerName": "moa", "modelId": "moa", +/// "aggregatorInstructions": null, "stripReferenceTools": true, +/// "failMode": "best_effort" | "fail_fast" }`. /// /// Fails on: bad JSON, an unknown aggregator handle (references may be empty; /// aggregator may not). @@ -3576,6 +3577,7 @@ pub extern "C" fn aimux_moa_new( /// FFI-side router config (lenient: all fields optional). #[derive(Default, serde::Deserialize)] +#[serde(rename_all = "camelCase")] struct RouterFfiConfig { router: Option, weights: Option>, @@ -4431,15 +4433,15 @@ mod tests { // Pull parts: StreamStart, delta("3 "), delta("2 "), Final, Finish, // then ENDED. let p1 = pull_part(session); - assert!(p1.contains("StreamStart"), "part 1: {p1}"); + assert!(p1.contains("\"type\":\"stream-start\""), "part 1: {p1}"); let p2 = pull_part(session); assert!(p2.contains("3 "), "part 2: {p2}"); let p3 = pull_part(session); assert!(p3.contains("2 "), "part 3: {p3}"); let p4 = pull_part(session); - assert!(p4.contains("TranscriptFinal"), "part 4: {p4}"); + assert!(p4.contains("\"type\":\"transcript-final\""), "part 4: {p4}"); let p5 = pull_part(session); - assert!(p5.contains("Finish"), "part 5: {p5}"); + assert!(p5.contains("\"type\":\"finish\""), "part 5: {p5}"); assert!(p5.contains("3 2 "), "finish text: {p5}"); // Stream ended: NULL error, ENDED state, no part. diff --git a/aimux-ffi/tests/exports_smoke_test.rs b/aimux-ffi/tests/exports_smoke_test.rs index 3d3e08d33..964904fec 100644 --- a/aimux-ffi/tests/exports_smoke_test.rs +++ b/aimux-ffi/tests/exports_smoke_test.rs @@ -7,7 +7,7 @@ //! the ABI contract: //! //! - **Constructor class** (`*_new` / `*_new_with_base` / composites): fake -//! API key + `base_url` pointing at a loopback port nothing listens on +//! API key + `baseUrl` pointing at a loopback port nothing listens on //! (`http://127.0.0.1:1`) → must return NULL and write a non-zero handle, //! which is then released with [`aimux_ffi::aimux_drop_handle`]. //! Constructors that cannot take a base URL (e.g. `aimux_provider_from_env`) @@ -18,7 +18,7 @@ //! transcription / rerank / video / search / files): handle + minimal //! options against the unreachable base URL → must return a returned error //! with the AiMuxError view and leave the out-param NULL (a clean error path). -//! Text calls pass `{"max_retries":0}` to skip the retry backoff; the +//! Text calls pass `{"maxRetries":0}` to skip the retry backoff; the //! multimodal option structs expose no retry override, so those tests run //! as separate `#[test]`s to parallelise the default backoff. //! - Every returned error and every returned JSON string is released @@ -494,7 +494,7 @@ fn constructor_exports_build_and_release_handles() { ); // Registry-backed constructors (RFC-0017 / RFC-0027). - let provider_opts = c(r#"{"base_url":"http://127.0.0.1:1"}"#); + let provider_opts = c(r#"{"baseUrl":"http://127.0.0.1:1"}"#); ctor!( "provider_new", aimux_provider_new( @@ -574,7 +574,7 @@ fn constructor_exports_build_and_release_handles() { fn text_generation_exports_fail_cleanly_on_unreachable_host() { let h = unreachable_model(); let prompt = c("\"ffi smoke\""); - let opts = c(r#"{"max_retries":0}"#); + let opts = c(r#"{"maxRetries":0}"#); let mut out: *mut c_char = ptr::null_mut(); let e = aimux_generate_text(h, prompt.as_ptr(), opts.as_ptr(), &mut out); @@ -598,7 +598,7 @@ fn text_generation_exports_fail_cleanly_on_unreachable_host() { fn streaming_exports_fail_cleanly_on_unreachable_host() { let h = unreachable_model(); let prompt = c("\"ffi smoke\""); - let opts = c(r#"{"max_retries":0}"#); + let opts = c(r#"{"maxRetries":0}"#); let done_before = DONE_SEEN.load(Ordering::Relaxed); let e = aimux_stream_text( @@ -701,7 +701,7 @@ fn image_generate_fails_cleanly_on_unreachable_host() { &mut h, ); expect_handle(e, h, "image handle"); - let opts = c(r#"{"prompt":"a rust crab","n":1,"provider_options":{}}"#); + let opts = c(r#"{"prompt":"a rust crab","n":1,"providerOptions":{}}"#); let mut out: *mut c_char = ptr::null_mut(); let e = aimux_image_generate(h, opts.as_ptr(), &mut out); expect_ptr_aimux_failure(e, out, "image_generate"); @@ -757,7 +757,7 @@ fn rerank_fails_cleanly_on_unreachable_host() { &mut h, ); expect_handle(e, h, "reranking handle"); - let opts = c(r#"{"query":"rust","documents":{"Text":{"values":["a","b"]}}}"#); + let opts = c(r#"{"query":"rust","documents":{"type":"text","values":["a","b"]}}"#); let mut out: *mut c_char = ptr::null_mut(); let e = aimux_rerank(h, opts.as_ptr(), &mut out); expect_ptr_aimux_failure(e, out, "rerank"); @@ -778,7 +778,7 @@ fn video_generate_fails_cleanly_on_unreachable_host() { // pins the serde defaults and the poll wire keys at the ABI boundary — // typed binding structs omit unset fields, so a strict parse here would // regress every C-ABI language at once. - let opts = c(r#"{"prompt":"waves","poll":{"interval_ms":1,"timeout_ms":1}}"#); + let opts = c(r#"{"prompt":"waves","poll":{"intervalMs":1,"timeoutMs":1}}"#); let mut out: *mut c_char = ptr::null_mut(); let e = aimux_video_generate(h, opts.as_ptr(), &mut out); assert!(out.is_null(), "video_generate: expected NULL out-param"); @@ -885,7 +885,7 @@ fn provider_discovery_and_catalogue_exports_fail_cleanly() { let e = aimux_provider_handle_new( c("groq").as_ptr(), c(FAKE_KEY).as_ptr(), - c(r#"{"base_url":"http://127.0.0.1:1"}"#).as_ptr(), + c(r#"{"baseUrl":"http://127.0.0.1:1"}"#).as_ptr(), &mut provider, ); expect_handle(e, provider, "provider_handle_new"); @@ -1098,9 +1098,9 @@ fn recording_exports_lifecycle_cleanly() { // ── host-side tool-call repair (RFC-0035, 3 exports) ─────────────────────── -const REPAIR_TOOLS: &str = r#"[{"type":"function","name":"weather","input_schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]"#; -const REPAIR_INVALID: &str = r#"{"tool_call_id":"call-1","tool_name":"weather","input":{"town":"SG"},"dynamic":true,"invalid":true,"error":{"InvalidToolInput":{"tool_name":"weather","tool_input":"{\"town\":\"SG\"}","cause":"missing city"}}}"#; -const REPAIR_REPLY: &str = r#"{"type":"repaired","tool_call":{"tool_call_id":"call-1","tool_name":"weather","input":"{\"city\":\"SG\"}"}}"#; +const REPAIR_TOOLS: &str = r#"[{"type":"function","name":"weather","inputSchema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]"#; +const REPAIR_INVALID: &str = r#"{"toolCallId":"call-1","toolName":"weather","input":{"town":"SG"},"dynamic":true,"invalid":true,"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{\"town\":\"SG\"}","cause":"missing city"}}"#; +const REPAIR_REPLY: &str = r#"{"type":"repaired","toolCall":{"toolCallId":"call-1","toolName":"weather","input":"{\"city\":\"SG\"}"}}"#; /// The three repair exports are pure: they take JSON, return JSON, and never /// touch a handle or the runtime. They also accept the very `prompt_json` / @@ -1119,7 +1119,7 @@ fn tool_call_repair_exports_round_trip() { ok(e, "tool_call_repair_context"); let context = read_and_free_json(out, "tool_call_repair_context"); assert!( - context.contains("\"input_schema\"") && context.contains("{\\\"town\\\":\\\"SG\\\"}"), + context.contains("\"inputSchema\"") && context.contains("{\\\"town\\\":\\\"SG\\\"}"), "context should carry the schema and the raw argument text: {context}" ); // The `{"prompt": …}` wrapper unwrapped into the one user message. @@ -1142,7 +1142,7 @@ fn tool_call_repair_exports_round_trip() { "repaired call should be valid: {repaired}" ); - let result = format!(r#"{{"tool_calls":[{REPAIR_INVALID}],"response_messages":[]}}"#); + let result = format!(r#"{{"toolCalls":[{REPAIR_INVALID}],"responseMessages":[]}}"#); let e = aimux_apply_tool_call_repair_to_result( c(&result).as_ptr(), c(&opts).as_ptr(), @@ -1173,10 +1173,10 @@ fn tool_call_repair_exports_round_trip() { #[test] fn a_repaired_result_converts_to_a_chat_completion() { let finish = r#"{"unified":"tool-calls","raw":null}"#; - let usage = r#"{"input_tokens":{"total":1},"output_tokens":{"total":1}}"#; - let repaired = r#"{"tool_call_id":"call-1","tool_name":"weather","input":{"city":"SG"}}"#; + let usage = r#"{"inputTokens":{"total":1},"outputTokens":{"total":1}}"#; + let repaired = r#"{"toolCallId":"call-1","toolName":"weather","input":{"city":"SG"}}"#; let result = format!( - r#"{{"text":"","tool_calls":[{repaired}],"finish_reason":{finish},"usage":{usage},"warnings":[],"raw":{{"content":[{{"ToolCall":{{"tool_call_id":"call-1","tool_name":"weather","input":"{{\"town\":\"SG\"}}"}}}}],"finish_reason":{finish},"usage":{usage},"warnings":[],"provider_metadata":null,"response":{{"id":null,"timestamp":null,"model_id":null}},"request_body":null,"response_headers":null}}}}"# + r#"{{"text":"","toolCalls":[{repaired}],"finishReason":{finish},"usage":{usage},"warnings":[],"raw":{{"content":[{{"type":"tool-call","toolCallId":"call-1","toolName":"weather","input":"{{\"town\":\"SG\"}}"}}],"finishReason":{finish},"usage":{usage},"warnings":[],"response":{{}}}}}}"# ); let h = unreachable_model(); diff --git a/aimux-ffi/tests/provider_handle_test.rs b/aimux-ffi/tests/provider_handle_test.rs index ffeeb0579..2b1ecd2bd 100644 --- a/aimux-ffi/tests/provider_handle_test.rs +++ b/aimux-ffi/tests/provider_handle_test.rs @@ -73,7 +73,7 @@ fn provider_handle_new_with_config_json() { let e = aimux_provider_handle_new( c("deepseek").as_ptr(), c("sk-test-fake").as_ptr(), - c(r#"{"base_url":"https://example.com/v1"}"#).as_ptr(), + c(r#"{"baseUrl":"https://example.com/v1"}"#).as_ptr(), &mut h, ); aimux_drop_handle(expect_handle(e, h, "provider_handle_new(deepseek, config)")); diff --git a/aimux-providers/src/catalogue.rs b/aimux-providers/src/catalogue.rs index 4b5815e93..de926e87f 100644 --- a/aimux-providers/src/catalogue.rs +++ b/aimux-providers/src/catalogue.rs @@ -86,6 +86,7 @@ pub fn normalize_provider_name(anya2a_id: &str) -> String { /// In-memory catalogue: per-provider model specs, keyed by aimux provider /// name then model id. #[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct Catalogue { #[serde(default)] pub updated_at: u64, diff --git a/aimux-providers/tests/google_evaluation_test.rs b/aimux-providers/tests/google_evaluation_test.rs index 54f0b2f75..dd1891123 100644 --- a/aimux-providers/tests/google_evaluation_test.rs +++ b/aimux-providers/tests/google_evaluation_test.rs @@ -58,9 +58,7 @@ fn options() -> EvaluationCallOptions { "instructions": ["Rate severity."], "criteria": ["Low", "Medium", "High"] } - }, - "headers": null, - "provider_options": null + } })) .unwrap() } diff --git a/aimux-providers/tests/mistral_upstream_e2e_test.rs b/aimux-providers/tests/mistral_upstream_e2e_test.rs index 768f8e82d..247f05d66 100644 --- a/aimux-providers/tests/mistral_upstream_e2e_test.rs +++ b/aimux-providers/tests/mistral_upstream_e2e_test.rs @@ -86,8 +86,8 @@ async fn generate_preserves_complete_usage() { assert_eq!( serde_json::to_value(result.usage).unwrap(), json!({ - "input_tokens": {"total": 20, "no_cache": 20}, - "output_tokens": {"total": 2, "text": 2}, + "inputTokens": {"total": 20, "noCache": 20}, + "outputTokens": {"total": 2, "text": 2}, "raw": body["usage"] }) ); @@ -171,8 +171,8 @@ async fn stream_text_and_usage() { assert_eq!( serde_json::to_value(usage).unwrap(), json!({ - "input_tokens": {"total": 13, "no_cache": 13}, - "output_tokens": {"total": 8, "text": 8}, + "inputTokens": {"total": 13, "noCache": 13}, + "outputTokens": {"total": 8, "text": 8}, "raw": {"prompt_tokens": 13, "completion_tokens": 8, "total_tokens": 21} }) ); diff --git a/aimux-providers/tests/openai_upstream_e2e_test.rs b/aimux-providers/tests/openai_upstream_e2e_test.rs index 29c399312..e74567530 100644 --- a/aimux-providers/tests/openai_upstream_e2e_test.rs +++ b/aimux-providers/tests/openai_upstream_e2e_test.rs @@ -257,28 +257,32 @@ async fn responses_stream() { assert_eq!( serde_json::to_value(parts).unwrap(), json!([ - {"StreamStart": {"warnings": []}}, - {"ResponseMetadata": { + {"type": "stream-start", "warnings": []}, + { + "type": "response-metadata", "id": "resp_67c9a81b6a048190a9ee441c5755a4e8", - "model_id": "test-model", + "modelId": "test-model", "timestamp": "2025-03-06T13:50:19+00:00" - }}, - {"TextStart": { + }, + { + "type": "text-start", "id": "msg_67c9a81dea8c8190b79651a2b3adf91e", - "provider_metadata": {"openai": {"itemId": "msg_67c9a81dea8c8190b79651a2b3adf91e"}} - }}, - {"TextDelta": {"id": "msg_67c9a81dea8c8190b79651a2b3adf91e", "delta": "Hello,"}}, - {"TextDelta": {"id": "msg_67c9a81dea8c8190b79651a2b3adf91e", "delta": " World!"}}, - {"TextEnd": { + "providerMetadata": {"openai": {"itemId": "msg_67c9a81dea8c8190b79651a2b3adf91e"}} + }, + {"type": "text-delta", "id": "msg_67c9a81dea8c8190b79651a2b3adf91e", "delta": "Hello,"}, + {"type": "text-delta", "id": "msg_67c9a81dea8c8190b79651a2b3adf91e", "delta": " World!"}, + { + "type": "text-end", "id": "msg_67c9a81dea8c8190b79651a2b3adf91e", - "provider_metadata": {"openai": {"itemId": "msg_67c9a81dea8c8190b79651a2b3adf91e"}} - }}, - {"Finish": { - "finish_reason": {"unified": "stop", "raw": null}, - "provider_metadata": {"openai": {"responseId": "resp_67c9a81b6a048190a9ee441c5755a4e8"}}, + "providerMetadata": {"openai": {"itemId": "msg_67c9a81dea8c8190b79651a2b3adf91e"}} + }, + { + "type": "finish", + "finishReason": {"unified": "stop"}, + "providerMetadata": {"openai": {"responseId": "resp_67c9a81b6a048190a9ee441c5755a4e8"}}, "usage": { - "input_tokens": {"total": 543, "no_cache": 309, "cache_read": 234}, - "output_tokens": {"total": 478, "reasoning": 123, "text": 355}, + "inputTokens": {"total": 543, "noCache": 309, "cacheRead": 234}, + "outputTokens": {"total": 478, "reasoning": 123, "text": 355}, "raw": { "input_tokens": 543, "input_tokens_details": {"cached_tokens": 234}, @@ -287,7 +291,7 @@ async fn responses_stream() { "total_tokens": 512 } } - }} + } ]) ); } @@ -562,13 +566,13 @@ async fn responses_web_search_stream() { assert_eq!( serde_json::to_value(parts).unwrap(), json!([ - {"StreamStart":{"warnings":[]}}, - {"ResponseMetadata":{"id":"resp_missing_action","model_id":"test-model","timestamp":"2025-03-10T18:10:55+00:00"}}, - {"ToolInputStart":{"id":"ws_missing_action","tool_name":"webSearch","provider_executed":true}}, - {"ToolInputEnd":{"id":"ws_missing_action"}}, - {"ToolCall":{"tool_call_id":"ws_missing_action","tool_name":"webSearch","input":"{}","provider_executed":true}}, - {"ToolResult":{"tool_call_id":"ws_missing_action","tool_name":"webSearch","result":{}}}, - {"Finish":{"finish_reason":{"unified":"stop","raw":null},"usage":{"input_tokens":{"total":10,"no_cache":10,"cache_read":0},"output_tokens":{"total":2,"reasoning":0,"text":2},"raw":{"input_tokens":10,"input_tokens_details":{"cached_tokens":0},"output_tokens":2,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":12}},"provider_metadata":{"openai":{"responseId":"resp_missing_action"}}}} + {"type":"stream-start","warnings":[]}, + {"type":"response-metadata","id":"resp_missing_action","modelId":"test-model","timestamp":"2025-03-10T18:10:55+00:00"}, + {"type":"tool-input-start","id":"ws_missing_action","toolName":"webSearch","providerExecuted":true}, + {"type":"tool-input-end","id":"ws_missing_action"}, + {"type":"tool-call","toolCallId":"ws_missing_action","toolName":"webSearch","input":"{}","providerExecuted":true}, + {"type":"tool-result","toolCallId":"ws_missing_action","toolName":"webSearch","result":{}}, + {"type":"finish","finishReason":{"unified":"stop"},"usage":{"inputTokens":{"total":10,"noCache":10,"cacheRead":0},"outputTokens":{"total":2,"reasoning":0,"text":2},"raw":{"input_tokens":10,"input_tokens_details":{"cached_tokens":0},"output_tokens":2,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":12}},"providerMetadata":{"openai":{"responseId":"resp_missing_action"}}} ]) ); } diff --git a/aimux-providers/tests/vertex_upstream_e2e_test.rs b/aimux-providers/tests/vertex_upstream_e2e_test.rs index 1bd3feff0..1e6038bc2 100644 --- a/aimux-providers/tests/vertex_upstream_e2e_test.rs +++ b/aimux-providers/tests/vertex_upstream_e2e_test.rs @@ -289,8 +289,8 @@ async fn transcription_generate_request_and_result() { assert_eq!( serde_json::to_value(result.segments).unwrap(), json!([ - {"text": "hello", "start_second": 0.0, "end_second": 0.5}, - {"text": "world", "start_second": 0.5, "end_second": 1.0} + {"text": "hello", "startSecond": 0.0, "endSecond": 0.5}, + {"text": "world", "startSecond": 0.5, "endSecond": 1.0} ]) ); assert_eq!(result.language.as_deref(), Some("en")); diff --git a/aimux-providers/tests/xai_transcription_test.rs b/aimux-providers/tests/xai_transcription_test.rs index 08c14ba5b..da99088e4 100644 --- a/aimux-providers/tests/xai_transcription_test.rs +++ b/aimux-providers/tests/xai_transcription_test.rs @@ -307,21 +307,26 @@ fn done(text: &str, duration: f64) -> Value { } fn kinds(parts: &[Value]) -> Vec<&str> { - parts - .iter() - .map(|p| p.as_object().unwrap().keys().next().unwrap().as_str()) - .collect() + parts.iter().map(|p| p["type"].as_str().unwrap()).collect() } fn of_kind(parts: &[Value], kind: &str) -> Vec { - parts.iter().filter_map(|p| p.get(kind).cloned()).collect() + parts + .iter() + .filter(|p| p["type"] == kind) + .cloned() + .collect() } fn finish(text: &str, language: Value, duration: Option) -> Value { - json!({"Finish": { - "text": text, "segments": [], "language": language, - "duration_in_seconds": duration, "provider_metadata": null - }}) + let mut part = json!({"type": "finish", "text": text, "segments": []}); + if !language.is_null() { + part["language"] = language; + } + if let Some(duration) = duration { + part["durationInSeconds"] = json!(duration); + } + part } // ── tests: model information ──────────────────────────────────────────────── @@ -579,15 +584,15 @@ async fn should_stream_xai_stt_over_websocket() { assert_eq!( run.values(), vec![ - json!({"StreamStart": {"warnings": []}}), - json!({"TranscriptPartial": { - "id": null, "text": "Hel", "start_second": 0.0, "duration_in_seconds": 0.5, - "channel_index": null, "provider_metadata": null - }}), - json!({"TranscriptFinal": { - "id": null, "text": "Hello", "start_second": 0.0, "end_second": 1.0, - "channel_index": null, "provider_metadata": null - }}), + json!({"type": "stream-start", "warnings": []}), + json!({ + "type": "transcript-partial", "text": "Hel", "startSecond": 0.0, + "durationInSeconds": 0.5 + }), + json!({ + "type": "transcript-final", "text": "Hello", "startSecond": 0.0, + "endSecond": 1.0 + }), finish("Hello", json!("en"), Some(1.0)), ] ); @@ -628,14 +633,14 @@ async fn should_emit_one_final_per_utterance_and_reconstruct_finish_text() { let parts = run(None, events).await.values(); assert_eq!( - of_kind(&parts, "TranscriptFinal"), + of_kind(&parts, "transcript-final"), vec![json!({ - "id": null, "text": "Hello world.", "start_second": 0.0, "end_second": 1.0, - "channel_index": null, "provider_metadata": null + "type": "transcript-final", "text": "Hello world.", "startSecond": 0.0, + "endSecond": 1.0 })] ); // the speech_final:false re-send surfaces as a partial - assert_eq!(of_kind(&parts, "TranscriptPartial").len(), 2); + assert_eq!(of_kind(&parts, "transcript-partial").len(), 2); assert_eq!( parts.last().unwrap(), &finish("Hello world.", Value::Null, Some(1.0)) @@ -656,14 +661,14 @@ async fn should_treat_is_final_fragments_as_partials_and_use_speech_final_text() assert_eq!( kinds(&parts), [ - "StreamStart", - "TranscriptPartial", - "TranscriptPartial", - "TranscriptFinal", - "Finish" + "stream-start", + "transcript-partial", + "transcript-partial", + "transcript-final", + "finish" ] ); - assert_eq!(parts.last().unwrap()["Finish"]["text"], "No, I'm not."); + assert_eq!(parts.last().unwrap()["text"], "No, I'm not."); } /// TS: doStream > should fall back to the latest pending text when no speech_final arrived before transcript.done @@ -677,8 +682,8 @@ async fn should_fall_back_to_latest_pending_text_without_speech_final() { let parts = run(None, events).await.values(); let last = parts.last().unwrap(); - assert_eq!(kinds(&parts).last(), Some(&"Finish")); - assert_eq!(last["Finish"]["text"], "Hello world"); + assert_eq!(kinds(&parts).last(), Some(&"finish")); + assert_eq!(last["text"], "Hello world"); } /// TS: doStream > should join finalized utterances per channel when transcript.done is empty @@ -692,7 +697,7 @@ async fn should_join_finalized_utterances_when_transcript_done_is_empty() { let parts = run(None, events).await.values(); assert_eq!( - parts.last().unwrap()["Finish"]["text"], + parts.last().unwrap()["text"], "First utterance. Second utterance." ); } diff --git a/aimux-providers/tests/xai_upstream_e2e_test.rs b/aimux-providers/tests/xai_upstream_e2e_test.rs index 89adc16f6..6ca24de7c 100644 --- a/aimux-providers/tests/xai_upstream_e2e_test.rs +++ b/aimux-providers/tests/xai_upstream_e2e_test.rs @@ -89,8 +89,8 @@ async fn generate_converts_usage() { assert_eq!( serde_json::to_value(result.usage).unwrap(), json!({ - "input_tokens": {"total": 345, "no_cache": 345, "cache_read": 0}, - "output_tokens": {"total": 538, "text": 415, "reasoning": 123}, + "inputTokens": {"total": 345, "noCache": 345, "cacheRead": 0}, + "outputTokens": {"total": 538, "text": 415, "reasoning": 123}, "raw": usage }) ); diff --git a/bindings/node/src/types/AiMuxError.ts b/bindings/node/src/types/AiMuxError.ts index f468763c1..87cd2b938 100644 --- a/bindings/node/src/types/AiMuxError.ts +++ b/bindings/node/src/types/AiMuxError.ts @@ -9,30 +9,8 @@ import type { RetryError } from "./RetryError"; * failure came from. `ApiCallError` is boxed only to keep the Rust enum * compact; serde and every binding still observe the same object shape. */ -export type AiMuxError = { "ApiCall": ApiCallError } | { "Retry": RetryError } | { "JsonParse": string } | { "NoOutputGenerated": string } | { "InvalidResponseData": string } | { "ToolCallNotFoundForApproval": { tool_call_id: string, approval_id: string, } } | { "NoSuchTool": { tool_name: string, available_tools?: Array | null, +export type AiMuxError = { "name": "AI_APICallError" } & ApiCallError | { "name": "AI_RetryError" } & RetryError | { "name": "AI_JSONParseError", message: string, } | { "name": "AI_NoOutputGeneratedError", message: string, } | { "name": "AI_InvalidResponseDataError", message: string, } | { "name": "AI_ToolCallNotFoundForApprovalError", toolCallId: string, approvalId: string, } | { "name": "AI_NoSuchToolError", toolName: string, availableTools?: Array, toolInput?: string, } | { "name": "AI_InvalidToolInputError", toolName: string, toolInput: string, cause: string, } | { "name": "AI_ToolCallRepairError", originalError: AiMuxError, cause: AiMuxError, } | { "name": "AI_InvalidArgumentError", message: string, } | { "name": "AI_InvalidPromptError", message: string, } | { "name": "AI_LoadAPIKeyError", envVar: string, description: string, } | { "name": "AI_LoadSettingError", envVar: string, /** - * Original argument text, when supplied by the provider. Absent in - * older serialized errors and errors constructed without a call. + * `name` is the union's tag key, so the setting's name is `settingName`. */ -tool_input?: string | null, } } | { "InvalidToolInput": { tool_name: string, tool_input: string, cause: string, } } | { "ToolCallRepair": { original_error: AiMuxError, cause: AiMuxError, } } | { "InvalidArgument": string } | { "InvalidPrompt": string } | { "LoadApiKey": { -/** - * The environment variable consulted as the fallback. - */ -env_var: string, -/** - * What the key is for, e.g. `"OpenAI"`. - */ -description: string, } } | { "LoadSetting": { -/** - * The environment variable consulted as the fallback. - */ -env_var: string, -/** - * The setting's parameter name, e.g. `"region"`. - */ -name: string, } } | { "TokenExpired": string } | { "UnsupportedFunctionality": string } | { "NoSuchModel": { model_id: string, -/** - * What kind of model was requested (`"languageModel"`, - * `"imageModel"`, …), the AI SDK's `modelType`. - */ -model_type: string, } } | { "NoSuchProvider": { provider_id: string, model_id: string, model_type: string, available_providers: Array, } } | { "Timeout": string } | { "Aborted": string } | { "Other": string }; +settingName: string, } | { "name": "AI_TokenExpiredError", message: string, } | { "name": "AI_UnsupportedFunctionalityError", message: string, } | { "name": "AI_NoSuchModelError", modelId: string, modelType: string, } | { "name": "AI_NoSuchProviderError", providerId: string, modelId: string, modelType: string, availableProviders: Array, } | { "name": "TimeoutError", message: string, } | { "name": "AbortError", message: string, } | { "name": "AI_Error", message: string, }; diff --git a/bindings/node/src/types/ApiCallError.ts b/bindings/node/src/types/ApiCallError.ts index fa26fb2d0..6dd28f598 100644 --- a/bindings/node/src/types/ApiCallError.ts +++ b/bindings/node/src/types/ApiCallError.ts @@ -31,20 +31,20 @@ url: string, /** * Sanitized request values used to create the API request. */ -request_body_values: JsonValue, +requestBodyValues: JsonValue, /** * HTTP status of the response, when it came from one * (`APICallError.statusCode`). Always the *observed* status: the HTTP * layer fills it for every response-derived error; errors built without * an HTTP exchange leave it `None`. */ -status_code: number | null, +statusCode?: number, /** * Provider's machine-readable code (OpenAI's `code`/`type`, * e.g. `"rate_limit_exceeded"` vs `"insufficient_quota"`). Never an HTTP * status. Our normalized take on `APICallError.data`. */ -provider_code: string | null, +providerCode?: string, /** * Human-readable failure text. Provider text is verbatim when available; * locally detected transport/parse failures include their source detail. @@ -56,19 +56,19 @@ message: string, * lossless evidence when `message`/`provider_code` are extractions. * `None` when the error did not come from a response body. */ -response_body: string | null, +responseBody?: string, /** * Sanitized response headers. */ -response_headers: { [key in string]: string } | null, +responseHeaders?: { [key in string]: string }, /** * Parsed provider error data. */ -data: JsonValue | null, +data?: JsonValue, /** * Whether retrying can help (`APICallError.isRetryable`) — stored at * construction, exactly like the AI SDK: the response path computes it * from the status (408/409/429 or 5xx), and the transport path (no response, so * no status to compute from) sets it to `true` explicitly. */ -is_retryable: boolean, }; +isRetryable: boolean, }; diff --git a/bindings/node/src/types/AudioChunk.ts b/bindings/node/src/types/AudioChunk.ts index 062104028..785ad95b9 100644 --- a/bindings/node/src/types/AudioChunk.ts +++ b/bindings/node/src/types/AudioChunk.ts @@ -3,4 +3,4 @@ /** * A chunk of audio in a streaming transcription request. */ -export type AudioChunk = { "Binary": Array } | { "Base64": string }; +export type AudioChunk = Array | string; diff --git a/bindings/node/src/types/AudioData.ts b/bindings/node/src/types/AudioData.ts index b2ae98665..3f9a7e85f 100644 --- a/bindings/node/src/types/AudioData.ts +++ b/bindings/node/src/types/AudioData.ts @@ -6,4 +6,4 @@ * The TS result type is `string | Uint8Array`. Providers should return data * without unnecessary conversion. */ -export type AudioData = { "Base64": string } | { "Binary": Array }; +export type AudioData = string | Array; diff --git a/bindings/node/src/types/AudioInput.ts b/bindings/node/src/types/AudioInput.ts index e513ffda3..b786aec38 100644 --- a/bindings/node/src/types/AudioInput.ts +++ b/bindings/node/src/types/AudioInput.ts @@ -3,4 +3,4 @@ /** * Audio input: raw bytes or a base64-encoded string. */ -export type AudioInput = { "Binary": Array } | { "Base64": string }; +export type AudioInput = Array | string; diff --git a/bindings/node/src/types/CallOptions.ts b/bindings/node/src/types/CallOptions.ts index 80ffacc3c..a384768c1 100644 --- a/bindings/node/src/types/CallOptions.ts +++ b/bindings/node/src/types/CallOptions.ts @@ -22,70 +22,70 @@ prompt: Array, /** * Maximum tokens to generate. */ -max_output_tokens: number | null, +maxOutputTokens?: number, /** * Sampling temperature. */ -temperature: number | null, +temperature?: number, /** * Stop sequences. */ -stop_sequences: Array | null, +stopSequences?: Array, /** * Nucleus sampling `top_p`. */ -top_p: number | null, +topP?: number, /** * Top-k sampling. */ -top_k: number | null, +topK?: number, /** * Presence penalty. */ -presence_penalty: number | null, +presencePenalty?: number, /** * Frequency penalty. */ -frequency_penalty: number | null, +frequencyPenalty?: number, /** * Response format (text or JSON). */ -response_format: ResponseFormat | null, +responseFormat?: ResponseFormat, /** * Seed for reproducibility. */ -seed: number | null, +seed?: number, /** * Tools available to the model (function tools and/or provider-defined tools). */ -tools: Array | null, +tools?: Array, /** * How the model should choose tools. */ -tool_choice: ToolChoice | null, +toolChoice?: ToolChoice, /** * Extra HTTP headers. */ -headers: { [key in string]: string } | null, +headers?: { [key in string]: string }, /** * Provider-specific options (keyed by provider name). */ -provider_options: { [key in string]: { [key in string]: JsonValue } } | null, +providerOptions?: { [key in string]: { [key in string]: JsonValue } }, /** * Top-level reasoning effort. Maps to OpenAI `reasoning_effort` and * Anthropic `thinking` config. */ -reasoning: ReasoningEffort | null, +reasoning?: ReasoningEffort, /** * Per-call retry count override. `None` uses the provider's configured * Core operation retry. `Some(0)` disables retries. */ -max_retries: number | null, +maxRetries?: number, /** * Per-call timeout configuration (total / first-chunk / chunk idle). * `None` = no timeouts (provider defaults still apply at the HTTP layer). */ -timeout: TimeoutConfiguration | null, +timeout?: TimeoutConfiguration, /** * Session identifier, for grouping consecutive calls into a session * (observability, see RFC-0024). Explicit values take precedence; when @@ -94,7 +94,7 @@ timeout: TimeoutConfiguration | null, * local grouping, while session headers in `headers` are for upstream * routing — both may share an id value but travel different paths. */ -session_id: string | null, +sessionId?: string, /** * Emit raw provider stream chunks as `StreamPart::Raw` (debugging aid). * When `Some(true)`, streaming providers yield one `Raw` part per JSON @@ -105,4 +105,4 @@ session_id: string | null, * openai-compatible registry providers); other provider families ignore * it for now (RFC-0016 M2). */ -include_raw_chunks: boolean | null, }; +includeRawChunks?: boolean, }; diff --git a/bindings/node/src/types/ContentPart.ts b/bindings/node/src/types/ContentPart.ts index b64f10087..07567af27 100644 --- a/bindings/node/src/types/ContentPart.ts +++ b/bindings/node/src/types/ContentPart.ts @@ -12,13 +12,13 @@ export type ContentPart = { "type": "text", text: string, /** * Provider-specific options for this part (e.g. `openai.promptCacheBreakpoint`). */ -provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "custom", kind: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "reasoning_file", data: GeneratedFileData, media_type: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "tool_approval_request", approval_id: string, tool_call_id: string, reason?: string | null, is_automatic?: boolean | null, signature?: string | null, input_schema_input?: JsonValue | null, } | { "type": "image", image: Array, media_type: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "file", data: Array, media_type: string, filename?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "file_base64", data: string, media_type: string, filename?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "file_url", url: string, media_type: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "file_reference", media_type: string, reference: JsonValue, filename?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "reasoning", text: string, signature: string | null, +providerOptions?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "custom", kind: string, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "reasoning-file", data: GeneratedFileData, mediaType: string, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "tool-approval-request", approvalId: string, toolCallId: string, reason?: string, isAutomatic?: boolean, signature?: string, inputSchemaInput?: JsonValue, } | { "type": "image", image: Array, mediaType: string, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "file", data: Array, mediaType: string, filename?: string, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "file-base64", data: string, mediaType: string, filename?: string, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "file-url", url: string, mediaType: string, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "file-reference", mediaType: string, reference: JsonValue, filename?: string, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "reasoning", text: string, signature?: string, /** * Provider-specific options for this part (e.g. * `anthropic.signature`, `anthropic.redactedData`, * `anthropic.cacheControl`). */ -provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "tool_call", tool_call_id: string, tool_name: string, +providerOptions?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "tool-call", toolCallId: string, toolName: string, /** * Arguments as a JSON value (usually an object). */ @@ -28,14 +28,14 @@ input: JsonValue, * the client. This is part of the prompt contract because providers * need it to replay server tool calls on a later turn. */ -provider_executed?: boolean | null, +providerExecuted?: boolean, /** * Provider-specific options for this part (e.g. * `anthropic.cacheControl`). */ -provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "tool_result", tool_call_id: string, tool_name: string, output: ToolResultOutput, +providerOptions?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "tool-result", toolCallId: string, toolName: string, output: ToolResultOutput, /** * Provider-specific options for this part (e.g. * `anthropic.cacheControl`). */ -provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; +providerOptions?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/CustomPart.ts b/bindings/node/src/types/CustomPart.ts index 39f66564d..2ea5ec97f 100644 --- a/bindings/node/src/types/CustomPart.ts +++ b/bindings/node/src/types/CustomPart.ts @@ -4,4 +4,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * Provider-specific content identified by its provider and kind. */ -export type CustomPart = { kind: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; +export type CustomPart = { kind: string, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/EmbeddingCallOptions.ts b/bindings/node/src/types/EmbeddingCallOptions.ts index 0d16b0ea1..33cfcf038 100644 --- a/bindings/node/src/types/EmbeddingCallOptions.ts +++ b/bindings/node/src/types/EmbeddingCallOptions.ts @@ -15,16 +15,16 @@ values: Array, /** * Per-call retry override. `None` uses the model default. */ -max_retries: number | null, +maxRetries?: number, /** * Per-call operation timeout. */ -timeout: TimeoutConfiguration | null, +timeout?: TimeoutConfiguration, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: { [key in string]: JsonValue } } | null, +providerOptions?: { [key in string]: { [key in string]: JsonValue } }, /** * Additional HTTP headers to send with the request. */ -headers: { [key in string]: string } | null, }; +headers?: { [key in string]: string }, }; diff --git a/bindings/node/src/types/EmbeddingResponse.ts b/bindings/node/src/types/EmbeddingResponse.ts index c758f7b32..72bc97787 100644 --- a/bindings/node/src/types/EmbeddingResponse.ts +++ b/bindings/node/src/types/EmbeddingResponse.ts @@ -8,8 +8,8 @@ export type EmbeddingResponse = { /** * Response headers. */ -headers: { [key in string]: string } | null, +headers?: { [key in string]: string }, /** * The response body (opaque JSON). */ -body: JsonValue | null, }; +body?: JsonValue, }; diff --git a/bindings/node/src/types/EmbeddingResult.ts b/bindings/node/src/types/EmbeddingResult.ts index fe4fc025a..c550a2dda 100644 --- a/bindings/node/src/types/EmbeddingResult.ts +++ b/bindings/node/src/types/EmbeddingResult.ts @@ -17,15 +17,15 @@ embeddings: Array>, /** * Token usage (input tokens only). */ -usage: EmbeddingUsage | null, +usage?: EmbeddingUsage, /** * Additional provider-specific metadata. */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, /** * Optional response information for debugging. */ -response: EmbeddingResponse | null, +response?: EmbeddingResponse, /** * Warnings for the call, e.g. unsupported settings. */ diff --git a/bindings/node/src/types/EvaluationAnswer.ts b/bindings/node/src/types/EvaluationAnswer.ts index 13aad7bc3..6c783e68e 100644 --- a/bindings/node/src/types/EvaluationAnswer.ts +++ b/bindings/node/src/types/EvaluationAnswer.ts @@ -12,7 +12,7 @@ choice: string, /** * Complete distribution over the question's options, when available. */ -probabilities?: { [key in string]: number } | null, } | { "type": "score", +probabilities?: { [key in string]: number }, } | { "type": "score", /** * Fractional position in `[0, levels - 1]`. */ @@ -21,7 +21,7 @@ score: number, * Complete distribution keyed by zero-based level indices as * strings; when supplied, `score` is its probability-weighted mean. */ -probabilities?: { [key in string]: number } | null, } | { "type": "boolean", +probabilities?: { [key in string]: number }, } | { "type": "boolean", /** * Model-estimated P(true), in `[0, 1]`. Not confidence in either * outcome. diff --git a/bindings/node/src/types/EvaluationCallOptions.ts b/bindings/node/src/types/EvaluationCallOptions.ts index 312f76c14..a223daf8b 100644 --- a/bindings/node/src/types/EvaluationCallOptions.ts +++ b/bindings/node/src/types/EvaluationCallOptions.ts @@ -14,4 +14,4 @@ state: EvaluationInput, /** * Questions by id; insertion order is the question order. */ -questions: Record, headers: { [key in string]: string } | null, provider_options: { [key in string]: { [key in string]: JsonValue } } | null, }; +questions: Record, headers?: { [key in string]: string }, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/EvaluationQuestion.ts b/bindings/node/src/types/EvaluationQuestion.ts index 5b2775b14..7dc4c6df0 100644 --- a/bindings/node/src/types/EvaluationQuestion.ts +++ b/bindings/node/src/types/EvaluationQuestion.ts @@ -14,4 +14,4 @@ criteria: Record, } | { "type": "score", instruc /** * At least two ordered levels, indexed from zero. */ -criteria: Array, } | { "type": "boolean", instructions: EvaluationInput, criteria?: BooleanCriteria | null, }; +criteria: Array, } | { "type": "boolean", instructions: EvaluationInput, criteria?: BooleanCriteria, }; diff --git a/bindings/node/src/types/EvaluationResult.ts b/bindings/node/src/types/EvaluationResult.ts index 6e0a9979f..70f2fd576 100644 --- a/bindings/node/src/types/EvaluationResult.ts +++ b/bindings/node/src/types/EvaluationResult.ts @@ -12,4 +12,4 @@ export type EvaluationResult = { /** * Exactly one answer per question, under the original question ids. */ -answers: Record, rounding: EvaluationRounding | null, usage: EvaluationUsage | null, warnings: Array, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, response: ResponseInfo | null, }; +answers: Record, rounding?: EvaluationRounding, usage?: EvaluationUsage, warnings: Array, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, response?: ResponseInfo, }; diff --git a/bindings/node/src/types/EvaluationRounding.ts b/bindings/node/src/types/EvaluationRounding.ts index e9d848269..127dd78ec 100644 --- a/bindings/node/src/types/EvaluationRounding.ts +++ b/bindings/node/src/types/EvaluationRounding.ts @@ -3,4 +3,4 @@ /** * Decimal places the provider rounded its output to; omit for full precision. */ -export type EvaluationRounding = { probability_decimals: number | null, score_decimals: number | null, }; +export type EvaluationRounding = { probabilityDecimals?: number, scoreDecimals?: number, }; diff --git a/bindings/node/src/types/EvaluationUsage.ts b/bindings/node/src/types/EvaluationUsage.ts index 467679edb..bf034ac58 100644 --- a/bindings/node/src/types/EvaluationUsage.ts +++ b/bindings/node/src/types/EvaluationUsage.ts @@ -3,4 +3,4 @@ /** * Token usage of an evaluation. */ -export type EvaluationUsage = { input_tokens: number | null, output_tokens: number | null, }; +export type EvaluationUsage = { inputTokens?: number, outputTokens?: number, }; diff --git a/bindings/node/src/types/FileBytes.ts b/bindings/node/src/types/FileBytes.ts index b06229434..c17813377 100644 --- a/bindings/node/src/types/FileBytes.ts +++ b/bindings/node/src/types/FileBytes.ts @@ -7,4 +7,4 @@ * the upstream API returns base64, return [`FileBytes::Base64`]; if it * returns binary, return [`FileBytes::Binary`]. */ -export type FileBytes = { "Binary": Array } | { "Base64": string }; +export type FileBytes = Array | string; diff --git a/bindings/node/src/types/FileData.ts b/bindings/node/src/types/FileData.ts index cec9eccbb..2410acc1e 100644 --- a/bindings/node/src/types/FileData.ts +++ b/bindings/node/src/types/FileData.ts @@ -6,4 +6,4 @@ import type { FileBytes } from "./FileBytes"; * * Aligned with V4 `SharedV4FileData`. */ -export type FileData = { "Data": { data: FileBytes, } } | { "Url": { url: string, original_url?: string | null, } } | { "Reference": { reference: { [key in string]: string }, } } | { "Text": { text: string, } }; +export type FileData = { "type": "data", data: FileBytes, } | { "type": "url", url: string, originalUrl?: string, } | { "type": "reference", reference: { [key in string]: string }, } | { "type": "text", text: string, }; diff --git a/bindings/node/src/types/FilePart.ts b/bindings/node/src/types/FilePart.ts index e412d1c0a..89a95b17b 100644 --- a/bindings/node/src/types/FilePart.ts +++ b/bindings/node/src/types/FilePart.ts @@ -5,4 +5,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * File content part (the only file part; `data` is a tagged `FileData`). */ -export type FilePart = { data: FileData, media_type: string, filename?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; +export type FilePart = { data: FileData, mediaType: string, filename?: string, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/Fingerprint.ts b/bindings/node/src/types/Fingerprint.ts index 0849548a0..6198bad6a 100644 --- a/bindings/node/src/types/Fingerprint.ts +++ b/bindings/node/src/types/Fingerprint.ts @@ -8,13 +8,13 @@ export type Fingerprint = { /** * 128-bit hash of the whole denoised body (hex). */ -body_hash: string, len_bytes: bigint, block_size: bigint, +bodyHash: string, lenBytes: bigint, blockSize: bigint, /** * Block-hash chain (hex), one entry per block. */ -block_hashes: Array, +blockHashes: Array, /** * Token estimate — byte proxy `len_bytes / 4` when no tokenizer is * attached (RFC-0015 §4.1: W capped at one block in this mode). */ -token_estimate: bigint, }; +tokenEstimate: bigint, }; diff --git a/bindings/node/src/types/FinishReason.ts b/bindings/node/src/types/FinishReason.ts index 3e557eb11..8a73936e6 100644 --- a/bindings/node/src/types/FinishReason.ts +++ b/bindings/node/src/types/FinishReason.ts @@ -12,4 +12,4 @@ unified: FinishReasonUnified, /** * The raw provider-specific reason string. */ -raw: string | null, }; +raw?: string, }; diff --git a/bindings/node/src/types/FunctionTool.ts b/bindings/node/src/types/FunctionTool.ts index c43b94a6a..b369875b0 100644 --- a/bindings/node/src/types/FunctionTool.ts +++ b/bindings/node/src/types/FunctionTool.ts @@ -13,23 +13,23 @@ name: string, /** * Human-readable description. */ -description: string | null, +description?: string, /** * JSON Schema describing the tool's parameters. */ -input_schema: JsonValue, +inputSchema: JsonValue, /** * Whether the tool supports strict schema enforcement (OpenAI strict mode). */ -strict: boolean | null, +strict?: boolean, /** * Provider-specific options, keyed by provider name * (e.g. `{"anthropic": {"eagerInputStreaming": true}}`). Aligned with the * V4 `providerOptions` field on function tools. */ -provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, +providerOptions?: { [key in string]: { [key in string]: JsonValue } }, /** * Example inputs for the tool (V4 `inputExamples`), used by some providers * to emit `input_examples` in the request body. */ -input_examples?: Array | null, }; +inputExamples?: Array, }; diff --git a/bindings/node/src/types/GenerateContent.ts b/bindings/node/src/types/GenerateContent.ts index 456e003b9..2d0de8e94 100644 --- a/bindings/node/src/types/GenerateContent.ts +++ b/bindings/node/src/types/GenerateContent.ts @@ -10,4 +10,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * A content item in the generation result. */ -export type GenerateContent = { "Text": { text: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolCall": C } | { "Source": Source } | { "Reasoning": ReasoningOutput } | { "File": GeneratedFile } | { "ReasoningFile": R } | { "Custom": { kind: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolApprovalRequest": A } | { "ToolResult": ToolResult }; +export type GenerateContent = { "type": "text", text: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "tool-call" } & C | { "type": "source" } & Source | { "type": "reasoning" } & ReasoningOutput | { "type": "file" } & GeneratedFile | { "type": "reasoning-file" } & R | { "type": "custom", kind: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "tool-approval-request" } & A | { "type": "tool-result" } & ToolResult; diff --git a/bindings/node/src/types/GenerateObjectResult.ts b/bindings/node/src/types/GenerateObjectResult.ts index 89e5eb445..b47ba2960 100644 --- a/bindings/node/src/types/GenerateObjectResult.ts +++ b/bindings/node/src/types/GenerateObjectResult.ts @@ -18,11 +18,11 @@ object: JsonValue, /** * Why generation stopped. */ -finish_reason: FinishReason, +finishReason: FinishReason, /** * Raw provider-specific finish reason string. */ -raw_finish_reason: string | null, +rawFinishReason?: string, /** * Token usage. */ @@ -34,11 +34,11 @@ warnings: Array, /** * Concatenated reasoning text (if the model produced reasoning/thinking). */ -reasoning: string | null, +reasoning?: string, /** * Provider-specific metadata (e.g. Anthropic cache info). */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, /** * Response metadata (id, timestamp, model_id). */ diff --git a/bindings/node/src/types/GenerateResult.ts b/bindings/node/src/types/GenerateResult.ts index 70e2ef62c..979381eab 100644 --- a/bindings/node/src/types/GenerateResult.ts +++ b/bindings/node/src/types/GenerateResult.ts @@ -21,7 +21,7 @@ content: Array, /** * Provider-specific metadata. */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, /** * Optional request information for telemetry and debugging. */ -request: RequestInfo | null, +request?: RequestInfo, /** * Optional response metadata, headers, and body. */ -response: ResponseInfo | null, }; +response?: ResponseInfo, }; diff --git a/bindings/node/src/types/GenerateTextOptions.ts b/bindings/node/src/types/GenerateTextOptions.ts index 4bbbcfc83..d80dc0be7 100644 --- a/bindings/node/src/types/GenerateTextOptions.ts +++ b/bindings/node/src/types/GenerateTextOptions.ts @@ -12,31 +12,31 @@ import type { JsonValue } from "./serde_json/JsonValue"; * Unlike `CallOptions` (provider-facing), this does not include `prompt` * (passed separately) and defaults are more ergonomic. */ -export type GenerateTextOptions = { max_output_tokens: number | null, temperature: number | null, stop_sequences: Array | null, top_p: number | null, top_k: number | null, presence_penalty: number | null, frequency_penalty: number | null, response_format: ResponseFormat | null, seed: number | null, tools: Array | null, tool_choice: ToolChoice | null, headers: { [key in string]: string } | null, provider_options: { [key in string]: { [key in string]: JsonValue } } | null, +export type GenerateTextOptions = { maxOutputTokens?: number, temperature?: number, stopSequences?: Array, topP?: number, topK?: number, presencePenalty?: number, frequencyPenalty?: number, responseFormat?: ResponseFormat, seed?: number, tools?: Array, toolChoice?: ToolChoice, headers?: { [key in string]: string }, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, /** * Top-level reasoning effort. */ -reasoning: ReasoningEffort | null, +reasoning?: ReasoningEffort, /** * System instructions prepended to the prompt. */ -instructions: string | null, +instructions?: string, /** * Per-call retry count override. `None` = provider default, `Some(0)` = disable. */ -max_retries: number | null, +maxRetries?: number, /** * Per-call timeout configuration (total / first-chunk / chunk idle). */ -timeout: TimeoutConfiguration | null, +timeout?: TimeoutConfiguration, /** * Session identifier, for grouping consecutive calls into a session * (observability, see RFC-0024). Orthogonal to RFC-0019 session-affinity * headers. When `None` and the optional session inferer is enabled, one * may be inferred from prompt-prefix continuation. */ -session_id: string | null, +sessionId?: string, /** * Emit raw provider stream chunks as `StreamPart::Raw` (debugging aid). */ -include_raw_chunks: boolean | null, }; +includeRawChunks?: boolean, }; diff --git a/bindings/node/src/types/GenerateTextResult.ts b/bindings/node/src/types/GenerateTextResult.ts index 1a5b0ef09..4fde225a8 100644 --- a/bindings/node/src/types/GenerateTextResult.ts +++ b/bindings/node/src/types/GenerateTextResult.ts @@ -30,11 +30,11 @@ text: string, /** * Tool calls requested by the model. */ -tool_calls: Array, +toolCalls: Array, /** * Why generation stopped. */ -finish_reason: FinishReason, +finishReason: FinishReason, /** * Token usage. */ @@ -54,7 +54,7 @@ reasoning: Array, /** * Concatenated reasoning text (convenience for `reasoning.iter().map(text).join("")`). */ -reasoning_text: string, +reasoningText: string, /** * Sources / citations (search-preview models). */ @@ -67,17 +67,17 @@ files: Array, * Assistant messages ready to append to the prompt for the next turn * (solves the multi-turn "manually build assistant message" footgun). */ -response_messages: Array, +responseMessages: Array, /** * The raw provider-specific finish reason string (e.g. "stop", * "end_turn", "safety"). Useful when `finish_reason.unified` is `Other`. */ -raw_finish_reason: string | null, +rawFinishReason?: string, /** * Provider-specific metadata (e.g. Anthropic cache info). Mirrored from * `raw.provider_metadata` for top-level convenience. */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, /** * Request information from the provider. */ @@ -90,4 +90,4 @@ response: ResponseInfo, * Total token usage across all steps. In single-step mode (aimux's * default), `total_usage` equals `usage`. Provided for AI SDK parity. */ -total_usage: Usage, }; +totalUsage: Usage, }; diff --git a/bindings/node/src/types/GeneratedFile.ts b/bindings/node/src/types/GeneratedFile.ts index 48333ce42..35493ce51 100644 --- a/bindings/node/src/types/GeneratedFile.ts +++ b/bindings/node/src/types/GeneratedFile.ts @@ -8,4 +8,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; * or URL. The payload of both `GenerateContent::File` and * `StreamPart::File`, and the element of the text results' `files`. */ -export type GeneratedFile = { data: GeneratedFileData, media_type: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, }; +export type GeneratedFile = { data: GeneratedFileData, mediaType: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/GeneratedFileData.ts b/bindings/node/src/types/GeneratedFileData.ts index f8b500f58..91d743a20 100644 --- a/bindings/node/src/types/GeneratedFileData.ts +++ b/bindings/node/src/types/GeneratedFileData.ts @@ -4,4 +4,4 @@ import type { FileBytes } from "./FileBytes"; /** * Data or a URL returned for a generated file. */ -export type GeneratedFileData = { "Data": { data: FileBytes, } } | { "Url": { url: string, original_url?: string | null, } }; +export type GeneratedFileData = { "type": "data", data: FileBytes, } | { "type": "url", url: string, originalUrl?: string, }; diff --git a/bindings/node/src/types/HttpExchange.ts b/bindings/node/src/types/HttpExchange.ts index 336f21769..7e0f6916f 100644 --- a/bindings/node/src/types/HttpExchange.ts +++ b/bindings/node/src/types/HttpExchange.ts @@ -11,7 +11,7 @@ export type HttpExchange = { * Composite step this exchange belongs to (e.g. `router[0]:openai/gpt-4o` * or `moa.ref[1]:...`). `None` for a plain, non-composite operation. */ -step?: string | null, +step?: string, /** * Core operation attempt, starting at 1. Attempt numbers are unique * across the whole call, including composite child steps, so @@ -21,11 +21,11 @@ attempt: number, /** * HTTP exchange within the operation attempt, starting at 1. */ -exchange_index: number, request: HttpRecord, +exchangeIndex: number, request: HttpRecord, /** * None = 请求失败未获响应。 */ -response: ResponseRecord | null, timing: TimingRecord, error: string | null, +response?: ResponseRecord, timing: TimingRecord, error?: string, /** * 流式:该 exchange 是否已终结(收到 response 补全)。非流式恒 true。 */ diff --git a/bindings/node/src/types/HttpRecord.ts b/bindings/node/src/types/HttpRecord.ts index 90ae88a53..55db75602 100644 --- a/bindings/node/src/types/HttpRecord.ts +++ b/bindings/node/src/types/HttpRecord.ts @@ -8,4 +8,4 @@ headers: Array<[string, string]>, /** * 明文(脱敏后);None = 无 body。 */ -body: string | null, }; +body?: string, }; diff --git a/bindings/node/src/types/ImageCallOptions.ts b/bindings/node/src/types/ImageCallOptions.ts index 1b490a4cb..b900b647e 100644 --- a/bindings/node/src/types/ImageCallOptions.ts +++ b/bindings/node/src/types/ImageCallOptions.ts @@ -15,7 +15,7 @@ export type ImageCallOptions = { * Prompt for the image generation. `None` for operations (e.g. upscaling) * that do not require a prompt. */ -prompt: string | null, +prompt?: string, /** * Number of images to generate. */ @@ -24,37 +24,37 @@ n: number, * Size of the images, in `{width}x{height}` format. * `None` uses the provider's default size. */ -size: Size | null, +size?: Size, /** * Aspect ratio of the images, in `{width}:{height}` format. * `None` uses the provider's default aspect ratio. */ -aspect_ratio: AspectRatio | null, +aspectRatio?: AspectRatio, /** * Seed for deterministic generation. `None` uses the provider's default. */ -seed: number | null, +seed?: number, /** * Images for image editing or variation generation. */ -files: Array | null, +files?: Array, /** * Mask image for inpainting operations. */ -mask: ImageFile | null, +mask?: ImageFile, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: { [key in string]: JsonValue } }, +providerOptions: { [key in string]: { [key in string]: JsonValue } }, /** * Per-call retry override. `None` uses the model default. */ -max_retries: number | null, +maxRetries?: number, /** * Per-call operation timeout. */ -timeout: TimeoutConfiguration | null, +timeout?: TimeoutConfiguration, /** * Additional HTTP headers to send with the request. */ -headers: { [key in string]: string } | null, }; +headers?: { [key in string]: string }, }; diff --git a/bindings/node/src/types/ImageFile.ts b/bindings/node/src/types/ImageFile.ts index 545e7cef5..64aef8164 100644 --- a/bindings/node/src/types/ImageFile.ts +++ b/bindings/node/src/types/ImageFile.ts @@ -6,16 +6,16 @@ import type { ImageFileData } from "./ImageFileData"; * * Aligned with V4 `ImageModelV4File`. */ -export type ImageFile = { "File": { +export type ImageFile = { "type": "file", /** * IANA media type, e.g. `"image/png"`. */ -media_type: string, +mediaType: string, /** * File data as base64 string or binary bytes. */ -data: ImageFileData, } } | { "Url": { +data: ImageFileData, } | { "type": "url", /** * The URL of the image file. */ -url: string, } }; +url: string, }; diff --git a/bindings/node/src/types/ImageFileData.ts b/bindings/node/src/types/ImageFileData.ts index 96be9636f..46fceec6a 100644 --- a/bindings/node/src/types/ImageFileData.ts +++ b/bindings/node/src/types/ImageFileData.ts @@ -3,4 +3,4 @@ /** * File payload for an [`ImageFile::File`]: base64 string or raw bytes. */ -export type ImageFileData = { "Base64": string } | { "Binary": Array }; +export type ImageFileData = string | Array; diff --git a/bindings/node/src/types/ImageOutputs.ts b/bindings/node/src/types/ImageOutputs.ts index 38be2705b..9ff238058 100644 --- a/bindings/node/src/types/ImageOutputs.ts +++ b/bindings/node/src/types/ImageOutputs.ts @@ -9,4 +9,4 @@ * upstream API returns base64, return [`ImageOutputs::Base64`]; if binary, * return [`ImageOutputs::Binary`]. */ -export type ImageOutputs = { "Base64": Array } | { "Binary": Array> }; +export type ImageOutputs = Array | Array>; diff --git a/bindings/node/src/types/ImageResponse.ts b/bindings/node/src/types/ImageResponse.ts index 0f5f33dd0..3e7e3660e 100644 --- a/bindings/node/src/types/ImageResponse.ts +++ b/bindings/node/src/types/ImageResponse.ts @@ -7,12 +7,12 @@ export type ImageResponse = { /** * Timestamp for the start of the generated response (ISO 8601 string). */ -timestamp: string | null, +timestamp?: string, /** * The ID of the model that was used to generate the response. */ -model_id: string | null, +modelId?: string, /** * Response headers. */ -headers: { [key in string]: string } | null, }; +headers?: { [key in string]: string }, }; diff --git a/bindings/node/src/types/ImageResult.ts b/bindings/node/src/types/ImageResult.ts index 4276c580f..cdf420366 100644 --- a/bindings/node/src/types/ImageResult.ts +++ b/bindings/node/src/types/ImageResult.ts @@ -22,7 +22,7 @@ warnings: Array, /** * Additional provider-specific metadata, keyed by provider name. */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, /** * Response information for telemetry and debugging. */ @@ -30,4 +30,4 @@ response: ImageResponse, /** * Optional token usage for the call (if the provider reports it). */ -usage: ImageUsage | null, }; +usage?: ImageUsage, }; diff --git a/bindings/node/src/types/ImageUsage.ts b/bindings/node/src/types/ImageUsage.ts index 86fb55df9..51e882868 100644 --- a/bindings/node/src/types/ImageUsage.ts +++ b/bindings/node/src/types/ImageUsage.ts @@ -9,12 +9,12 @@ export type ImageUsage = { /** * Number of input (prompt) tokens used. */ -input_tokens: number | null, +inputTokens?: number, /** * Number of output tokens used, if reported. */ -output_tokens: number | null, +outputTokens?: number, /** * Total tokens as reported by the provider. */ -total_tokens: number | null, }; +totalTokens?: number, }; diff --git a/bindings/node/src/types/InputAudioFormat.ts b/bindings/node/src/types/InputAudioFormat.ts index c45946104..20d045790 100644 --- a/bindings/node/src/types/InputAudioFormat.ts +++ b/bindings/node/src/types/InputAudioFormat.ts @@ -7,8 +7,8 @@ export type InputAudioFormat = { /** * Audio format type, e.g. `"audio/pcm"`, `"audio/pcmu"`, `"audio/pcma"`. */ -format_type: string, +formatType: string, /** * Sample rate in Hz. Only applicable for formats that require a rate. */ -rate: number | null, }; +rate?: number, }; diff --git a/bindings/node/src/types/InputTokenUsage.ts b/bindings/node/src/types/InputTokenUsage.ts index 658af2dab..10d3c62eb 100644 --- a/bindings/node/src/types/InputTokenUsage.ts +++ b/bindings/node/src/types/InputTokenUsage.ts @@ -7,16 +7,16 @@ export type InputTokenUsage = { /** * Total tokens. */ -total: number | null, +total?: number, /** * Tokens that were not served from cache. */ -no_cache: number | null, +noCache?: number, /** * Tokens served from cache (read). */ -cache_read: number | null, +cacheRead?: number, /** * Tokens written to cache. */ -cache_write: number | null, }; +cacheWrite?: number, }; diff --git a/bindings/node/src/types/LanguageModelMessage.ts b/bindings/node/src/types/LanguageModelMessage.ts index a7052a024..4efb3d34d 100644 --- a/bindings/node/src/types/LanguageModelMessage.ts +++ b/bindings/node/src/types/LanguageModelMessage.ts @@ -10,4 +10,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; * `provider_options` is message-level (e.g. `anthropic.cacheControl`), * mirroring `LanguageModelV4Message.providerOptions`. */ -export type LanguageModelMessage = { "role": "system", content: string, provider_options: { [key in string]: { [key in string]: JsonValue } } | null, } | { "role": "user", content: Array, provider_options: { [key in string]: { [key in string]: JsonValue } } | null, } | { "role": "assistant", content: Array, provider_options: { [key in string]: { [key in string]: JsonValue } } | null, } | { "role": "tool", content: Array, provider_options: { [key in string]: { [key in string]: JsonValue } } | null, }; +export type LanguageModelMessage = { "role": "system", content: string, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, } | { "role": "user", content: Array, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, } | { "role": "assistant", content: Array, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, } | { "role": "tool", content: Array, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/LanguageModelReasoningPart.ts b/bindings/node/src/types/LanguageModelReasoningPart.ts index 4fb52fa16..de94608a6 100644 --- a/bindings/node/src/types/LanguageModelReasoningPart.ts +++ b/bindings/node/src/types/LanguageModelReasoningPart.ts @@ -4,4 +4,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * Reasoning / thinking content part. */ -export type LanguageModelReasoningPart = { text: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; +export type LanguageModelReasoningPart = { text: string, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/ModelCapabilities.ts b/bindings/node/src/types/ModelCapabilities.ts index e38d3622b..c5926ecb4 100644 --- a/bindings/node/src/types/ModelCapabilities.ts +++ b/bindings/node/src/types/ModelCapabilities.ts @@ -7,11 +7,11 @@ export type ModelCapabilities = { /** * Supports function/tool calling. */ -tool_call: boolean, +toolCall: boolean, /** * Supports structured / JSON-schema output. */ -structured_output: boolean, +structuredOutput: boolean, /** * Accepts a `temperature` parameter. */ diff --git a/bindings/node/src/types/ModelCost.ts b/bindings/node/src/types/ModelCost.ts index 39e510c55..434d43609 100644 --- a/bindings/node/src/types/ModelCost.ts +++ b/bindings/node/src/types/ModelCost.ts @@ -4,4 +4,4 @@ * Pricing (advisory, read-only metadata). Units follow the source catalog * (anya2a uses USD per 1M tokens). */ -export type ModelCost = { input?: number | null, output?: number | null, cache_read?: number | null, cache_write?: number | null, }; +export type ModelCost = { input?: number, output?: number, cacheRead?: number, cacheWrite?: number, }; diff --git a/bindings/node/src/types/ModelLimits.ts b/bindings/node/src/types/ModelLimits.ts index 8835a50e6..9688b1e8c 100644 --- a/bindings/node/src/types/ModelLimits.ts +++ b/bindings/node/src/types/ModelLimits.ts @@ -7,12 +7,12 @@ export type ModelLimits = { /** * Maximum total context window (input + output), in tokens. */ -context?: number | null, +context?: number, /** * Maximum output tokens. */ -output?: number | null, +output?: number, /** * Maximum input tokens (when distinct from `context`). */ -input?: number | null, }; +input?: number, }; diff --git a/bindings/node/src/types/ModelSpec.ts b/bindings/node/src/types/ModelSpec.ts index 69ead413e..6c435f5a1 100644 --- a/bindings/node/src/types/ModelSpec.ts +++ b/bindings/node/src/types/ModelSpec.ts @@ -14,12 +14,12 @@ import type { JsonValue } from "./serde_json/JsonValue"; * All fields are advisory — aimux never auto-applies them in the request path. * The caller reads them to decide how to configure `GenerateTextOptions`. */ -export type ModelSpec = { display_name?: string | null, type: ModelType, limits: ModelLimits, modalities: ModelModalities, capabilities: ModelCapabilities, reasoning?: ReasoningSpec | null, cost?: ModelCost | null, source: CatalogueSource, +export type ModelSpec = { displayName?: string, type: ModelType, limits: ModelLimits, modalities: ModelModalities, capabilities: ModelCapabilities, reasoning?: ReasoningSpec, cost?: ModelCost, source: CatalogueSource, /** * Provider name (normalized to aimux registry spelling). */ -provider?: string | null, +provider?: string, /** * Verbatim source entry, preserved for forward compatibility / debugging. */ -raw?: JsonValue | null, }; +raw?: JsonValue, }; diff --git a/bindings/node/src/types/OutcomeRecord.ts b/bindings/node/src/types/OutcomeRecord.ts index c843d8101..9b8ea6df3 100644 --- a/bindings/node/src/types/OutcomeRecord.ts +++ b/bindings/node/src/types/OutcomeRecord.ts @@ -5,13 +5,13 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * 最终结果摘要。 */ -export type OutcomeRecord = { status: OutcomeStatus, finish_reason: string | null, error: string | null, +export type OutcomeRecord = { status: OutcomeStatus, finishReason?: string, error?: string, /** * Lossless structured domain error. In particular, `RetryError.errors` * keeps the complete attempt history rather than only its display text. */ -error_value: JsonValue | null, +errorValue?: JsonValue, /** * 序列化的 Usage。 */ -usage: JsonValue | null, }; +usage?: JsonValue, }; diff --git a/bindings/node/src/types/OutputTokenUsage.ts b/bindings/node/src/types/OutputTokenUsage.ts index 47528d6d8..38a53cdb0 100644 --- a/bindings/node/src/types/OutputTokenUsage.ts +++ b/bindings/node/src/types/OutputTokenUsage.ts @@ -3,12 +3,12 @@ /** * Output token usage detail. */ -export type OutputTokenUsage = { total: number | null, +export type OutputTokenUsage = { total?: number, /** * Text (non-reasoning) tokens (output side). */ -text: number | null, +text?: number, /** * Reasoning tokens (output side). */ -reasoning: number | null, }; +reasoning?: number, }; diff --git a/bindings/node/src/types/PrefixBreak.ts b/bindings/node/src/types/PrefixBreak.ts index 78aa70bdb..dadebc493 100644 --- a/bindings/node/src/types/PrefixBreak.ts +++ b/bindings/node/src/types/PrefixBreak.ts @@ -4,4 +4,4 @@ import type { BreakKind } from "./BreakKind"; /** * A prefix break between two consecutive records of a session. */ -export type PrefixBreak = { at_record_id: string, prev_record_id: string, lcp_bytes: bigint, expected_break: boolean, kind: BreakKind, }; +export type PrefixBreak = { atRecordId: string, prevRecordId: string, lcpBytes: bigint, expectedBreak: boolean, kind: BreakKind, }; diff --git a/bindings/node/src/types/ProviderAuditSpec.ts b/bindings/node/src/types/ProviderAuditSpec.ts index 6f60f4e92..a6189f4d6 100644 --- a/bindings/node/src/types/ProviderAuditSpec.ts +++ b/bindings/node/src/types/ProviderAuditSpec.ts @@ -13,7 +13,7 @@ family: ProviderFamily, /** * Token quantization granularity; `None` = no quantization. */ -gran: bigint | null, +gran?: bigint, /** * Minimum cache threshold (tokens): below it `claimed` must be 0. */ @@ -21,7 +21,7 @@ threshold: bigint, /** * TTL_idle (ms), conservative upper bound. */ -ttl_ms: bigint, +ttlMs: bigint, /** * gpt-5.6+ semantics (no quantization, implicit-breakpoint whitelist, * write/read equality). @@ -31,4 +31,4 @@ model56plus: boolean, * Claimed-hit ceiling: above this multiple of the client upper bound * the verdict is overclaim (W) even in shared mode. `None` = use U+τ. */ -shared_ceiling_mult: number | null, }; +sharedCeilingMult?: number, }; diff --git a/bindings/node/src/types/ProviderRecord.ts b/bindings/node/src/types/ProviderRecord.ts index a59cfc113..a7a5071dc 100644 --- a/bindings/node/src/types/ProviderRecord.ts +++ b/bindings/node/src/types/ProviderRecord.ts @@ -10,7 +10,7 @@ export type ProviderRecord = { /** * provider 名(registry 名),回放重建的查找键,如 "openai"、"deepseek"。 */ -provider_id: string, +providerId: string, /** * `model.provider()`,如 "openai.chat"。 */ @@ -18,4 +18,4 @@ provider: string, /** * `model.model_id()`,如 "gpt-4o"。 */ -model_id: string, }; +modelId: string, }; diff --git a/bindings/node/src/types/RawToolApprovalRequest.ts b/bindings/node/src/types/RawToolApprovalRequest.ts index 5b3dd37db..fc5e8e66b 100644 --- a/bindings/node/src/types/RawToolApprovalRequest.ts +++ b/bindings/node/src/types/RawToolApprovalRequest.ts @@ -4,4 +4,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * Provider approval request for a provider-executed call. */ -export type RawToolApprovalRequest = { approval_id: string, tool_call_id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, }; +export type RawToolApprovalRequest = { approvalId: string, toolCallId: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/RawToolCall.ts b/bindings/node/src/types/RawToolCall.ts index c44ead3c6..a924f9231 100644 --- a/bindings/node/src/types/RawToolCall.ts +++ b/bindings/node/src/types/RawToolCall.ts @@ -7,4 +7,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; * The wire shape matches [`ToolCall`] field for field, except that `input` * is the provider's raw argument *text* rather than a parsed value. */ -export type RawToolCall = { tool_call_id: string, tool_name: string, input: string, provider_executed?: boolean | null, dynamic?: boolean | null, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, }; +export type RawToolCall = { toolCallId: string, toolName: string, input: string, providerExecuted?: boolean, dynamic?: boolean, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/ReasoningFileOutput.ts b/bindings/node/src/types/ReasoningFileOutput.ts index 57eaa2c0b..59f585e2c 100644 --- a/bindings/node/src/types/ReasoningFileOutput.ts +++ b/bindings/node/src/types/ReasoningFileOutput.ts @@ -2,4 +2,4 @@ import type { GeneratedFile } from "./GeneratedFile"; import type { JsonValue } from "./serde_json/JsonValue"; -export type ReasoningFileOutput = { file: GeneratedFile, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, }; +export type ReasoningFileOutput = { file: GeneratedFile, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/ReasoningFilePart.ts b/bindings/node/src/types/ReasoningFilePart.ts index b8c1e4aab..9b70a21f3 100644 --- a/bindings/node/src/types/ReasoningFilePart.ts +++ b/bindings/node/src/types/ReasoningFilePart.ts @@ -5,4 +5,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * A file generated as part of reasoning. */ -export type ReasoningFilePart = { data: GeneratedFileData, media_type: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; +export type ReasoningFilePart = { data: GeneratedFileData, mediaType: string, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/ReasoningOutput.ts b/bindings/node/src/types/ReasoningOutput.ts index 90a4f59b8..60837bf23 100644 --- a/bindings/node/src/types/ReasoningOutput.ts +++ b/bindings/node/src/types/ReasoningOutput.ts @@ -8,4 +8,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; * (`providerMetadata.anthropic.signature`). The payload of * `GenerateContent::Reasoning` and the element of the text results' `reasoning`. */ -export type ReasoningOutput = { text: string, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, }; +export type ReasoningOutput = { text: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/ReasoningPart.ts b/bindings/node/src/types/ReasoningPart.ts index 37a7c5873..70292cde6 100644 --- a/bindings/node/src/types/ReasoningPart.ts +++ b/bindings/node/src/types/ReasoningPart.ts @@ -5,4 +5,4 @@ import type { ReasoningOutput } from "./ReasoningOutput"; /** * Text or a generated file produced during reasoning. */ -export type ReasoningPart = ReasoningOutput | ReasoningFileOutput; +export type ReasoningPart = { "type": "reasoning" } & ReasoningOutput | { "type": "reasoning-file" } & ReasoningFileOutput; diff --git a/bindings/node/src/types/ReasoningSpec.ts b/bindings/node/src/types/ReasoningSpec.ts index 2ce4266a0..03f35424a 100644 --- a/bindings/node/src/types/ReasoningSpec.ts +++ b/bindings/node/src/types/ReasoningSpec.ts @@ -13,23 +13,23 @@ supported: boolean, /** * Whether reasoning should be on by default. */ -default_enabled: boolean, +defaultEnabled: boolean, /** * Primary control mode. */ -mode: ReasoningMode | null, +mode?: ReasoningMode, /** * Default effort level (when `mode` is Effort/Mixed). */ -effort_default?: string | null, +effortDefault?: string, /** * Supported effort levels. */ -effort_options?: Array, +effortOptions?: Array, /** * Minimum token budget (when `mode` is Budget/Mixed). */ -budget_min?: number | null, +budgetMin?: number, /** * Interleaved reasoning (thinking blocks between tool calls). */ @@ -37,4 +37,4 @@ interleaved: boolean, /** * Output visibility. */ -visibility: ReasoningVisibility | null, }; +visibility?: ReasoningVisibility, }; diff --git a/bindings/node/src/types/Recording.ts b/bindings/node/src/types/Recording.ts index 8ef090200..28c75efe9 100644 --- a/bindings/node/src/types/Recording.ts +++ b/bindings/node/src/types/Recording.ts @@ -16,11 +16,11 @@ schema: number, /** * 暴露逻辑调用 ID,关联三层。 */ -call_id: string, +callId: string, /** * ISO 8601 时间戳。 */ -recorded_at: string, +recordedAt: string, /** * ① 输入侧:调用参数(prompt + options)。 */ @@ -45,14 +45,14 @@ complete: boolean, * 传输层封闭信号:层 B 声明"不再有 exchange"(P1:层 A 收尾自动发; * P2:由层 B 发送)。false = 仍可能来 exchange(记录暂不可定稿)。 */ -transport_closed: boolean, +transportClosed: boolean, /** * 会话归组(RFC-0024 P3):所在会话 id。None = 未归组(无 session_id 且 * 推断关闭)。由 `Recorder::record_session` 填充,写入 InputRecord 之前 * 或之后均可(writer 端按 call_id 合并)。 */ -session_id?: string | null, +sessionId?: string, /** * 会话内步号(0 起,由 SessionStore 分配)。与 `session_id` 同生命周期。 */ -step?: number | null, }; +step?: number, }; diff --git a/bindings/node/src/types/RequestCacheHints.ts b/bindings/node/src/types/RequestCacheHints.ts index 9e3ff369d..7f90b919c 100644 --- a/bindings/node/src/types/RequestCacheHints.ts +++ b/bindings/node/src/types/RequestCacheHints.ts @@ -7,4 +7,4 @@ export type RequestCacheHints = { /** * Whether any message requested `cache_control` (write) this call. */ -requested_write: boolean, }; +requestedWrite: boolean, }; diff --git a/bindings/node/src/types/RequestInfo.ts b/bindings/node/src/types/RequestInfo.ts index 098d3be65..ce8a9ae4e 100644 --- a/bindings/node/src/types/RequestInfo.ts +++ b/bindings/node/src/types/RequestInfo.ts @@ -10,4 +10,4 @@ export type RequestInfo = { /** * The request body that was sent (opaque JSON). */ -body: JsonValue | null, }; +body?: JsonValue, }; diff --git a/bindings/node/src/types/RerankingCallOptions.ts b/bindings/node/src/types/RerankingCallOptions.ts index 3de208995..57bfbf122 100644 --- a/bindings/node/src/types/RerankingCallOptions.ts +++ b/bindings/node/src/types/RerankingCallOptions.ts @@ -20,20 +20,20 @@ query: string, /** * Optional limit: return only the top `n` documents. */ -top_n: number | null, +topN?: number, /** * Per-call retry override. `None` uses the model default. */ -max_retries: number | null, +maxRetries?: number, /** * Per-call operation timeout. */ -timeout: TimeoutConfiguration | null, +timeout?: TimeoutConfiguration, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: { [key in string]: JsonValue } } | null, +providerOptions?: { [key in string]: { [key in string]: JsonValue } }, /** * Additional HTTP headers to send with the request. */ -headers: { [key in string]: string } | null, }; +headers?: { [key in string]: string }, }; diff --git a/bindings/node/src/types/RerankingDocuments.ts b/bindings/node/src/types/RerankingDocuments.ts index 2fd5f51f2..3e5941d58 100644 --- a/bindings/node/src/types/RerankingDocuments.ts +++ b/bindings/node/src/types/RerankingDocuments.ts @@ -6,4 +6,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; * * Aligned with V4 `RerankingModelV4CallOptions.documents`. */ -export type RerankingDocuments = { "Text": { values: Array, } } | { "Object": { values: Array, } }; +export type RerankingDocuments = { "type": "text", values: Array, } | { "type": "object", values: Array, }; diff --git a/bindings/node/src/types/RerankingRank.ts b/bindings/node/src/types/RerankingRank.ts index 986d3b2e0..6d236bd16 100644 --- a/bindings/node/src/types/RerankingRank.ts +++ b/bindings/node/src/types/RerankingRank.ts @@ -11,4 +11,4 @@ index: number, /** * The relevance score of the document after reranking. */ -relevance_score: number, }; +relevanceScore: number, }; diff --git a/bindings/node/src/types/RerankingResponse.ts b/bindings/node/src/types/RerankingResponse.ts index 83a869529..179b39959 100644 --- a/bindings/node/src/types/RerankingResponse.ts +++ b/bindings/node/src/types/RerankingResponse.ts @@ -8,20 +8,20 @@ export type RerankingResponse = { /** * ID for the generated response, if the provider sends one. */ -id: string | null, +id?: string, /** * Timestamp for the start of the generated response (ISO 8601 string). */ -timestamp: string | null, +timestamp?: string, /** * The ID of the model that was used to generate the response. */ -model_id: string | null, +modelId?: string, /** * Response headers. */ -headers: { [key in string]: string } | null, +headers?: { [key in string]: string }, /** * Response body (opaque JSON). */ -body: JsonValue | null, }; +body?: JsonValue, }; diff --git a/bindings/node/src/types/RerankingResult.ts b/bindings/node/src/types/RerankingResult.ts index 4d339296c..6534cba8e 100644 --- a/bindings/node/src/types/RerankingResult.ts +++ b/bindings/node/src/types/RerankingResult.ts @@ -19,12 +19,12 @@ ranking: Array, /** * Additional provider-specific metadata, keyed by provider name. */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, /** * Warnings for the call, e.g. unsupported settings. */ -warnings: Array | null, +warnings?: Array, /** * Optional response information for debugging. */ -response: RerankingResponse | null, }; +response?: RerankingResponse, }; diff --git a/bindings/node/src/types/ResponseFormat.ts b/bindings/node/src/types/ResponseFormat.ts index 88f2405e1..19760bc4f 100644 --- a/bindings/node/src/types/ResponseFormat.ts +++ b/bindings/node/src/types/ResponseFormat.ts @@ -4,4 +4,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * How the model should format its response. */ -export type ResponseFormat = "Text" | { "Json": { schema: JsonValue | null, name: string | null, description: string | null, } }; +export type ResponseFormat = { "type": "text" } | { "type": "json", schema?: JsonValue, name?: string, description?: string, }; diff --git a/bindings/node/src/types/ResponseInfo.ts b/bindings/node/src/types/ResponseInfo.ts index 52103ad78..c832686c2 100644 --- a/bindings/node/src/types/ResponseInfo.ts +++ b/bindings/node/src/types/ResponseInfo.ts @@ -14,20 +14,20 @@ export type ResponseInfo = { /** * ID of the generated response, when supplied by the provider. */ -id?: string | null, +id?: string, /** * Timestamp for the start of the generated response (ISO 8601 string). */ -timestamp: string | null, +timestamp?: string, /** * The ID of the model that was used to generate the response. */ -model_id: string | null, +modelId?: string, /** * Response headers. */ -headers: { [key in string]: string } | null, +headers?: { [key in string]: string }, /** * The response body (opaque JSON). */ -body: JsonValue | null, }; +body?: JsonValue, }; diff --git a/bindings/node/src/types/ResponseMetadata.ts b/bindings/node/src/types/ResponseMetadata.ts index 4c2084e8c..7ead4483e 100644 --- a/bindings/node/src/types/ResponseMetadata.ts +++ b/bindings/node/src/types/ResponseMetadata.ts @@ -3,4 +3,4 @@ /** * Metadata about the API response. */ -export type ResponseMetadata = { id: string | null, timestamp: string | null, model_id: string | null, }; +export type ResponseMetadata = { id?: string, timestamp?: string, modelId?: string, }; diff --git a/bindings/node/src/types/ResponseRecord.ts b/bindings/node/src/types/ResponseRecord.ts index 28abf8d33..0edbedf7e 100644 --- a/bindings/node/src/types/ResponseRecord.ts +++ b/bindings/node/src/types/ResponseRecord.ts @@ -4,8 +4,8 @@ export type ResponseRecord = { status: number, headers: Array<[string, string]>, /** * 非流式:完整 JSON;流式:原始 SSE 拼接文本(上限截断)。 */ -body: string | null, stream_chunks: number | null, +body?: string, streamChunks?: number, /** * 首字节延迟(流式)。 */ -ttfb_ms?: number | null, }; +ttfbMs?: number, }; diff --git a/bindings/node/src/types/RuntimeModel.ts b/bindings/node/src/types/RuntimeModel.ts index f12ea8802..5a131cbac 100644 --- a/bindings/node/src/types/RuntimeModel.ts +++ b/bindings/node/src/types/RuntimeModel.ts @@ -16,8 +16,8 @@ id: string, /** * `owned_by` field from OpenAI-compatible `/models` responses. */ -owned_by?: string | null, +ownedBy?: string, /** * `created` unix timestamp from OpenAI-compatible `/models` responses. */ -created?: number | null, }; +created?: number, }; diff --git a/bindings/node/src/types/SearchCallOptions.ts b/bindings/node/src/types/SearchCallOptions.ts index 1658b5e28..d4c69eb05 100644 --- a/bindings/node/src/types/SearchCallOptions.ts +++ b/bindings/node/src/types/SearchCallOptions.ts @@ -13,40 +13,40 @@ query: string, /** * Maximum number of results to return. */ -max_results: number | null, +maxResults?: number, /** * Whether to include raw page content in results. * * Provider support varies; providers that cannot honor this should * issue a warning rather than erroring. */ -include_raw_content: boolean | null, +includeRawContent?: boolean, /** * Optional time range filter (e.g. `"day"`, `"week"`, `"month"`, * `"year"`). Provider support varies. */ -time_range: string | null, +timeRange?: string, /** * Optional list of domains to include in results. */ -include_domains: Array | null, +includeDomains?: Array, /** * Optional list of domains to exclude from results. */ -exclude_domains: Array | null, +excludeDomains?: Array, /** * Per-call retry override. `None` uses the model default. */ -max_retries: number | null, +maxRetries?: number, /** * Per-call operation timeout. */ -timeout: TimeoutConfiguration | null, +timeout?: TimeoutConfiguration, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: { [key in string]: JsonValue } } | null, +providerOptions?: { [key in string]: { [key in string]: JsonValue } }, /** * Additional HTTP headers to send with the request. */ -headers: { [key in string]: string } | null, }; +headers?: { [key in string]: string }, }; diff --git a/bindings/node/src/types/SearchResponse.ts b/bindings/node/src/types/SearchResponse.ts index 287e4d97b..ced52e84b 100644 --- a/bindings/node/src/types/SearchResponse.ts +++ b/bindings/node/src/types/SearchResponse.ts @@ -8,8 +8,8 @@ export type SearchResponse = { /** * Response headers. */ -headers: { [key in string]: string } | null, +headers?: { [key in string]: string }, /** * The response body (opaque JSON). */ -body: JsonValue | null, }; +body?: JsonValue, }; diff --git a/bindings/node/src/types/SearchResult.ts b/bindings/node/src/types/SearchResult.ts index ee3f6a49b..b009ede13 100644 --- a/bindings/node/src/types/SearchResult.ts +++ b/bindings/node/src/types/SearchResult.ts @@ -16,11 +16,11 @@ results: Array, * An optional direct answer / summary (some providers return * an AI-generated answer alongside results). */ -answer: string | null, +answer?: string, /** * Additional provider-specific metadata. */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, /** * Warnings for the call. */ @@ -28,4 +28,4 @@ warnings: Array, /** * Optional response information for debugging. */ -response: SearchResponse | null, }; +response?: SearchResponse, }; diff --git a/bindings/node/src/types/SearchResultItem.ts b/bindings/node/src/types/SearchResultItem.ts index 0c8b8d67a..7a4b9fb14 100644 --- a/bindings/node/src/types/SearchResultItem.ts +++ b/bindings/node/src/types/SearchResultItem.ts @@ -8,25 +8,25 @@ export type SearchResultItem = { /** * The title of the result (e.g. page title). */ -title: string | null, +title?: string, /** * The URL of the result. */ -url: string | null, +url?: string, /** * A snippet/summary of the result content. */ -content: string | null, +content?: string, /** * Raw page content (when `include_raw_content` is requested and * supported by the provider). */ -raw_content: string | null, +rawContent?: string, /** * A relevance score (0.0–1.0) if the provider returns one. */ -score: number | null, +score?: number, /** * Provider-specific metadata for this result. */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, }; +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/SessionCall.ts b/bindings/node/src/types/SessionCall.ts index 695d5a603..79c38fd77 100644 --- a/bindings/node/src/types/SessionCall.ts +++ b/bindings/node/src/types/SessionCall.ts @@ -10,7 +10,7 @@ export type SessionCall = { * Recording / TraceRecord `call_id` (RFC-0015/0023): once those land, * this field carries their value. */ -call_id: string, +callId: string, /** * Step within the session (0-based). */ @@ -18,4 +18,4 @@ step: number, /** * When the call was recorded (RFC 3339 UTC, millisecond precision). */ -recorded_at: string, }; +recordedAt: string, }; diff --git a/bindings/node/src/types/SessionChainView.ts b/bindings/node/src/types/SessionChainView.ts index 3212b456a..3d416fd18 100644 --- a/bindings/node/src/types/SessionChainView.ts +++ b/bindings/node/src/types/SessionChainView.ts @@ -4,4 +4,4 @@ import type { PrefixBreak } from "./PrefixBreak"; /** * Session chain view (append-only order + prefix stability, RFC-0015 §5.3). */ -export type SessionChainView = { session_id: string, record_ids: Array, prefix_stability: number, breaks: Array, }; +export type SessionChainView = { sessionId: string, recordIds: Array, prefixStability: number, breaks: Array, }; diff --git a/bindings/node/src/types/SessionStepStat.ts b/bindings/node/src/types/SessionStepStat.ts index 1eeb3bf02..a75b0652f 100644 --- a/bindings/node/src/types/SessionStepStat.ts +++ b/bindings/node/src/types/SessionStepStat.ts @@ -12,17 +12,17 @@ step: number, /** * The call's id (association key into Recording / replay, RFC-0023). */ -call_id: string, +callId: string, /** * Reported hit rate for this step (`cache_read / input_total`); `None` * when the call carried no input/cache-read usage. */ -hit_rate?: number | null, +hitRate?: number, /** * Audit verdict (present only when an auditor is attached). */ -verdict?: Verdict | null, +verdict?: Verdict, /** * Error string for failed calls (failures are part of the session). */ -error?: string | null, }; +error?: string, }; diff --git a/bindings/node/src/types/SessionView.ts b/bindings/node/src/types/SessionView.ts index 60a153778..709ebfef1 100644 --- a/bindings/node/src/types/SessionView.ts +++ b/bindings/node/src/types/SessionView.ts @@ -5,7 +5,7 @@ import type { SessionSource } from "./SessionSource"; /** * Aggregated view of one session (session_id → ordered calls). */ -export type SessionView = { session_id: string, +export type SessionView = { sessionId: string, /** * Where the session id came from (first call's source). */ diff --git a/bindings/node/src/types/Source.ts b/bindings/node/src/types/Source.ts index f9239ad68..49c10d7d3 100644 --- a/bindings/node/src/types/Source.ts +++ b/bindings/node/src/types/Source.ts @@ -6,4 +6,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; * payload of both `GenerateContent::Source` and `StreamPart::Source`, and the * element of the text results' `sources`. */ -export type Source = { "source_type": "url", id: string, url: string, title?: string | null, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "source_type": "document", id: string, media_type: string, title: string, filename?: string | null, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, }; +export type Source = { "sourceType": "url", id: string, url: string, title?: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "sourceType": "document", id: string, mediaType: string, title: string, filename?: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/SpeechCallOptions.ts b/bindings/node/src/types/SpeechCallOptions.ts index d9424d439..9f947c252 100644 --- a/bindings/node/src/types/SpeechCallOptions.ts +++ b/bindings/node/src/types/SpeechCallOptions.ts @@ -16,38 +16,38 @@ text: string, * The voice to use (provider-specific voice ID, name, or other * identifier). */ -voice: string | null, +voice?: string, /** * The desired output format for the audio, e.g. `"mp3"`, `"wav"`. */ -output_format: string | null, +outputFormat?: string, /** * Instructions for the speech generation, e.g. * `"Speak in a slow and steady tone"`. */ -instructions: string | null, +instructions?: string, /** * The speed of the speech generation. */ -speed: number | null, +speed?: number, /** * The language for speech generation (ISO 639-1 code, e.g. `"en"`, or * `"auto"` for automatic detection). Provider support varies. */ -language: string | null, +language?: string, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: { [key in string]: JsonValue } } | null, +providerOptions?: { [key in string]: { [key in string]: JsonValue } }, /** * Per-call retry override. `None` uses the model default. */ -max_retries: number | null, +maxRetries?: number, /** * Per-call operation timeout. */ -timeout: TimeoutConfiguration | null, +timeout?: TimeoutConfiguration, /** * Additional HTTP headers to send with the request. */ -headers: { [key in string]: string } | null, }; +headers?: { [key in string]: string }, }; diff --git a/bindings/node/src/types/SpeechRequest.ts b/bindings/node/src/types/SpeechRequest.ts index 1d5fbe382..6131173a4 100644 --- a/bindings/node/src/types/SpeechRequest.ts +++ b/bindings/node/src/types/SpeechRequest.ts @@ -8,4 +8,4 @@ export type SpeechRequest = { /** * Response body (HTTP providers only). */ -body: JsonValue | null, }; +body?: JsonValue, }; diff --git a/bindings/node/src/types/SpeechResponse.ts b/bindings/node/src/types/SpeechResponse.ts index 9d83fe59d..0c447bbe0 100644 --- a/bindings/node/src/types/SpeechResponse.ts +++ b/bindings/node/src/types/SpeechResponse.ts @@ -8,16 +8,16 @@ export type SpeechResponse = { /** * Timestamp for the start of the generated response (ISO 8601 string). */ -timestamp: string | null, +timestamp?: string, /** * The ID of the model that was used to generate the response. */ -model_id: string | null, +modelId?: string, /** * Response headers. */ -headers: { [key in string]: string } | null, +headers?: { [key in string]: string }, /** * Response body (opaque JSON). */ -body: JsonValue | null, }; +body?: JsonValue, }; diff --git a/bindings/node/src/types/SpeechResult.ts b/bindings/node/src/types/SpeechResult.ts index 4dd161ffe..5fe572a8e 100644 --- a/bindings/node/src/types/SpeechResult.ts +++ b/bindings/node/src/types/SpeechResult.ts @@ -22,7 +22,7 @@ warnings: Array, /** * Optional request information for telemetry and debugging. */ -request: SpeechRequest | null, +request?: SpeechRequest, /** * Response information for telemetry and debugging. */ @@ -30,4 +30,4 @@ response: SpeechResponse, /** * Additional provider-specific metadata, keyed by provider name. */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, }; +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/StreamPart.ts b/bindings/node/src/types/StreamPart.ts index a17cf1e48..2f96fba3b 100644 --- a/bindings/node/src/types/StreamPart.ts +++ b/bindings/node/src/types/StreamPart.ts @@ -18,28 +18,28 @@ import type { JsonValue } from "./serde_json/JsonValue"; * [`StreamPart`] (raw argument text); `stream_text` emits [`TextStreamPart`] * (parsed and validated [`ToolCall`]). */ -export type StreamPart = { "TextStart": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TextDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TextEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "StreamStart": { warnings: Array, } } | { "Finish": { finish_reason: FinishReason, usage: Usage, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "Error": { error: AiMuxError, } } | { "ToolInputStart": { id: string, tool_name: string, +export type StreamPart = { "type": "text-start", id: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "text-delta", id: string, delta: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "text-end", id: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "stream-start", warnings: Array, } | { "type": "finish", finishReason: FinishReason, usage: Usage, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "error", error: AiMuxError, } | { "type": "tool-input-start", id: string, toolName: string, /** * Whether the tool call will be executed by the provider. */ -provider_executed?: boolean | null, +providerExecuted?: boolean, /** * Whether the tool is dynamic (defined at runtime, e.g. MCP tools). */ -dynamic?: boolean | null, +dynamic?: boolean, /** * Optional title for the tool call. */ -title?: string | null, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolCall": RawToolCall } | { "ToolResult": ToolResult } | { "File": GeneratedFile } | { "ReasoningFile": GeneratedFile } | { "Custom": { kind: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolApprovalRequest": RawToolApprovalRequest } | { "ReasoningStart": { id: string, +title?: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "tool-input-delta", id: string, delta: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "tool-input-end", id: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "tool-call" } & RawToolCall | { "type": "tool-result" } & ToolResult | { "type": "file" } & GeneratedFile | { "type": "reasoning-file" } & GeneratedFile | { "type": "custom", kind: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "tool-approval-request" } & RawToolApprovalRequest | { "type": "reasoning-start", id: string, /** * Provider-specific metadata (e.g. xAI `itemId`). */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ReasoningDelta": { id: string, delta: string, +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "reasoning-delta", id: string, delta: string, /** * Provider-specific metadata (e.g. xAI `itemId`). */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ReasoningEnd": { id: string, +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "reasoning-end", id: string, /** * Provider-specific metadata (e.g. xAI `itemId`, `reasoningEncryptedContent`). */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ResponseMetadata": ResponseMetadata } | { "Source": Source } | { "Raw": { raw_value: JsonValue, } }; +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "response-metadata" } & ResponseMetadata | { "type": "source" } & Source | { "type": "raw", rawValue: JsonValue, }; diff --git a/bindings/node/src/types/StreamResponseInfo.ts b/bindings/node/src/types/StreamResponseInfo.ts index 8426efa47..3afa51caf 100644 --- a/bindings/node/src/types/StreamResponseInfo.ts +++ b/bindings/node/src/types/StreamResponseInfo.ts @@ -3,4 +3,4 @@ /** * Response information returned when starting a language-model stream. */ -export type StreamResponseInfo = { headers: { [key in string]: string } | null, }; +export type StreamResponseInfo = { headers?: { [key in string]: string }, }; diff --git a/bindings/node/src/types/StreamTextResultAggregated.ts b/bindings/node/src/types/StreamTextResultAggregated.ts index fb3fb902a..7192e5db3 100644 --- a/bindings/node/src/types/StreamTextResultAggregated.ts +++ b/bindings/node/src/types/StreamTextResultAggregated.ts @@ -35,11 +35,11 @@ reasoning: Array, /** * Concatenated reasoning text. */ -reasoning_text: string, +reasoningText: string, /** * Tool calls requested by the model. */ -tool_calls: Array, +toolCalls: Array, /** * Sources / citations. */ @@ -51,11 +51,11 @@ files: Array, /** * Why generation stopped. */ -finish_reason: FinishReason, +finishReason: FinishReason, /** * Raw provider-specific finish reason string. */ -raw_finish_reason: string | null, +rawFinishReason?: string, /** * Token usage. */ @@ -64,7 +64,7 @@ usage: Usage, * Total token usage across all steps. In single-step mode (aimux's * default), `total_usage` equals `usage`. Provided for AI SDK parity. */ -total_usage: Usage, +totalUsage: Usage, /** * Warnings from the provider. */ @@ -72,7 +72,7 @@ warnings: Array, /** * Provider-specific metadata from the Finish chunk. */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, /** * Request information from the provider. */ @@ -84,4 +84,4 @@ response: ResponseInfo, /** * Assistant messages ready to append for the next turn. */ -response_messages: Array, }; +responseMessages: Array, }; diff --git a/bindings/node/src/types/TextContent.ts b/bindings/node/src/types/TextContent.ts index 09ad96b47..2db52e39b 100644 --- a/bindings/node/src/types/TextContent.ts +++ b/bindings/node/src/types/TextContent.ts @@ -10,4 +10,4 @@ import type { ToolCall } from "./ToolCall"; import type { ToolResult } from "./ToolResult"; import type { JsonValue } from "./serde_json/JsonValue"; -export type TextContent = { "Text": { text: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolCall": ToolCall } | { "Source": Source } | { "Reasoning": ReasoningOutput } | { "File": GeneratedFile } | { "ReasoningFile": ReasoningFileOutput } | { "Custom": { kind: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolApprovalRequest": ToolApprovalRequestOutput } | { "ToolResult": ToolResult }; +export type TextContent = { "type": "text", text: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "tool-call" } & ToolCall | { "type": "source" } & Source | { "type": "reasoning" } & ReasoningOutput | { "type": "file" } & GeneratedFile | { "type": "reasoning-file" } & ReasoningFileOutput | { "type": "custom", kind: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "tool-approval-request" } & ToolApprovalRequestOutput | { "type": "tool-result" } & ToolResult; diff --git a/bindings/node/src/types/TextPart.ts b/bindings/node/src/types/TextPart.ts index 2e01804cf..9cc05f537 100644 --- a/bindings/node/src/types/TextPart.ts +++ b/bindings/node/src/types/TextPart.ts @@ -4,4 +4,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * Text content part. */ -export type TextPart = { text: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; +export type TextPart = { text: string, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/TextStreamPart.ts b/bindings/node/src/types/TextStreamPart.ts index ed76d8312..1f2537033 100644 --- a/bindings/node/src/types/TextStreamPart.ts +++ b/bindings/node/src/types/TextStreamPart.ts @@ -15,28 +15,28 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * A public call-layer stream chunk, with parsed tool calls. */ -export type TextStreamPart = { "TextStart": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TextDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TextEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "StreamStart": { warnings: Array, } } | { "Finish": { finish_reason: FinishReason, usage: Usage, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "FinishStep": { finish_reason: FinishReason, usage: Usage, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, response: ResponseInfo, } } | { "Error": { error: AiMuxError, } } | { "ToolInputStart": { id: string, tool_name: string, +export type TextStreamPart = { "type": "text-start", id: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "text-delta", id: string, delta: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "text-end", id: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "stream-start", warnings: Array, } | { "type": "finish", finishReason: FinishReason, usage: Usage, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "finish-step", finishReason: FinishReason, usage: Usage, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, response: ResponseInfo, } | { "type": "error", error: AiMuxError, } | { "type": "tool-input-start", id: string, toolName: string, /** * Whether the tool call will be executed by the provider. */ -provider_executed?: boolean | null, +providerExecuted?: boolean, /** * Whether the tool is dynamic (defined at runtime, e.g. MCP tools). */ -dynamic?: boolean | null, +dynamic?: boolean, /** * Optional title for the tool call. */ -title?: string | null, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolCall": ToolCall } | { "ToolResult": ToolResult } | { "File": GeneratedFile } | { "ReasoningFile": ReasoningFileOutput } | { "Custom": { kind: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolApprovalRequest": ToolApprovalRequestOutput } | { "ReasoningStart": { id: string, +title?: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "tool-input-delta", id: string, delta: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "tool-input-end", id: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "tool-call" } & ToolCall | { "type": "tool-result" } & ToolResult | { "type": "file" } & GeneratedFile | { "type": "reasoning-file" } & ReasoningFileOutput | { "type": "custom", kind: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "tool-approval-request" } & ToolApprovalRequestOutput | { "type": "reasoning-start", id: string, /** * Provider-specific metadata (e.g. xAI `itemId`). */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ReasoningDelta": { id: string, delta: string, +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "reasoning-delta", id: string, delta: string, /** * Provider-specific metadata (e.g. xAI `itemId`). */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ReasoningEnd": { id: string, +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "reasoning-end", id: string, /** * Provider-specific metadata (e.g. xAI `itemId`, `reasoningEncryptedContent`). */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "Source": Source } | { "Raw": { raw_value: JsonValue, } }; +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "source" } & Source | { "type": "raw", rawValue: JsonValue, }; diff --git a/bindings/node/src/types/TimeoutConfiguration.ts b/bindings/node/src/types/TimeoutConfiguration.ts index d547f0a0f..086865d32 100644 --- a/bindings/node/src/types/TimeoutConfiguration.ts +++ b/bindings/node/src/types/TimeoutConfiguration.ts @@ -13,12 +13,12 @@ export type TimeoutConfiguration = { * Overall timeout for the entire call (including retries and, for * streaming, the whole stream), in milliseconds. */ -total_ms: number | null, +totalMs?: number, /** * Timeout for one generation step, including that step's attempts and * retry backoff, in milliseconds. Aimux currently has one step. */ -step_ms: number | null, +stepMs?: number, /** * Timeout waiting for the first stream chunk (streaming only). * @@ -26,8 +26,8 @@ step_ms: number | null, * and any retries before the first semantic output: it is the * user-perceived time-to-first-output budget, not a per-attempt timer. */ -first_chunk_ms: number | null, +firstChunkMs?: number, /** * Maximum idle time between consecutive stream chunks (streaming only). */ -chunk_ms: number | null, }; +chunkMs?: number, }; diff --git a/bindings/node/src/types/TimingRecord.ts b/bindings/node/src/types/TimingRecord.ts index b24ae6478..cac3c5e8a 100644 --- a/bindings/node/src/types/TimingRecord.ts +++ b/bindings/node/src/types/TimingRecord.ts @@ -4,4 +4,4 @@ export type TimingRecord = { /** * ms 级延迟,TS 用 number(远小于 2^53,避免 bigint)。 */ -latency_ms: number, ttfb_ms: number | null, }; +latencyMs: number, ttfbMs?: number, }; diff --git a/bindings/node/src/types/ToolApprovalRequestOutput.ts b/bindings/node/src/types/ToolApprovalRequestOutput.ts index 8e5d70b34..9b4f34fc1 100644 --- a/bindings/node/src/types/ToolApprovalRequestOutput.ts +++ b/bindings/node/src/types/ToolApprovalRequestOutput.ts @@ -4,4 +4,4 @@ import type { ToolCall } from "./ToolCall"; /** * Approval request exposed by text generation. */ -export type ToolApprovalRequestOutput = { approval_id: string, tool_call: ToolCall, reason?: string | null, is_automatic?: boolean | null, signature?: string | null, }; +export type ToolApprovalRequestOutput = { approvalId: string, toolCall: ToolCall, reason?: string, isAutomatic?: boolean, signature?: string, }; diff --git a/bindings/node/src/types/ToolApprovalResponsePart.ts b/bindings/node/src/types/ToolApprovalResponsePart.ts index 12163b325..d624ac2f3 100644 --- a/bindings/node/src/types/ToolApprovalResponsePart.ts +++ b/bindings/node/src/types/ToolApprovalResponsePart.ts @@ -4,4 +4,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * The user's decision for a provider-executed tool approval request. */ -export type ToolApprovalResponsePart = { approval_id: string, approved: boolean, reason?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; +export type ToolApprovalResponsePart = { approvalId: string, approved: boolean, reason?: string, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/ToolCall.ts b/bindings/node/src/types/ToolCall.ts index 46500aa89..442cf0c80 100644 --- a/bindings/node/src/types/ToolCall.ts +++ b/bindings/node/src/types/ToolCall.ts @@ -9,11 +9,11 @@ export type ToolCall = { /** * Provider-assigned call id. */ -tool_call_id: string, +toolCallId: string, /** * Tool name. */ -tool_name: string, +toolName: string, /** * Parsed arguments, or the original string when `invalid` is true and the * provider input was not valid JSON. @@ -22,21 +22,21 @@ input: JsonValue, /** * Whether the tool call will be executed by the provider. */ -provider_executed?: boolean | null, +providerExecuted?: boolean, /** * Whether the tool is dynamic (defined at runtime, e.g. MCP tools). */ -dynamic?: boolean | null, +dynamic?: boolean, /** * Additional provider-specific metadata associated with this call. */ -provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, /** * Set when lookup, JSON parsing, or schema validation still failed after * the optional repair attempt. */ -invalid?: boolean | null, +invalid?: boolean, /** * Typed failure associated with an invalid tool call. */ -error?: AiMuxError | null, }; +error?: AiMuxError, }; diff --git a/bindings/node/src/types/ToolCallPart.ts b/bindings/node/src/types/ToolCallPart.ts index bac25885b..ca46e2b8b 100644 --- a/bindings/node/src/types/ToolCallPart.ts +++ b/bindings/node/src/types/ToolCallPart.ts @@ -4,4 +4,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * Tool call content part. */ -export type ToolCallPart = { tool_call_id: string, tool_name: string, input: JsonValue, provider_executed?: boolean | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; +export type ToolCallPart = { toolCallId: string, toolName: string, input: JsonValue, providerExecuted?: boolean, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/ToolCallRepairReply.ts b/bindings/node/src/types/ToolCallRepairReply.ts index 6de422082..4208158c3 100644 --- a/bindings/node/src/types/ToolCallRepairReply.ts +++ b/bindings/node/src/types/ToolCallRepairReply.ts @@ -7,7 +7,7 @@ import type { RawToolCall } from "./RawToolCall"; * The bindings cannot carry a [`ToolCallRepair`] closure, so a host instead * reads the invalid call off the result, repairs it in its own language, and * hands the answer back as this value. Wire shape: - * `{"type":"repaired","tool_call":{…}}`, `{"type":"unchanged"}`, or + * `{"type":"repaired","toolCall":{…}}`, `{"type":"unchanged"}`, or * `{"type":"failed","message":"…"}`. */ -export type ToolCallRepairReply = { "type": "repaired", tool_call: RawToolCall, } | { "type": "unchanged" } | { "type": "failed", message: string, }; +export type ToolCallRepairReply = { "type": "repaired", toolCall: RawToolCall, } | { "type": "unchanged" } | { "type": "failed", message: string, }; diff --git a/bindings/node/src/types/ToolResult.ts b/bindings/node/src/types/ToolResult.ts index 544c2e653..6f9839746 100644 --- a/bindings/node/src/types/ToolResult.ts +++ b/bindings/node/src/types/ToolResult.ts @@ -9,7 +9,7 @@ export type ToolResult = { /** * Must match the corresponding call's `tool_call_id`. */ -tool_call_id: string, tool_name: string, +toolCallId: string, toolName: string, /** * The tool's output (usually a JSON-serializable value or plain text). */ @@ -17,16 +17,16 @@ result: JsonValue, /** * Whether the result is an error or error message. */ -is_error?: boolean | null, +isError?: boolean, /** * Whether the result is preliminary (replaces prior, e.g. image previews). */ -preliminary?: boolean | null, +preliminary?: boolean, /** * Whether the tool is dynamic (defined at runtime, e.g. MCP tools). */ -dynamic?: boolean | null, +dynamic?: boolean, /** * Additional provider-specific metadata for the tool result. */ -provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, }; +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/ToolResultContent.ts b/bindings/node/src/types/ToolResultContent.ts index 3511c045d..742dc5312 100644 --- a/bindings/node/src/types/ToolResultContent.ts +++ b/bindings/node/src/types/ToolResultContent.ts @@ -6,4 +6,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * Content within a tool result. */ -export type ToolResultContent = { "type": "text" } & TextPart | { "type": "file" } & FilePart | { "type": "custom", provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; +export type ToolResultContent = { "type": "text" } & TextPart | { "type": "file" } & FilePart | { "type": "custom", providerOptions?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/ToolResultOutput.ts b/bindings/node/src/types/ToolResultOutput.ts index 2855f2918..f0d7c4672 100644 --- a/bindings/node/src/types/ToolResultOutput.ts +++ b/bindings/node/src/types/ToolResultOutput.ts @@ -5,4 +5,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * The provider-facing result of a tool call. */ -export type ToolResultOutput = { "type": "text", value: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "json", value: JsonValue, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "execution-denied", reason?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "error-text", value: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "error-json", value: JsonValue, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "content", value: Array, }; +export type ToolResultOutput = { "type": "text", value: string, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "json", value: JsonValue, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "execution-denied", reason?: string, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "error-text", value: string, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "error-json", value: JsonValue, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "content", value: Array, }; diff --git a/bindings/node/src/types/ToolResultPart.ts b/bindings/node/src/types/ToolResultPart.ts index 6a479932d..36d87abb1 100644 --- a/bindings/node/src/types/ToolResultPart.ts +++ b/bindings/node/src/types/ToolResultPart.ts @@ -5,4 +5,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * Tool result content part. */ -export type ToolResultPart = { tool_call_id: string, tool_name: string, output: ToolResultOutput, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; +export type ToolResultPart = { toolCallId: string, toolName: string, output: ToolResultOutput, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/TraceFilter.ts b/bindings/node/src/types/TraceFilter.ts index 314c21083..59d2b8ec3 100644 --- a/bindings/node/src/types/TraceFilter.ts +++ b/bindings/node/src/types/TraceFilter.ts @@ -3,4 +3,4 @@ /** * Query filter (RFC-0015 §5.3). */ -export type TraceFilter = { provider: string | null, model: string | null, session_id: string | null, since_unix_ms: bigint | null, }; +export type TraceFilter = { provider?: string, model?: string, sessionId?: string, sinceUnixMs?: bigint, }; diff --git a/bindings/node/src/types/TraceRecord.ts b/bindings/node/src/types/TraceRecord.ts index fe903fb3f..cfd91ae37 100644 --- a/bindings/node/src/types/TraceRecord.ts +++ b/bindings/node/src/types/TraceRecord.ts @@ -7,41 +7,41 @@ import type { Verdict } from "./Verdict"; /** * One probed call. Plaintext never persists — fingerprints only. */ -export type TraceRecord = { provider: string, model: string, request_id: string | null, +export type TraceRecord = { provider: string, model: string, requestId?: string, /** * From `CallOptions.session_id` (RFC-0024) — explicit first, inference * fallback; may also be the layer's default session. */ -session_id: string | null, +sessionId?: string, /** * Call-level unique id (association key for Recording / replay, * RFC-0023). */ -call_id: string, +callId: string, /** * When the call was sent (epoch ms). */ -sent_at_unix_ms: number, +sentAtUnixMs: number, /** * Client-side LCP token upper bound (block upper bound, byte-proxy * len/4). `None` when no history matched. Consumed by `TraceStats` * aggregation. */ -lcp_token_upper?: bigint | null, +lcpTokenUpper?: bigint, /** * Time to first streamed token (ms). Non-streaming: `None`. */ -ttft_ms: number | null, fingerprint: Fingerprint, usage: UsageSnapshot, +ttftMs?: number, fingerprint: Fingerprint, usage: UsageSnapshot, /** * Response-side cache headers (e.g. `x-openrouter-cache-status`). */ -response_cache_headers?: { [key in string]: string } | null, request_cache_hints?: RequestCacheHints | null, +responseCacheHeaders?: { [key in string]: string }, requestCacheHints?: RequestCacheHints, /** * Audit verdict (`None` when no auditor is attached — the default). */ -verdict?: Verdict | null, +verdict?: Verdict, /** * Error string when the call failed (still recorded: failures are part * of the session). */ -error?: string | null, }; +error?: string, }; diff --git a/bindings/node/src/types/TraceStats.ts b/bindings/node/src/types/TraceStats.ts index 2f836deac..d182f8fd5 100644 --- a/bindings/node/src/types/TraceStats.ts +++ b/bindings/node/src/types/TraceStats.ts @@ -4,4 +4,4 @@ * Aggregated statistics (RFC-0015 §5.2 — the two hit rates are reported * side by side, never merged into a single number). */ -export type TraceStats = { provider: string, model: string, requests: bigint, input_tokens_total: bigint, claimed_cache_read_total: bigint, claimed_cache_write_total: bigint, reported_hit_rate: number | null, client_upper_bound_hit_rate: number | null, verdict_counts: { [key in string]: bigint }, ttft_p50_ms: bigint | null, ttft_p95_ms: bigint | null, errors: bigint, }; +export type TraceStats = { provider: string, model: string, requests: bigint, inputTokensTotal: bigint, claimedCacheReadTotal: bigint, claimedCacheWriteTotal: bigint, reportedHitRate?: number, clientUpperBoundHitRate?: number, verdictCounts: { [key in string]: bigint }, ttftP50Ms?: bigint, ttftP95Ms?: bigint, errors: bigint, }; diff --git a/bindings/node/src/types/TranscriptionCallOptions.ts b/bindings/node/src/types/TranscriptionCallOptions.ts index e5e7debdc..bb28dcfcd 100644 --- a/bindings/node/src/types/TranscriptionCallOptions.ts +++ b/bindings/node/src/types/TranscriptionCallOptions.ts @@ -16,20 +16,20 @@ audio: AudioInput, /** * The IANA media type of the audio data, e.g. `"audio/mp3"`. */ -media_type: string, +mediaType: string, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: { [key in string]: JsonValue } } | null, +providerOptions?: { [key in string]: { [key in string]: JsonValue } }, /** * Per-call retry override. `None` uses the model default. */ -max_retries: number | null, +maxRetries?: number, /** * Per-call operation timeout. */ -timeout: TimeoutConfiguration | null, +timeout?: TimeoutConfiguration, /** * Additional HTTP headers to send with the request. */ -headers: { [key in string]: string } | null, }; +headers?: { [key in string]: string }, }; diff --git a/bindings/node/src/types/TranscriptionRequest.ts b/bindings/node/src/types/TranscriptionRequest.ts index d1dac6010..d20adefc3 100644 --- a/bindings/node/src/types/TranscriptionRequest.ts +++ b/bindings/node/src/types/TranscriptionRequest.ts @@ -7,4 +7,4 @@ export type TranscriptionRequest = { /** * Raw request HTTP body that was sent (JSON stringified). */ -body: string | null, }; +body?: string, }; diff --git a/bindings/node/src/types/TranscriptionResponse.ts b/bindings/node/src/types/TranscriptionResponse.ts index cdf3331b2..7508f88b6 100644 --- a/bindings/node/src/types/TranscriptionResponse.ts +++ b/bindings/node/src/types/TranscriptionResponse.ts @@ -8,16 +8,16 @@ export type TranscriptionResponse = { /** * Timestamp for the start of the generated response (ISO 8601 string). */ -timestamp: string | null, +timestamp?: string, /** * The ID of the model that was used to generate the response. */ -model_id: string | null, +modelId?: string, /** * Response headers. */ -headers: { [key in string]: string } | null, +headers?: { [key in string]: string }, /** * Response body (opaque JSON). */ -body: JsonValue | null, }; +body?: JsonValue, }; diff --git a/bindings/node/src/types/TranscriptionResult.ts b/bindings/node/src/types/TranscriptionResult.ts index 2c5356fc1..8f2cd7f08 100644 --- a/bindings/node/src/types/TranscriptionResult.ts +++ b/bindings/node/src/types/TranscriptionResult.ts @@ -22,11 +22,11 @@ segments: Array, /** * The detected language (ISO 639-1 code, e.g. `"en"`), if detected. */ -language: string | null, +language?: string, /** * The total duration of the audio in seconds, if determined. */ -duration_in_seconds: number | null, +durationInSeconds?: number, /** * Warnings for the call, e.g. unsupported settings. */ @@ -34,7 +34,7 @@ warnings: Array, /** * Optional request information for telemetry and debugging. */ -request: TranscriptionRequest | null, +request?: TranscriptionRequest, /** * Response information for telemetry and debugging. */ @@ -42,4 +42,4 @@ response: TranscriptionResponse, /** * Additional provider-specific metadata, keyed by provider name. */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, }; +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/TranscriptionSegment.ts b/bindings/node/src/types/TranscriptionSegment.ts index dcb5e02a2..1165793e7 100644 --- a/bindings/node/src/types/TranscriptionSegment.ts +++ b/bindings/node/src/types/TranscriptionSegment.ts @@ -11,8 +11,8 @@ text: string, /** * The start time of this segment in seconds. */ -start_second: number, +startSecond: number, /** * The end time of this segment in seconds. */ -end_second: number, }; +endSecond: number, }; diff --git a/bindings/node/src/types/TranscriptionStreamPart.ts b/bindings/node/src/types/TranscriptionStreamPart.ts index ecfa20e24..eb9e00737 100644 --- a/bindings/node/src/types/TranscriptionStreamPart.ts +++ b/bindings/node/src/types/TranscriptionStreamPart.ts @@ -8,4 +8,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; * * Aligned with V4 `TranscriptionModelV4StreamPart`. */ -export type TranscriptionStreamPart = { "StreamStart": { warnings: Array, } } | { "TranscriptDelta": { id: string | null, delta: string, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TranscriptPartial": { id: string | null, text: string, start_second: number | null, duration_in_seconds: number | null, channel_index: number | null, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TranscriptFinal": { id: string | null, text: string, start_second: number | null, end_second: number | null, channel_index: number | null, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ResponseMetadata": { timestamp: string | null, model_id: string | null, headers: { [key in string]: string } | null, body: JsonValue | null, } } | { "Finish": { text: string, segments: Array, language: string | null, duration_in_seconds: number | null, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "Raw": { raw_value: JsonValue, } } | { "Error": { error: JsonValue, } }; +export type TranscriptionStreamPart = { "type": "stream-start", warnings: Array, } | { "type": "transcript-delta", id?: string, delta: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "transcript-partial", id?: string, text: string, startSecond?: number, durationInSeconds?: number, channelIndex?: number, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "transcript-final", id?: string, text: string, startSecond?: number, endSecond?: number, channelIndex?: number, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "response-metadata", timestamp?: string, modelId?: string, headers?: { [key in string]: string }, body?: JsonValue, } | { "type": "finish", text: string, segments: Array, language?: string, durationInSeconds?: number, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, } | { "type": "raw", rawValue: JsonValue, } | { "type": "error", error: JsonValue, }; diff --git a/bindings/node/src/types/UploadFileCallOptions.ts b/bindings/node/src/types/UploadFileCallOptions.ts index b39d69c19..bbcae7f87 100644 --- a/bindings/node/src/types/UploadFileCallOptions.ts +++ b/bindings/node/src/types/UploadFileCallOptions.ts @@ -15,12 +15,12 @@ data: UploadFileData, /** * The IANA media type of the file, e.g. `"application/pdf"`. */ -media_type: string, +mediaType: string, /** * The filename of the file. */ -filename: string | null, +filename?: string, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: { [key in string]: JsonValue } } | null, }; +providerOptions?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/UploadFileData.ts b/bindings/node/src/types/UploadFileData.ts index 6b50bff3d..de6a48ad3 100644 --- a/bindings/node/src/types/UploadFileData.ts +++ b/bindings/node/src/types/UploadFileData.ts @@ -8,4 +8,4 @@ import type { FileBytes } from "./FileBytes"; * `SharedV4FileData` (URLs and provider references are not valid uploads), so * this is a dedicated two-variant enum rather than the full [`crate::shared::FileData`]. */ -export type UploadFileData = { "Data": { data: FileBytes, } } | { "Text": { text: string, } }; +export type UploadFileData = { "type": "data", data: FileBytes, } | { "type": "text", text: string, }; diff --git a/bindings/node/src/types/UploadFileResult.ts b/bindings/node/src/types/UploadFileResult.ts index 981c04b59..b671ec9ef 100644 --- a/bindings/node/src/types/UploadFileResult.ts +++ b/bindings/node/src/types/UploadFileResult.ts @@ -12,20 +12,20 @@ export type UploadFileResult = { * A provider reference mapping provider names to provider-specific file * identifiers. */ -provider_reference: { [key in string]: string }, +providerReference: { [key in string]: string }, /** * The IANA media type of the uploaded file, if available from the * provider. */ -media_type: string | null, +mediaType?: string, /** * The filename of the uploaded file, if available from the provider. */ -filename: string | null, +filename?: string, /** * Additional provider-specific metadata, keyed by provider name. */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, /** * Warnings from the provider. */ diff --git a/bindings/node/src/types/UploadSkillCallOptions.ts b/bindings/node/src/types/UploadSkillCallOptions.ts index 7d6e3aa38..7ffbc3259 100644 --- a/bindings/node/src/types/UploadSkillCallOptions.ts +++ b/bindings/node/src/types/UploadSkillCallOptions.ts @@ -2,4 +2,4 @@ import type { SkillFile } from "./SkillFile"; import type { JsonValue } from "./serde_json/JsonValue"; -export type UploadSkillCallOptions = { files: Array, display_title: string | null, provider_options: { [key in string]: { [key in string]: JsonValue } } | null, }; +export type UploadSkillCallOptions = { files: Array, displayTitle?: string, providerOptions?: { [key in string]: { [key in string]: JsonValue } }, }; diff --git a/bindings/node/src/types/UploadSkillResult.ts b/bindings/node/src/types/UploadSkillResult.ts index 17a61f981..01340ea2c 100644 --- a/bindings/node/src/types/UploadSkillResult.ts +++ b/bindings/node/src/types/UploadSkillResult.ts @@ -2,4 +2,4 @@ import type { Warning } from "./Warning"; import type { JsonValue } from "./serde_json/JsonValue"; -export type UploadSkillResult = { provider_reference: { [key in string]: string }, display_title: string | null, name: string | null, description: string | null, latest_version: string | null, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, warnings: Array, }; +export type UploadSkillResult = { providerReference: { [key in string]: string }, displayTitle?: string, name?: string, description?: string, latestVersion?: string, providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, warnings: Array, }; diff --git a/bindings/node/src/types/Usage.ts b/bindings/node/src/types/Usage.ts index 385ac2af6..bcde45b69 100644 --- a/bindings/node/src/types/Usage.ts +++ b/bindings/node/src/types/Usage.ts @@ -5,8 +5,8 @@ import type { OutputTokenUsage } from "./OutputTokenUsage"; /** * Token usage statistics. */ -export type Usage = { input_tokens: InputTokenUsage, output_tokens: OutputTokenUsage, +export type Usage = { inputTokens: InputTokenUsage, outputTokens: OutputTokenUsage, /** * Raw usage information from the provider (opaque, provider-specific). */ -raw?: Record | null, }; +raw?: Record, }; diff --git a/bindings/node/src/types/UsageSnapshot.ts b/bindings/node/src/types/UsageSnapshot.ts index 2db90e806..368e98740 100644 --- a/bindings/node/src/types/UsageSnapshot.ts +++ b/bindings/node/src/types/UsageSnapshot.ts @@ -4,9 +4,9 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * Token usage snapshot (7 flat fields + raw passthrough). */ -export type UsageSnapshot = { input_total: bigint | null, input_no_cache: bigint | null, cache_read: bigint | null, cache_write: bigint | null, output_total: bigint | null, output_text: bigint | null, output_reasoning: bigint | null, +export type UsageSnapshot = { inputTotal?: bigint, inputNoCache?: bigint, cacheRead?: bigint, cacheWrite?: bigint, outputTotal?: bigint, outputText?: bigint, outputReasoning?: bigint, /** * Raw provider usage payload (opaque passthrough; numbers only — no * prompt text). */ -raw?: JsonValue | null, }; +raw?: JsonValue, }; diff --git a/bindings/node/src/types/Verdict.ts b/bindings/node/src/types/Verdict.ts index 14b282efa..f48c0dedf 100644 --- a/bindings/node/src/types/Verdict.ts +++ b/bindings/node/src/types/Verdict.ts @@ -13,8 +13,8 @@ violated: Array, /** * Expected upper bound U (tokens). */ -expected_max: bigint, claimed: bigint, +expectedMax: bigint, claimed: bigint, /** * Client-side LCP (bytes). */ -lcp_bytes: bigint, notes: Array, }; +lcpBytes: bigint, notes: Array, }; diff --git a/bindings/node/src/types/VideoCallOptions.ts b/bindings/node/src/types/VideoCallOptions.ts index 9f3791316..63235c733 100644 --- a/bindings/node/src/types/VideoCallOptions.ts +++ b/bindings/node/src/types/VideoCallOptions.ts @@ -16,7 +16,7 @@ export type VideoCallOptions = { /** * Text prompt for the video generation. `None` when not required. */ -prompt: string | null, +prompt?: string, /** * Number of videos to generate. Default `1`; most models only support * `n = 1` due to computational cost. @@ -25,57 +25,57 @@ n: number, /** * Aspect ratio, in `{width}:{height}` format (e.g. `"16:9"`). */ -aspect_ratio: AspectRatio | null, +aspectRatio?: AspectRatio, /** * Resolution, in `{width}x{height}` format (e.g. `"1280x720"`). */ -resolution: Size | null, +resolution?: Size, /** * Duration of the video in seconds. Typically 3–10 seconds. */ -duration: number | null, +duration?: number, /** * Frames per second. Common values: 24, 30, 60. */ -fps: number | null, +fps?: number, /** * Seed for deterministic generation. `None` uses a random seed. */ -seed: number | null, +seed?: number, /** * Input image for image-to-video generation (the starting frame). */ -image: VideoFile | null, +image?: VideoFile, /** * Role-tagged image inputs for first-last-frame generation. */ -frame_images: Array | null, +frameImages?: Array, /** * Reference inputs for reference-to-video generation (images or videos). */ -input_references: Array | null, +inputReferences?: Array, /** * Whether the model should generate audio alongside the video. */ -generate_audio: boolean | null, +generateAudio?: boolean, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: { [key in string]: JsonValue } }, +providerOptions: { [key in string]: { [key in string]: JsonValue } }, /** * Per-call retry override. `None` uses the model default. */ -max_retries: number | null, +maxRetries?: number, /** * Per-call poll pacing override for the start/status flow. Unset fields * fall back to the model's [`VideoModel::poll_config`]. */ -poll: VideoPollOptions | null, +poll?: VideoPollOptions, /** * Per-call operation timeout. */ -timeout: TimeoutConfiguration | null, +timeout?: TimeoutConfiguration, /** * Additional HTTP headers to send with the request. */ -headers: { [key in string]: string } | null, }; +headers?: { [key in string]: string }, }; diff --git a/bindings/node/src/types/VideoData.ts b/bindings/node/src/types/VideoData.ts index 443d5449b..5af500ef5 100644 --- a/bindings/node/src/types/VideoData.ts +++ b/bindings/node/src/types/VideoData.ts @@ -6,4 +6,4 @@ * Aligned with V4 `VideoModelV4VideoData`. Most providers return URLs due to * large file sizes. */ -export type VideoData = { "Url": { url: string, media_type: string, } } | { "Base64": { data: string, media_type: string, } } | { "Binary": { data: Array, media_type: string, } }; +export type VideoData = { "type": "url", url: string, mediaType: string, } | { "type": "base64", data: string, mediaType: string, } | { "type": "binary", data: Array, mediaType: string, }; diff --git a/bindings/node/src/types/VideoFile.ts b/bindings/node/src/types/VideoFile.ts index 9d32e3648..b74cd2ef2 100644 --- a/bindings/node/src/types/VideoFile.ts +++ b/bindings/node/src/types/VideoFile.ts @@ -6,15 +6,15 @@ import type { VideoFileData } from "./VideoFileData"; * * Aligned with V4 `VideoModelV4File`. */ -export type VideoFile = { "File": { +export type VideoFile = { "type": "file", /** * IANA media type, e.g. `"video/mp4"` or `"image/png"`. */ -media_type: string, +mediaType: string, /** * File data as a base64 string or binary bytes. */ -data: VideoFileData, } } | { "Url": { +data: VideoFileData, } | { "type": "url", /** * The URL of the video or image file. */ @@ -22,4 +22,4 @@ url: string, /** * The media type of the referenced file, when known. */ -media_type: string | null, } }; +mediaType?: string, }; diff --git a/bindings/node/src/types/VideoFileData.ts b/bindings/node/src/types/VideoFileData.ts index d7b1d6a6b..dec5fdba2 100644 --- a/bindings/node/src/types/VideoFileData.ts +++ b/bindings/node/src/types/VideoFileData.ts @@ -3,4 +3,4 @@ /** * File payload for a [`VideoFile::File`]: base64 string or raw bytes. */ -export type VideoFileData = { "Base64": string } | { "Binary": Array }; +export type VideoFileData = string | Array; diff --git a/bindings/node/src/types/VideoFrameImage.ts b/bindings/node/src/types/VideoFrameImage.ts index de49fcb3d..070d96b90 100644 --- a/bindings/node/src/types/VideoFrameImage.ts +++ b/bindings/node/src/types/VideoFrameImage.ts @@ -15,4 +15,4 @@ image: VideoFile, /** * Which frame this image represents. */ -frame_type: VideoFrameType, }; +frameType: VideoFrameType, }; diff --git a/bindings/node/src/types/VideoFrameType.ts b/bindings/node/src/types/VideoFrameType.ts index 4279f6dfe..fb10478ee 100644 --- a/bindings/node/src/types/VideoFrameType.ts +++ b/bindings/node/src/types/VideoFrameType.ts @@ -5,4 +5,4 @@ * * Aligned with V4 `VideoModelV4FrameType`. */ -export type VideoFrameType = "FirstFrame" | "LastFrame"; +export type VideoFrameType = "first_frame" | "last_frame"; diff --git a/bindings/node/src/types/VideoPollOptions.ts b/bindings/node/src/types/VideoPollOptions.ts index 5950d03ed..5fbf42814 100644 --- a/bindings/node/src/types/VideoPollOptions.ts +++ b/bindings/node/src/types/VideoPollOptions.ts @@ -7,8 +7,8 @@ export type VideoPollOptions = { /** * Delay between consecutive status checks, in milliseconds. */ -interval_ms: number | null, +intervalMs?: number, /** * Maximum total time to wait for completion, in milliseconds. */ -timeout_ms: number | null, }; +timeoutMs?: number, }; diff --git a/bindings/node/src/types/VideoResponse.ts b/bindings/node/src/types/VideoResponse.ts index 9e7fff9cb..e7721fdab 100644 --- a/bindings/node/src/types/VideoResponse.ts +++ b/bindings/node/src/types/VideoResponse.ts @@ -7,12 +7,12 @@ export type VideoResponse = { /** * Timestamp for the start of the generated response (ISO 8601 string). */ -timestamp: string | null, +timestamp?: string, /** * The ID of the model that was used to generate the response. */ -model_id: string | null, +modelId?: string, /** * Response headers. */ -headers: { [key in string]: string } | null, }; +headers?: { [key in string]: string }, }; diff --git a/bindings/node/src/types/VideoResult.ts b/bindings/node/src/types/VideoResult.ts index 8ab4fec8f..df032c3e0 100644 --- a/bindings/node/src/types/VideoResult.ts +++ b/bindings/node/src/types/VideoResult.ts @@ -21,7 +21,7 @@ warnings: Array, /** * Additional provider-specific metadata, keyed by provider name. */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, /** * Response information for telemetry and debugging. */ diff --git a/bindings/node/src/types/Warning.ts b/bindings/node/src/types/Warning.ts index 14edc447c..23e1093ae 100644 --- a/bindings/node/src/types/Warning.ts +++ b/bindings/node/src/types/Warning.ts @@ -3,4 +3,4 @@ /** * A warning issued by the provider (e.g. unsupported parameter). */ -export type Warning = { "Unsupported": { feature: string, details: string | null, } } | { "Compatibility": { feature: string, details: string | null, } } | { "Deprecated": { setting: string, message: string, } } | { "Other": { message: string, } }; +export type Warning = { "type": "unsupported", feature: string, details?: string, } | { "type": "compatibility", feature: string, details?: string, } | { "type": "deprecated", setting: string, message: string, } | { "type": "other", message: string, }; diff --git a/tools/aimux-cli/src/probe/offline.rs b/tools/aimux-cli/src/probe/offline.rs index 3ad13eea2..c848f24a8 100644 --- a/tools/aimux-cli/src/probe/offline.rs +++ b/tools/aimux-cli/src/probe/offline.rs @@ -88,31 +88,29 @@ mod tests { r#"{{ "provider": "openai", "model": "gpt-4o", - "session_id": "{session}", - "call_id": "t-{session}-{claimed}", - "sent_at_unix_ms": 1785900000000, - "monotonic_sent_ms": 0, + "sessionId": "{session}", + "callId": "t-{session}-{claimed}", + "sentAtUnixMs": 1785900000000, "fingerprint": {{ - "body_hash": "", - "len_bytes": 8192, - "block_size": 4096, - "block_hashes": [], - "token_estimate": 2048 + "bodyHash": "", + "lenBytes": 8192, + "blockSize": 4096, + "blockHashes": [], + "tokenEstimate": 2048 }}, "usage": {{ - "input_total": 2048, - "cache_read": {claimed} + "inputTotal": 2048, + "cacheRead": {claimed} }}, "verdict": {{ "kind": "Trusted", "confidence": "High", "violated": [], - "expected_max": 2048, + "expectedMax": 2048, "claimed": {claimed}, - "lcp_bytes": 8192, + "lcpBytes": 8192, "notes": [] - }}, - "scope_key": 1 + }} }}"# )) .unwrap() @@ -140,7 +138,7 @@ mod tests { // 捕获 stdout 断言 JSON 输出。 let out = run_captured(&args); assert!(out.contains("\"requests\":2"), "aggregate count: {out}"); - assert!(out.contains("\"claimed_cache_read_total\":1536"), "{out}"); + assert!(out.contains("\"claimedCacheReadTotal\":1536"), "{out}"); assert!(out.contains("\"provider\":\"openai\""), "{out}"); // session 过滤。 diff --git a/tools/aimux-cli/src/probe/session.rs b/tools/aimux-cli/src/probe/session.rs index e74b6199d..a27170be1 100644 --- a/tools/aimux-cli/src/probe/session.rs +++ b/tools/aimux-cli/src/probe/session.rs @@ -40,17 +40,17 @@ mod tests { serde_json::json!({ "provider": "openai", "model": "gpt-4o", - "session_id": session, - "call_id": format!("t-{idx}"), - "sent_at_unix_ms": 1785900000000 + idx as i64 * 1000, + "sessionId": session, + "callId": format!("t-{idx}"), + "sentAtUnixMs": 1785900000000 + idx as i64 * 1000, "fingerprint": { - "body_hash": format!("{idx:032x}"), - "len_bytes": body_len, - "block_size": 4096, - "block_hashes": vec![format!("{:032x}", idx)], - "token_estimate": body_len / 4, + "bodyHash": format!("{idx:032x}"), + "lenBytes": body_len, + "blockSize": 4096, + "blockHashes": vec![format!("{:032x}", idx)], + "tokenEstimate": body_len / 4, }, - "usage": {"input_total": body_len / 4}, + "usage": {"inputTotal": body_len / 4}, }) } @@ -80,7 +80,7 @@ mod tests { let chain = store.session_chain("sess-1").unwrap(); let json = serde_json::to_string(&chain).unwrap(); assert_eq!(chain.record_ids.len(), 2); - assert!(json.contains("\"session_id\":\"sess-1\""), "{json}"); + assert!(json.contains("\"sessionId\":\"sess-1\""), "{json}"); // 未知 session → 无输出错误路径。 let args2 = SessionArgs { diff --git a/tools/aimux-web/examples/gen_demo_fixtures.rs b/tools/aimux-web/examples/gen_demo_fixtures.rs index 612cff054..6861d8f25 100644 --- a/tools/aimux-web/examples/gen_demo_fixtures.rs +++ b/tools/aimux-web/examples/gen_demo_fixtures.rs @@ -96,7 +96,7 @@ fn main() { .nth(1) .unwrap_or_else(|| "demo.jsonl".to_string()); - // OpenAI chat.completions SSE bodies so mock streaming emits TextDelta. + // OpenAI chat.completions SSE bodies so mock streaming emits text-delta parts. let sse_pg = r#"data: {"id":"chatcmpl-demo-pg","model":"gpt-4o-mini"} data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"1 + 1 = 2"}}]} @@ -129,8 +129,8 @@ data: [DONE]"#; sse_pg, 320, serde_json::json!({ - "input_tokens": { "total": 12, "cache_read": 0 }, - "output_tokens": { "total": 5 } + "inputTokens": { "total": 12, "cacheRead": 0 }, + "outputTokens": { "total": 5 } }), ), // Agent mock target: [system (agent), user] — matches the Agent page's default def. @@ -153,8 +153,8 @@ data: [DONE]"#; sse_agent, 412, serde_json::json!({ - "input_tokens": { "total": 64, "cache_read": 0 }, - "output_tokens": { "total": 9 } + "inputTokens": { "total": 64, "cacheRead": 0 }, + "outputTokens": { "total": 9 } }), ), // Enrichment: a multi-step session (steps 1 & 2) on deepseek. @@ -177,8 +177,8 @@ data: [DONE]"#; "{\"choices\":[{\"message\":{\"content\":\"Prefix caching reuses the KV cache...\"}}]}", 810, serde_json::json!({ - "input_tokens": { "total": 2300, "cache_read": 0 }, - "output_tokens": { "total": 210 } + "inputTokens": { "total": 2300, "cacheRead": 0 }, + "outputTokens": { "total": 210 } }), ), recording( @@ -208,8 +208,8 @@ data: [DONE]"#; "{\"choices\":[{\"message\":{\"content\":\"cache_read / input_total\"}}]}", 796, serde_json::json!({ - "input_tokens": { "total": 2650, "cache_read": 2300 }, - "output_tokens": { "total": 18 } + "inputTokens": { "total": 2650, "cacheRead": 2300 }, + "outputTokens": { "total": 18 } }), ), // Enrichment: an errored call (shows the error badge). @@ -225,7 +225,7 @@ data: [DONE]"#; None, "{}", 1200, - serde_json::json!({ "input_tokens": null, "output_tokens": null }), + serde_json::json!({ "inputTokens": null, "outputTokens": null }), ), // Enrichment: an older successful call on a different model. recording( @@ -241,8 +241,8 @@ data: [DONE]"#; "{\"choices\":[{\"message\":{\"content\":\"Ownership, borrowing — memory safe by design...\"}}]}", 540, serde_json::json!({ - "input_tokens": { "total": 42, "cache_read": 0 }, - "output_tokens": { "total": 24 } + "inputTokens": { "total": 42, "cacheRead": 0 }, + "outputTokens": { "total": 24 } }), ), ]; diff --git a/tools/aimux-web/examples/gen_fixture.rs b/tools/aimux-web/examples/gen_fixture.rs index a61e3ba75..a1d4bd963 100644 --- a/tools/aimux-web/examples/gen_fixture.rs +++ b/tools/aimux-web/examples/gen_fixture.rs @@ -69,8 +69,8 @@ fn main() { error: None, error_value: None, usage: Some(serde_json::json!({ - "input_tokens": { "total": 12, "cache_read": 0 }, - "output_tokens": { "total": 5 } + "inputTokens": { "total": 12, "cacheRead": 0 }, + "outputTokens": { "total": 5 } })), }, complete: true, diff --git a/tools/aimux-web/src/api/calls.rs b/tools/aimux-web/src/api/calls.rs index 7cd390a11..5106fc34c 100644 --- a/tools/aimux-web/src/api/calls.rs +++ b/tools/aimux-web/src/api/calls.rs @@ -21,7 +21,7 @@ use crate::wire::{self, WireCallRequest, WireCallResponse}; /// `POST /api/calls` — validates the wire request, builds (or reuses) the /// model, and returns either a non-streaming JSON result or an SSE stream of -/// `StreamPart` events + a final `meta` event (trace anchor). +/// `TextStreamPart` events + a final `meta` event (trace anchor). pub async fn call(State(state): State, Json(req): Json) -> Response { match run(state, req).await { Ok(resp) => resp, @@ -70,7 +70,7 @@ async fn run(state: AppState, req: WireCallRequest) -> Result, diff --git a/tools/aimux-web/src/api/replay.rs b/tools/aimux-web/src/api/replay.rs index 19233ba7e..e96291493 100644 --- a/tools/aimux-web/src/api/replay.rs +++ b/tools/aimux-web/src/api/replay.rs @@ -20,6 +20,7 @@ use crate::state::AppState; use crate::wire::{self, WireMessage}; #[derive(Deserialize)] +#[serde(rename_all = "camelCase")] pub struct ReplayRequest { pub call_id: String, /// Fallback for recordings whose key source is `explicit`/`unknown`: @@ -32,6 +33,7 @@ pub struct ReplayRequest { } #[derive(Deserialize)] +#[serde(rename_all = "camelCase")] pub struct ReplayOverridesWire { #[serde(default, skip_serializing_if = "Option::is_none")] pub messages: Option>, @@ -106,11 +108,11 @@ async fn run_inner(state: AppState, req: ReplayRequest) -> Result, Path(id): Path) -> Re .filter(|r| r.session_id.as_deref() == Some(id.as_str())) .collect(); Json(json!({ - "session_id": id, + "sessionId": id, "calls": calls, "recordings": recordings, })) diff --git a/tools/aimux-web/src/wire.rs b/tools/aimux-web/src/wire.rs index 1cdfd834f..3d40dc275 100644 --- a/tools/aimux-web/src/wire.rs +++ b/tools/aimux-web/src/wire.rs @@ -13,6 +13,7 @@ use aimux_core::error::AiMuxError; use aimux_core::generate::GenerateTextOptions; use aimux_core::language_model_message::ToolResultOutput; use aimux_core::message::{MessageContent, ModelMessage, ModelPrompt, Role}; +use aimux_core::options::ResponseFormat; use aimux_core::tool::{FunctionTool, Tool}; // ───────────────────────────────────────────────────────────────────────────── @@ -31,6 +32,7 @@ use aimux_core::tool::{FunctionTool, Tool}; /// One model call from the console (RFC-0029 §5.3). #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[cfg_attr(feature = "ts-export", ts(export))] +#[serde(rename_all = "camelCase")] pub struct WireCallRequest { /// Registry name or native protocol name ("openai", "deepseek", …). pub provider: String, @@ -39,12 +41,14 @@ pub struct WireCallRequest { /// API key: `None` (Settings-saved key or the provider's registered env /// var), `Some("env:VAR")`, or a plaintext literal — the latter only /// while the console is loopback-bound (RFC-0029 §5.5). + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub api_key: Option, /// Base URL override (proxies / local endpoints). + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub base_url: Option, - /// Stream the response as SSE (`StreamPart` events). Default true. + /// Stream the response as SSE (`TextStreamPart` events). Default true. #[serde(default = "default_true")] pub stream: bool, /// Route through the loaded mock model (offline mode, RFC-0023 P3). @@ -53,9 +57,11 @@ pub struct WireCallRequest { #[serde(default)] pub options: WireOptions, /// Session grouping id (RFC-0024). The console reuses one id per agent run. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub session_id: Option, /// Informational step marker (the backend assigns the authoritative step). + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub step: Option, pub messages: Vec, @@ -67,24 +73,34 @@ fn default_true() -> bool { #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[cfg_attr(feature = "ts-export", ts(export))] +#[serde(rename_all = "camelCase")] pub struct WireOptions { + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub temperature: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub max_output_tokens: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub top_p: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub stop_sequences: Option>, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub tools: Option>, - /// `"text"` or `{"json": …}` — passed through as `ResponseFormat`. + /// A `ResponseFormat` object: `{"type":"text"}` or `{"type":"json",…}`. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub response_format: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option>, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub max_retries: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub include_raw_chunks: Option, } @@ -92,8 +108,10 @@ pub struct WireOptions { /// A function tool definition (JSON Schema parameters). #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[cfg_attr(feature = "ts-export", ts(export))] +#[serde(rename_all = "camelCase")] pub struct WireTool { pub name: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, /// JSON Schema describing the parameters. @@ -103,6 +121,7 @@ pub struct WireTool { /// A chat message in the wire format. #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[cfg_attr(feature = "ts-export", ts(export))] +#[serde(rename_all = "camelCase")] pub struct WireMessage { /// "system" | "user" | "assistant" | "tool". pub role: String, @@ -112,7 +131,11 @@ pub struct WireMessage { /// A content part in the wire format. #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[cfg_attr(feature = "ts-export", ts(export))] -#[serde(tag = "type", rename_all = "snake_case")] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] pub enum WireContentPart { Text { text: String, @@ -121,6 +144,7 @@ pub enum WireContentPart { tool_call_id: String, tool_name: String, input: Value, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_executed: Option, }, @@ -136,12 +160,15 @@ pub enum WireContentPart { /// Response for non-streaming calls. #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[cfg_attr(feature = "ts-export", ts(export))] +#[serde(rename_all = "camelCase")] pub struct WireCallResponse { pub text: String, pub finish_reason: Value, pub usage: Value, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub meta: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, } @@ -149,10 +176,13 @@ pub struct WireCallResponse { /// SSE `meta` event payload — the frontend trace anchor (RFC-0029 §5.2). #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[cfg_attr(feature = "ts-export", ts(export))] +#[serde(rename_all = "camelCase")] pub struct WireMeta { pub call_id: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub session_id: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub step: Option, pub outcome: String, @@ -241,27 +271,18 @@ pub fn to_generate_options( o: &WireOptions, session_id: Option<&str>, ) -> Result { + // `{"type":"text"}` is the default format, so it maps to "unset". let response_format = match &o.response_format { None => None, - Some(Value::String(s)) if s == "text" => None, - Some(Value::Object(obj)) => { - let schema = obj.get("schema").cloned(); - let name = obj.get("name").and_then(Value::as_str).map(str::to_string); - let description = obj - .get("description") - .and_then(Value::as_str) - .map(str::to_string); - Some(aimux_core::options::ResponseFormat::Json { - schema, - name, - description, - }) - } - Some(other) => { - return Err(AiMuxError::InvalidArgument(format!( - "response_format must be \"text\" or an object, got {other}" - ))); - } + Some(v) => match serde_json::from_value::(v.clone()) { + Ok(ResponseFormat::Text) => None, + Ok(json) => Some(json), + Err(e) => { + return Err(AiMuxError::InvalidArgument(format!( + "invalid responseFormat: {e}" + ))); + } + }, }; Ok(GenerateTextOptions { @@ -282,7 +303,7 @@ pub fn to_generate_options( }) } -/// Validate and resolve the wire `api_key` field for one call (RFC-0029 §5.5). +/// Validate and resolve the wire `apiKey` field for one call (RFC-0029 §5.5). /// /// Priority: /// 1. **explicit request spec** — `Some("env:VAR")` reads the environment diff --git a/tools/aimux-web/web/src/agent/engine.ts b/tools/aimux-web/web/src/agent/engine.ts index 4c2828e37..f5ccebdc1 100644 --- a/tools/aimux-web/web/src/agent/engine.ts +++ b/tools/aimux-web/web/src/agent/engine.ts @@ -8,7 +8,7 @@ import { api, callStream, parseStreamPart } from '../api/client' import type { WireCallRequest } from '@/types/generated/WireCallRequest' -import type { StreamPart } from '@aimux/types/StreamPart' +import type { TextStreamPart } from '@aimux/types/TextStreamPart' /** A chat message as sent to the backend (loose typing: the backend wire * schema's `JsonValue` is a recursive union that trips TS2589 inside @@ -32,7 +32,7 @@ export interface AgentStepView { usage?: unknown latencyMs?: number error?: string - meta?: { call_id?: string; session_id?: string | null; step?: number | null; outcome?: string } + meta?: { callId?: string; sessionId?: string; step?: number; outcome?: string } } export interface AgentDef { @@ -99,16 +99,16 @@ export async function runAgent( const body: WireCallRequest = { provider: def.provider, model: def.model, - api_key: def.api_key || null, - base_url: def.base_url || null, + apiKey: def.api_key || undefined, + baseUrl: def.base_url || undefined, stream: true, mock: def.mock, options: { temperature: def.temperature, - max_output_tokens: 2048, + maxOutputTokens: 2048, tools: def.tools.map((t) => toolSchemas.get(t)).filter(Boolean) as never[], }, - session_id: def.session_id, + sessionId: def.session_id, step, messages: run.messages, } @@ -116,7 +116,7 @@ export async function runAgent( try { for await (const ev of callStream(body, controller.signal)) { if (ev.event === 'stream_part') { - const part: StreamPart = parseStreamPart(ev.data) + const part: TextStreamPart = parseStreamPart(ev.data) applyPart(view, part) } else if (ev.event === 'meta') { view.meta = JSON.parse(ev.data) @@ -147,9 +147,9 @@ export async function runAgent( if (view.text.trim()) assistantParts.push({ type: 'text', text: view.text }) for (const tc of view.toolCalls) { assistantParts.push({ - type: 'tool_call', - tool_call_id: tc.id, - tool_name: tc.name, + type: 'tool-call', + toolCallId: tc.id, + toolName: tc.name, input: tc.input, }) } @@ -163,18 +163,18 @@ export async function runAgent( tc.result = out.result tc.is_error = false toolResults.push({ - type: 'tool_result', - tool_call_id: tc.id, - tool_name: tc.name, + type: 'tool-result', + toolCallId: tc.id, + toolName: tc.name, output: { type: typeof out.result === 'string' ? 'text' : 'json', value: out.result }, }) } catch (e) { tc.result = String(e) tc.is_error = true toolResults.push({ - type: 'tool_result', - tool_call_id: tc.id, - tool_name: tc.name, + type: 'tool-result', + toolCallId: tc.id, + toolName: tc.name, output: { type: 'error-text', value: String(e) }, }) } finally { @@ -191,22 +191,18 @@ export async function runAgent( } } -function applyPart(view: AgentStepView, part: StreamPart): void { - if ('TextDelta' in part) { - view.text += part.TextDelta.delta - } else if ('ToolCall' in part) { - view.toolCalls.push({ - id: part.ToolCall.tool_call_id, - name: part.ToolCall.tool_name, - input: part.ToolCall.input, - }) - } else if ('ToolInputStart' in part) { +function applyPart(view: AgentStepView, part: TextStreamPart): void { + if (part.type === 'text-delta') { + view.text += part.delta + } else if (part.type === 'tool-call') { + view.toolCalls.push({ id: part.toolCallId, name: part.toolName, input: part.input }) + } else if (part.type === 'tool-input-start') { // Streamed tool input — collect into a pending call (rare path). - view.toolCalls.push({ id: part.ToolInputStart.id, name: part.ToolInputStart.tool_name, input: '' }) - } else if ('ToolInputDelta' in part) { - const pending = view.toolCalls.find((t) => t.id === part.ToolInputDelta.id) + view.toolCalls.push({ id: part.id, name: part.toolName, input: '' }) + } else if (part.type === 'tool-input-delta') { + const pending = view.toolCalls.find((t) => t.id === part.id) if (pending && typeof pending.input === 'string') { - pending.input += part.ToolInputDelta.delta + pending.input += part.delta // Best-effort parse of accumulated JSON arguments. try { pending.input = JSON.parse(pending.input) @@ -214,7 +210,7 @@ function applyPart(view: AgentStepView, part: StreamPart): void { /* keep accumulating */ } } - } else if ('Finish' in part) { - view.usage = part.Finish.usage + } else if (part.type === 'finish') { + view.usage = part.usage } } diff --git a/tools/aimux-web/web/src/api/client.ts b/tools/aimux-web/web/src/api/client.ts index 97bc7a2ca..202f3979a 100644 --- a/tools/aimux-web/web/src/api/client.ts +++ b/tools/aimux-web/web/src/api/client.ts @@ -5,7 +5,7 @@ import type { WireCallResponse } from '@/types/generated/WireCallResponse' import type { Recording } from '@aimux/types/Recording' import type { TraceRecord } from '@aimux/types/TraceRecord' import type { SessionView } from '@aimux/types/SessionView' -import type { StreamPart } from '@aimux/types/StreamPart' +import type { TextStreamPart } from '@aimux/types/TextStreamPart' const API = '/api' @@ -179,15 +179,15 @@ export const api = { return fetch(`${API}/sessions`).then((r) => j(r)) }, - sessionDetail(id: string): Promise<{ session_id: string; calls: unknown[]; recordings: Recording[] }> { + sessionDetail(id: string): Promise<{ sessionId: string; calls: unknown[]; recordings: Recording[] }> { return fetch(`${API}/sessions/${encodeURIComponent(id)}`).then((r) => j(r)) }, replay(body: { - call_id: string - api_key?: string | null - overrides?: { messages?: unknown[]; temperature?: number | null; max_output_tokens?: number | null } | null - }): Promise { + callId: string + apiKey?: string | null + overrides?: { messages?: unknown[]; temperature?: number | null; maxOutputTokens?: number | null } | null + }): Promise { return fetch(`${API}/replay`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -220,7 +220,7 @@ export const api = { }, } -/** Parse a `stream_part` JSON payload into a StreamPart (best-effort). */ -export function parseStreamPart(json: string): StreamPart { - return JSON.parse(json) as StreamPart +/** Parse a `stream_part` JSON payload into a TextStreamPart (best-effort). */ +export function parseStreamPart(json: string): TextStreamPart { + return JSON.parse(json) as TextStreamPart } diff --git a/tools/aimux-web/web/src/components/StepCard.vue b/tools/aimux-web/web/src/components/StepCard.vue index ddfe7bf76..ab618403b 100644 --- a/tools/aimux-web/web/src/components/StepCard.vue +++ b/tools/aimux-web/web/src/components/StepCard.vue @@ -16,7 +16,7 @@ const emit = defineEmits<{ trace: [] }>() {{ step.latencyMs }}ms