diff --git a/CHANGELOG.md b/CHANGELOG.md index 326678d19..b8aad6ef9 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 25681a97c..398108ade 100644 --- a/aimux-core/src/content.rs +++ b/aimux-core/src/content.rs @@ -4,19 +4,25 @@ 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. /// /// 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, }, @@ -24,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, }, @@ -32,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, }, @@ -40,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, }, @@ -54,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, }, @@ -62,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, }, @@ -77,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, }, @@ -89,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, }, @@ -101,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, }, @@ -116,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, }, @@ -133,41 +156,24 @@ 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, }, - /// 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`). + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -197,19 +203,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/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..9fd4097b0 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, }, @@ -280,7 +290,11 @@ pub enum AiMuxError { /// Registry-level "provider name does not resolve" (the AI SDK's /// `NoSuchProviderError`). - #[error("No such provider: {provider_id} (available providers: {})", .available_providers.join(","))] + /// + /// The message adds up to three close names from `available_providers` + /// (an aimux addition to the upstream text; the fields are unchanged). + #[error("No such provider: {provider_id}{} (available providers: {})", + did_you_mean(.provider_id, .available_providers), .available_providers.join(","))] NoSuchProvider { provider_id: String, model_id: String, @@ -298,6 +312,197 @@ 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 } +} + +/// `". Did you mean 'a', 'b'?"` for the names in `candidates` closest to +/// `name`, or `""` when none is close. Names compare case-insensitively with +/// `-` read as `_` (the built-in names use `_`); a candidate is close when its +/// edit distance (adjacent swaps count once) is at most 2 and at most a third +/// of the longer name. At most three names, nearest first. +fn did_you_mean(name: &str, candidates: &[String]) -> String { + fn normalize(name: &str) -> Vec { + name.chars() + .map(|c| { + if c == '-' { + '_' + } else { + c.to_ascii_lowercase() + } + }) + .collect() + } + let wanted = normalize(name); + let mut close: Vec<(usize, &str)> = candidates + .iter() + .filter_map(|candidate| { + let distance = edit_distance(&wanted, &normalize(candidate)); + let longer = wanted.len().max(candidate.chars().count()); + (distance <= 2 && distance * 3 <= longer).then_some((distance, candidate.as_str())) + }) + .collect(); + close.sort_unstable(); + close.dedup_by(|a, b| a.1 == b.1); + if close.is_empty() { + return String::new(); + } + let names: Vec = close + .iter() + .take(3) + .map(|(_, name)| format!("'{name}'")) + .collect(); + format!(". Did you mean {}?", names.join(", ")) +} + +/// Optimal-string-alignment distance: insertions, deletions, substitutions +/// and swaps of two adjacent characters each cost one. +fn edit_distance(a: &[char], b: &[char]) -> usize { + let mut rows = vec![vec![0usize; b.len() + 1]; a.len() + 1]; + for (i, row) in rows.iter_mut().enumerate() { + row[0] = i; + } + for (j, cell) in rows[0].iter_mut().enumerate() { + *cell = j; + } + for i in 1..=a.len() { + for j in 1..=b.len() { + let cost = usize::from(a[i - 1] != b[j - 1]); + let mut best = (rows[i - 1][j] + 1) + .min(rows[i][j - 1] + 1) + .min(rows[i - 1][j - 1] + cost); + if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] { + best = best.min(rows[i - 2][j - 2] + 1); + } + rows[i][j] = best; + } + } + rows[a.len()][b.len()] +} + fn no_such_tool_availability(available_tools: &Option>) -> String { match available_tools { Some(tools) => format!("Available tools: {}.", tools.join(", ")), @@ -435,6 +640,62 @@ impl AiMuxError { mod tests { use super::*; + fn no_such_provider(name: &str, available: &[&str]) -> String { + AiMuxError::NoSuchProvider { + provider_id: name.into(), + model_id: name.into(), + model_type: String::new(), + available_providers: available.iter().map(|n| (*n).to_string()).collect(), + } + .to_string() + } + + /// A misspelled provider name gets the close built-in names first; the + /// full list still follows. + #[test] + fn no_such_provider_suggests_close_names() { + let available = ["anthropic", "google_vertex", "groq", "openai", "openrouter"]; + assert_eq!( + no_such_provider("opeanai", &available), + "No such provider: opeanai. Did you mean 'openai'? \ + (available providers: anthropic,google_vertex,groq,openai,openrouter)" + ); + // An adjacent swap counts once. + assert!(no_such_provider("gorq", &available).contains("Did you mean 'groq'?")); + // Case and `-` versus `_` do not matter. + assert!( + no_such_provider("Google-Vertex", &available).contains("Did you mean 'google_vertex'?") + ); + assert!(no_such_provider("antropic", &available).contains("Did you mean 'anthropic'?")); + } + + /// No suggestion when nothing is close or there is nothing to suggest. + #[test] + fn no_such_provider_without_a_close_name_keeps_the_plain_message() { + assert_eq!( + no_such_provider("acme", &["anthropic", "openai"]), + "No such provider: acme (available providers: anthropic,openai)" + ); + assert_eq!( + no_such_provider("p", &[]), + "No such provider: p (available providers: )" + ); + } + + /// At most three names, nearest first, then by name. + #[test] + fn no_such_provider_suggests_at_most_three_names() { + let candidates = ["abcdefxy", "abcdefgz", "abcdefgy", "abcdefgx"]; + assert!( + no_such_provider("abcdefgh", &candidates) + .contains("Did you mean 'abcdefgx', 'abcdefgy', 'abcdefgz'?") + ); + assert!( + no_such_provider("abcdefgh", &["abcdefxy", "abcdefgz"]) + .contains("Did you mean 'abcdefgz', 'abcdefxy'?") + ); + } + fn api_error(message: &str) -> ApiCallError { ApiCallError::new(message, "https://example.test", serde_json::json!({})) } diff --git a/aimux-core/src/evaluation_model.rs b/aimux-core/src/evaluation_model.rs index 809305fdb..f07e1fdd2 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(as = "std::collections::HashMap")] 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 2f8f920ee..fbaef20ef 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; @@ -25,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, }, @@ -139,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, }, @@ -152,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, } @@ -229,10 +274,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 +380,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 +436,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 +452,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 +464,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/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 a784bbc6e..79be0bacd 100644 --- a/aimux-core/src/parse_tool_call.rs +++ b/aimux-core/src/parse_tool_call.rs @@ -163,7 +163,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, @@ -317,10 +319,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 +442,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 +475,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 +509,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` @@ -521,7 +528,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( @@ -532,43 +539,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()) }) { @@ -580,20 +587,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; } @@ -601,17 +608,17 @@ 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()); } // `content` carries the same call, and an approval request embeds it, so - // both must agree with `tool_calls` too. `raw` stays as is: it is the + // both must agree with `toolCalls` too. `raw` stays as is: it is the // provider's output, which a repair does not change. for entry in target .get_mut("content") @@ -636,7 +643,7 @@ pub fn apply_tool_call_repair_to_result( } if repaired.tool_call_id != tool_call_id { for part in target - .get_mut("response_messages") + .get_mut("responseMessages") .and_then(Value::as_array_mut) .into_iter() .flatten() diff --git a/aimux-core/src/recording.rs b/aimux-core/src/recording.rs index c7282c597..785cf8df1 100644 --- a/aimux-core/src/recording.rs +++ b/aimux-core/src/recording.rs @@ -65,6 +65,7 @@ impl RecordingOperation { /// 一次完整调用的录制记录(三层 + call_id 关联)。 #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct Recording { /// 格式版本。读取时只接受当前 [`RECORDING_SCHEMA`];旧 schema 的录制带有 @@ -99,9 +100,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, } @@ -150,6 +153,7 @@ impl Recording { /// ① 输入侧:完整调用参数,足以重建 generate_text 调用。 #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct InputRecord { /// Decision contract used for validation and deterministic offline replay. @@ -197,6 +201,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"。 @@ -230,10 +235,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 @@ -245,8 +252,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")] @@ -258,6 +269,7 @@ fn default_finalized() -> bool { } #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct HttpRecord { pub method: String, @@ -265,30 +277,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, } @@ -311,19 +332,27 @@ pub enum OutcomeStatus { /// 最终结果摘要。 #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct OutcomeRecord { /// Normalized decision result, independent of a provider's wire format. #[serde(default, skip_serializing_if = "Option::is_none")] pub decision_result: Option, 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, } @@ -1957,8 +1986,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] @@ -1979,7 +2008,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"); } } @@ -1993,7 +2022,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", @@ -2003,9 +2032,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, }); @@ -2014,17 +2043,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); } @@ -2228,7 +2254,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")); } @@ -2241,7 +2267,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)。 @@ -2249,7 +2275,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(); @@ -2740,7 +2766,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 934aadc83..1c11bb1ed 100644 --- a/aimux-core/src/replay.rs +++ b/aimux-core/src/replay.rs @@ -104,13 +104,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(), }) } @@ -120,13 +120,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(), }) } @@ -180,7 +180,7 @@ pub(crate) fn redaction_aware_eq(rec: &serde_json::Value, req: &serde_json::Valu /// 被脱敏为 `"[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/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/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..0b8218e24 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,7 +143,8 @@ pub enum GeneratedFileData { /// `resolution` options. Aligned with the TS template-literal type /// `` `${number}x${number}` ``. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] -#[ts(export)] +#[serde(into = "String", try_from = "String")] +#[ts(export, type = "`${number}x${number}`")] pub struct Size { width: u32, height: u32, @@ -181,6 +195,19 @@ impl std::fmt::Display for Size { } } +impl From for String { + fn from(value: Size) -> Self { + value.to_string() + } +} + +impl TryFrom for Size { + type Error = String; + fn try_from(value: String) -> Result { + Self::parse(&value) + } +} + impl std::str::FromStr for Size { type Err = String; fn from_str(s: &str) -> Result { @@ -194,7 +221,8 @@ 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)] -#[ts(export)] +#[serde(into = "String", try_from = "String")] +#[ts(export, type = "`${number}:${number}`")] pub struct AspectRatio { width: u32, height: u32, @@ -246,6 +274,19 @@ impl std::fmt::Display for AspectRatio { } } +impl From for String { + fn from(value: AspectRatio) -> Self { + value.to_string() + } +} + +impl TryFrom for AspectRatio { + type Error = String; + fn try_from(value: String) -> Result { + Self::parse(&value) + } +} + impl std::str::FromStr for AspectRatio { type Err = String; fn from_str(s: &str) -> Result { @@ -269,18 +310,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 +339,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-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-ffi/aimux-ffi.h b/aimux-ffi/aimux-ffi.h index d694ef1ae..fe17d31bc 100644 --- a/aimux-ffi/aimux-ffi.h +++ b/aimux-ffi/aimux-ffi.h @@ -48,7 +48,7 @@ * ## Concurrency * * All functions are synchronous (block until the operation completes). - * `opts_json.timeout` (`{"total_ms","step_ms","first_chunk_ms","chunk_ms"}`, + * `opts_json.timeout` (`{"totalMs","stepMs","firstChunkMs","chunkMs"}`, * milliseconds; the latter two streaming-only) bounds a call from the callee * side; `aimux_abort_signal_*` plus the `*_with_abort` entry points cancel a * running call from another thread. @@ -67,7 +67,7 @@ * - Malformed JSON text is a C ABI failure; well-formed JSON of the wrong * shape is AiMuxError AIMUX_E_INVALID_ARGUMENT. * - Results: serialized JSON of GenerateTextResult on success - * - Stream parts: serialized JSON of StreamPart + * - Stream parts: serialized JSON of TextStreamPart */ #ifndef AIMUX_FFI_H @@ -206,7 +206,7 @@ aimux_error_t *aimux_provider_handle_new(const char *name, const char *api_key, /** * [AiMuxError] List models on a provider handle (RFC-0027 runtime discovery). - * Writes a JSON array of sparse RuntimeModel (id / owned_by / created) — + * Writes a JSON array of sparse RuntimeModel (id / ownedBy / created) — * no community enrichment. To supplement with model specs, call * aimux_get_model_specs separately and merge in the host. */ @@ -262,7 +262,7 @@ aimux_error_t *aimux_consume_stream_text(uint64_t handle, * @param handle Model handle from aimux_*_new. * @param prompt_json JSON prompt (see wire format above). * @param opts_json JSON options (NULL or empty for defaults). - * @param on_part Called for each StreamPart (JSON string, valid during + * @param on_part Called for each TextStreamPart (JSON string, valid during * the call only). Required: NULL is a C ABI failure * ("on_part: must not be NULL"). * @param on_done Called once when the stream ends normally. Required. @@ -310,7 +310,7 @@ void aimux_abort_signal_drop(uint64_t abort_handle); * @param abort_handle Handle from aimux_abort_signal_new. * @param prompt_json JSON prompt. * @param opts_json JSON options. NULL or empty uses defaults. - * @param on_part Called for each StreamPart. + * @param on_part Called for each TextStreamPart. * @param on_done Called once after normal completion. * @param stream_ctx Opaque pointer passed through to callbacks. */ @@ -393,7 +393,7 @@ aimux_error_t *aimux_generate_text_result_as_openai(uint64_t handle, const char * defaults. * * reply_json is one of: - * {"type":"repaired","tool_call":{"tool_call_id":…,"tool_name":…,"input":""}} + * {"type":"repaired","toolCall":{"toolCallId":…,"toolName":…,"input":""}} * {"type":"unchanged"} * {"type":"failed","message":"…"} * @@ -521,9 +521,9 @@ typedef enum aimux_transcription_next_part_state { parts with aimux_transcription_next_part. model_handle must support streaming (models without do_stream fail on the first next_part). abort_handle: 0 = no cancellation, or an aimux_abort_signal_new handle. - opts_json (all optional): { "input_audio_format": {"format_type","rate"}, - "provider_options", "headers", "include_raw_chunks", "timeout": - {"total_ms","first_chunk_ms","chunk_ms"} }; NULL/empty/"null" = defaults + opts_json (all optional): { "inputAudioFormat": {"formatType","rate"}, + "providerOptions", "headers", "includeRawChunks", "timeout": + {"totalMs","firstChunkMs","chunkMs"} }; NULL/empty/"null" = defaults (wrong shape → AiMuxError AIMUX_E_INVALID_ARGUMENT). Writes the session handle. */ aimux_error_t *aimux_transcription_session_new(uint64_t model_handle, uint64_t abort_handle, const char *opts_json, uint64_t *out_handle); @@ -749,7 +749,7 @@ aimux_error_t *aimux_mock_replay_new(const char *recordings_jsonl, uint64_t *out dropped; len == 0 (no children) is a C ABI failure too; NULL with len > 0 is a C ABI failure. config_json selects the router + fallback policy: { "router": "rule"|"weighted", "weights": [..], "fallback": "on_error"|"none", - "provider_name": "router", "model_id": "router" } — all optional; defaults + "providerName": "router", "modelId": "router" } — all optional; defaults are rule / on_error / "router" / "router" (wrong shape → AiMuxError AIMUX_E_INVALID_ARGUMENT). */ aimux_error_t *aimux_router_new(const uint64_t *handles, size_t len, @@ -764,8 +764,8 @@ aimux_error_t *aimux_router_new(const uint64_t *handles, size_t len, with ref_len > 0 is a C ABI failure). aggregator: a single live handle. Any dead reference or aggregator handle is a C ABI failure — nothing is silently dropped. config_json is a serialized MoaConfig (all fields - optional): { "provider_name", "model_id", "aggregator_instructions", - "strip_reference_tools", "fail_mode": "best_effort"|"fail_fast" } (wrong shape → + optional): { "providerName", "modelId", "aggregatorInstructions", + "stripReferenceTools", "failMode": "best_effort"|"fail_fast" } (wrong shape → AiMuxError AIMUX_E_INVALID_ARGUMENT). */ aimux_error_t *aimux_moa_new(const uint64_t *reference_handles, size_t ref_len, uint64_t aggregator, const char *config_json, diff --git a/aimux-ffi/src/lib.rs b/aimux-ffi/src/lib.rs index 49fb34140..5e9a5b9a0 100644 --- a/aimux-ffi/src/lib.rs +++ b/aimux-ffi/src/lib.rs @@ -1660,7 +1660,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)] @@ -1755,8 +1755,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( @@ -1898,13 +1898,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 @@ -1936,7 +1936,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 @@ -1968,7 +1968,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 @@ -2004,7 +2004,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. @@ -2624,9 +2624,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, @@ -3589,7 +3590,7 @@ fn model_handles( /// `handles` is an array of `len` existing model handles (e.g. from /// `aimux_openai_new`). `config_json` selects the router + fallback policy: /// `{ "router": "rule" | "weighted", "weights": [..], "fallback": "on_error" | -/// "none", "provider_name": "router", "model_id": "router" }`. All keys are +/// "none", "providerName": "router", "modelId": "router" }`. All keys are /// optional; defaults are `rule` / `on_error` / `"router"` / `"router"`. /// /// Fails on: NULL `handles` with `len > 0`, bad JSON, zero-length `handles`, @@ -3646,9 +3647,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). @@ -3682,6 +3683,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>, @@ -4537,15 +4539,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 4a5bf8701..b151e269f 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 @@ -495,7 +495,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( @@ -575,7 +575,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); @@ -599,7 +599,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( @@ -702,7 +702,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"); @@ -758,7 +758,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"); @@ -779,7 +779,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"); @@ -886,7 +886,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"); @@ -1099,9 +1099,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` / @@ -1120,7 +1120,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. @@ -1143,7 +1143,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(), @@ -1174,10 +1174,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 3acc32e7d..f8d26a736 100644 --- a/aimux-ffi/tests/provider_handle_test.rs +++ b/aimux-ffi/tests/provider_handle_test.rs @@ -124,7 +124,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/decision_recording_test.rs b/aimux-providers/tests/decision_recording_test.rs index 08cb9d732..0fef961ed 100644 --- a/aimux-providers/tests/decision_recording_test.rs +++ b/aimux-providers/tests/decision_recording_test.rs @@ -85,7 +85,7 @@ async fn ring_records_one_complete_call_and_replays_exact_inputs_with_redaction( assert_eq!( serde_json::to_value(&records[0].provider).unwrap(), json!({ - "provider_id": "jev", "provider": "jev", "model_id": "jev-latest" + "providerId": "jev", "provider": "jev", "modelId": "jev-latest" }) ); assert_eq!(records[0].exchanges.len(), 1); 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/aimux-providers/tests/fixtures/jev_systemone_live.jsonl b/aimux-providers/tests/fixtures/jev_systemone_live.jsonl index b2fff1e5d..a8a84f561 100644 --- a/aimux-providers/tests/fixtures/jev_systemone_live.jsonl +++ b/aimux-providers/tests/fixtures/jev_systemone_live.jsonl @@ -1,2 +1,2 @@ -{"schema":3,"call_id":"call-1791293057374663321-0","recorded_at":"2026-10-06T13:24:17.375Z","input":{"operation":"decision","prompt":[],"options":{"state":"I was billed twice and payments are blocked. Please fix this urgently.","questions":[{"type":"boolean","id":"urgent","instructions":"Does the customer request urgent action?"},{"type":"choice","id":"department","instructions":"Choose the responsible department.","options":[{"label":"billing"},{"label":"technical"},{"label":"sales"}]},{"type":"score","id":"severity","instructions":"Rate the impact of the issue.","levels":["Minor inconvenience","Significant issue","Payments completely blocked"]}],"max_retries":0,"timeout":{"total_ms":30000,"step_ms":null,"first_chunk_ms":null,"chunk_ms":null}},"decision_capabilities":{"rounding":{"probability_decimals":2,"score_decimals":2},"probability_source":"native","supports_boolean":true,"supports_choice":true,"supports_score":true,"returns_distributions":true,"max_questions":null,"min_choices":1,"max_choices":255,"max_score_levels":10}},"provider":{"provider":"jev","model_id":"jev-latest","provider_id":"jev"},"exchanges":[{"attempt":1,"exchange_index":1,"request":{"method":"POST","url":"https://api.typesafe.ai/v1/systemone","headers":[["Authorization","[REDACTED]"]],"body":"{\"model\":\"jev-latest\",\"state\":\"I was billed twice and payments are blocked. Please fix this urgently.\",\"questions\":{\"urgent\":{\"type\":\"noul\",\"instructions\":\"Does the customer request urgent action?\"},\"department\":{\"type\":\"choice\",\"instructions\":\"Choose the responsible department.\",\"criteria\":{\"billing\":null,\"technical\":null,\"sales\":null}},\"severity\":{\"type\":\"score\",\"instructions\":\"Rate the impact of the issue.\",\"criteria\":[\"Minor inconvenience\",\"Significant issue\",\"Payments completely blocked\"]}}}"},"response":{"status":200,"headers":[["date","Tue, 06 Oct 2026 13:24:17 GMT"],["content-type","application/json"],["content-length","440"],["connection","keep-alive"],["server","cloudflare"],["x-typesafe-request-id","req_01a11162eaba71f5b6825f0466bca790"],["cf-cache-status","DYNAMIC"],["set-cookie","[REDACTED]"],["cf-ray","a464fc4aaee03760-LAX"],["alt-svc","h3=\":443\"; ma=86400"]],"body":"{\"model\":\"jev-1.13.0\",\"answers\":{\"urgent\":{\"type\":\"noul\",\"noul\":0.99},\"department\":{\"type\":\"choice\",\"choice\":\"billing\",\"confidence\":1.0,\"probabilities\":{\"billing\":1.0,\"technical\":0.0,\"sales\":0.0}},\"severity\":{\"type\":\"score\",\"score\":2.0,\"confidence\":1.0,\"legend\":{\"0\":\"Minor inconvenience\",\"1\":\"Significant issue\",\"2\":\"Payments completely blocked\"},\"probabilities\":{\"0\":0.0,\"1\":0.0,\"2\":1.0}}},\"usage\":{\"input_tokens\":365,\"output_tokens\":68}}","stream_chunks":1,"ttfb_ms":486},"timing":{"latency_ms":486,"ttfb_ms":486},"error":null,"finalized":true}],"outcome":{"decision_result":{"rounding":{"probability_decimals":2,"score_decimals":2},"answers":{"department":{"type":"choice","selected":"billing","probabilities":{"billing":1.0,"sales":0.0,"technical":0.0},"confidence":1.0},"severity":{"type":"score","expected_value":2.0,"levels":["Minor inconvenience","Significant issue","Payments completely blocked"],"probabilities":[0.0,0.0,1.0],"confidence":1.0},"urgent":{"type":"boolean","probability_true":0.99}},"provider":"jev","model":"jev-1.13.0","model_version":null,"probability_source":"native","usage":{"input_tokens":{"total":365},"output_tokens":{"total":68},"raw":{"input_tokens":365,"output_tokens":68}},"latency_ms":null,"provider_metadata":{"jev":{"model":"jev-1.13.0","answers":{"urgent":{"type":"noul","noul":0.99},"department":{"type":"choice","choice":"billing","confidence":1.0,"probabilities":{"billing":1.0,"technical":0.0,"sales":0.0}},"severity":{"type":"score","score":2.0,"confidence":1.0,"legend":{"0":"Minor inconvenience","1":"Significant issue","2":"Payments completely blocked"},"probabilities":{"0":0.0,"1":0.0,"2":1.0}}},"usage":{"input_tokens":365,"output_tokens":68}}},"response":{"headers":{"connection":"keep-alive","date":"Tue, 06 Oct 2026 13:24:17 GMT","set-cookie":"[REDACTED]","alt-svc":"h3=\":443\"; ma=86400","x-typesafe-request-id":"req_01a11162eaba71f5b6825f0466bca790","cf-cache-status":"DYNAMIC","server":"cloudflare","content-length":"440","content-type":"application/json","cf-ray":"a464fc4aaee03760-LAX"},"body":{"model":"jev-1.13.0","answers":{"urgent":{"type":"noul","noul":0.99},"department":{"type":"choice","choice":"billing","confidence":1.0,"probabilities":{"billing":1.0,"technical":0.0,"sales":0.0}},"severity":{"type":"score","score":2.0,"confidence":1.0,"legend":{"0":"Minor inconvenience","1":"Significant issue","2":"Payments completely blocked"},"probabilities":{"0":0.0,"1":0.0,"2":1.0}}},"usage":{"input_tokens":365,"output_tokens":68}}}},"status":"success","finish_reason":null,"error":null,"error_value":null,"usage":{"input_tokens":{"total":365},"output_tokens":{"total":68},"raw":{"input_tokens":365,"output_tokens":68}}},"complete":true,"transport_closed":true} -{"schema":3,"call_id":"call-1791293057868486266-1","recorded_at":"2026-10-06T13:24:17.868Z","input":{"operation":"decision","prompt":[],"options":{"state":{"ticket":"Payments blocked"},"questions":[{"type":"boolean","id":"urgent","instructions":{"question":"Is action urgent?","context":["payments"]},"criteria":{"true":{"includes":["blocked"]},"false":["routine","can wait"]}},{"type":"choice","id":"department","instructions":["Route the ticket",{"priority":"payments"}],"options":[{"label":"billing","description":{"includes":["payments"],"examples":["payout failure"]}},{"label":"technical"}]},{"type":"score","id":"severity","instructions":"Rate severity","levels":[{"label":"low","includes":["minor inconvenience"]},["blocking","No workaround"]]}],"max_retries":0,"timeout":{"total_ms":30000,"step_ms":null,"first_chunk_ms":null,"chunk_ms":null}},"decision_capabilities":{"rounding":{"probability_decimals":2,"score_decimals":2},"probability_source":"native","supports_boolean":true,"supports_choice":true,"supports_score":true,"returns_distributions":true,"max_questions":null,"min_choices":1,"max_choices":255,"max_score_levels":10}},"provider":{"provider":"jev","model_id":"jev-latest","provider_id":"jev"},"exchanges":[{"attempt":1,"exchange_index":1,"request":{"method":"POST","url":"https://api.typesafe.ai/v1/systemone","headers":[["Authorization","[REDACTED]"]],"body":"{\"model\":\"jev-latest\",\"state\":{\"ticket\":\"Payments blocked\"},\"questions\":{\"urgent\":{\"type\":\"noul\",\"instructions\":{\"question\":\"Is action urgent?\",\"context\":[\"payments\"]},\"criteria\":{\"true\":{\"includes\":[\"blocked\"]},\"false\":[\"routine\",\"can wait\"]}},\"department\":{\"type\":\"choice\",\"instructions\":[\"Route the ticket\",{\"priority\":\"payments\"}],\"criteria\":{\"billing\":{\"includes\":[\"payments\"],\"examples\":[\"payout failure\"]},\"technical\":null}},\"severity\":{\"type\":\"score\",\"instructions\":\"Rate severity\",\"criteria\":[{\"label\":\"low\",\"includes\":[\"minor inconvenience\"]},[\"blocking\",\"No workaround\"]]}}}"},"response":{"status":200,"headers":[["date","Tue, 06 Oct 2026 13:24:18 GMT"],["content-type","application/json"],["content-length","431"],["connection","keep-alive"],["server","cloudflare"],["set-cookie","[REDACTED]"],["x-typesafe-request-id","req_01a11162ebc076ee849d8e67ae47f547"],["cf-cache-status","DYNAMIC"],["cf-ray","a464fc4c4b1c3760-LAX"],["alt-svc","h3=\":443\"; ma=86400"]],"body":"{\"model\":\"jev-1.13.0\",\"answers\":{\"urgent\":{\"type\":\"noul\",\"noul\":0.92},\"department\":{\"type\":\"choice\",\"choice\":\"billing\",\"confidence\":0.98,\"probabilities\":{\"technical\":0.01,\"billing\":0.99}},\"severity\":{\"type\":\"score\",\"score\":0.99,\"confidence\":0.98,\"legend\":{\"0\":{\"label\":\"low\",\"includes\":[\"minor inconvenience\"]},\"1\":[\"blocking\",\"No workaround\"]},\"probabilities\":{\"0\":0.01,\"1\":0.99}}},\"usage\":{\"input_tokens\":448,\"output_tokens\":61}}","stream_chunks":1,"ttfb_ms":257},"timing":{"latency_ms":257,"ttfb_ms":257},"error":null,"finalized":true}],"outcome":{"decision_result":{"rounding":{"probability_decimals":2,"score_decimals":2},"answers":{"department":{"type":"choice","selected":"billing","probabilities":{"billing":0.99,"technical":0.01},"confidence":0.98},"severity":{"type":"score","expected_value":0.99,"levels":[{"label":"low","includes":["minor inconvenience"]},["blocking","No workaround"]],"probabilities":[0.01,0.99],"confidence":0.98},"urgent":{"type":"boolean","probability_true":0.92}},"provider":"jev","model":"jev-1.13.0","model_version":null,"probability_source":"native","usage":{"input_tokens":{"total":448},"output_tokens":{"total":61},"raw":{"input_tokens":448,"output_tokens":61}},"latency_ms":null,"provider_metadata":{"jev":{"model":"jev-1.13.0","answers":{"urgent":{"type":"noul","noul":0.92},"department":{"type":"choice","choice":"billing","confidence":0.98,"probabilities":{"technical":0.01,"billing":0.99}},"severity":{"type":"score","score":0.99,"confidence":0.98,"legend":{"0":{"label":"low","includes":["minor inconvenience"]},"1":["blocking","No workaround"]},"probabilities":{"0":0.01,"1":0.99}}},"usage":{"input_tokens":448,"output_tokens":61}}},"response":{"headers":{"date":"Tue, 06 Oct 2026 13:24:18 GMT","content-length":"431","set-cookie":"[REDACTED]","connection":"keep-alive","server":"cloudflare","cf-ray":"a464fc4c4b1c3760-LAX","alt-svc":"h3=\":443\"; ma=86400","x-typesafe-request-id":"req_01a11162ebc076ee849d8e67ae47f547","cf-cache-status":"DYNAMIC","content-type":"application/json"},"body":{"model":"jev-1.13.0","answers":{"urgent":{"type":"noul","noul":0.92},"department":{"type":"choice","choice":"billing","confidence":0.98,"probabilities":{"technical":0.01,"billing":0.99}},"severity":{"type":"score","score":0.99,"confidence":0.98,"legend":{"0":{"label":"low","includes":["minor inconvenience"]},"1":["blocking","No workaround"]},"probabilities":{"0":0.01,"1":0.99}}},"usage":{"input_tokens":448,"output_tokens":61}}}},"status":"success","finish_reason":null,"error":null,"error_value":null,"usage":{"input_tokens":{"total":448},"output_tokens":{"total":61},"raw":{"input_tokens":448,"output_tokens":61}}},"complete":true,"transport_closed":true} +{"schema":3,"input":{"operation":"decision","prompt":[],"options":{"state":"I was billed twice and payments are blocked. Please fix this urgently.","questions":[{"type":"boolean","id":"urgent","instructions":"Does the customer request urgent action?"},{"type":"choice","id":"department","instructions":"Choose the responsible department.","options":[{"label":"billing"},{"label":"technical"},{"label":"sales"}]},{"type":"score","id":"severity","instructions":"Rate the impact of the issue.","levels":["Minor inconvenience","Significant issue","Payments completely blocked"]}],"max_retries":0,"timeout":{"totalMs":30000,"stepMs":null,"firstChunkMs":null,"chunkMs":null}},"decisionCapabilities":{"rounding":{"probability_decimals":2,"score_decimals":2},"probability_source":"native","supports_boolean":true,"supports_choice":true,"supports_score":true,"returns_distributions":true,"max_questions":null,"min_choices":1,"max_choices":255,"max_score_levels":10}},"provider":{"provider":"jev","modelId":"jev-latest","providerId":"jev"},"exchanges":[{"attempt":1,"request":{"method":"POST","url":"https://api.typesafe.ai/v1/systemone","headers":[["Authorization","[REDACTED]"]],"body":"{\"model\":\"jev-latest\",\"state\":\"I was billed twice and payments are blocked. Please fix this urgently.\",\"questions\":{\"urgent\":{\"type\":\"noul\",\"instructions\":\"Does the customer request urgent action?\"},\"department\":{\"type\":\"choice\",\"instructions\":\"Choose the responsible department.\",\"criteria\":{\"billing\":null,\"technical\":null,\"sales\":null}},\"severity\":{\"type\":\"score\",\"instructions\":\"Rate the impact of the issue.\",\"criteria\":[\"Minor inconvenience\",\"Significant issue\",\"Payments completely blocked\"]}}}"},"response":{"status":200,"headers":[["date","Tue, 06 Oct 2026 13:24:17 GMT"],["content-type","application/json"],["content-length","440"],["connection","keep-alive"],["server","cloudflare"],["x-typesafe-request-id","req_01a11162eaba71f5b6825f0466bca790"],["cf-cache-status","DYNAMIC"],["set-cookie","[REDACTED]"],["cf-ray","a464fc4aaee03760-LAX"],["alt-svc","h3=\":443\"; ma=86400"]],"body":"{\"model\":\"jev-1.13.0\",\"answers\":{\"urgent\":{\"type\":\"noul\",\"noul\":0.99},\"department\":{\"type\":\"choice\",\"choice\":\"billing\",\"confidence\":1.0,\"probabilities\":{\"billing\":1.0,\"technical\":0.0,\"sales\":0.0}},\"severity\":{\"type\":\"score\",\"score\":2.0,\"confidence\":1.0,\"legend\":{\"0\":\"Minor inconvenience\",\"1\":\"Significant issue\",\"2\":\"Payments completely blocked\"},\"probabilities\":{\"0\":0.0,\"1\":0.0,\"2\":1.0}}},\"usage\":{\"input_tokens\":365,\"output_tokens\":68}}","streamChunks":1,"ttfbMs":486},"timing":{"latencyMs":486,"ttfbMs":486},"error":null,"finalized":true,"exchangeIndex":1}],"outcome":{"status":"success","error":null,"usage":{"raw":{"input_tokens":365,"output_tokens":68},"inputTokens":{"total":365},"outputTokens":{"total":68}},"decisionResult":{"rounding":{"probability_decimals":2,"score_decimals":2},"answers":{"department":{"type":"choice","selected":"billing","probabilities":{"billing":1.0,"sales":0.0,"technical":0.0},"confidence":1.0},"severity":{"type":"score","expected_value":2.0,"levels":["Minor inconvenience","Significant issue","Payments completely blocked"],"probabilities":[0.0,0.0,1.0],"confidence":1.0},"urgent":{"type":"boolean","probability_true":0.99}},"provider":"jev","model":"jev-1.13.0","model_version":null,"probability_source":"native","usage":{"raw":{"input_tokens":365,"output_tokens":68},"inputTokens":{"total":365},"outputTokens":{"total":68}},"latency_ms":null,"provider_metadata":{"jev":{"model":"jev-1.13.0","answers":{"urgent":{"type":"noul","noul":0.99},"department":{"type":"choice","choice":"billing","confidence":1.0,"probabilities":{"billing":1.0,"technical":0.0,"sales":0.0}},"severity":{"type":"score","score":2.0,"confidence":1.0,"legend":{"0":"Minor inconvenience","1":"Significant issue","2":"Payments completely blocked"},"probabilities":{"0":0.0,"1":0.0,"2":1.0}}},"usage":{"input_tokens":365,"output_tokens":68}}},"response":{"headers":{"connection":"keep-alive","date":"Tue, 06 Oct 2026 13:24:17 GMT","set-cookie":"[REDACTED]","alt-svc":"h3=\":443\"; ma=86400","x-typesafe-request-id":"req_01a11162eaba71f5b6825f0466bca790","cf-cache-status":"DYNAMIC","server":"cloudflare","content-length":"440","content-type":"application/json","cf-ray":"a464fc4aaee03760-LAX"},"body":{"model":"jev-1.13.0","answers":{"urgent":{"type":"noul","noul":0.99},"department":{"type":"choice","choice":"billing","confidence":1.0,"probabilities":{"billing":1.0,"technical":0.0,"sales":0.0}},"severity":{"type":"score","score":2.0,"confidence":1.0,"legend":{"0":"Minor inconvenience","1":"Significant issue","2":"Payments completely blocked"},"probabilities":{"0":0.0,"1":0.0,"2":1.0}}},"usage":{"input_tokens":365,"output_tokens":68}}}},"finishReason":null,"errorValue":null},"complete":true,"callId":"call-1791293057374663321-0","recordedAt":"2026-10-06T13:24:17.375Z","transportClosed":true} +{"schema":3,"input":{"operation":"decision","prompt":[],"options":{"state":{"ticket":"Payments blocked"},"questions":[{"type":"boolean","id":"urgent","instructions":{"question":"Is action urgent?","context":["payments"]},"criteria":{"true":{"includes":["blocked"]},"false":["routine","can wait"]}},{"type":"choice","id":"department","instructions":["Route the ticket",{"priority":"payments"}],"options":[{"label":"billing","description":{"includes":["payments"],"examples":["payout failure"]}},{"label":"technical"}]},{"type":"score","id":"severity","instructions":"Rate severity","levels":[{"label":"low","includes":["minor inconvenience"]},["blocking","No workaround"]]}],"max_retries":0,"timeout":{"totalMs":30000,"stepMs":null,"firstChunkMs":null,"chunkMs":null}},"decisionCapabilities":{"rounding":{"probability_decimals":2,"score_decimals":2},"probability_source":"native","supports_boolean":true,"supports_choice":true,"supports_score":true,"returns_distributions":true,"max_questions":null,"min_choices":1,"max_choices":255,"max_score_levels":10}},"provider":{"provider":"jev","modelId":"jev-latest","providerId":"jev"},"exchanges":[{"attempt":1,"request":{"method":"POST","url":"https://api.typesafe.ai/v1/systemone","headers":[["Authorization","[REDACTED]"]],"body":"{\"model\":\"jev-latest\",\"state\":{\"ticket\":\"Payments blocked\"},\"questions\":{\"urgent\":{\"type\":\"noul\",\"instructions\":{\"question\":\"Is action urgent?\",\"context\":[\"payments\"]},\"criteria\":{\"true\":{\"includes\":[\"blocked\"]},\"false\":[\"routine\",\"can wait\"]}},\"department\":{\"type\":\"choice\",\"instructions\":[\"Route the ticket\",{\"priority\":\"payments\"}],\"criteria\":{\"billing\":{\"includes\":[\"payments\"],\"examples\":[\"payout failure\"]},\"technical\":null}},\"severity\":{\"type\":\"score\",\"instructions\":\"Rate severity\",\"criteria\":[{\"label\":\"low\",\"includes\":[\"minor inconvenience\"]},[\"blocking\",\"No workaround\"]]}}}"},"response":{"status":200,"headers":[["date","Tue, 06 Oct 2026 13:24:18 GMT"],["content-type","application/json"],["content-length","431"],["connection","keep-alive"],["server","cloudflare"],["set-cookie","[REDACTED]"],["x-typesafe-request-id","req_01a11162ebc076ee849d8e67ae47f547"],["cf-cache-status","DYNAMIC"],["cf-ray","a464fc4c4b1c3760-LAX"],["alt-svc","h3=\":443\"; ma=86400"]],"body":"{\"model\":\"jev-1.13.0\",\"answers\":{\"urgent\":{\"type\":\"noul\",\"noul\":0.92},\"department\":{\"type\":\"choice\",\"choice\":\"billing\",\"confidence\":0.98,\"probabilities\":{\"technical\":0.01,\"billing\":0.99}},\"severity\":{\"type\":\"score\",\"score\":0.99,\"confidence\":0.98,\"legend\":{\"0\":{\"label\":\"low\",\"includes\":[\"minor inconvenience\"]},\"1\":[\"blocking\",\"No workaround\"]},\"probabilities\":{\"0\":0.01,\"1\":0.99}}},\"usage\":{\"input_tokens\":448,\"output_tokens\":61}}","streamChunks":1,"ttfbMs":257},"timing":{"latencyMs":257,"ttfbMs":257},"error":null,"finalized":true,"exchangeIndex":1}],"outcome":{"status":"success","error":null,"usage":{"raw":{"input_tokens":448,"output_tokens":61},"inputTokens":{"total":448},"outputTokens":{"total":61}},"decisionResult":{"rounding":{"probability_decimals":2,"score_decimals":2},"answers":{"department":{"type":"choice","selected":"billing","probabilities":{"billing":0.99,"technical":0.01},"confidence":0.98},"severity":{"type":"score","expected_value":0.99,"levels":[{"label":"low","includes":["minor inconvenience"]},["blocking","No workaround"]],"probabilities":[0.01,0.99],"confidence":0.98},"urgent":{"type":"boolean","probability_true":0.92}},"provider":"jev","model":"jev-1.13.0","model_version":null,"probability_source":"native","usage":{"raw":{"input_tokens":448,"output_tokens":61},"inputTokens":{"total":448},"outputTokens":{"total":61}},"latency_ms":null,"provider_metadata":{"jev":{"model":"jev-1.13.0","answers":{"urgent":{"type":"noul","noul":0.92},"department":{"type":"choice","choice":"billing","confidence":0.98,"probabilities":{"technical":0.01,"billing":0.99}},"severity":{"type":"score","score":0.99,"confidence":0.98,"legend":{"0":{"label":"low","includes":["minor inconvenience"]},"1":["blocking","No workaround"]},"probabilities":{"0":0.01,"1":0.99}}},"usage":{"input_tokens":448,"output_tokens":61}}},"response":{"headers":{"date":"Tue, 06 Oct 2026 13:24:18 GMT","content-length":"431","set-cookie":"[REDACTED]","connection":"keep-alive","server":"cloudflare","cf-ray":"a464fc4c4b1c3760-LAX","alt-svc":"h3=\":443\"; ma=86400","x-typesafe-request-id":"req_01a11162ebc076ee849d8e67ae47f547","cf-cache-status":"DYNAMIC","content-type":"application/json"},"body":{"model":"jev-1.13.0","answers":{"urgent":{"type":"noul","noul":0.92},"department":{"type":"choice","choice":"billing","confidence":0.98,"probabilities":{"technical":0.01,"billing":0.99}},"severity":{"type":"score","score":0.99,"confidence":0.98,"legend":{"0":{"label":"low","includes":["minor inconvenience"]},"1":["blocking","No workaround"]},"probabilities":{"0":0.01,"1":0.99}}},"usage":{"input_tokens":448,"output_tokens":61}}}},"finishReason":null,"errorValue":null},"complete":true,"callId":"call-1791293057868486266-1","recordedAt":"2026-10-06T13:24:17.868Z","transportClosed":true} diff --git a/aimux-providers/tests/fixtures/jev_systemone_live.md b/aimux-providers/tests/fixtures/jev_systemone_live.md index 092850b56..5d08ea3c5 100644 --- a/aimux-providers/tests/fixtures/jev_systemone_live.md +++ b/aimux-providers/tests/fixtures/jev_systemone_live.md @@ -16,3 +16,5 @@ normalized result. Offline provider tests replay the wire exchange via the shared replay helper; Core and the CLI replay the normalized result. Stack integration migrates the recording envelope to schema 3: provider identity only, with decision capabilities under input. Recorded requests, responses and normalized outcomes are unchanged. + +The wire-format layer uses camelCase for the recording envelope and shared Usage/Timeout types. The original HTTP request and response body strings remain unchanged. 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_decision_test.rs b/aimux-providers/tests/openai_decision_test.rs index b9a35a6df..a19b33837 100644 --- a/aimux-providers/tests/openai_decision_test.rs +++ b/aimux-providers/tests/openai_decision_test.rs @@ -474,7 +474,7 @@ async fn bad_media_and_duplicate_native_values_are_rejected_before_http() { .unwrap(); let server = MockServer::start().await; for (pointer, value) in [ - ("/images/0/data", json!({"Base64":"bad!"})), + ("/images/0/data", json!("bad!")), ("/images/0/detail", json!("unknown")), ("/images/0/media_type", json!("text/plain")), ("/questions/0/options/1/value", json!(true)), 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/runtime_decision_test.rs b/aimux-providers/tests/runtime_decision_test.rs index 8872e8d4b..da3bbf645 100644 --- a/aimux-providers/tests/runtime_decision_test.rs +++ b/aimux-providers/tests/runtime_decision_test.rs @@ -180,7 +180,7 @@ async fn media_is_native_per_runtime_and_recorded_for_replay() { let mut request = options(); request.images = vec![serde_json::from_value(image.clone()).unwrap()]; let mut expected = fixture()["request"].clone(); - let encoded = image["data"]["Base64"].as_str().unwrap(); + let encoded = image["data"].as_str().unwrap(); let data_url = format!("data:image/png;base64,{encoded}"); expected["images"] = match provider { "ollama" => json!([encoded]), 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/flutter/CHANGELOG.md b/bindings/flutter/CHANGELOG.md index 3327a9844..4f7772016 100644 --- a/bindings/flutter/CHANGELOG.md +++ b/bindings/flutter/CHANGELOG.md @@ -1,3 +1,21 @@ +## Unreleased + +- Breaking: the typed layer and the maps `Model` returns follow the AI SDK + JSON of the Rust core: camelCase fields, `type`-tagged unions with + kebab-case values (`text-delta`, `tool-call`, ...), optional fields absent + instead of `null`, `FileBytes` untagged, errors keyed by `name`. +- Breaking: `ContentPartToolResult` carries `toolCallId`, `toolName` and a + typed `ToolResultOutput`; `result`, `isError`, `preliminary`, `dynamic` are + gone from the message part (provider-executed `ToolResult` keeps them). +- Breaking: `StreamPart` models the stream's `TextStreamPart` (adds + `finish-step`; `StreamPartToolCall` / `StreamPartToolResult` / `StreamPartFile` + wrap `ToolCall` / `ToolResult` / `GeneratedFile`; no `response-metadata`). +- Breaking: `registerProviders` is removed together with + `aimux_register_providers`; `config_json` keys are camelCase (`baseUrl`) and + unknown keys are rejected. +- `types.dart` no longer uses `json_serializable` except for the OpenAI Chat + Completions family; `types.g.dart` is regenerated for it. + ## 0.5.0 - Breaking release aligning the whole aimux family — see the diff --git a/bindings/flutter/README.md b/bindings/flutter/README.md index 828bec400..c231335eb 100644 --- a/bindings/flutter/README.md +++ b/bindings/flutter/README.md @@ -35,7 +35,8 @@ Desktop platforms resolve the library from the platform library path ```bash cargo build -p aimux-ffi --release -LD_LIBRARY_PATH=../../target/release flutter test # Linux/macOS +LD_LIBRARY_PATH=../../target/release dart test # Linux +DYLD_LIBRARY_PATH=../../target/release dart test # macOS ``` ## License diff --git a/bindings/flutter/lib/aimux.dart b/bindings/flutter/lib/aimux.dart index 39428eccd..4785784d4 100644 --- a/bindings/flutter/lib/aimux.dart +++ b/bindings/flutter/lib/aimux.dart @@ -118,7 +118,7 @@ typedef _DropHandleC = Void Function(Uint64); typedef _DropHandleDart = void Function(int); // (const char *) → error: aimux_init_logging, aimux_init_recording, -// aimux_register_providers, aimux_init_proxy. +// aimux_init_proxy. typedef _StrC = _Err Function(Pointer); typedef _StrDart = _Err Function(Pointer); @@ -292,8 +292,6 @@ final class _AimuxFFI { 'aimux_recording_try_flush'); late final mockReplayNew = _lib .lookupFunction<_MockReplayC, _MockReplayDart>('aimux_mock_replay_new'); - late final registerProviders = - _lib.lookupFunction<_StrC, _StrDart>('aimux_register_providers'); late final initProxy = _lib.lookupFunction<_StrC, _StrDart>('aimux_init_proxy'); late final routerNew = _lib @@ -499,9 +497,10 @@ class Model implements Finalizable { /// /// [apiKey] may be null to read the provider's env var from the registry /// entry. [configJson] is an optional JSON object of ProviderOptions - /// (`{"base_url": "...", "headers": {...}, "organization": "...", - /// "project": "...", "params": {...}}`). `max_retries` (a per-call option) - /// and `body_overrides` (removed) are rejected as [InvalidArgumentError]. + /// (`{"baseUrl": "...", "headers": {...}, "organization": "...", + /// "project": "...", "params": {...}}`). Any other key (`maxRetries` is a + /// per-call option, `bodyOverrides` is gone) is rejected as + /// [InvalidArgumentError]. factory Model.provider(String name, String modelId, {String? apiKey, String? configJson}) { checkJson(configJson, 'config_json', emptyIsDefault: true); @@ -537,7 +536,7 @@ class Model implements Finalizable { /// Generate a structured JSON object (M12, RFC-0016). /// /// Same signature as [generateText]; returns the parsed - /// GenerateObjectResult as a Map. Pass `response_format: { "Json": { ... } }` + /// GenerateObjectResult as a Map. Pass `responseFormat: {"type": "json", ...}` /// via [options] for schema control; aimux-core applies JSON repair before /// parsing. /// @@ -623,7 +622,7 @@ class Model implements Finalizable { /// Stream text from the model. /// /// Returns a Stream of StreamPart maps. Recoverable frame errors arrive - /// as `Error` stream-part maps and the stream continues; only + /// as `error` stream-part maps and the stream continues; only /// transport/Core failures throw [AimuxException] via [Stream.addError] /// (and do not call on_done). /// @@ -646,7 +645,7 @@ class Model implements Finalizable { /// Returns a Stream of ChatCompletionChunk maps (OpenAI /// `chat.completion.chunk` objects). Stream options (`include_usage`, /// `include_reasoning`) are passed via - /// `options['provider_options']['openai']['stream_options']`. + /// `options['providerOptions']['openai']['stream_options']`. /// /// NOTE: like [streamText], this blocks the calling isolate until the /// stream completes and effectively buffers all parts until then. @@ -748,8 +747,8 @@ class ProviderConfig { /// only the ones the preset declares are accepted. /// /// Retry is a per-call setting ([GenerateTextOptions.maxRetries]) and - /// request-body overrides no longer exist: a `max_retries` or - /// `body_overrides` key in the config is rejected as [InvalidArgumentError], + /// request-body overrides no longer exist: a `maxRetries` or + /// `bodyOverrides` key in the config is rejected as [InvalidArgumentError], /// so this class has neither. final Map? params; @@ -761,12 +760,12 @@ class ProviderConfig { this.params, }); - /// Serialize to the `ProviderOptions` JSON wire format (snake_case keys, - /// null fields omitted). Returns null when no field is set, so callers can + /// Serialize to the `config_json` wire format (camelCase keys, null fields + /// omitted). Returns null when no field is set, so callers can /// pass null to the FFI for defaults. String? toJson() { final map = {}; - if (baseUrl != null) map['base_url'] = baseUrl; + if (baseUrl != null) map['baseUrl'] = baseUrl; if (headers != null) map['headers'] = headers; if (organization != null) map['organization'] = organization; if (project != null) map['project'] = project; @@ -817,7 +816,7 @@ class ProviderHandle implements Finalizable { /// (anya2a) when available. /// /// Returns a JSON array string of `ResolvedModel` - /// (`[{"id":"...","owned_by":"...","created":,"spec":{...}}, ...]`). + /// (`[{"id":"...","ownedBy":"...","created":,"spec":{...}}, ...]`). /// Synchronous: blocks the calling isolate until the network call completes. String listModels() { _checkOpen(); @@ -1003,19 +1002,6 @@ Model mockReplay(String recordingsJsonl) { return Model._(handle); } -/// Register external OpenAI-compatible providers from a JSON config string -/// (RFC-0020). -/// -/// `configJSON` is `{ "providers": [ { "name", "base_url", ... } ] }`. Entries -/// override same-named built-ins or add new ones. Like `initRecording`, this -/// mutates process-global registry state. A well-formed document the registry -/// rejects throws [InvalidArgumentError]. -void registerProviders(String configJSON) { - checkJson(configJSON, 'config_json'); - withUtf8(configJSON, - (p) => expectAimuxError(_ffi.registerProviders(p), 'register_providers')); -} - /// 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. @@ -1032,7 +1018,7 @@ void initProxy(String configJSON) { /// (per `configJson`). /// /// `configJson` (optional): `{"router": "rule"|"weighted", "weights": [...], -/// "fallback": "on_error"|"none", "provider_name", "model_id"}`. +/// "fallback": "on_error"|"none", "providerName", "modelId"}`. Model router(List models, {String? configJson}) { if (models.isEmpty) { throw ArgumentError('router: models must be non-empty'); diff --git a/bindings/flutter/lib/errors.dart b/bindings/flutter/lib/errors.dart index 470ab8b09..50443813f 100644 --- a/bindings/flutter/lib/errors.dart +++ b/bindings/flutter/lib/errors.dart @@ -720,8 +720,8 @@ class InvalidToolInputError extends AimuxException { /// A `repairToolCall` hook itself failed. class ToolCallRepairError extends AimuxException { /// The original lookup/parse/validation error the hook was repairing, - /// decoded from its externally-tagged wire JSON (the same shape as - /// `ToolCall.error`). + /// decoded from its wire JSON (an `AiMuxError` object keyed by `name`, the + /// same shape as `ToolCall.error`). final dynamic originalError; ToolCallRepairError(super.message, diff --git a/bindings/flutter/lib/multimodal.dart b/bindings/flutter/lib/multimodal.dart index df455a322..fedc79263 100644 --- a/bindings/flutter/lib/multimodal.dart +++ b/bindings/flutter/lib/multimodal.dart @@ -381,8 +381,8 @@ class TranscriptionModel { /// gpt-realtime-whisper). /// /// [optsJson] — optional session options JSON - /// (`{"input_audio_format": {...}, "provider_options", "headers", - /// "include_raw_chunks"}`). + /// (`{"inputAudioFormat": {...}, "providerOptions", "headers", + /// "includeRawChunks"}`). /// [abortHandle] — optional abort handle; firing it aborts the session. TranscriptionSession startStream({String? optsJson, int abortHandle = 0}) { _checkOpen(); diff --git a/bindings/flutter/lib/tool_call_repair.dart b/bindings/flutter/lib/tool_call_repair.dart index d46bc932c..982d9cc75 100644 --- a/bindings/flutter/lib/tool_call_repair.dart +++ b/bindings/flutter/lib/tool_call_repair.dart @@ -98,7 +98,7 @@ ToolCallRepairContext? _contextFor( Map _replyFor(RawToolCall? replacement) => replacement == null ? const {'type': 'unchanged'} - : {'type': 'repaired', 'tool_call': replacement.toJson()}; + : {'type': 'repaired', 'toolCall': replacement.toJson()}; /// A thrown object has no typed counterpart in the core, so it becomes /// `failed` — the core turns that into `ToolCallRepair{cause: Other(message)}`. @@ -129,7 +129,7 @@ Never _asyncHookInSyncCall() => throw StateError( /// derives the tool set, messages and instructions from them. /// /// Call this on the raw result map *before* decoding it into typed classes — -/// both `tool_calls` and the matching `response_messages` part are rewritten. +/// both `toolCalls` and the matching `responseMessages` part are rewritten. Map repairResultToolCalls( Map result, Object prompt, @@ -168,7 +168,7 @@ Map repairResultToolCalls( _applyRepairToResult, jsonEncode(current), optsJson, - call['tool_call_id'] as String, + call['toolCallId'] as String, encodeJson(reply, 'repairToolCall reply'), 'apply_tool_call_repair_to_result')) as Map; } @@ -178,8 +178,8 @@ Map repairResultToolCalls( /// The invalid calls of a result document. `generate_object` nests the /// `generate_text` result under `raw`. List> _invalidToolCalls(Map result) { - final calls = result['tool_calls'] ?? - (result['raw'] as Map?)?['tool_calls']; + final calls = result['toolCalls'] ?? + (result['raw'] as Map?)?['toolCalls']; if (calls is! List) return const []; return calls .whereType>() @@ -191,7 +191,7 @@ List> _invalidToolCalls(Map result) { // Streaming: patch the tool-call part in flight // ───────────────────────────────────────────────────────────────────────────── -/// Replace every invalid `ToolCall` part of [parts] with its repaired version. +/// Replace every invalid `tool-call` part of [parts] with its repaired version. /// /// Returns [parts] unchanged when `options.repairToolCall` is null. Every other /// part — tool-input deltas included — passes through untouched and in order @@ -209,9 +209,9 @@ Stream> repairStreamToolCalls( final optsJson = encodeJson(options!.toJson(), 'options'); return parts.asyncMap((part) async { - // Externally tagged: {"ToolCall": {...}}. - final call = part['ToolCall']; - if (call is! Map || call['invalid'] != true) return part; + // `{"type": "tool-call", ...ToolCall}`: the call is the part minus its tag. + if (part['type'] != 'tool-call' || part['invalid'] != true) return part; + final call = {...part}..remove('type'); final context = _contextFor(call, promptJson, optsJson); if (context == null) return part; @@ -225,6 +225,6 @@ Stream> repairStreamToolCalls( final patched = _call3(_applyRepair, jsonEncode(call), optsJson, encodeJson(reply, 'repairToolCall reply'), 'apply_tool_call_repair'); - return {'ToolCall': jsonDecode(patched)}; + return {'type': 'tool-call', ...jsonDecode(patched) as Map}; }); } diff --git a/bindings/flutter/lib/typed_model.dart b/bindings/flutter/lib/typed_model.dart index 6c924baa3..ee36d71d2 100644 --- a/bindings/flutter/lib/typed_model.dart +++ b/bindings/flutter/lib/typed_model.dart @@ -67,7 +67,7 @@ class TypedModel { /// Generate a structured JSON object from a string [prompt] (M12). /// /// Same signature as [generateText]; returns a typed - /// [GenerateObjectResult]. Pass `response_format: { "Json": { ... } }` + /// [GenerateObjectResult]. Pass `responseFormat: {"type": "json", ...}` /// via [options] for schema control; the engine applies JSON repair /// before parsing. GenerateObjectResult generateObject( diff --git a/bindings/flutter/lib/types.dart b/bindings/flutter/lib/types.dart index 7293725f7..2b8885754 100644 --- a/bindings/flutter/lib/types.dart +++ b/bindings/flutter/lib/types.dart @@ -1,22 +1,15 @@ -// types.dart — typed models mirroring bindings/node/src/types/*.ts (ts-rs). +// types.dart — typed models for the JSON the Rust core exchanges across the +// C ABI. Mirrors the ts-rs generated types in `bindings/node/src/types/*.ts`: +// camelCase field names, unions tagged with a `type` key (kebab-case values; +// `sourceType` for sources), optional fields **absent** rather than `null`. // -// Hand-written Dart classes with json_serializable. Field names are -// camelCase; `@JsonKey(name: ...)` maps to the snake_case wire format -// produced by the Rust core (serde). These wrap the raw -// `Map` boundary exposed by the dart:ffi binding in -// `aimux.dart`, so callers get compile-time types instead of dynamic maps. +// The classes are hand-written: every `fromJson` reads exactly that JSON and +// every `toJson` writes it back (nested values are plain maps, ready for +// `jsonEncode`). Only the OpenAI Chat Completions family at the bottom keeps +// OpenAI's own snake_case and uses `json_serializable`. // -// Wire shapes are derived from the Rust structs in aimux-core -// (`types.rs`, `tool.rs`, `generate.rs`, `result.rs`, `stream_part.rs`, -// `shared.rs`): -// - GenerateTextResult (generate.rs:90) -// - GenerateContent (result.rs) — sealed, externally-tagged -// - StreamPart (stream_part.rs) — sealed, externally-tagged -// - ContentPart (content.rs) — sealed, internally-tagged (type) -// - FileData/FileBytes (shared.rs) — sealed, externally-tagged -// - ToolCall (tool.rs:102) -// - Usage / InputTokenUsage / OutputTokenUsage (types.rs:33, types.rs:44) -// - FinishReason (types.rs:10) +// Weak-typed fields (`Map`) are the ones the TS types leave +// open (`JsonValue`, provider metadata, warnings, errors). import 'dart:async'; @@ -24,6 +17,15 @@ import 'package:json_annotation/json_annotation.dart'; part 'types.g.dart'; +typedef Json = Map; + +List _list(Object? v, T Function(Json) f) => + [for (final e in (v as List? ?? const [])) f(e as Json)]; +List _maps(Object? v) => _list(v, (m) => m); +Json? _opt(Object? v) => v as Json?; +List? _optList(Object? v, T Function(Json) f) => + v == null ? null : _list(v, f); + // ───────────────────────────────────────────────────────────────────────────── // Shared enums // ───────────────────────────────────────────────────────────────────────────── @@ -73,98 +75,103 @@ enum ReasoningEffort { } // ───────────────────────────────────────────────────────────────────────────── -// Token usage +// Token usage, finish reason // ───────────────────────────────────────────────────────────────────────────── -/// Input token usage detail with cache breakdown. -/// -/// All fields are nullable: `total` is always serialized by the Rust core -/// (null when unknown), the rest are omitted when `None`. -@JsonSerializable() +/// Input token usage detail with cache breakdown. Mirrors `InputTokenUsage.ts`. class InputTokenUsage { final int? total; - @JsonKey(name: 'no_cache') final int? noCache; - @JsonKey(name: 'cache_read') final int? cacheRead; - @JsonKey(name: 'cache_write') final int? cacheWrite; - InputTokenUsage({this.total, this.noCache, this.cacheRead, this.cacheWrite}); - factory InputTokenUsage.fromJson(Map json) => _$InputTokenUsageFromJson(json); - Map toJson() => _$InputTokenUsageToJson(this); + final int? noCache; + final int? cacheRead; + final int? cacheWrite; + const InputTokenUsage({this.total, this.noCache, this.cacheRead, this.cacheWrite}); + factory InputTokenUsage.fromJson(Json j) => InputTokenUsage( + total: j['total'] as int?, + noCache: j['noCache'] as int?, + cacheRead: j['cacheRead'] as int?, + cacheWrite: j['cacheWrite'] as int?); + Json toJson() => { + if (total != null) 'total': total, + if (noCache != null) 'noCache': noCache, + if (cacheRead != null) 'cacheRead': cacheRead, + if (cacheWrite != null) 'cacheWrite': cacheWrite, + }; } -@JsonSerializable() +/// Mirrors `OutputTokenUsage.ts`. class OutputTokenUsage { final int? total; final int? text; final int? reasoning; - OutputTokenUsage({this.total, this.text, this.reasoning}); - factory OutputTokenUsage.fromJson(Map json) => _$OutputTokenUsageFromJson(json); - Map toJson() => _$OutputTokenUsageToJson(this); + const OutputTokenUsage({this.total, this.text, this.reasoning}); + factory OutputTokenUsage.fromJson(Json j) => OutputTokenUsage( + total: j['total'] as int?, + text: j['text'] as int?, + reasoning: j['reasoning'] as int?); + Json toJson() => { + if (total != null) 'total': total, + if (text != null) 'text': text, + if (reasoning != null) 'reasoning': reasoning, + }; } /// Token usage statistics. Mirrors `Usage.ts`. -@JsonSerializable() class Usage { - @JsonKey(name: 'input_tokens') final InputTokenUsage inputTokens; - @JsonKey(name: 'output_tokens') final OutputTokenUsage outputTokens; - /// Raw usage information from the provider (opaque JSON). - final Map? raw; - - Usage({required this.inputTokens, required this.outputTokens, this.raw}); - factory Usage.fromJson(Map json) => _$UsageFromJson(json); - Map toJson() => _$UsageToJson(this); + /// Raw usage information from the provider (opaque JSON). + final Json? raw; + + const Usage({required this.inputTokens, required this.outputTokens, this.raw}); + factory Usage.fromJson(Json j) => Usage( + inputTokens: InputTokenUsage.fromJson(j['inputTokens'] as Json), + outputTokens: OutputTokenUsage.fromJson(j['outputTokens'] as Json), + raw: _opt(j['raw'])); + Json toJson() => { + 'inputTokens': inputTokens.toJson(), + 'outputTokens': outputTokens.toJson(), + if (raw != null) 'raw': raw, + }; } -// ───────────────────────────────────────────────────────────────────────────── -// Finish reason -// ───────────────────────────────────────────────────────────────────────────── - /// Why generation stopped. Mirrors `FinishReason.ts`. /// -/// `unified` is the kebab-case unified reason (`"stop"`, `"length"`, -/// `"content-filter"`, `"tool-calls"`, `"error"`, `"other"`); `raw` is the -/// provider-specific reason string (nullable). -@JsonSerializable() +/// [unified] is the kebab-case unified reason ([FinishReasonUnified]); +/// [raw] is the provider-specific reason string. class FinishReason { final String unified; final String? raw; - - FinishReason({required this.unified, this.raw}); - - factory FinishReason.fromJson(Map json) => - _$FinishReasonFromJson(json); - Map toJson() => _$FinishReasonToJson(this); + const FinishReason({required this.unified, this.raw}); + factory FinishReason.fromJson(Json j) => + FinishReason(unified: j['unified'] as String, raw: j['raw'] as String?); + Json toJson() => {'unified': unified, if (raw != null) 'raw': raw}; } // ───────────────────────────────────────────────────────────────────────────── // Tool calls // ───────────────────────────────────────────────────────────────────────────── -/// A tool call requested by the model. Mirrors `ToolCall.ts`. -@JsonSerializable() +/// A parsed tool call requested by the model. Mirrors `ToolCall.ts`. +/// +/// [error] is the typed lookup, parse, schema or repair failure of an invalid +/// call: an `AiMuxError` object keyed by `name` (`{"name":"AI_NoSuchToolError", +/// "toolName":...}`). class ToolCall { - @JsonKey(name: 'tool_call_id') final String toolCallId; - @JsonKey(name: 'tool_name') final String toolName; final dynamic input; - @JsonKey(name: 'provider_executed') final bool? providerExecuted; - // `dynamic` is a Dart built-in identifier — field is named `isDynamic` - // and mapped to the `dynamic` JSON key. - @JsonKey(name: 'dynamic') + + /// The wire key is `dynamic`, a Dart built-in identifier, hence the name. final bool? isDynamic; - /// Additional provider-specific metadata associated with this call. - @JsonKey(name: 'provider_metadata', includeIfNull: false) - final dynamic providerMetadata; - /// Set by Core when the tool call stays invalid after optional repair. + final Json? providerMetadata; + + /// Set by the core when the call stays invalid after optional repair. final bool? invalid; - /// The typed lookup, parse, schema, or repair failure for an invalid call. - final dynamic error; + final Json? error; - ToolCall({ + const ToolCall({ required this.toolCallId, required this.toolName, required this.input, @@ -175,9 +182,27 @@ class ToolCall { this.error, }); - factory ToolCall.fromJson(Map json) => - _$ToolCallFromJson(json); - Map toJson() => _$ToolCallToJson(this); + factory ToolCall.fromJson(Json j) => ToolCall( + toolCallId: j['toolCallId'] as String, + toolName: j['toolName'] as String, + input: j['input'], + providerExecuted: j['providerExecuted'] as bool?, + isDynamic: j['dynamic'] as bool?, + providerMetadata: _opt(j['providerMetadata']), + invalid: j['invalid'] as bool?, + error: _opt(j['error']), + ); + + Json toJson() => { + 'toolCallId': toolCallId, + 'toolName': toolName, + 'input': input, + if (providerExecuted != null) 'providerExecuted': providerExecuted, + if (isDynamic != null) 'dynamic': isDynamic, + if (providerMetadata != null) 'providerMetadata': providerMetadata, + if (invalid != null) 'invalid': invalid, + if (error != null) 'error': error, + }; } // ───────────────────────────────────────────────────────────────────────────── @@ -185,14 +210,14 @@ class ToolCall { // ───────────────────────────────────────────────────────────────────────────── /// A tool call as the model emitted it: [input] is the provider's raw argument -/// **text**, not a decoded object. +/// **text**, not a decoded object. Mirrors `RawToolCall.ts`. /// /// Both the call handed to a [RepairToolCall] hook /// ([ToolCallRepairContext.toolCall]) and the replacement it returns. The /// replacement is re-parsed and re-validated against the tool's schema, so /// [input] must be the argument text the tool expects (usually a JSON object /// literal); returning text that still fails validation leaves the call -/// invalid, now carrying a [ToolCallRepairError]. +/// invalid, now carrying a `ToolCallRepairError`. class RawToolCall { final String toolCallId; final String toolName; @@ -202,15 +227,12 @@ class RawToolCall { /// Whether the provider executes the call itself. The core adopts the /// replacement as returned, so a hook must carry this over (as - /// `copyWith` does) or the call turns into a client-executed one. + /// [copyWith] does) or the call turns into a client-executed one. final bool? providerExecuted; - /// Whether the call targets a dynamic tool. `dynamic` is a Dart built-in - /// identifier, so the field is `isDynamic` and maps to the `dynamic` key. + /// Whether the call targets a dynamic tool (the wire key is `dynamic`). final bool? isDynamic; - - /// Additional provider-specific metadata associated with this call. - final dynamic providerMetadata; + final Json? providerMetadata; const RawToolCall({ required this.toolCallId, @@ -221,13 +243,13 @@ class RawToolCall { this.providerMetadata, }); - factory RawToolCall.fromJson(Map json) => RawToolCall( - toolCallId: json['tool_call_id'] as String, - toolName: json['tool_name'] as String, - input: json['input'] as String, - providerExecuted: json['provider_executed'] as bool?, - isDynamic: json['dynamic'] as bool?, - providerMetadata: json['provider_metadata'], + factory RawToolCall.fromJson(Json j) => RawToolCall( + toolCallId: j['toolCallId'] as String, + toolName: j['toolName'] as String, + input: j['input'] as String, + providerExecuted: j['providerExecuted'] as bool?, + isDynamic: j['dynamic'] as bool?, + providerMetadata: _opt(j['providerMetadata']), ); /// A copy with [input] (and optionally the id or name) replaced and every @@ -242,13 +264,13 @@ class RawToolCall { providerMetadata: providerMetadata, ); - Map toJson() => { - 'tool_call_id': toolCallId, - 'tool_name': toolName, + Json toJson() => { + 'toolCallId': toolCallId, + 'toolName': toolName, 'input': input, - if (providerExecuted != null) 'provider_executed': providerExecuted, + if (providerExecuted != null) 'providerExecuted': providerExecuted, if (isDynamic != null) 'dynamic': isDynamic, - if (providerMetadata != null) 'provider_metadata': providerMetadata, + if (providerMetadata != null) 'providerMetadata': providerMetadata, }; } @@ -262,13 +284,12 @@ class ToolCallRepairContext { /// The invalid call, with the provider's raw argument text. final RawToolCall toolCall; - /// The lookup, parse or validation failure, externally tagged - /// (`{"InvalidToolInput": {...}}`). Weak type — same shape as - /// [ToolCall.error]. - final Object? error; + /// The lookup, parse or validation failure as an `AiMuxError` object keyed by + /// `name` — the same shape as [ToolCall.error]. + final Json? error; - /// The schema [toolCall] failed to satisfy; null when the tool is unknown. - final Map? inputSchema; + /// The schema [toolCall] failed to satisfy. + final Json? inputSchema; /// The full tool set the call was generated with. final List tools; @@ -288,19 +309,13 @@ class ToolCallRepairContext { this.instructions, }); - factory ToolCallRepairContext.fromJson(Map json) => - ToolCallRepairContext( - toolCall: - RawToolCall.fromJson(json['tool_call'] as Map), - error: json['error'], - inputSchema: json['input_schema'] as Map?, - tools: (json['tools'] as List? ?? const []) - .map((t) => Tool.fromJson(t as Map)) - .toList(), - messages: (json['messages'] as List? ?? const []) - .map((m) => ModelMessage.fromJson(m as Map)) - .toList(), - instructions: json['instructions'] as String?, + factory ToolCallRepairContext.fromJson(Json j) => ToolCallRepairContext( + toolCall: RawToolCall.fromJson(j['toolCall'] as Json), + error: _opt(j['error']), + inputSchema: _opt(j['inputSchema']), + tools: _list(j['tools'], Tool.fromJson), + messages: _list(j['messages'], ModelMessage.fromJson), + instructions: j['instructions'] as String?, ); } @@ -309,7 +324,7 @@ class ToolCallRepairContext { /// /// Return a [RawToolCall] to replace the call, or null to leave it as it is. /// Throwing means the repair failed: the call stays invalid and carries a -/// [ToolCallRepairError] whose cause is the thrown object's `toString()`. +/// `ToolCallRepairError` whose cause is the thrown object's `toString()`. /// /// The hook runs on the caller's isolate, after the native call has returned, /// so it may itself call back into aimux (including another model call). @@ -324,27 +339,23 @@ typedef RepairToolCall = FutureOr Function( // ───────────────────────────────────────────────────────────────────────────── class FunctionToolInputExample { - final Map input; - FunctionToolInputExample({required this.input}); - factory FunctionToolInputExample.fromJson(Map json) => - FunctionToolInputExample(input: json['input'] as Map); - Map toJson() => {'input': input}; + final Json input; + const FunctionToolInputExample({required this.input}); + factory FunctionToolInputExample.fromJson(Json j) => + FunctionToolInputExample(input: j['input'] as Json); + Json toJson() => {'input': input}; } /// A function tool definition. Mirrors `FunctionTool.ts`. -@JsonSerializable() class FunctionTool { final String name; final String? description; - @JsonKey(name: 'input_schema') - final Map inputSchema; + final Json inputSchema; final bool? strict; - @JsonKey(name: 'provider_options') - final Map? providerOptions; - @JsonKey(name: 'input_examples') + final Json? providerOptions; final List? inputExamples; - FunctionTool({ + const FunctionTool({ required this.name, this.description, required this.inputSchema, @@ -353,36 +364,52 @@ class FunctionTool { this.inputExamples, }); - factory FunctionTool.fromJson(Map json) => - _$FunctionToolFromJson(json); - Map toJson() => _$FunctionToolToJson(this); + factory FunctionTool.fromJson(Json j) => FunctionTool( + name: j['name'] as String, + description: j['description'] as String?, + inputSchema: j['inputSchema'] as Json, + strict: j['strict'] as bool?, + providerOptions: _opt(j['providerOptions']), + inputExamples: + _optList(j['inputExamples'], FunctionToolInputExample.fromJson), + ); + + Json toJson() => { + 'name': name, + if (description != null) 'description': description, + 'inputSchema': inputSchema, + if (strict != null) 'strict': strict, + if (providerOptions != null) 'providerOptions': providerOptions, + if (inputExamples != null) + 'inputExamples': [for (final e in inputExamples!) e.toJson()], + }; } -/// A tool definition: function or provider tool. Mirrors `Tool.ts`. +/// A tool definition: function or provider tool. Mirrors `Tool.ts` +/// (`{"type":"function", ...FunctionTool}` / `{"type":"provider", id, name, +/// args}`). class Tool { final String type; final FunctionTool? function; final String? id; final String? name; - final Map? args; + final Json? args; - Tool._({required this.type, this.function, this.id, this.name, this.args}); + const Tool._({required this.type, this.function, this.id, this.name, this.args}); - factory Tool.function(FunctionTool fn) => - Tool._(type: 'function', function: fn); - factory Tool.provider({required String id, required String name, required Map args}) => + factory Tool.function(FunctionTool fn) => Tool._(type: 'function', function: fn); + factory Tool.provider( + {required String id, required String name, required Json args}) => Tool._(type: 'provider', id: id, name: name, args: args); - Map toJson() { - if (function != null) return {'type': 'function', ...function!.toJson()}; - return {'type': 'provider', 'id': id!, 'name': name!, 'args': args ?? {}}; - } - factory Tool.fromJson(Map json) { - if (json['type'] == 'function') { - return Tool._(type: 'function', function: FunctionTool.fromJson(json)); - } - return Tool._(type: 'provider', id: json['id'] as String, name: json['name'] as String, args: json['args'] as Map?); - } + Json toJson() => function != null + ? {'type': 'function', ...function!.toJson()} + : {'type': 'provider', 'id': id!, 'name': name!, 'args': args ?? {}}; + + factory Tool.fromJson(Json j) => j['type'] == 'function' + ? Tool.function(FunctionTool.fromJson(j)) + : Tool.provider( + id: j['id'] as String, name: j['name'] as String, args: j['args'] as Json); } /// How the model should choose tools. Mirrors `ToolChoice.ts`. @@ -395,202 +422,231 @@ class ToolChoice { static const required = ToolChoice._('required', null); factory ToolChoice.tool(String toolName) => ToolChoice._('tool', toolName); - dynamic toJson() => _kind == 'tool' ? {'type': 'tool', 'toolName': toolName} : _kind; - factory ToolChoice.fromJson(dynamic json) => - json is String ? ToolChoice._(json, null) : ToolChoice._('tool', (json as Map)['toolName'] as String); + dynamic toJson() => + _kind == 'tool' ? {'type': 'tool', 'toolName': toolName} : _kind; + factory ToolChoice.fromJson(dynamic json) => json is String + ? ToolChoice._(json, null) + : ToolChoice._('tool', (json as Json)['toolName'] as String); } // ───────────────────────────────────────────────────────────────────────────── -// GenerateResult (raw provider result) +// Request / response metadata // ───────────────────────────────────────────────────────────────────────────── -@JsonSerializable() -class ResponseMetadata { - final String? id; - final String? timestamp; - @JsonKey(name: 'model_id') final String? modelId; - ResponseMetadata({this.id, this.timestamp, this.modelId}); - factory ResponseMetadata.fromJson(Map json) => _$ResponseMetadataFromJson(json); - Map toJson() => _$ResponseMetadataToJson(this); -} - -class GenerateRequest { +/// Mirrors `RequestInfo.ts`. +class RequestInfo { final dynamic body; - GenerateRequest({this.body}); - factory GenerateRequest.fromJson(Map json) => GenerateRequest(body: json['body']); - Map toJson() => {'body': body}; + const RequestInfo({this.body}); + factory RequestInfo.fromJson(Json j) => RequestInfo(body: j['body']); + Json toJson() => {if (body != null) 'body': body}; } -class GenerateResponse { +/// Mirrors `ResponseInfo.ts`. +class ResponseInfo { final String? id; final String? timestamp; final String? modelId; final Map? headers; final dynamic body; - GenerateResponse({this.id, this.timestamp, this.modelId, this.headers, this.body}); - factory GenerateResponse.fromJson(Map json) => GenerateResponse(id: json['id'] as String?, timestamp: json['timestamp'] as String?, modelId: json['model_id'] as String?, headers: (json['headers'] as Map?)?.map((key, value) => MapEntry(key, value as String)), body: json['body']); - Map toJson() => {'id': id, 'timestamp': timestamp, 'model_id': modelId, if (headers != null) 'headers': headers, if (body != null) 'body': body}; + const ResponseInfo({this.id, this.timestamp, this.modelId, this.headers, this.body}); + factory ResponseInfo.fromJson(Json j) => ResponseInfo( + id: j['id'] as String?, + timestamp: j['timestamp'] as String?, + modelId: j['modelId'] as String?, + headers: (j['headers'] as Json?)?.cast(), + body: j['body']); + Json toJson() => { + if (id != null) 'id': id, + if (timestamp != null) 'timestamp': timestamp, + if (modelId != null) 'modelId': modelId, + if (headers != null) 'headers': headers, + if (body != null) 'body': body, + }; } -class GenerateRequest { - final dynamic body; - GenerateRequest({this.body}); - factory GenerateRequest.fromJson(Map json) => GenerateRequest(body: json['body']); - Map toJson() => {if (body != null) 'body': body}; +/// Mirrors `ResponseMetadata.ts`. +class ResponseMetadata { + final String? id; + final String? timestamp; + final String? modelId; + const ResponseMetadata({this.id, this.timestamp, this.modelId}); + factory ResponseMetadata.fromJson(Json j) => ResponseMetadata( + id: j['id'] as String?, + timestamp: j['timestamp'] as String?, + modelId: j['modelId'] as String?); + Json toJson() => { + if (id != null) 'id': id, + if (timestamp != null) 'timestamp': timestamp, + if (modelId != null) 'modelId': modelId, + }; } -/// A content item in a `GenerateResult`. Mirrors `GenerateContent.ts` -/// (`aimux-core/src/result.rs`). -/// -/// Externally-tagged union — each item is a single-key map whose key is the -/// variant tag and whose value is the variant payload. Modeled as a sealed -/// class hierarchy so callers get compile-time types: every known variant is a -/// typed subclass with named fields, and [GenerateContentUnknown] is the -/// fallback for tags added by newer core versions so they pass through verbatim -/// instead of crashing. -/// -/// Known variants: [GenerateContentText], [GenerateContentToolCall], -/// [GenerateContentSource], [GenerateContentReasoning], [GenerateContentFile], -/// [GenerateContentToolResult]. The `File` variant carries model-generated -/// files (e.g. images or documents) as a `FileData` tagged union under `data`, -/// alongside a `media_type` (there is **no** `filename` field). Narrow via -/// `switch`/`is`/`whereType` to read variant-specific fields. -sealed class GenerateContent { - const GenerateContent(); +// ───────────────────────────────────────────────────────────────────────────── +// File data, generated files, sources +// ───────────────────────────────────────────────────────────────────────────── - /// The variant tag — the single top-level key on the wire, e.g. `'Text'`, - /// `'ToolCall'`. Useful for logging / generic dispatch. - String get tag; - - /// Re-encode to the externally-tagged wire shape (`{tag: payload}`). - Map toJson(); - - /// Decode an externally-tagged content map. Unknown tags fall back to - /// [GenerateContentUnknown] instead of throwing. - factory GenerateContent.fromJson(Map json) { - final e = json.entries.first; - final payload = e.value as Map; - return switch (e.key) { - 'Text' => GenerateContentText.fromJson(payload), - 'ToolCall' => GenerateContentToolCall.fromJson(payload), - 'Source' => GenerateContentSource.fromJson(payload), - 'Reasoning' => GenerateContentReasoning.fromJson(payload), - 'Custom' => GenerateContentCustom.fromJson(payload), - 'ReasoningFile' => GenerateContentReasoningFile.fromJson(payload), - 'ToolApprovalRequest' => GenerateContentToolApprovalRequest.fromJson(payload), - 'File' => GenerateContentFile.fromJson(payload), - 'ToolResult' => GenerateContentToolResult.fromJson(payload), - _ => GenerateContentUnknown(tag: e.key, data: payload), - }; - } +/// Raw bytes (a JSON array of ints) or a base64 string — `FileBytes.ts` is the +/// untagged union `Array | string`. +sealed class FileBytes { + const FileBytes(); + factory FileBytes.fromJson(Object json) => json is String + ? FileBytesBase64(data: json) + : FileBytesBinary(data: (json as List).cast()); + Object toJson(); } -/// Generated text. -final class GenerateContentText extends GenerateContent { - final String text; - final Map? providerMetadata; - - GenerateContentText({required this.text, this.providerMetadata}); +final class FileBytesBinary extends FileBytes { + final List data; + const FileBytesBinary({required this.data}); + @override + Object toJson() => data; +} +final class FileBytesBase64 extends FileBytes { + final String data; + const FileBytesBase64({required this.data}); @override - String get tag => 'Text'; + Object toJson() => data; +} - factory GenerateContentText.fromJson(Map json) => - GenerateContentText( - text: json['text'] as String, - providerMetadata: - json['provider_metadata'] as Map?, - ); +/// File data of a file part. Mirrors `FileData.ts`: +/// `{"type":"data"|"url"|"reference"|"text", ...}`. +sealed class FileData { + const FileData(); + factory FileData.fromJson(Json j) => switch (j['type']) { + 'data' => FileDataData(data: FileBytes.fromJson(j['data'] as Object)), + 'url' => FileDataUrl( + url: j['url'] as String, originalUrl: j['originalUrl'] as String?), + 'reference' => FileDataReference(reference: j['reference'] as Json), + 'text' => FileDataText(text: j['text'] as String), + _ => throw FormatException('unknown FileData type: ${j['type']}'), + }; + Json toJson(); +} + +final class FileDataData extends FileData { + final FileBytes data; + const FileDataData({required this.data}); + @override + Json toJson() => {'type': 'data', 'data': data.toJson()}; +} +final class FileDataUrl extends FileData { + final String url; + final String? originalUrl; + const FileDataUrl({required this.url, this.originalUrl}); @override - Map toJson() => { - 'Text': { - 'text': text, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }, + Json toJson() => { + 'type': 'url', + 'url': url, + if (originalUrl != null) 'originalUrl': originalUrl, }; } -/// A tool call requested by the model. -final class GenerateContentToolCall extends GenerateContent { - final String toolCallId; - final String toolName; - final dynamic input; - final bool? providerExecuted; - final bool? isDynamic; - final Map? providerMetadata; +final class FileDataReference extends FileData { + final Json reference; + const FileDataReference({required this.reference}); + @override + Json toJson() => {'type': 'reference', 'reference': reference}; +} - GenerateContentToolCall({ - required this.toolCallId, - required this.toolName, - required this.input, - this.providerExecuted, - this.isDynamic, - this.providerMetadata, - }); +final class FileDataText extends FileData { + final String text; + const FileDataText({required this.text}); + @override + Json toJson() => {'type': 'text', 'text': text}; +} + +/// Data or a URL returned for a generated file. Mirrors `GeneratedFileData.ts`. +sealed class GeneratedFileData { + const GeneratedFileData(); + factory GeneratedFileData.fromJson(Json j) => switch (j['type']) { + 'data' => + GeneratedFileDataData(data: FileBytes.fromJson(j['data'] as Object)), + 'url' => GeneratedFileDataUrl( + url: j['url'] as String, originalUrl: j['originalUrl'] as String?), + _ => throw FormatException('unknown GeneratedFileData type: ${j['type']}'), + }; + Json toJson(); +} +final class GeneratedFileDataData extends GeneratedFileData { + final FileBytes data; + const GeneratedFileDataData({required this.data}); @override - String get tag => 'ToolCall'; - - factory GenerateContentToolCall.fromJson(Map json) => - GenerateContentToolCall( - toolCallId: json['tool_call_id'] as String, - toolName: json['tool_name'] as String, - input: json['input'], - providerExecuted: json['provider_executed'] as bool?, - isDynamic: json['dynamic'] as bool?, - providerMetadata: - json['provider_metadata'] as Map?, - ); + Json toJson() => {'type': 'data', 'data': data.toJson()}; +} +final class GeneratedFileDataUrl extends GeneratedFileData { + final String url; + final String? originalUrl; + const GeneratedFileDataUrl({required this.url, this.originalUrl}); @override - Map toJson() => { - 'ToolCall': { - 'tool_call_id': toolCallId, - 'tool_name': toolName, - 'input': input, - if (providerExecuted != null) 'provider_executed': providerExecuted, - if (isDynamic != null) 'dynamic': isDynamic, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }, + Json toJson() => { + 'type': 'url', + 'url': url, + if (originalUrl != null) 'originalUrl': originalUrl, + }; +} + +/// A file generated by the model. Mirrors `GeneratedFile.ts`. +class GeneratedFile { + final GeneratedFileData data; + final String mediaType; + final Json? providerMetadata; + const GeneratedFile( + {required this.data, required this.mediaType, this.providerMetadata}); + factory GeneratedFile.fromJson(Json j) => GeneratedFile( + data: GeneratedFileData.fromJson(j['data'] as Json), + mediaType: j['mediaType'] as String, + providerMetadata: _opt(j['providerMetadata'])); + Json toJson() => { + 'data': data.toJson(), + 'mediaType': mediaType, + if (providerMetadata != null) 'providerMetadata': providerMetadata, }; } +/// A source / citation. Mirrors `Source.ts`, tagged by `sourceType` +/// (`"url"` / `"document"`). sealed class Source { const Source(); - factory Source.fromJson(Map json) => switch (json['source_type']) { - 'url' => UrlSource( - id: json['id'] as String, - url: json['url'] as String, - title: json['title'] as String?, - providerMetadata: json['provider_metadata'] as Map?), - 'document' => DocumentSource( - id: json['id'] as String, - mediaType: json['media_type'] as String, - title: json['title'] as String, - filename: json['filename'] as String?, - providerMetadata: json['provider_metadata'] as Map?), - _ => throw FormatException('invalid source type: ${json['source_type']}'), - }; + factory Source.fromJson(Json j) => switch (j['sourceType']) { + 'url' => UrlSource( + id: j['id'] as String, + url: j['url'] as String, + title: j['title'] as String?, + providerMetadata: _opt(j['providerMetadata'])), + 'document' => DocumentSource( + id: j['id'] as String, + mediaType: j['mediaType'] as String, + title: j['title'] as String, + filename: j['filename'] as String?, + providerMetadata: _opt(j['providerMetadata'])), + _ => throw FormatException('invalid source type: ${j['sourceType']}'), + }; - Map toJson(); + Json toJson(); } final class UrlSource extends Source { final String id; final String url; final String? title; - final Map? providerMetadata; + final Json? providerMetadata; - const UrlSource({required this.id, required this.url, this.title, this.providerMetadata}); + const UrlSource( + {required this.id, required this.url, this.title, this.providerMetadata}); @override - Map toJson() => { - 'source_type': 'url', 'id': id, 'url': url, - if (title != null) 'title': title, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }; + Json toJson() => { + 'sourceType': 'url', + 'id': id, + 'url': url, + if (title != null) 'title': title, + if (providerMetadata != null) 'providerMetadata': providerMetadata, + }; } final class DocumentSource extends Source { @@ -598,250 +654,637 @@ final class DocumentSource extends Source { final String mediaType; final String title; final String? filename; - final Map? providerMetadata; + final Json? providerMetadata; - const DocumentSource({required this.id, required this.mediaType, required this.title, - this.filename, this.providerMetadata}); + const DocumentSource( + {required this.id, + required this.mediaType, + required this.title, + this.filename, + this.providerMetadata}); @override - Map toJson() => { - 'source_type': 'document', 'id': id, 'media_type': mediaType, 'title': title, - if (filename != null) 'filename': filename, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }; + Json toJson() => { + 'sourceType': 'document', + 'id': id, + 'mediaType': mediaType, + 'title': title, + if (filename != null) 'filename': filename, + if (providerMetadata != null) 'providerMetadata': providerMetadata, + }; } -/// A source / citation (e.g. URL citation from search-preview models). -final class GenerateContentSource extends GenerateContent { - final Source source; - - GenerateContentSource({required this.source}); +// ───────────────────────────────────────────────────────────────────────────── +// ContentPart (message content) — `{"type": "text", ...}` +// ───────────────────────────────────────────────────────────────────────────── - @override - String get tag => 'Source'; +/// The output of a tool call in a `tool-result` part. Mirrors +/// `ToolResultOutput.ts`: `{"type":"text"|"error-text","value":"…"}`, +/// `{"type":"json"|"error-json","value":}`, +/// `{"type":"execution-denied","reason"?}` or `{"type":"content","value":[…]}` +/// (`value` is then a list of text / file / custom content maps, kept as +/// weak-typed maps). +class ToolResultOutput { + final String type; + final dynamic value; + final String? reason; + final Json? providerOptions; + + const ToolResultOutput._(this.type, {this.value, this.reason, this.providerOptions}); + + factory ToolResultOutput.text(String value, {Json? providerOptions}) => + ToolResultOutput._('text', value: value, providerOptions: providerOptions); + factory ToolResultOutput.json(Object? value, {Json? providerOptions}) => + ToolResultOutput._('json', value: value, providerOptions: providerOptions); + factory ToolResultOutput.errorText(String value, {Json? providerOptions}) => + ToolResultOutput._('error-text', value: value, providerOptions: providerOptions); + factory ToolResultOutput.errorJson(Object? value, {Json? providerOptions}) => + ToolResultOutput._('error-json', value: value, providerOptions: providerOptions); + factory ToolResultOutput.executionDenied({String? reason, Json? providerOptions}) => + ToolResultOutput._('execution-denied', + reason: reason, providerOptions: providerOptions); + factory ToolResultOutput.content(List value) => + ToolResultOutput._('content', value: value); + + factory ToolResultOutput.fromJson(Json j) => ToolResultOutput._( + j['type'] as String, + value: j['value'], + reason: j['reason'] as String?, + providerOptions: _opt(j['providerOptions']), + ); - factory GenerateContentSource.fromJson(Map json) => - GenerateContentSource(source: Source.fromJson(json)); + Json toJson() => { + 'type': type, + if (type != 'execution-denied') 'value': value, + if (reason != null) 'reason': reason, + if (providerOptions != null) 'providerOptions': providerOptions, + }; +} - @override - Map toJson() => {'Source': source.toJson()}; +/// One part of a message's content. Mirrors `ContentPart.ts`; unknown `type` +/// values fall back to [ContentPartUnknown] and pass through verbatim. +sealed class ContentPart { + const ContentPart(); + factory ContentPart.fromJson(Json j) => switch (j['type']) { + 'text' => ContentPartText( + text: j['text'] as String, providerOptions: _opt(j['providerOptions'])), + 'custom' => ContentPartCustom( + kind: j['kind'] as String, providerOptions: _opt(j['providerOptions'])), + 'reasoning-file' => ContentPartReasoningFile( + data: GeneratedFileData.fromJson(j['data'] as Json), + mediaType: j['mediaType'] as String, + providerOptions: _opt(j['providerOptions'])), + 'tool-approval-request' => ContentPartToolApprovalRequest( + approvalId: j['approvalId'] as String, + toolCallId: j['toolCallId'] as String, + reason: j['reason'] as String?, + isAutomatic: j['isAutomatic'] as bool?, + signature: j['signature'] as String?, + inputSchemaInput: j['inputSchemaInput']), + 'image' => ContentPartImage( + image: (j['image'] as List).cast(), + mediaType: j['mediaType'] as String, + providerOptions: _opt(j['providerOptions'])), + 'file' => ContentPartFile( + data: (j['data'] as List).cast(), + mediaType: j['mediaType'] as String, + filename: j['filename'] as String?, + providerOptions: _opt(j['providerOptions'])), + 'file-base64' => ContentPartFileBase64( + data: j['data'] as String, + mediaType: j['mediaType'] as String, + filename: j['filename'] as String?, + providerOptions: _opt(j['providerOptions'])), + 'file-url' => ContentPartFileUrl( + url: j['url'] as String, + mediaType: j['mediaType'] as String, + providerOptions: _opt(j['providerOptions'])), + 'file-reference' => ContentPartFileReference( + mediaType: j['mediaType'] as String, + reference: j['reference'], + filename: j['filename'] as String?, + providerOptions: _opt(j['providerOptions'])), + 'reasoning' => ContentPartReasoning( + text: j['text'] as String, + signature: j['signature'] as String?, + providerOptions: _opt(j['providerOptions'])), + 'tool-call' => ContentPartToolCall( + toolCallId: j['toolCallId'] as String, + toolName: j['toolName'] as String, + input: j['input'], + providerExecuted: j['providerExecuted'] as bool?, + providerOptions: _opt(j['providerOptions'])), + 'tool-result' => ContentPartToolResult( + toolCallId: j['toolCallId'] as String, + toolName: j['toolName'] as String, + output: ToolResultOutput.fromJson(j['output'] as Json), + providerOptions: _opt(j['providerOptions'])), + _ => ContentPartUnknown(j), + }; + Json toJson(); } -/// A reasoning / thinking segment produced by the model. -final class GenerateContentReasoning extends GenerateContent { +final class ContentPartText extends ContentPart { final String text; - final Map? providerMetadata; - - GenerateContentReasoning({required this.text, this.providerMetadata}); - + final Json? providerOptions; + const ContentPartText({required this.text, this.providerOptions}); @override - String get tag => 'Reasoning'; - - factory GenerateContentReasoning.fromJson(Map json) => - GenerateContentReasoning( - text: json['text'] as String, - providerMetadata: - json['provider_metadata'] as Map?, - ); + Json toJson() => { + 'type': 'text', + 'text': text, + if (providerOptions != null) 'providerOptions': providerOptions, + }; +} +final class ContentPartCustom extends ContentPart { + final String kind; + final Json? providerOptions; + const ContentPartCustom({required this.kind, this.providerOptions}); @override - Map toJson() => { - 'Reasoning': { - 'text': text, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }, + Json toJson() => { + 'type': 'custom', + 'kind': kind, + if (providerOptions != null) 'providerOptions': providerOptions, }; } -/// A file generated by the model (e.g. an image or document). The `data` -/// field is a `FileData` tagged union; there is **no** `filename` field. -final class GenerateContentFile extends GenerateContent { +final class ContentPartReasoningFile extends ContentPart { final GeneratedFileData data; final String mediaType; - final Map? providerMetadata; - - GenerateContentFile({ - required this.data, - required this.mediaType, - this.providerMetadata, - }); - - @override - String get tag => 'File'; - - factory GenerateContentFile.fromJson(Map json) => - GenerateContentFile( - data: GeneratedFileData.fromJson(json['data'] as Map), - mediaType: json['media_type'] as String, - providerMetadata: - json['provider_metadata'] as Map?, - ); - + final Json? providerOptions; + const ContentPartReasoningFile( + {required this.data, required this.mediaType, this.providerOptions}); @override - Map toJson() => { - 'File': { - 'data': data.toJson(), - 'media_type': mediaType, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }, + Json toJson() => { + 'type': 'reasoning-file', + 'data': data.toJson(), + 'mediaType': mediaType, + if (providerOptions != null) 'providerOptions': providerOptions, }; } -/// A tool result from a provider-executed tool (e.g. xAI file_search, -/// web_search). Emitted alongside the preceding `ToolCall`. -final class GenerateContentToolResult extends GenerateContent { +final class ContentPartToolApprovalRequest extends ContentPart { + final String approvalId; final String toolCallId; - final String toolName; - final dynamic result; - final bool? isError; - final bool? preliminary; - final bool? isDynamic; - final Map? providerMetadata; - - GenerateContentToolResult({ - required this.toolCallId, - required this.toolName, - required this.result, - this.isError, - this.preliminary, - this.isDynamic, - this.providerMetadata, - }); - - @override - String get tag => 'ToolResult'; - - factory GenerateContentToolResult.fromJson(Map json) => - GenerateContentToolResult( - toolCallId: json['tool_call_id'] as String, - toolName: json['tool_name'] as String, - result: json['result'], - isError: json['is_error'] as bool?, - preliminary: json['preliminary'] as bool?, - isDynamic: json['dynamic'] as bool?, - providerMetadata: - json['provider_metadata'] as Map?, - ); - + final String? reason; + final bool? isAutomatic; + final String? signature; + final dynamic inputSchemaInput; + const ContentPartToolApprovalRequest( + {required this.approvalId, + required this.toolCallId, + this.reason, + this.isAutomatic, + this.signature, + this.inputSchemaInput}); @override - Map toJson() => { - 'ToolResult': { - 'tool_call_id': toolCallId, - 'tool_name': toolName, - 'result': result, - if (isError != null) 'is_error': isError, - if (preliminary != null) 'preliminary': preliminary, - if (isDynamic != null) 'dynamic': isDynamic, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }, + Json toJson() => { + 'type': 'tool-approval-request', + 'approvalId': approvalId, + 'toolCallId': toolCallId, + if (reason != null) 'reason': reason, + if (isAutomatic != null) 'isAutomatic': isAutomatic, + if (signature != null) 'signature': signature, + if (inputSchemaInput != null) 'inputSchemaInput': inputSchemaInput, }; } -/// Fallback for unknown/forward-compatible `GenerateContent` variants. -/// -/// Newer core versions may emit tags this binding does not model yet; this -/// class passes them through verbatim (`{tag: data}`) instead of discarding -/// or crashing. -final class GenerateContentCustom extends GenerateContent { - final String kind; - final Map? providerMetadata; - GenerateContentCustom({required this.kind, this.providerMetadata}); - @override String get tag => 'Custom'; - factory GenerateContentCustom.fromJson(Map json) => GenerateContentCustom(kind: json['kind'] as String, providerMetadata: json['provider_metadata'] as Map?); - @override Map toJson() => {'Custom': {'kind': kind, if (providerMetadata != null) 'provider_metadata': providerMetadata}}; -} - -final class GenerateContentReasoningFile extends GenerateContent { - final GeneratedFileData data; +final class ContentPartImage extends ContentPart { + final List image; final String mediaType; - final Map? providerMetadata; - GenerateContentReasoningFile({required this.data, required this.mediaType, this.providerMetadata}); - @override String get tag => 'ReasoningFile'; - factory GenerateContentReasoningFile.fromJson(Map json) => GenerateContentReasoningFile(data: GeneratedFileData.fromJson(json['data'] as Map), mediaType: json['media_type'] as String, providerMetadata: json['provider_metadata'] as Map?); - @override Map toJson() => {'ReasoningFile': {'data': data.toJson(), 'media_type': mediaType, if (providerMetadata != null) 'provider_metadata': providerMetadata}}; + final Json? providerOptions; + const ContentPartImage( + {required this.image, required this.mediaType, this.providerOptions}); + @override + Json toJson() => { + 'type': 'image', + 'image': image, + 'mediaType': mediaType, + if (providerOptions != null) 'providerOptions': providerOptions, + }; } -final class GenerateContentToolApprovalRequest extends GenerateContent { - final String approvalId; - final String toolCallId; - final Map? providerMetadata; - GenerateContentToolApprovalRequest({required this.approvalId, required this.toolCallId, this.providerMetadata}); - @override String get tag => 'ToolApprovalRequest'; - factory GenerateContentToolApprovalRequest.fromJson(Map json) => GenerateContentToolApprovalRequest(approvalId: json['approval_id'] as String, toolCallId: json['tool_call_id'] as String, providerMetadata: json['provider_metadata'] as Map?); - @override Map toJson() => {'ToolApprovalRequest': {'approval_id': approvalId, 'tool_call_id': toolCallId, if (providerMetadata != null) 'provider_metadata': providerMetadata}}; +final class ContentPartFile extends ContentPart { + final List data; + final String mediaType; + final String? filename; + final Json? providerOptions; + const ContentPartFile( + {required this.data, + required this.mediaType, + this.filename, + this.providerOptions}); + @override + Json toJson() => { + 'type': 'file', + 'data': data, + 'mediaType': mediaType, + if (filename != null) 'filename': filename, + if (providerOptions != null) 'providerOptions': providerOptions, + }; } -final class GenerateContentUnknown extends GenerateContent { +final class ContentPartFileBase64 extends ContentPart { + final String data; + final String mediaType; + final String? filename; + final Json? providerOptions; + const ContentPartFileBase64( + {required this.data, + required this.mediaType, + this.filename, + this.providerOptions}); @override - final String tag; - final Map data; - - GenerateContentUnknown({required this.tag, required this.data}); + Json toJson() => { + 'type': 'file-base64', + 'data': data, + 'mediaType': mediaType, + if (filename != null) 'filename': filename, + if (providerOptions != null) 'providerOptions': providerOptions, + }; +} +final class ContentPartFileUrl extends ContentPart { + final String url; + final String mediaType; + final Json? providerOptions; + const ContentPartFileUrl( + {required this.url, required this.mediaType, this.providerOptions}); @override - Map toJson() => {tag: data}; + Json toJson() => { + 'type': 'file-url', + 'url': url, + 'mediaType': mediaType, + if (providerOptions != null) 'providerOptions': providerOptions, + }; } -class GenerateResult { - final List content; - final FinishReason finishReason; - final Usage usage; - final List> warnings; - final Map? providerMetadata; - final GenerateResponse? response; - final GenerateRequest? request; - GenerateResult({required this.content, required this.finishReason, required this.usage, this.warnings = const [], this.providerMetadata, this.response, this.request}); - factory GenerateResult.fromJson(Map json) => GenerateResult( - content: (json['content'] as List? ?? []).map((e) => GenerateContent.fromJson(e as Map)).toList(), - finishReason: FinishReason.fromJson(json['finish_reason'] as Map), - usage: Usage.fromJson(json['usage'] as Map), - warnings: (json['warnings'] as List? ?? []).map((e) => e as Map).toList(), - providerMetadata: json['provider_metadata'] as Map?, - response: json['response'] == null ? null : GenerateResponse.fromJson(json['response'] as Map), - request: json['request'] == null ? null : GenerateRequest.fromJson(json['request'] as Map), - ); - Map toJson() => {'content': content.map((c) => c.toJson()).toList(), 'finish_reason': finishReason.toJson(), 'usage': usage.toJson(), 'warnings': warnings, 'response': response?.toJson(), if (providerMetadata != null) 'provider_metadata': providerMetadata, if (request != null) 'request': request?.toJson()}; +final class ContentPartFileReference extends ContentPart { + final String mediaType; + final dynamic reference; + final String? filename; + final Json? providerOptions; + const ContentPartFileReference( + {required this.mediaType, + required this.reference, + this.filename, + this.providerOptions}); + @override + Json toJson() => { + 'type': 'file-reference', + 'mediaType': mediaType, + 'reference': reference, + if (filename != null) 'filename': filename, + if (providerOptions != null) 'providerOptions': providerOptions, + }; +} + +final class ContentPartReasoning extends ContentPart { + final String text; + final String? signature; + final Json? providerOptions; + const ContentPartReasoning( + {required this.text, this.signature, this.providerOptions}); + @override + Json toJson() => { + 'type': 'reasoning', + 'text': text, + if (signature != null) 'signature': signature, + if (providerOptions != null) 'providerOptions': providerOptions, + }; +} + +final class ContentPartToolCall extends ContentPart { + final String toolCallId; + final String toolName; + final dynamic input; + final bool? providerExecuted; + final Json? providerOptions; + const ContentPartToolCall( + {required this.toolCallId, + required this.toolName, + required this.input, + this.providerExecuted, + this.providerOptions}); + @override + Json toJson() => { + 'type': 'tool-call', + 'toolCallId': toolCallId, + 'toolName': toolName, + 'input': input, + if (providerExecuted != null) 'providerExecuted': providerExecuted, + if (providerOptions != null) 'providerOptions': providerOptions, + }; +} + +/// The call-layer tool result a host appends to the transcript: the call's +/// [toolCallId] and [toolName] plus a typed [output]. +final class ContentPartToolResult extends ContentPart { + final String toolCallId; + final String toolName; + final ToolResultOutput output; + final Json? providerOptions; + const ContentPartToolResult( + {required this.toolCallId, + required this.toolName, + required this.output, + this.providerOptions}); + @override + Json toJson() => { + 'type': 'tool-result', + 'toolCallId': toolCallId, + 'toolName': toolName, + 'output': output.toJson(), + if (providerOptions != null) 'providerOptions': providerOptions, + }; +} + +/// Forward-compatibility: a `type` this binding does not model, verbatim. +final class ContentPartUnknown extends ContentPart { + final Json data; + const ContentPartUnknown(this.data); + String get tag => data['type'] as String; + @override + Json toJson() => data; } // ───────────────────────────────────────────────────────────────────────────── -// Result / options +// Messages // ───────────────────────────────────────────────────────────────────────────── -/// Result of `generate_text` (user-facing). Mirrors `GenerateTextResult.ts`. -@JsonSerializable() +/// A single chat message. Mirrors `ModelMessage.ts`. +/// +/// `content` is either a plain `String` or a `List` of content-part maps +/// (see [ContentPart]); it is kept as `Object` so both shapes pass through +/// verbatim. +class ModelMessage { + final String role; + final Object content; + + const ModelMessage({required this.role, required this.content}); + + factory ModelMessage.fromJson(Json j) => + ModelMessage(role: j['role'] as String, content: j['content'] as Object); + Json toJson() => {'role': role, 'content': content}; + + /// [content] as a list of content-part maps: a `String` becomes a single + /// `{'type': 'text', 'text': content}` part. + List get contentParts { + final c = content; + if (c is String) { + return [ + {'type': 'text', 'text': c}, + ]; + } + if (c is List) return c.whereType().toList(); + return const []; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// GenerateResult (provider result) and its content +// ───────────────────────────────────────────────────────────────────────────── + +/// A content item in a [GenerateResult]. Mirrors `GenerateContent.ts` with the +/// provider-level parameters (`RawToolCall`, `RawToolApprovalRequest`, +/// `GeneratedFile`): `{"type": "text" | "tool-call" | "source" | "reasoning" | +/// "file" | "reasoning-file" | "custom" | "tool-approval-request" | +/// "tool-result", ...}`. Unknown types fall back to [GenerateContentUnknown]. +sealed class GenerateContent { + const GenerateContent(); + + /// The wire `type` value, e.g. `'text'`, `'tool-call'`. + String get tag => toJson()['type'] as String; + + Json toJson(); + + factory GenerateContent.fromJson(Json j) => switch (j['type']) { + 'text' => GenerateContentText( + text: j['text'] as String, + providerMetadata: _opt(j['providerMetadata'])), + 'tool-call' => GenerateContentToolCall(RawToolCall.fromJson(j)), + 'source' => GenerateContentSource(source: Source.fromJson(j)), + 'reasoning' => GenerateContentReasoning( + text: j['text'] as String, + providerMetadata: _opt(j['providerMetadata'])), + 'file' => GenerateContentFile(GeneratedFile.fromJson(j)), + 'reasoning-file' => GenerateContentReasoningFile(GeneratedFile.fromJson(j)), + 'custom' => GenerateContentCustom( + kind: j['kind'] as String, + providerMetadata: _opt(j['providerMetadata'])), + 'tool-approval-request' => GenerateContentToolApprovalRequest( + approvalId: j['approvalId'] as String, + toolCallId: j['toolCallId'] as String, + providerMetadata: _opt(j['providerMetadata'])), + 'tool-result' => GenerateContentToolResult(ToolResult.fromJson(j)), + _ => GenerateContentUnknown(j), + }; +} + +final class GenerateContentText extends GenerateContent { + final String text; + final Json? providerMetadata; + const GenerateContentText({required this.text, this.providerMetadata}); + @override + Json toJson() => { + 'type': 'text', + 'text': text, + if (providerMetadata != null) 'providerMetadata': providerMetadata, + }; +} + +/// A tool call as the provider emitted it (raw argument text). +final class GenerateContentToolCall extends GenerateContent { + final RawToolCall call; + const GenerateContentToolCall(this.call); + @override + Json toJson() => {'type': 'tool-call', ...call.toJson()}; +} + +final class GenerateContentSource extends GenerateContent { + final Source source; + const GenerateContentSource({required this.source}); + @override + Json toJson() => {'type': 'source', ...source.toJson()}; +} + +final class GenerateContentReasoning extends GenerateContent { + final String text; + final Json? providerMetadata; + const GenerateContentReasoning({required this.text, this.providerMetadata}); + @override + Json toJson() => { + 'type': 'reasoning', + 'text': text, + if (providerMetadata != null) 'providerMetadata': providerMetadata, + }; +} + +final class GenerateContentFile extends GenerateContent { + final GeneratedFile file; + const GenerateContentFile(this.file); + @override + Json toJson() => {'type': 'file', ...file.toJson()}; +} + +final class GenerateContentReasoningFile extends GenerateContent { + final GeneratedFile file; + const GenerateContentReasoningFile(this.file); + @override + Json toJson() => {'type': 'reasoning-file', ...file.toJson()}; +} + +final class GenerateContentCustom extends GenerateContent { + final String kind; + final Json? providerMetadata; + const GenerateContentCustom({required this.kind, this.providerMetadata}); + @override + Json toJson() => { + 'type': 'custom', + 'kind': kind, + if (providerMetadata != null) 'providerMetadata': providerMetadata, + }; +} + +final class GenerateContentToolApprovalRequest extends GenerateContent { + final String approvalId; + final String toolCallId; + final Json? providerMetadata; + const GenerateContentToolApprovalRequest( + {required this.approvalId, required this.toolCallId, this.providerMetadata}); + @override + Json toJson() => { + 'type': 'tool-approval-request', + 'approvalId': approvalId, + 'toolCallId': toolCallId, + if (providerMetadata != null) 'providerMetadata': providerMetadata, + }; +} + +/// The result of a provider-executed tool. Mirrors `ToolResult.ts`: [result] is +/// the tool's output, [isError] / [preliminary] / [isDynamic] (wire key +/// `dynamic`) its flags. +class ToolResult { + final String toolCallId; + final String toolName; + final dynamic result; + final bool? isError; + final bool? preliminary; + final bool? isDynamic; + final Json? providerMetadata; + const ToolResult({ + required this.toolCallId, + required this.toolName, + required this.result, + this.isError, + this.preliminary, + this.isDynamic, + this.providerMetadata, + }); + factory ToolResult.fromJson(Json j) => ToolResult( + toolCallId: j['toolCallId'] as String, + toolName: j['toolName'] as String, + result: j['result'], + isError: j['isError'] as bool?, + preliminary: j['preliminary'] as bool?, + isDynamic: j['dynamic'] as bool?, + providerMetadata: _opt(j['providerMetadata']), + ); + Json toJson() => { + 'toolCallId': toolCallId, + 'toolName': toolName, + 'result': result, + if (isError != null) 'isError': isError, + if (preliminary != null) 'preliminary': preliminary, + if (isDynamic != null) 'dynamic': isDynamic, + if (providerMetadata != null) 'providerMetadata': providerMetadata, + }; +} + +final class GenerateContentToolResult extends GenerateContent { + final ToolResult result; + const GenerateContentToolResult(this.result); + @override + Json toJson() => {'type': 'tool-result', ...result.toJson()}; +} + +/// Forward-compatibility: a `type` this binding does not model, verbatim. +final class GenerateContentUnknown extends GenerateContent { + final Json data; + const GenerateContentUnknown(this.data); + @override + Json toJson() => data; +} + +/// The raw provider result. Mirrors `GenerateResult.ts`. +class GenerateResult { + final List content; + final FinishReason finishReason; + final Usage usage; + final List warnings; + final Json? providerMetadata; + final RequestInfo? request; + final ResponseInfo? response; + const GenerateResult({ + required this.content, + required this.finishReason, + required this.usage, + required this.warnings, + this.providerMetadata, + this.request, + this.response, + }); + factory GenerateResult.fromJson(Json j) => GenerateResult( + content: _list(j['content'], GenerateContent.fromJson), + finishReason: FinishReason.fromJson(j['finishReason'] as Json), + usage: Usage.fromJson(j['usage'] as Json), + warnings: _maps(j['warnings']), + providerMetadata: _opt(j['providerMetadata']), + request: j['request'] == null ? null : RequestInfo.fromJson(j['request'] as Json), + response: + j['response'] == null ? null : ResponseInfo.fromJson(j['response'] as Json), + ); + Json toJson() => { + 'content': [for (final c in content) c.toJson()], + 'finishReason': finishReason.toJson(), + 'usage': usage.toJson(), + 'warnings': warnings, + if (providerMetadata != null) 'providerMetadata': providerMetadata, + if (request != null) 'request': request!.toJson(), + if (response != null) 'response': response!.toJson(), + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Results / options +// ───────────────────────────────────────────────────────────────────────────── + +/// Result of `generate_text`. Mirrors `GenerateTextResult.ts`. The typed-union +/// fields (`content`, `reasoning`, `files`, `warnings`) stay weak-typed maps. class GenerateTextResult { final String text; - final List> content; - @JsonKey(name: 'tool_calls') + final List content; final List toolCalls; - @JsonKey(name: 'finish_reason') final FinishReason finishReason; final Usage usage; - final List> warnings; + final List warnings; final GenerateResult raw; - // M7: top-level aggregation fields - final List> reasoning; - @JsonKey(name: 'reasoning_text') + final List reasoning; final String reasoningText; final List sources; - final List> files; - @JsonKey(name: 'response_messages') + final List files; + + /// Assistant messages ready to append to the prompt for the next turn. final List responseMessages; - /// Raw provider-specific finish reason string (M12, e.g. "stop", "end_turn"). - @JsonKey(name: 'raw_finish_reason') final String? rawFinishReason; - /// Provider-specific metadata (e.g. Anthropic cache info). Mirrored from - /// raw.provider_metadata for top-level convenience. Weak type. - @JsonKey(name: 'provider_metadata') - final Map? providerMetadata; - /// Response metadata (id, timestamp, model_id). Mirrored from raw.response. - final ResponseMetadata response; - /// Total token usage across all steps. In single-step mode (aimux's - /// default), equals usage. Provided for AI SDK parity. - @JsonKey(name: 'total_usage') + final Json? providerMetadata; + final RequestInfo request; + final ResponseInfo response; + + /// Total token usage across all steps (equals [usage] in single-step mode). final Usage totalUsage; - GenerateTextResult({ + const GenerateTextResult({ required this.text, this.content = const [], required this.toolCalls, @@ -856,40 +1299,68 @@ class GenerateTextResult { this.responseMessages = const [], this.rawFinishReason, this.providerMetadata, + required this.request, required this.response, required this.totalUsage, }); - factory GenerateTextResult.fromJson(Map json) => - _$GenerateTextResultFromJson(json); - Map toJson() => _$GenerateTextResultToJson(this); + factory GenerateTextResult.fromJson(Json j) => GenerateTextResult( + text: j['text'] as String, + content: _maps(j['content']), + toolCalls: _list(j['toolCalls'], ToolCall.fromJson), + finishReason: FinishReason.fromJson(j['finishReason'] as Json), + usage: Usage.fromJson(j['usage'] as Json), + warnings: _maps(j['warnings']), + raw: GenerateResult.fromJson(j['raw'] as Json), + reasoning: _maps(j['reasoning']), + reasoningText: j['reasoningText'] as String, + sources: _list(j['sources'], Source.fromJson), + files: _maps(j['files']), + responseMessages: _list(j['responseMessages'], ModelMessage.fromJson), + rawFinishReason: j['rawFinishReason'] as String?, + providerMetadata: _opt(j['providerMetadata']), + request: RequestInfo.fromJson(j['request'] as Json), + response: ResponseInfo.fromJson(j['response'] as Json), + totalUsage: Usage.fromJson(j['totalUsage'] as Json), + ); + + Json toJson() => { + 'content': content, + 'text': text, + 'toolCalls': [for (final c in toolCalls) c.toJson()], + 'finishReason': finishReason.toJson(), + 'usage': usage.toJson(), + 'warnings': warnings, + 'raw': raw.toJson(), + 'reasoning': reasoning, + 'reasoningText': reasoningText, + 'sources': [for (final s in sources) s.toJson()], + 'files': files, + 'responseMessages': [for (final m in responseMessages) m.toJson()], + if (rawFinishReason != null) 'rawFinishReason': rawFinishReason, + if (providerMetadata != null) 'providerMetadata': providerMetadata, + 'request': request.toJson(), + 'response': response.toJson(), + 'totalUsage': totalUsage.toJson(), + }; } -/// Result of `generate_object` (user-facing, M12). The parsed JSON object plus -/// convenience fields from the underlying `generate_text` call. -/// -/// Mirrors `GenerateObjectResult.ts`. `object` is an arbitrary JSON value -/// (weak type — `Map` / primitive). -@JsonSerializable() +/// Result of `generate_object`. Mirrors `GenerateObjectResult.ts`; [object] is +/// an arbitrary JSON value. class GenerateObjectResult { - /// The parsed JSON object returned by the model (arbitrary JSON, weak type). final Object? object; - @JsonKey(name: 'finish_reason') final FinishReason finishReason; - @JsonKey(name: 'raw_finish_reason') final String? rawFinishReason; final Usage usage; - final List> warnings; - /// Concatenated reasoning text (if the model produced reasoning/thinking). + final List warnings; + + /// Concatenated reasoning text, if the model produced any. final String? reasoning; - /// Provider-specific metadata (e.g. Anthropic cache info). Weak type. - @JsonKey(name: 'provider_metadata') - final Map? providerMetadata; - /// Response metadata (id, timestamp, model_id). + final Json? providerMetadata; final ResponseMetadata response; final GenerateTextResult raw; - GenerateObjectResult({ + const GenerateObjectResult({ this.object, required this.finishReason, this.rawFinishReason, @@ -901,49 +1372,55 @@ class GenerateObjectResult { required this.raw, }); - factory GenerateObjectResult.fromJson(Map json) => - _$GenerateObjectResultFromJson(json); - Map toJson() => _$GenerateObjectResultToJson(this); + factory GenerateObjectResult.fromJson(Json j) => GenerateObjectResult( + object: j['object'], + finishReason: FinishReason.fromJson(j['finishReason'] as Json), + rawFinishReason: j['rawFinishReason'] as String?, + usage: Usage.fromJson(j['usage'] as Json), + warnings: _maps(j['warnings']), + reasoning: j['reasoning'] as String?, + providerMetadata: _opt(j['providerMetadata']), + response: ResponseMetadata.fromJson(j['response'] as Json), + raw: GenerateTextResult.fromJson(j['raw'] as Json), + ); + + Json toJson() => { + 'object': object, + 'finishReason': finishReason.toJson(), + if (rawFinishReason != null) 'rawFinishReason': rawFinishReason, + 'usage': usage.toJson(), + 'warnings': warnings, + if (reasoning != null) 'reasoning': reasoning, + if (providerMetadata != null) 'providerMetadata': providerMetadata, + 'response': response.toJson(), + 'raw': raw.toJson(), + }; } -/// Aggregated result of `stream_text().consume()` (M11). Mirrors -/// `GenerateTextResult`'s user-facing fields (without `raw`, since streaming -/// has no `GenerateResult` equivalent). -/// -/// Mirrors `StreamTextResultAggregated.ts`. reasoning/files use weak -/// types (`Map`) — same strategy as `GenerateTextResult`. -@JsonSerializable() +/// Aggregated result of `consume_stream_text`. Mirrors +/// `StreamTextResultAggregated.ts` (the fields of [GenerateTextResult] except +/// `raw`). class StreamTextResultAggregated { final String text; - final List> content; - // reasoning/files use weak types — same strategy as GenerateTextResult. - final List> reasoning; - @JsonKey(name: 'reasoning_text') + final List content; + final List reasoning; final String reasoningText; - @JsonKey(name: 'tool_calls') final List toolCalls; final List sources; - final List> files; - @JsonKey(name: 'finish_reason') + final List files; final FinishReason finishReason; - @JsonKey(name: 'raw_finish_reason') final String? rawFinishReason; final Usage usage; - /// Total token usage across all steps. In single-step mode (aimux's - /// default), equals usage. Provided for AI SDK parity. - @JsonKey(name: 'total_usage') final Usage totalUsage; - final List> warnings; - /// Provider-specific metadata from the Finish chunk. Weak type. - @JsonKey(name: 'provider_metadata') - final Map? providerMetadata; - /// Response metadata (id, timestamp, model_id) if emitted by the stream. - final ResponseMetadata? response; - @JsonKey(name: 'response_messages') + final List warnings; + final Json? providerMetadata; + final RequestInfo request; + final ResponseInfo response; final List responseMessages; - StreamTextResultAggregated({ - this.text = '', + const StreamTextResultAggregated({ + required this.text, + this.content = const [], this.reasoning = const [], this.reasoningText = '', this.toolCalls = const [], @@ -955,80 +1432,97 @@ class StreamTextResultAggregated { required this.totalUsage, this.warnings = const [], this.providerMetadata, - this.response, + required this.request, + required this.response, this.responseMessages = const [], }); - factory StreamTextResultAggregated.fromJson(Map json) => - _$StreamTextResultAggregatedFromJson(json); - Map toJson() => _$StreamTextResultAggregatedToJson(this); + factory StreamTextResultAggregated.fromJson(Json j) => StreamTextResultAggregated( + text: j['text'] as String, + content: _maps(j['content']), + reasoning: _maps(j['reasoning']), + reasoningText: j['reasoningText'] as String, + toolCalls: _list(j['toolCalls'], ToolCall.fromJson), + sources: _list(j['sources'], Source.fromJson), + files: _maps(j['files']), + finishReason: FinishReason.fromJson(j['finishReason'] as Json), + rawFinishReason: j['rawFinishReason'] as String?, + usage: Usage.fromJson(j['usage'] as Json), + totalUsage: Usage.fromJson(j['totalUsage'] as Json), + warnings: _maps(j['warnings']), + providerMetadata: _opt(j['providerMetadata']), + request: RequestInfo.fromJson(j['request'] as Json), + response: ResponseInfo.fromJson(j['response'] as Json), + responseMessages: _list(j['responseMessages'], ModelMessage.fromJson), + ); + + Json toJson() => { + 'content': content, + 'text': text, + 'reasoning': reasoning, + 'reasoningText': reasoningText, + 'toolCalls': [for (final c in toolCalls) c.toJson()], + 'sources': [for (final s in sources) s.toJson()], + 'files': files, + 'finishReason': finishReason.toJson(), + if (rawFinishReason != null) 'rawFinishReason': rawFinishReason, + 'usage': usage.toJson(), + 'totalUsage': totalUsage.toJson(), + 'warnings': warnings, + if (providerMetadata != null) 'providerMetadata': providerMetadata, + 'request': request.toJson(), + 'response': response.toJson(), + 'responseMessages': [for (final m in responseMessages) m.toJson()], + }; } -/// Per-call timeout configuration. Mirrors `TimeoutConfiguration.ts`. -/// -/// All values are milliseconds; `null` disables the corresponding limit. -@JsonSerializable() +/// Per-call timeout configuration, in milliseconds; null disables a limit. +/// Mirrors `TimeoutConfiguration.ts`. class TimeoutConfiguration { - @JsonKey(name: 'total_ms') final int? totalMs; - @JsonKey(name: 'step_ms') final int? stepMs; - @JsonKey(name: 'first_chunk_ms') final int? firstChunkMs; - @JsonKey(name: 'chunk_ms') final int? chunkMs; - TimeoutConfiguration({this.totalMs, this.stepMs, this.firstChunkMs, this.chunkMs}); + const TimeoutConfiguration({this.totalMs, this.stepMs, this.firstChunkMs, this.chunkMs}); - factory TimeoutConfiguration.fromJson(Map json) => - TimeoutConfiguration( - totalMs: json['total_ms'] as int?, - stepMs: json['step_ms'] as int?, - firstChunkMs: json['first_chunk_ms'] as int?, - chunkMs: json['chunk_ms'] as int?, + factory TimeoutConfiguration.fromJson(Json j) => TimeoutConfiguration( + totalMs: j['totalMs'] as int?, + stepMs: j['stepMs'] as int?, + firstChunkMs: j['firstChunkMs'] as int?, + chunkMs: j['chunkMs'] as int?, ); - Map toJson() => { - if (totalMs != null) 'total_ms': totalMs, - if (stepMs != null) 'step_ms': stepMs, - if (firstChunkMs != null) 'first_chunk_ms': firstChunkMs, - if (chunkMs != null) 'chunk_ms': chunkMs, + Json toJson() => { + if (totalMs != null) 'totalMs': totalMs, + if (stepMs != null) 'stepMs': stepMs, + if (firstChunkMs != null) 'firstChunkMs': firstChunkMs, + if (chunkMs != null) 'chunkMs': chunkMs, }; } /// User-facing options for `generate_text` / `stream_text`. Mirrors -/// `GenerateTextOptions.ts`. -@JsonSerializable() +/// `GenerateTextOptions.ts`; unset fields are omitted from the JSON. class GenerateTextOptions { - @JsonKey(name: 'max_output_tokens') final int? maxOutputTokens; final double? temperature; - @JsonKey(name: 'stop_sequences') final List? stopSequences; - @JsonKey(name: 'top_p') final double? topP; - @JsonKey(name: 'top_k') final double? topK; - @JsonKey(name: 'presence_penalty') final double? presencePenalty; - @JsonKey(name: 'frequency_penalty') final double? frequencyPenalty; - @JsonKey(name: 'response_format') - final Map? responseFormat; + + /// `{"type": "text"}` or `{"type": "json", "schema"?, "name"?, "description"?}`. + final Json? responseFormat; final int? seed; final List? tools; - @JsonKey(name: 'tool_choice') final ToolChoice? toolChoice; final Map? headers; - @JsonKey(name: 'provider_options') - final Map? providerOptions; + final Json? providerOptions; final ReasoningEffort? reasoning; final String? instructions; - @JsonKey(name: 'max_retries') final int? maxRetries; final TimeoutConfiguration? timeout; - @JsonKey(name: 'include_raw_chunks') final bool? includeRawChunks; - @JsonKey(name: 'session_id') final String? sessionId; /// Repair a tool call the model got wrong (RFC-0035), mirroring the AI SDK's @@ -1046,10 +1540,9 @@ class GenerateTextOptions { /// argument deltas verbatim, as in the AI SDK. /// /// Never serialized — it is a host closure, not part of the wire options. - @JsonKey(includeFromJson: false, includeToJson: false) final RepairToolCall? repairToolCall; - GenerateTextOptions({ + const GenerateTextOptions({ this.maxOutputTokens, this.temperature, this.stopSequences, @@ -1072,182 +1565,125 @@ class GenerateTextOptions { this.repairToolCall, }); - factory GenerateTextOptions.fromJson(Map json) { - return GenerateTextOptions( - maxOutputTokens: json['max_output_tokens'] as int?, - temperature: (json['temperature'] as num?)?.toDouble(), - stopSequences: (json['stop_sequences'] as List?)?.cast(), - topP: (json['top_p'] as num?)?.toDouble(), - topK: (json['top_k'] as num?)?.toDouble(), - presencePenalty: (json['presence_penalty'] as num?)?.toDouble(), - frequencyPenalty: (json['frequency_penalty'] as num?)?.toDouble(), - responseFormat: json['response_format'] as Map?, - seed: json['seed'] as int?, - tools: (json['tools'] as List?) - ?.map((e) => Tool.fromJson(e as Map)) - .toList(), - toolChoice: json['tool_choice'] != null - ? ToolChoice.fromJson(json['tool_choice']) - : null, - headers: (json['headers'] as Map?)?.cast(), - providerOptions: json['provider_options'] as Map?, - reasoning: json['reasoning'] != null - ? ReasoningEffort.fromJson(json['reasoning'] as String) - : null, - instructions: json['instructions'] as String?, - maxRetries: json['max_retries'] as int?, - timeout: json['timeout'] != null - ? TimeoutConfiguration.fromJson(json['timeout'] as Map) - : null, - includeRawChunks: json['include_raw_chunks'] as bool?, - sessionId: json['session_id'] as String?, - ); - } - Map toJson() => { - if (maxOutputTokens != null) 'max_output_tokens': maxOutputTokens, + factory GenerateTextOptions.fromJson(Json j) => GenerateTextOptions( + maxOutputTokens: j['maxOutputTokens'] as int?, + temperature: (j['temperature'] as num?)?.toDouble(), + stopSequences: (j['stopSequences'] as List?)?.cast(), + topP: (j['topP'] as num?)?.toDouble(), + topK: (j['topK'] as num?)?.toDouble(), + presencePenalty: (j['presencePenalty'] as num?)?.toDouble(), + frequencyPenalty: (j['frequencyPenalty'] as num?)?.toDouble(), + responseFormat: _opt(j['responseFormat']), + seed: j['seed'] as int?, + tools: _optList(j['tools'], Tool.fromJson), + toolChoice: + j['toolChoice'] == null ? null : ToolChoice.fromJson(j['toolChoice']), + headers: (j['headers'] as Json?)?.cast(), + providerOptions: _opt(j['providerOptions']), + reasoning: j['reasoning'] == null + ? null + : ReasoningEffort.fromJson(j['reasoning'] as String), + instructions: j['instructions'] as String?, + maxRetries: j['maxRetries'] as int?, + timeout: j['timeout'] == null + ? null + : TimeoutConfiguration.fromJson(j['timeout'] as Json), + includeRawChunks: j['includeRawChunks'] as bool?, + sessionId: j['sessionId'] as String?, + ); + + Json toJson() => { + if (maxOutputTokens != null) 'maxOutputTokens': maxOutputTokens, if (temperature != null) 'temperature': temperature, - if (stopSequences != null) 'stop_sequences': stopSequences, - if (topP != null) 'top_p': topP, - if (topK != null) 'top_k': topK, - if (presencePenalty != null) 'presence_penalty': presencePenalty, - if (frequencyPenalty != null) 'frequency_penalty': frequencyPenalty, - if (responseFormat != null) 'response_format': responseFormat, + if (stopSequences != null) 'stopSequences': stopSequences, + if (topP != null) 'topP': topP, + if (topK != null) 'topK': topK, + if (presencePenalty != null) 'presencePenalty': presencePenalty, + if (frequencyPenalty != null) 'frequencyPenalty': frequencyPenalty, + if (responseFormat != null) 'responseFormat': responseFormat, if (seed != null) 'seed': seed, - if (tools != null) 'tools': tools!.map((t) => t.toJson()).toList(), - if (toolChoice != null) 'tool_choice': toolChoice!.toJson(), + if (tools != null) 'tools': [for (final t in tools!) t.toJson()], + if (toolChoice != null) 'toolChoice': toolChoice!.toJson(), if (headers != null) 'headers': headers, - if (providerOptions != null) 'provider_options': providerOptions, + if (providerOptions != null) 'providerOptions': providerOptions, if (reasoning != null) 'reasoning': reasoning!.toJson(), if (instructions != null) 'instructions': instructions, - if (maxRetries != null) 'max_retries': maxRetries, + if (maxRetries != null) 'maxRetries': maxRetries, if (timeout != null) 'timeout': timeout!.toJson(), - if (includeRawChunks != null) 'include_raw_chunks': includeRawChunks, - if (sessionId != null) 'session_id': sessionId, + if (includeRawChunks != null) 'includeRawChunks': includeRawChunks, + if (sessionId != null) 'sessionId': sessionId, }; } -// ───────────────────────────────────────────────────────────────────────────── -// Messages -// ───────────────────────────────────────────────────────────────────────────── - -/// A single user-facing chat message. Mirrors `ModelMessage.ts`. -/// -/// `content` is either a plain `String` or a `List` of content-part maps -/// (`[{"type":"text","text":"..."}, ...]`); it is kept as `Object` so both -/// shapes pass through verbatim. -@JsonSerializable() -class ModelMessage { - final String role; - final Object content; - - ModelMessage({required this.role, required this.content}); - - factory ModelMessage.fromJson(Map json) => - _$ModelMessageFromJson(json); - Map toJson() => _$ModelMessageToJson(this); - - /// Convenience view of [content] as a list of content-part maps. - /// - /// If [content] is a `String`, returns `[{'type': 'text', 'text': content}]`; - /// if it is a `List`, returns it cast to `List>`; - /// otherwise an empty list. `content` itself stays `Object` so both the - /// string and the part-list wire shapes pass through verbatim. - List> get contentParts { - final c = content; - if (c is String) { - return >[ - {'type': 'text', 'text': c}, - ]; - } - if (c is List) { - return c.whereType>().toList(); - } - return const >[]; - } -} - // ───────────────────────────────────────────────────────────────────────────── // Streaming // ───────────────────────────────────────────────────────────────────────────── -/// A single chunk in the stream returned by `stream_text`. Mirrors -/// `StreamPart.ts` (`aimux-core/src/stream_part.rs`). -/// -/// `StreamPart` is an externally-tagged union: each part is a single-key map -/// like `{"TextDelta": {"id": "...", "delta": "..."}}`. Modeled as a sealed -/// class hierarchy so callers get compile-time types: every known variant is a -/// typed subclass with named fields, and [StreamPartUnknown] is the fallback -/// for tags added by newer core versions. -/// -/// Known variants: [StreamPartTextStart]/[StreamPartTextDelta]/[StreamPartTextEnd], -/// [StreamPartStreamStart], [StreamPartFinish], [StreamPartError], -/// [StreamPartToolInputStart]/[StreamPartToolInputDelta]/[StreamPartToolInputEnd], -/// [StreamPartToolCall], [StreamPartToolResult], [StreamPartFile], -/// [StreamPartReasoningStart]/[StreamPartReasoningDelta]/[StreamPartReasoningEnd], -/// [StreamPartResponseMetadata], [StreamPartSource], [StreamPartRaw]. Narrow -/// via `switch`/`is`/`whereType` to read variant-specific fields. +/// A single part of the stream returned by `stream_text`. Mirrors +/// `TextStreamPart.ts`: `{"type": "text-delta", "id": ..., "delta": ...}` — +/// the tag values are `text-start`, `text-delta`, `text-end`, `stream-start`, +/// `finish`, `finish-step`, `error`, `tool-input-start`, `tool-input-delta`, +/// `tool-input-end`, `tool-call`, `tool-result`, `file`, `reasoning-file`, +/// `custom`, `tool-approval-request`, `reasoning-start`, `reasoning-delta`, +/// `reasoning-end`, `source` and `raw`. A `type` this binding does not model +/// becomes [StreamPartUnknown] and passes through verbatim. Narrow via +/// `switch` / `is` / `whereType`. sealed class StreamPart { const StreamPart(); - /// The variant tag — the single top-level key on the wire (e.g. - /// `'TextDelta'`, `'ToolCall'`, `'ToolResult'`, `'File'`, `'Finish'`, - /// `'Error'`). Empty only for a malformed part. - String get type => switch (this) { - StreamPartTextStart() => 'TextStart', - StreamPartTextDelta() => 'TextDelta', - StreamPartTextEnd() => 'TextEnd', - StreamPartStreamStart() => 'StreamStart', - StreamPartFinish() => 'Finish', - StreamPartError() => 'Error', - StreamPartToolInputStart() => 'ToolInputStart', - StreamPartToolInputDelta() => 'ToolInputDelta', - StreamPartToolInputEnd() => 'ToolInputEnd', - StreamPartToolCall() => 'ToolCall', - StreamPartToolResult() => 'ToolResult', - StreamPartCustom() => 'Custom', - StreamPartReasoningFile() => 'ReasoningFile', - StreamPartToolApprovalRequest() => 'ToolApprovalRequest', - StreamPartFile() => 'File', - StreamPartReasoningStart() => 'ReasoningStart', - StreamPartReasoningDelta() => 'ReasoningDelta', - StreamPartReasoningEnd() => 'ReasoningEnd', - StreamPartResponseMetadata() => 'ResponseMetadata', - StreamPartSource() => 'Source', - StreamPartRaw() => 'Raw', - StreamPartUnknown(:final tag) => tag, - }; - - /// Re-encode to the externally-tagged wire shape (`{tag: payload}`). - Map toJson(); - - /// Decode an externally-tagged stream-part map. Unknown tags fall back to - /// [StreamPartUnknown] instead of throwing. - factory StreamPart.fromJson(Map json) { - final e = json.entries.first; - final payload = e.value as Map; - return switch (e.key) { - 'TextStart' => StreamPartTextStart.fromJson(payload), - 'TextDelta' => StreamPartTextDelta.fromJson(payload), - 'TextEnd' => StreamPartTextEnd.fromJson(payload), - 'StreamStart' => StreamPartStreamStart.fromJson(payload), - 'Finish' => StreamPartFinish.fromJson(payload), - 'Error' => StreamPartError.fromJson(payload), - 'ToolInputStart' => StreamPartToolInputStart.fromJson(payload), - 'ToolInputDelta' => StreamPartToolInputDelta.fromJson(payload), - 'ToolInputEnd' => StreamPartToolInputEnd.fromJson(payload), - 'ToolCall' => StreamPartToolCall.fromJson(payload), - 'ToolResult' => StreamPartToolResult.fromJson(payload), - 'Custom' => StreamPartCustom.fromJson(payload), - 'ReasoningFile' => StreamPartReasoningFile.fromJson(payload), - 'ToolApprovalRequest' => StreamPartToolApprovalRequest.fromJson(payload), - 'File' => StreamPartFile.fromJson(payload), - 'ReasoningStart' => StreamPartReasoningStart.fromJson(payload), - 'ReasoningDelta' => StreamPartReasoningDelta.fromJson(payload), - 'ReasoningEnd' => StreamPartReasoningEnd.fromJson(payload), - 'ResponseMetadata' => StreamPartResponseMetadata.fromJson(payload), - 'Source' => StreamPartSource.fromJson(payload), - 'Raw' => StreamPartRaw.fromJson(payload), - _ => StreamPartUnknown(tag: e.key, data: payload), + /// The wire `type` value, e.g. `'text-delta'`. + String get type => toJson()['type'] as String; + + Json toJson(); + + factory StreamPart.fromJson(Json j) { + String id() => j['id'] as String; + Json? meta() => _opt(j['providerMetadata']); + return switch (j['type']) { + 'text-start' => StreamPartTextStart(id: id(), providerMetadata: meta()), + 'text-delta' => StreamPartTextDelta( + id: id(), delta: j['delta'] as String, providerMetadata: meta()), + 'text-end' => StreamPartTextEnd(id: id(), providerMetadata: meta()), + 'stream-start' => StreamPartStreamStart(warnings: _maps(j['warnings'])), + 'finish' => StreamPartFinish( + finishReason: FinishReason.fromJson(j['finishReason'] as Json), + usage: Usage.fromJson(j['usage'] as Json), + providerMetadata: meta()), + 'finish-step' => StreamPartFinishStep( + finishReason: FinishReason.fromJson(j['finishReason'] as Json), + usage: Usage.fromJson(j['usage'] as Json), + providerMetadata: meta(), + response: ResponseInfo.fromJson(j['response'] as Json)), + 'error' => StreamPartError(error: j['error'] as Json), + 'tool-input-start' => StreamPartToolInputStart( + id: id(), + toolName: j['toolName'] as String, + providerExecuted: j['providerExecuted'] as bool?, + isDynamic: j['dynamic'] as bool?, + title: j['title'] as String?, + providerMetadata: meta()), + 'tool-input-delta' => StreamPartToolInputDelta( + id: id(), delta: j['delta'] as String, providerMetadata: meta()), + 'tool-input-end' => StreamPartToolInputEnd(id: id(), providerMetadata: meta()), + 'tool-call' => StreamPartToolCall(ToolCall.fromJson(j)), + 'tool-result' => StreamPartToolResult(ToolResult.fromJson(j)), + 'file' => StreamPartFile(GeneratedFile.fromJson(j)), + 'reasoning-file' => StreamPartReasoningFile( + file: GeneratedFile.fromJson(j['file'] as Json), providerMetadata: meta()), + 'custom' => StreamPartCustom(kind: j['kind'] as String, providerMetadata: meta()), + 'tool-approval-request' => StreamPartToolApprovalRequest( + approvalId: j['approvalId'] as String, + toolCall: ToolCall.fromJson(j['toolCall'] as Json), + reason: j['reason'] as String?, + isAutomatic: j['isAutomatic'] as bool?, + signature: j['signature'] as String?), + 'reasoning-start' => StreamPartReasoningStart(id: id(), providerMetadata: meta()), + 'reasoning-delta' => StreamPartReasoningDelta( + id: id(), delta: j['delta'] as String, providerMetadata: meta()), + 'reasoning-end' => StreamPartReasoningEnd(id: id(), providerMetadata: meta()), + 'source' => StreamPartSource(source: Source.fromJson(j)), + 'raw' => StreamPartRaw(rawValue: j['rawValue']), + _ => StreamPartUnknown(j), }; } @@ -1255,153 +1691,101 @@ sealed class StreamPart { String toString() => 'StreamPart($type)'; } -// ── Text variants ──────────────────────────────────────────────────────────── - -/// Start of a text segment. +/// Start / delta / end of a text segment. final class StreamPartTextStart extends StreamPart { final String id; - final Map? providerMetadata; - - StreamPartTextStart({required this.id, this.providerMetadata}); - - factory StreamPartTextStart.fromJson(Map json) => - StreamPartTextStart( - id: json['id'] as String, - providerMetadata: - json['provider_metadata'] as Map?, - ); - + final Json? providerMetadata; + const StreamPartTextStart({required this.id, this.providerMetadata}); @override - Map toJson() => { - 'TextStart': { - 'id': id, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }, + Json toJson() => { + 'type': 'text-start', + 'id': id, + if (providerMetadata != null) 'providerMetadata': providerMetadata, }; } -/// A delta of generated text. final class StreamPartTextDelta extends StreamPart { final String id; final String delta; - final Map? providerMetadata; - - StreamPartTextDelta({ - required this.id, - required this.delta, - this.providerMetadata, - }); - - factory StreamPartTextDelta.fromJson(Map json) => - StreamPartTextDelta( - id: json['id'] as String, - delta: json['delta'] as String, - providerMetadata: - json['provider_metadata'] as Map?, - ); - + final Json? providerMetadata; + const StreamPartTextDelta( + {required this.id, required this.delta, this.providerMetadata}); @override - Map toJson() => { - 'TextDelta': { - 'id': id, - 'delta': delta, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }, + Json toJson() => { + 'type': 'text-delta', + 'id': id, + 'delta': delta, + if (providerMetadata != null) 'providerMetadata': providerMetadata, }; } -/// End of a text segment. final class StreamPartTextEnd extends StreamPart { final String id; - final Map? providerMetadata; - - StreamPartTextEnd({required this.id, this.providerMetadata}); - - factory StreamPartTextEnd.fromJson(Map json) => - StreamPartTextEnd( - id: json['id'] as String, - providerMetadata: - json['provider_metadata'] as Map?, - ); - + final Json? providerMetadata; + const StreamPartTextEnd({required this.id, this.providerMetadata}); @override - Map toJson() => { - 'TextEnd': { - 'id': id, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }, + Json toJson() => { + 'type': 'text-end', + 'id': id, + if (providerMetadata != null) 'providerMetadata': providerMetadata, }; } -// ── Stream lifecycle ────────────────────────────────────────────────────────── - -/// First chunk — carries warnings from the provider. +/// First part — carries the provider's warnings. final class StreamPartStreamStart extends StreamPart { - final List> warnings; - - StreamPartStreamStart({this.warnings = const >[]}); - - factory StreamPartStreamStart.fromJson(Map json) => - StreamPartStreamStart( - warnings: ((json['warnings'] as List?) ?? const []) - .map((e) => e as Map) - .toList(), - ); - + final List warnings; + const StreamPartStreamStart({this.warnings = const []}); @override - Map toJson() => { - 'StreamStart': {'warnings': warnings}, - }; + Json toJson() => {'type': 'stream-start', 'warnings': warnings}; } -/// Final chunk — carries usage, finish reason, and metadata. +/// Final part — carries usage, finish reason and metadata. final class StreamPartFinish extends StreamPart { final FinishReason finishReason; final Usage usage; - final Map? providerMetadata; + final Json? providerMetadata; + const StreamPartFinish( + {required this.finishReason, required this.usage, this.providerMetadata}); + @override + Json toJson() => { + 'type': 'finish', + 'finishReason': finishReason.toJson(), + 'usage': usage.toJson(), + if (providerMetadata != null) 'providerMetadata': providerMetadata, + }; +} - StreamPartFinish({ +/// End of one generation step. +final class StreamPartFinishStep extends StreamPart { + final FinishReason finishReason; + final Usage usage; + final Json? providerMetadata; + final ResponseInfo response; + const StreamPartFinishStep({ required this.finishReason, required this.usage, this.providerMetadata, + required this.response, }); - - factory StreamPartFinish.fromJson(Map json) => - StreamPartFinish( - finishReason: FinishReason.fromJson( - json['finish_reason'] as Map), - usage: Usage.fromJson(json['usage'] as Map), - providerMetadata: - json['provider_metadata'] as Map?, - ); - @override - Map toJson() => { - 'Finish': { - 'finish_reason': finishReason.toJson(), - 'usage': usage.toJson(), - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }, + Json toJson() => { + 'type': 'finish-step', + 'finishReason': finishReason.toJson(), + 'usage': usage.toJson(), + if (providerMetadata != null) 'providerMetadata': providerMetadata, + 'response': response.toJson(), }; } -/// An error occurred mid-stream. +/// An error mid-stream: an `AiMuxError` object keyed by `name` +/// (`{"name": "AI_APICallError", ...}`). final class StreamPartError extends StreamPart { - final dynamic error; - - StreamPartError({required this.error}); - - factory StreamPartError.fromJson(Map json) => - StreamPartError(error: json['error']); - + final Json error; + const StreamPartError({required this.error}); @override - Map toJson() => { - 'Error': {'error': error}, - }; + Json toJson() => {'type': 'error', 'error': error}; } -// ── Tool calls ───────────────────────────────────────────────────────────────── - /// Start of a tool call's input streaming. final class StreamPartToolInputStart extends StreamPart { final String id; @@ -1409,9 +1793,8 @@ final class StreamPartToolInputStart extends StreamPart { final bool? providerExecuted; final bool? isDynamic; final String? title; - final Map? providerMetadata; - - StreamPartToolInputStart({ + final Json? providerMetadata; + const StreamPartToolInputStart({ required this.id, required this.toolName, this.providerExecuted, @@ -1419,772 +1802,183 @@ final class StreamPartToolInputStart extends StreamPart { this.title, this.providerMetadata, }); - - factory StreamPartToolInputStart.fromJson(Map json) => - StreamPartToolInputStart( - id: json['id'] as String, - toolName: json['tool_name'] as String, - providerExecuted: json['provider_executed'] as bool?, - isDynamic: json['dynamic'] as bool?, - title: json['title'] as String?, - providerMetadata: - json['provider_metadata'] as Map?, - ); - @override - Map toJson() => { - 'ToolInputStart': { - 'id': id, - 'tool_name': toolName, - if (providerExecuted != null) 'provider_executed': providerExecuted, - if (isDynamic != null) 'dynamic': isDynamic, - if (title != null) 'title': title, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }, + Json toJson() => { + 'type': 'tool-input-start', + 'id': id, + 'toolName': toolName, + if (providerExecuted != null) 'providerExecuted': providerExecuted, + if (isDynamic != null) 'dynamic': isDynamic, + if (title != null) 'title': title, + if (providerMetadata != null) 'providerMetadata': providerMetadata, }; } -/// A delta of tool call input (partial JSON). +/// A delta of tool call input (partial JSON text). final class StreamPartToolInputDelta extends StreamPart { final String id; final String delta; - final Map? providerMetadata; - - StreamPartToolInputDelta({ - required this.id, - required this.delta, - this.providerMetadata, - }); - - factory StreamPartToolInputDelta.fromJson(Map json) => - StreamPartToolInputDelta( - id: json['id'] as String, - delta: json['delta'] as String, - providerMetadata: - json['provider_metadata'] as Map?, - ); - + final Json? providerMetadata; + const StreamPartToolInputDelta( + {required this.id, required this.delta, this.providerMetadata}); @override - Map toJson() => { - 'ToolInputDelta': { - 'id': id, - 'delta': delta, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }, + Json toJson() => { + 'type': 'tool-input-delta', + 'id': id, + 'delta': delta, + if (providerMetadata != null) 'providerMetadata': providerMetadata, }; } -/// End of a tool call's input streaming. final class StreamPartToolInputEnd extends StreamPart { final String id; - final Map? providerMetadata; - - StreamPartToolInputEnd({required this.id, this.providerMetadata}); - - factory StreamPartToolInputEnd.fromJson(Map json) => - StreamPartToolInputEnd( - id: json['id'] as String, - providerMetadata: - json['provider_metadata'] as Map?, - ); - + final Json? providerMetadata; + const StreamPartToolInputEnd({required this.id, this.providerMetadata}); @override - Map toJson() => { - 'ToolInputEnd': { - 'id': id, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }, + Json toJson() => { + 'type': 'tool-input-end', + 'id': id, + if (providerMetadata != null) 'providerMetadata': providerMetadata, }; } -/// A complete tool call (alternative to the start/delta/end flow). +/// A complete, parsed tool call ([ToolCall.invalid] marks one that stayed +/// invalid after optional repair). final class StreamPartToolCall extends StreamPart { - final String toolCallId; - final String toolName; - final dynamic input; - final bool? providerExecuted; - final bool? isDynamic; - final Map? providerMetadata; - /// Set by Core when the tool call stays invalid after optional repair. - final bool? invalid; - /// The typed lookup, parse, schema, or repair failure for an invalid call. - final dynamic error; - - StreamPartToolCall({ - required this.toolCallId, - required this.toolName, - required this.input, - this.providerExecuted, - this.isDynamic, - this.providerMetadata, - this.invalid, - this.error, - }); - - factory StreamPartToolCall.fromJson(Map json) => - StreamPartToolCall( - toolCallId: json['tool_call_id'] as String, - toolName: json['tool_name'] as String, - input: json['input'], - providerExecuted: json['provider_executed'] as bool?, - isDynamic: json['dynamic'] as bool?, - providerMetadata: - json['provider_metadata'] as Map?, - invalid: json['invalid'] as bool?, - error: json['error'], - ); - + final ToolCall call; + const StreamPartToolCall(this.call); @override - Map toJson() => { - 'ToolCall': { - 'tool_call_id': toolCallId, - 'tool_name': toolName, - 'input': input, - if (providerExecuted != null) 'provider_executed': providerExecuted, - if (isDynamic != null) 'dynamic': isDynamic, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - if (invalid != null) 'invalid': invalid, - if (error != null) 'error': error, - }, - }; + Json toJson() => {'type': 'tool-call', ...call.toJson()}; } -/// A tool result (provider-executed tools). +/// A provider-executed tool's result. final class StreamPartToolResult extends StreamPart { - final String toolCallId; - final String toolName; - final dynamic result; - final bool? isError; - final bool? preliminary; - final bool? isDynamic; - final Map? providerMetadata; - - StreamPartToolResult({ - required this.toolCallId, - required this.toolName, - required this.result, - this.isError, - this.preliminary, - this.isDynamic, - this.providerMetadata, - }); - - factory StreamPartToolResult.fromJson(Map json) => - StreamPartToolResult( - toolCallId: json['tool_call_id'] as String, - toolName: json['tool_name'] as String, - result: json['result'], - isError: json['is_error'] as bool?, - preliminary: json['preliminary'] as bool?, - isDynamic: json['dynamic'] as bool?, - providerMetadata: - json['provider_metadata'] as Map?, - ); - + final ToolResult result; + const StreamPartToolResult(this.result); @override - Map toJson() => { - 'ToolResult': { - 'tool_call_id': toolCallId, - 'tool_name': toolName, - 'result': result, - if (isError != null) 'is_error': isError, - if (preliminary != null) 'preliminary': preliminary, - if (isDynamic != null) 'dynamic': isDynamic, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }, - }; + Json toJson() => {'type': 'tool-result', ...result.toJson()}; } -// ── File ────────────────────────────────────────────────────────────────────── - -/// A file generated by the model (e.g. an image or document). The `data` -/// field is a `FileData` tagged union; there is **no** `filename` field. +/// A file generated by the model. final class StreamPartFile extends StreamPart { - final GeneratedFileData data; - final String mediaType; - final Map? providerMetadata; - - StreamPartFile({ - required this.data, - required this.mediaType, - this.providerMetadata, - }); - - factory StreamPartFile.fromJson(Map json) => StreamPartFile( - data: GeneratedFileData.fromJson(json['data'] as Map), - mediaType: json['media_type'] as String, - providerMetadata: - json['provider_metadata'] as Map?, - ); - + final GeneratedFile file; + const StreamPartFile(this.file); @override - Map toJson() => { - 'File': { - 'data': data.toJson(), - 'media_type': mediaType, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }, - }; + Json toJson() => {'type': 'file', ...file.toJson()}; } -// ── Reasoning ────────────────────────────────────────────────────────────────── - -/// Start of a reasoning / thinking segment. -final class StreamPartReasoningStart extends StreamPart { - final String id; - final Map? providerMetadata; - - StreamPartReasoningStart({required this.id, this.providerMetadata}); - - factory StreamPartReasoningStart.fromJson(Map json) => - StreamPartReasoningStart( - id: json['id'] as String, - providerMetadata: - json['provider_metadata'] as Map?, - ); - - @override - Map toJson() => { - 'ReasoningStart': { - 'id': id, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }, - }; -} - -/// A delta of a reasoning / thinking segment. -final class StreamPartReasoningDelta extends StreamPart { - final String id; - final String delta; - final Map? providerMetadata; - - StreamPartReasoningDelta({ - required this.id, - required this.delta, - this.providerMetadata, - }); - - factory StreamPartReasoningDelta.fromJson(Map json) => - StreamPartReasoningDelta( - id: json['id'] as String, - delta: json['delta'] as String, - providerMetadata: - json['provider_metadata'] as Map?, - ); - - @override - Map toJson() => { - 'ReasoningDelta': { - 'id': id, - 'delta': delta, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }, - }; -} - -/// End of a reasoning / thinking segment. -final class StreamPartReasoningEnd extends StreamPart { - final String id; - final Map? providerMetadata; - - StreamPartReasoningEnd({required this.id, this.providerMetadata}); - - factory StreamPartReasoningEnd.fromJson(Map json) => - StreamPartReasoningEnd( - id: json['id'] as String, - providerMetadata: - json['provider_metadata'] as Map?, - ); - - @override - Map toJson() => { - 'ReasoningEnd': { - 'id': id, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }, - }; -} - -// ── Metadata / sources / raw ────────────────────────────────────────────────── - -/// Response metadata (id, timestamp, model_id). -final class StreamPartResponseMetadata extends StreamPart { - final String? id; - final String? timestamp; - final String? modelId; - - StreamPartResponseMetadata({this.id, this.timestamp, this.modelId}); - - factory StreamPartResponseMetadata.fromJson(Map json) => - StreamPartResponseMetadata( - id: json['id'] as String?, - timestamp: json['timestamp'] as String?, - modelId: json['model_id'] as String?, - ); - - @override - Map toJson() => { - 'ResponseMetadata': { - if (id != null) 'id': id, - if (timestamp != null) 'timestamp': timestamp, - if (modelId != null) 'model_id': modelId, - }, - }; -} - -/// A source / citation (e.g. URL citation from search-preview models). -final class StreamPartSource extends StreamPart { - final Source source; - - StreamPartSource({required this.source}); - - factory StreamPartSource.fromJson(Map json) => - StreamPartSource(source: Source.fromJson(json)); - - @override - Map toJson() => {'Source': source.toJson()}; -} - -/// A raw chunk from the provider (for debugging, when `include_raw_chunks` -/// is set). -final class StreamPartRaw extends StreamPart { - final dynamic rawValue; - - StreamPartRaw({required this.rawValue}); - - factory StreamPartRaw.fromJson(Map json) => - StreamPartRaw(rawValue: json['raw_value']); - +/// A file generated as part of reasoning (`ReasoningFileOutput.ts`). +final class StreamPartReasoningFile extends StreamPart { + final GeneratedFile file; + final Json? providerMetadata; + const StreamPartReasoningFile({required this.file, this.providerMetadata}); @override - Map toJson() => { - 'Raw': {'raw_value': rawValue}, + Json toJson() => { + 'type': 'reasoning-file', + 'file': file.toJson(), + if (providerMetadata != null) 'providerMetadata': providerMetadata, }; } -/// Fallback for unknown/forward-compatible `StreamPart` variants. -/// -/// Newer core versions may emit tags this binding does not model yet; this -/// class passes them through verbatim (`{tag: data}`) instead of discarding -/// or crashing. final class StreamPartCustom extends StreamPart { final String kind; - final Map? providerMetadata; - StreamPartCustom({required this.kind, this.providerMetadata}); - factory StreamPartCustom.fromJson(Map json) => StreamPartCustom(kind: json['kind'] as String, providerMetadata: json['provider_metadata'] as Map?); - @override Map toJson() => {'Custom': {'kind': kind, if (providerMetadata != null) 'provider_metadata': providerMetadata}}; -} - -final class StreamPartReasoningFile extends StreamPart { - final GenerateContentFile file; - final Map? providerMetadata; - StreamPartReasoningFile({required this.file, this.providerMetadata}); - factory StreamPartReasoningFile.fromJson(Map json) => StreamPartReasoningFile(file: GenerateContentFile.fromJson(json['file'] as Map), providerMetadata: json['provider_metadata'] as Map?); - @override Map toJson() => {'ReasoningFile': {'file': file.toJson(), if (providerMetadata != null) 'provider_metadata': providerMetadata}}; + final Json? providerMetadata; + const StreamPartCustom({required this.kind, this.providerMetadata}); + @override + Json toJson() => { + 'type': 'custom', + 'kind': kind, + if (providerMetadata != null) 'providerMetadata': providerMetadata, + }; } +/// A tool call waiting for approval (`ToolApprovalRequestOutput.ts`). final class StreamPartToolApprovalRequest extends StreamPart { final String approvalId; final ToolCall toolCall; final String? reason; final bool? isAutomatic; final String? signature; - StreamPartToolApprovalRequest({required this.approvalId, required this.toolCall, this.reason, this.isAutomatic, this.signature}); - factory StreamPartToolApprovalRequest.fromJson(Map json) => StreamPartToolApprovalRequest(approvalId: json['approval_id'] as String, toolCall: ToolCall.fromJson(json['tool_call'] as Map), reason: json['reason'] as String?, isAutomatic: json['is_automatic'] as bool?, signature: json['signature'] as String?); - @override Map toJson() => {'ToolApprovalRequest': {'approval_id': approvalId, 'tool_call': toolCall.toJson(), if (reason != null) 'reason': reason, if (isAutomatic != null) 'is_automatic': isAutomatic, if (signature != null) 'signature': signature}}; -} - -final class StreamPartUnknown extends StreamPart { - final String tag; - final Map data; - - StreamPartUnknown({required this.tag, required this.data}); - - @override - Map toJson() => {tag: data}; -} - -// ───────────────────────────────────────────────────────────────────────────── -// FileBytes (shared.rs) — externally-tagged: {"Binary": [...]} / {"Base64": "..."} -// ───────────────────────────────────────────────────────────────────────────── - -sealed class FileBytes { - const FileBytes(); - factory FileBytes.fromJson(Map json) { - final e = json.entries.first; - return switch (e.key) { - 'Binary' => FileBytesBinary(data: (e.value as List).cast()), - 'Base64' => FileBytesBase64(data: e.value as String), - _ => throw FormatException('unknown FileBytes tag: $e'), - }; - } - Map toJson(); -} - -final class FileBytesBinary extends FileBytes { - final List data; - const FileBytesBinary({required this.data}); - @override - Map toJson() => {'Binary': data}; -} - -final class FileBytesBase64 extends FileBytes { - final String data; - const FileBytesBase64({required this.data}); - @override - Map toJson() => {'Base64': data}; -} - -// ───────────────────────────────────────────────────────────────────────────── -// FileData (shared.rs) — externally-tagged: {"Data": {...}} / {"Url": {...}} / ... -// ───────────────────────────────────────────────────────────────────────────── - -sealed class FileData { - const FileData(); - factory FileData.fromJson(Map json) { - final e = json.entries.first; - return switch (e.key) { - 'Data' => FileDataData( - data: FileBytes.fromJson((e.value as Map)['data'] as Map)), - 'Url' => FileDataUrl(url: (e.value as Map)['url'] as String, originalUrl: (e.value as Map)['original_url'] as String?), - 'Reference' => FileDataReference( - reference: (e.value as Map)['reference'] as Map), - 'Text' => FileDataText(text: (e.value as Map)['text'] as String), - _ => throw FormatException('unknown FileData tag: $e'), - }; - } - Map toJson(); -} - -final class FileDataData extends FileData { - final FileBytes data; - const FileDataData({required this.data}); - @override - Map toJson() => {'Data': {'data': data.toJson()}}; -} - -final class FileDataUrl extends FileData { - final String url; - final String? originalUrl; - const FileDataUrl({required this.url, this.originalUrl}); - @override - Map toJson() => {'Url': {'url': url, if (originalUrl != null) 'original_url': originalUrl}}; -} - -final class FileDataReference extends FileData { - final Map reference; - const FileDataReference({required this.reference}); - @override - Map toJson() => {'Reference': {'reference': reference}}; -} - -final class FileDataText extends FileData { - final String text; - const FileDataText({required this.text}); - @override - Map toJson() => {'Text': {'text': text}}; -} - -sealed class GeneratedFileData { - const GeneratedFileData(); - factory GeneratedFileData.fromJson(Map json) { - final e = json.entries.first; - return switch (e.key) { - 'Data' => GeneratedFileDataData( - data: FileBytes.fromJson((e.value as Map)['data'] as Map)), - 'Url' => GeneratedFileDataUrl(url: (e.value as Map)['url'] as String, originalUrl: (e.value as Map)['original_url'] as String?), - _ => throw FormatException('unknown GeneratedFileData tag: $e'), - }; - } - Map toJson(); -} - -final class GeneratedFileDataData extends GeneratedFileData { - final FileBytes data; - const GeneratedFileDataData({required this.data}); - @override - Map toJson() => {'Data': {'data': data.toJson()}}; -} - -final class GeneratedFileDataUrl extends GeneratedFileData { - final String url; - final String? originalUrl; - const GeneratedFileDataUrl({required this.url, this.originalUrl}); - @override - Map toJson() => {'Url': {'url': url, if (originalUrl != null) 'original_url': originalUrl}}; -} - -// ───────────────────────────────────────────────────────────────────────────── -// ContentPart (content.rs) — internally-tagged: {"type": "text", "text": "..."} -// ───────────────────────────────────────────────────────────────────────────── - -sealed class ContentPart { - const ContentPart(); - factory ContentPart.fromJson(Map json) { - final tag = json['type'] as String; - return switch (tag) { - 'custom' => ContentPartCustom.fromJson(json), - 'reasoning_file' => ContentPartReasoningFile.fromJson(json), - 'tool_approval_request' => ContentPartToolApprovalRequest.fromJson(json), - 'text' => ContentPartText.fromJson(json), - 'image' => ContentPartImage.fromJson(json), - 'file' => ContentPartFile.fromJson(json), - 'file_base64' => ContentPartFileBase64.fromJson(json), - 'file_url' => ContentPartFileUrl.fromJson(json), - 'file_reference' => ContentPartFileReference.fromJson(json), - 'reasoning' => ContentPartReasoning.fromJson(json), - 'tool_call' => ContentPartToolCall.fromJson(json), - 'tool_result' => ContentPartToolResult.fromJson(json), - _ => ContentPartUnknown(tag: tag, data: json), - }; - } - Map toJson(); -} - -final class ContentPartText extends ContentPart { - final String text; - final Map? providerOptions; - const ContentPartText({required this.text, this.providerOptions}); - static ContentPartText fromJson(Map json) => ContentPartText( - text: json['text'] as String, - providerOptions: json['provider_options'] as Map?, - ); - @override - Map toJson() => { - 'type': 'text', - 'text': text, - if (providerOptions != null) 'provider_options': providerOptions, - }; -} - -final class ContentPartImage extends ContentPart { - final List image; - final String mediaType; - final Map? providerOptions; - const ContentPartImage( - {required this.image, required this.mediaType, this.providerOptions}); - static ContentPartImage fromJson(Map json) => - ContentPartImage( - image: (json['image'] as List).cast(), - mediaType: json['media_type'] as String, - providerOptions: json['provider_options'] as Map?, - ); - @override - Map toJson() => { - 'type': 'image', - 'image': image, - 'media_type': mediaType, - if (providerOptions != null) 'provider_options': providerOptions, - }; -} - -final class ContentPartFile extends ContentPart { - final List data; - final String mediaType; - final String? filename; - final Map? providerOptions; - const ContentPartFile( - {required this.data, required this.mediaType, this.filename, this.providerOptions}); - static ContentPartFile fromJson(Map json) => ContentPartFile( - data: (json['data'] as List).cast(), - mediaType: json['media_type'] as String, - filename: json['filename'] as String?, - providerOptions: json['provider_options'] as Map?, - ); - @override - Map toJson() => { - 'type': 'file', - 'data': data, - 'media_type': mediaType, - if (filename != null) 'filename': filename, - if (providerOptions != null) 'provider_options': providerOptions, - }; -} - -final class ContentPartFileBase64 extends ContentPart { - final String data; - final String mediaType; - final String? filename; - final Map? providerOptions; - const ContentPartFileBase64( - {required this.data, required this.mediaType, this.filename, this.providerOptions}); - static ContentPartFileBase64 fromJson(Map json) => - ContentPartFileBase64( - data: json['data'] as String, - mediaType: json['media_type'] as String, - filename: json['filename'] as String?, - providerOptions: json['provider_options'] as Map?, - ); + const StreamPartToolApprovalRequest({ + required this.approvalId, + required this.toolCall, + this.reason, + this.isAutomatic, + this.signature, + }); @override - Map toJson() => { - 'type': 'file_base64', - 'data': data, - 'media_type': mediaType, - if (filename != null) 'filename': filename, - if (providerOptions != null) 'provider_options': providerOptions, + Json toJson() => { + 'type': 'tool-approval-request', + 'approvalId': approvalId, + 'toolCall': toolCall.toJson(), + if (reason != null) 'reason': reason, + if (isAutomatic != null) 'isAutomatic': isAutomatic, + if (signature != null) 'signature': signature, }; } -final class ContentPartFileUrl extends ContentPart { - final String url; - final String mediaType; - final Map? providerOptions; - const ContentPartFileUrl( - {required this.url, required this.mediaType, this.providerOptions}); - static ContentPartFileUrl fromJson(Map json) => - ContentPartFileUrl( - url: json['url'] as String, - mediaType: json['media_type'] as String, - providerOptions: json['provider_options'] as Map?, - ); +/// Start / delta / end of a reasoning segment. +final class StreamPartReasoningStart extends StreamPart { + final String id; + final Json? providerMetadata; + const StreamPartReasoningStart({required this.id, this.providerMetadata}); @override - Map toJson() => { - 'type': 'file_url', - 'url': url, - 'media_type': mediaType, - if (providerOptions != null) 'provider_options': providerOptions, + Json toJson() => { + 'type': 'reasoning-start', + 'id': id, + if (providerMetadata != null) 'providerMetadata': providerMetadata, }; } -final class ContentPartFileReference extends ContentPart { - final String mediaType; - final Map reference; - final String? filename; - final Map? providerOptions; - const ContentPartFileReference( - {required this.mediaType, required this.reference, this.filename, this.providerOptions}); - static ContentPartFileReference fromJson(Map json) => - ContentPartFileReference( - mediaType: json['media_type'] as String, - reference: json['reference'] as Map, - filename: json['filename'] as String?, - providerOptions: json['provider_options'] as Map?, - ); +final class StreamPartReasoningDelta extends StreamPart { + final String id; + final String delta; + final Json? providerMetadata; + const StreamPartReasoningDelta( + {required this.id, required this.delta, this.providerMetadata}); @override - Map toJson() => { - 'type': 'file_reference', - 'media_type': mediaType, - 'reference': reference, - if (filename != null) 'filename': filename, - if (providerOptions != null) 'provider_options': providerOptions, + Json toJson() => { + 'type': 'reasoning-delta', + 'id': id, + 'delta': delta, + if (providerMetadata != null) 'providerMetadata': providerMetadata, }; } -final class ContentPartReasoning extends ContentPart { - final String text; - final String? signature; - final Map? providerOptions; - const ContentPartReasoning( - {required this.text, this.signature, this.providerOptions}); - static ContentPartReasoning fromJson(Map json) => - ContentPartReasoning( - text: json['text'] as String, - signature: json['signature'] as String?, - providerOptions: json['provider_options'] as Map?, - ); +final class StreamPartReasoningEnd extends StreamPart { + final String id; + final Json? providerMetadata; + const StreamPartReasoningEnd({required this.id, this.providerMetadata}); @override - Map toJson() => { - 'type': 'reasoning', - 'text': text, - if (signature != null) 'signature': signature, - if (providerOptions != null) 'provider_options': providerOptions, + Json toJson() => { + 'type': 'reasoning-end', + 'id': id, + if (providerMetadata != null) 'providerMetadata': providerMetadata, }; } -final class ContentPartToolCall extends ContentPart { - final String toolCallId; - final String toolName; - final dynamic input; - final bool? providerExecuted; - final Map? providerOptions; - const ContentPartToolCall( - {required this.toolCallId, required this.toolName, required this.input, this.providerExecuted, this.providerOptions}); - static ContentPartToolCall fromJson(Map json) => - ContentPartToolCall( - toolCallId: json['tool_call_id'] as String, - toolName: json['tool_name'] as String, - input: json['input'], - providerExecuted: json['provider_executed'] as bool?, - providerOptions: json['provider_options'] as Map?, - ); +/// A source / citation. +final class StreamPartSource extends StreamPart { + final Source source; + const StreamPartSource({required this.source}); @override - Map toJson() => { - 'type': 'tool_call', - 'tool_call_id': toolCallId, - 'tool_name': toolName, - 'input': input, - if (providerExecuted != null) 'provider_executed': providerExecuted, - if (providerOptions != null) 'provider_options': providerOptions, - }; + Json toJson() => {'type': 'source', ...source.toJson()}; } -final class ContentPartToolResult extends ContentPart { - final String toolCallId; - final dynamic result; - final String? toolName; - final bool? isError; - final bool? preliminary; - final bool? isDynamic; - final Map? providerOptions; - const ContentPartToolResult( - {required this.toolCallId, required this.result, this.toolName, this.isError, this.preliminary, this.isDynamic, this.providerOptions}); - static ContentPartToolResult fromJson(Map json) => - ContentPartToolResult( - toolCallId: json['tool_call_id'] as String, - result: json['result'], - toolName: json['tool_name'] as String?, - isError: json['is_error'] as bool?, - preliminary: json['preliminary'] as bool?, - isDynamic: json['dynamic'] as bool?, - providerOptions: json['provider_options'] as Map?, - ); +/// A raw provider chunk (when `includeRawChunks` is set). +final class StreamPartRaw extends StreamPart { + final dynamic rawValue; + const StreamPartRaw({required this.rawValue}); @override - Map toJson() => { - 'type': 'tool_result', - 'tool_call_id': toolCallId, - 'result': result, - if (toolName != null) 'tool_name': toolName, - if (isError != null) 'is_error': isError, - if (preliminary != null) 'preliminary': preliminary, - if (isDynamic != null) 'dynamic': isDynamic, - if (providerOptions != null) 'provider_options': providerOptions, - }; -} - -final class ContentPartCustom extends ContentPart { - final String kind; - final Map? providerOptions; - ContentPartCustom({required this.kind, this.providerOptions}); - factory ContentPartCustom.fromJson(Map json) => ContentPartCustom(kind: json['kind'] as String, providerOptions: json['provider_options'] as Map?); - @override Map toJson() => {'type': 'custom', 'kind': kind, if (providerOptions != null) 'provider_options': providerOptions}; + Json toJson() => {'type': 'raw', 'rawValue': rawValue}; } -final class ContentPartReasoningFile extends ContentPart { - final GeneratedFileData data; - final String mediaType; - final Map? providerOptions; - ContentPartReasoningFile({required this.data, required this.mediaType, this.providerOptions}); - factory ContentPartReasoningFile.fromJson(Map json) => ContentPartReasoningFile(data: GeneratedFileData.fromJson(json['data'] as Map), mediaType: json['media_type'] as String, providerOptions: json['provider_options'] as Map?); - @override Map toJson() => {'type': 'reasoning_file', 'data': data.toJson(), 'media_type': mediaType, if (providerOptions != null) 'provider_options': providerOptions}; -} - -final class ContentPartToolApprovalRequest extends ContentPart { - final String approvalId; - final String toolCallId; - final String? reason; - final bool? isAutomatic; - final String? signature; - final dynamic inputSchemaInput; - ContentPartToolApprovalRequest({required this.approvalId, required this.toolCallId, this.reason, this.isAutomatic, this.signature, this.inputSchemaInput}); - factory ContentPartToolApprovalRequest.fromJson(Map json) => ContentPartToolApprovalRequest(approvalId: json['approval_id'] as String, toolCallId: json['tool_call_id'] as String, reason: json['reason'] as String?, isAutomatic: json['is_automatic'] as bool?, signature: json['signature'] as String?, inputSchemaInput: json['input_schema_input']); - @override Map toJson() => {'type': 'tool_approval_request', 'approval_id': approvalId, 'tool_call_id': toolCallId, if (reason != null) 'reason': reason, if (isAutomatic != null) 'is_automatic': isAutomatic, if (signature != null) 'signature': signature, if (inputSchemaInput != null) 'input_schema_input': inputSchemaInput}; -} - -final class ContentPartUnknown extends ContentPart { - final String tag; - final Map data; - const ContentPartUnknown({required this.tag, required this.data}); +/// Forward-compatibility: a `type` this binding does not model, verbatim. +final class StreamPartUnknown extends StreamPart { + final Json data; + const StreamPartUnknown(this.data); @override - Map toJson() => data; + Json toJson() => data; } // ───────────────────────────────────────────────────────────────────────────── diff --git a/bindings/flutter/lib/types.g.dart b/bindings/flutter/lib/types.g.dart index 5de8fc8cb..db681d685 100644 --- a/bindings/flutter/lib/types.g.dart +++ b/bindings/flutter/lib/types.g.dart @@ -1,5 +1,4 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint, unused_element part of 'types.dart'; @@ -7,356 +6,6 @@ part of 'types.dart'; // JsonSerializableGenerator // ************************************************************************** -InputTokenUsage _$InputTokenUsageFromJson(Map json) => InputTokenUsage(total: (json['total'] as num?)?.toInt(), noCache: (json['no_cache'] as num?)?.toInt(), cacheRead: (json['cache_read'] as num?)?.toInt(), cacheWrite: (json['cache_write'] as num?)?.toInt()); -Map _$InputTokenUsageToJson(InputTokenUsage instance) => {'total': instance.total, 'no_cache': instance.noCache, 'cache_read': instance.cacheRead, 'cache_write': instance.cacheWrite}; - -OutputTokenUsage _$OutputTokenUsageFromJson(Map json) => OutputTokenUsage(total: (json['total'] as num?)?.toInt(), text: (json['text'] as num?)?.toInt(), reasoning: (json['reasoning'] as num?)?.toInt()); -Map _$OutputTokenUsageToJson(OutputTokenUsage instance) => {'total': instance.total, 'text': instance.text, 'reasoning': instance.reasoning}; - -Usage _$UsageFromJson(Map json) => Usage( - inputTokens: - InputTokenUsage.fromJson(json['input_tokens'] as Map), - outputTokens: - OutputTokenUsage.fromJson(json['output_tokens'] as Map), - raw: json['raw'] as Map?, - ); - -Map _$UsageToJson(Usage instance) => { - 'input_tokens': instance.inputTokens, - 'output_tokens': instance.outputTokens, - 'raw': instance.raw, - }; - -FinishReason _$FinishReasonFromJson(Map json) => FinishReason( - unified: json['unified'] as String, - raw: json['raw'] as String?, - ); - -Map _$FinishReasonToJson(FinishReason instance) => - { - 'unified': instance.unified, - 'raw': instance.raw, - }; - -ToolCall _$ToolCallFromJson(Map json) => ToolCall( - toolCallId: json['tool_call_id'] as String, - toolName: json['tool_name'] as String, - input: json['input'], - providerExecuted: json['provider_executed'] as bool?, - isDynamic: json['dynamic'] as bool?, - providerMetadata: json['provider_metadata'], - invalid: json['invalid'] as bool?, - error: json['error'], - ); - -Map _$ToolCallToJson(ToolCall instance) => { - 'tool_call_id': instance.toolCallId, - 'tool_name': instance.toolName, - 'input': instance.input, - 'provider_executed': instance.providerExecuted, - 'dynamic': instance.isDynamic, - if (instance.providerMetadata != null) - 'provider_metadata': instance.providerMetadata, - 'invalid': instance.invalid, - 'error': instance.error, - }; - -FunctionTool _$FunctionToolFromJson(Map json) => FunctionTool( - name: json['name'] as String, - description: json['description'] as String?, - inputSchema: json['input_schema'] as Map, - strict: json['strict'] as bool?, - providerOptions: json['provider_options'] as Map?, - inputExamples: (json['input_examples'] as List?) - ?.map((e) => FunctionToolInputExample.fromJson(e as Map)) - .toList(), - ); - -Map _$FunctionToolToJson(FunctionTool instance) => - { - 'name': instance.name, - 'description': instance.description, - 'input_schema': instance.inputSchema, - 'strict': instance.strict, - 'provider_options': instance.providerOptions, - 'input_examples': instance.inputExamples?.map((e) => e.toJson()).toList(), - }; - -ResponseMetadata _$ResponseMetadataFromJson(Map json) => - ResponseMetadata( - id: json['id'] as String?, - timestamp: json['timestamp'] as String?, - modelId: json['model_id'] as String?, - ); - -Map _$ResponseMetadataToJson(ResponseMetadata instance) => - { - 'id': instance.id, - 'timestamp': instance.timestamp, - 'model_id': instance.modelId, - }; - -GenerateTextResult _$GenerateTextResultFromJson(Map json) => - GenerateTextResult( - content: (json['content'] as List? ?? []).map((item) => item as Map).toList(), - text: json['text'] as String, - toolCalls: (json['tool_calls'] as List) - .map((e) => ToolCall.fromJson(e as Map)) - .toList(), - finishReason: - FinishReason.fromJson(json['finish_reason'] as Map), - usage: Usage.fromJson(json['usage'] as Map), - warnings: (json['warnings'] as List?) - ?.map((e) => e as Map) - .toList() ?? - const [], - raw: GenerateResult.fromJson(json['raw'] as Map), - reasoning: (json['reasoning'] as List?) - ?.map((e) => e as Map) - .toList() ?? - const [], - reasoningText: json['reasoning_text'] as String? ?? '', - sources: (json['sources'] as List?) - ?.map((e) => Source.fromJson(e as Map)) - .toList() ?? - const [], - files: (json['files'] as List?) - ?.map((e) => e as Map) - .toList() ?? - const [], - responseMessages: (json['response_messages'] as List?) - ?.map((e) => ModelMessage.fromJson(e as Map)) - .toList() ?? - const [], - rawFinishReason: json['raw_finish_reason'] as String?, - providerMetadata: json['provider_metadata'] as Map?, - response: json['response'] != null - ? ResponseMetadata.fromJson(json['response'] as Map) - : ResponseMetadata(), - totalUsage: json['total_usage'] != null - ? Usage.fromJson(json['total_usage'] as Map) - : Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), - ); - -Map _$GenerateTextResultToJson(GenerateTextResult instance) => - { - 'text': instance.text, - 'content': instance.content, - 'tool_calls': instance.toolCalls, - 'finish_reason': instance.finishReason, - 'usage': instance.usage, - 'warnings': instance.warnings, - 'raw': instance.raw, - 'reasoning': instance.reasoning, - 'reasoning_text': instance.reasoningText, - 'sources': instance.sources, - 'files': instance.files, - 'response_messages': instance.responseMessages, - 'raw_finish_reason': instance.rawFinishReason, - 'provider_metadata': instance.providerMetadata, - 'response': instance.response, - 'total_usage': instance.totalUsage, - }; - -GenerateObjectResult _$GenerateObjectResultFromJson( - Map json) => - GenerateObjectResult( - object: json['object'], - finishReason: - FinishReason.fromJson(json['finish_reason'] as Map), - rawFinishReason: json['raw_finish_reason'] as String?, - usage: json['usage'] != null - ? Usage.fromJson(json['usage'] as Map) - : Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), - warnings: (json['warnings'] as List?) - ?.map((e) => e as Map) - .toList() ?? - const [], - reasoning: json['reasoning'] as String?, - providerMetadata: json['provider_metadata'] as Map?, - response: json['response'] != null - ? ResponseMetadata.fromJson(json['response'] as Map) - : ResponseMetadata(), - raw: GenerateTextResult.fromJson(json['raw'] as Map), - ); - -Map _$GenerateObjectResultToJson( - GenerateObjectResult instance) => - { - 'object': instance.object, - 'finish_reason': instance.finishReason, - 'raw_finish_reason': instance.rawFinishReason, - 'usage': instance.usage, - 'warnings': instance.warnings, - 'reasoning': instance.reasoning, - 'provider_metadata': instance.providerMetadata, - 'response': instance.response, - 'raw': instance.raw, - }; - -StreamTextResultAggregated _$StreamTextResultAggregatedFromJson( - Map json) => - StreamTextResultAggregated( - content: (json['content'] as List? ?? []).map((item) => item as Map).toList(), - text: json['text'] as String? ?? '', - reasoning: (json['reasoning'] as List?) - ?.map((e) => e as Map) - .toList() ?? - const [], - reasoningText: json['reasoning_text'] as String? ?? '', - toolCalls: (json['tool_calls'] as List?) - ?.map((e) => ToolCall.fromJson(e as Map)) - .toList() ?? - const [], - sources: (json['sources'] as List?) - ?.map((e) => Source.fromJson(e as Map)) - .toList() ?? - const [], - files: (json['files'] as List?) - ?.map((e) => e as Map) - .toList() ?? - const [], - finishReason: - FinishReason.fromJson(json['finish_reason'] as Map), - rawFinishReason: json['raw_finish_reason'] as String?, - usage: json['usage'] != null - ? Usage.fromJson(json['usage'] as Map) - : Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), - totalUsage: json['total_usage'] != null - ? Usage.fromJson(json['total_usage'] as Map) - : Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), - warnings: (json['warnings'] as List?) - ?.map((e) => e as Map) - .toList() ?? - const [], - providerMetadata: json['provider_metadata'] as Map?, - response: json['response'] == null - ? null - : ResponseMetadata.fromJson(json['response'] as Map), - responseMessages: (json['response_messages'] as List?) - ?.map((e) => ModelMessage.fromJson(e as Map)) - .toList() ?? - const [], - ); - -Map _$StreamTextResultAggregatedToJson( - StreamTextResultAggregated instance) => - { - 'text': instance.text, - 'content': instance.content, - 'reasoning': instance.reasoning, - 'reasoning_text': instance.reasoningText, - 'tool_calls': instance.toolCalls, - 'sources': instance.sources, - 'files': instance.files, - 'finish_reason': instance.finishReason, - 'raw_finish_reason': instance.rawFinishReason, - 'usage': instance.usage, - 'total_usage': instance.totalUsage, - 'warnings': instance.warnings, - 'provider_metadata': instance.providerMetadata, - 'response': instance.response, - 'response_messages': instance.responseMessages, - }; - -TimeoutConfiguration _$TimeoutConfigurationFromJson( - Map json) => - TimeoutConfiguration( - totalMs: (json['total_ms'] as num?)?.toInt(), - stepMs: (json['step_ms'] as num?)?.toInt(), - firstChunkMs: (json['first_chunk_ms'] as num?)?.toInt(), - chunkMs: (json['chunk_ms'] as num?)?.toInt(), - ); - -Map _$TimeoutConfigurationToJson( - TimeoutConfiguration instance) => - { - 'total_ms': instance.totalMs, - 'step_ms': instance.stepMs, - 'first_chunk_ms': instance.firstChunkMs, - 'chunk_ms': instance.chunkMs, - }; - -GenerateTextOptions _$GenerateTextOptionsFromJson(Map json) => - GenerateTextOptions( - maxOutputTokens: (json['max_output_tokens'] as num?)?.toInt(), - temperature: (json['temperature'] as num?)?.toDouble(), - stopSequences: (json['stop_sequences'] as List?) - ?.map((e) => e as String) - .toList(), - topP: (json['top_p'] as num?)?.toDouble(), - topK: (json['top_k'] as num?)?.toDouble(), - presencePenalty: (json['presence_penalty'] as num?)?.toDouble(), - frequencyPenalty: (json['frequency_penalty'] as num?)?.toDouble(), - responseFormat: json['response_format'] as Map?, - seed: (json['seed'] as num?)?.toInt(), - tools: (json['tools'] as List?) - ?.map((e) => Tool.fromJson(e as Map)) - .toList(), - toolChoice: json['tool_choice'] == null - ? null - : ToolChoice.fromJson(json['tool_choice']), - headers: (json['headers'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ), - providerOptions: json['provider_options'] as Map?, - reasoning: - $enumDecodeNullable(_$ReasoningEffortEnumMap, json['reasoning']), - instructions: json['instructions'] as String?, - maxRetries: (json['max_retries'] as num?)?.toInt(), - timeout: json['timeout'] == null - ? null - : TimeoutConfiguration.fromJson( - json['timeout'] as Map), - ); - -Map _$GenerateTextOptionsToJson( - GenerateTextOptions instance) => - { - 'max_output_tokens': instance.maxOutputTokens, - 'temperature': instance.temperature, - 'stop_sequences': instance.stopSequences, - 'top_p': instance.topP, - 'top_k': instance.topK, - 'presence_penalty': instance.presencePenalty, - 'frequency_penalty': instance.frequencyPenalty, - 'response_format': instance.responseFormat, - 'seed': instance.seed, - 'tools': instance.tools, - 'tool_choice': instance.toolChoice, - 'headers': instance.headers, - 'provider_options': instance.providerOptions, - 'reasoning': instance.reasoning, - 'instructions': instance.instructions, - 'max_retries': instance.maxRetries, - 'timeout': instance.timeout, - }; - -const _$ReasoningEffortEnumMap = { - ReasoningEffort.providerDefault: 'providerDefault', - ReasoningEffort.none: 'none', - ReasoningEffort.minimal: 'minimal', - ReasoningEffort.low: 'low', - ReasoningEffort.medium: 'medium', - ReasoningEffort.high: 'high', - ReasoningEffort.xhigh: 'xhigh', -}; - -ModelMessage _$ModelMessageFromJson(Map json) => ModelMessage( - role: json['role'] as String, - content: json['content'] as Object, - ); - -Map _$ModelMessageToJson(ModelMessage instance) => - { - 'role': instance.role, - 'content': instance.content, - }; - -// ───────────────────────────────────────────────────────────────────────────── -// OpenAI Chat Completions output (RFC-0026). -// ───────────────────────────────────────────────────────────────────────────── - ChatCompletion _$ChatCompletionFromJson(Map json) => ChatCompletion( id: json['id'] as String, @@ -366,7 +15,8 @@ ChatCompletion _$ChatCompletionFromJson(Map json) => choices: (json['choices'] as List) .map((e) => ChatCompletionChoice.fromJson(e as Map)) .toList(), - usage: ChatCompletionUsage.fromJson(json['usage'] as Map), + usage: + ChatCompletionUsage.fromJson(json['usage'] as Map), systemFingerprint: json['system_fingerprint'] as String?, ); @@ -381,7 +31,8 @@ Map _$ChatCompletionToJson(ChatCompletion instance) => 'system_fingerprint': instance.systemFingerprint, }; -ChatCompletionChoice _$ChatCompletionChoiceFromJson(Map json) => +ChatCompletionChoice _$ChatCompletionChoiceFromJson( + Map json) => ChatCompletionChoice( index: (json['index'] as num).toInt(), message: ChatCompletionMessage.fromJson( @@ -406,7 +57,8 @@ ChatCompletionMessage _$ChatCompletionMessageFromJson( content: json['content'] as String?, reasoningContent: json['reasoning_content'] as String?, toolCalls: (json['tool_calls'] as List?) - ?.map((e) => ChatCompletionToolCall.fromJson(e as Map)) + ?.map( + (e) => ChatCompletionToolCall.fromJson(e as Map)) .toList(), annotations: json['annotations'] as List?, ); @@ -532,8 +184,8 @@ ChatCompletionChunkChoice _$ChatCompletionChunkChoiceFromJson( Map json) => ChatCompletionChunkChoice( index: (json['index'] as num).toInt(), - delta: ChatCompletionDelta.fromJson( - json['delta'] as Map), + delta: + ChatCompletionDelta.fromJson(json['delta'] as Map), finishReason: json['finish_reason'] as String?, logprobs: json['logprobs'], ); diff --git a/bindings/flutter/test/aimux_test.dart b/bindings/flutter/test/aimux_test.dart index aaa09384d..27be6cd13 100644 --- a/bindings/flutter/test/aimux_test.dart +++ b/bindings/flutter/test/aimux_test.dart @@ -154,14 +154,6 @@ void main() { ); }); - test('registerProviders validates raw config_json in Dart', () { - expect( - () => registerProviders('{not json'), - throwsA(isA() - .having((e) => e.source, 'source', 'config_json')), - ); - }); - test('multimodal use-after-close throws StateError', () { final m = EmbeddingModel.openai('sk-test-fake-key', 'text-embedding-3-small'); m.close(); diff --git a/bindings/flutter/test/content_part_test.dart b/bindings/flutter/test/content_part_test.dart index 898211e9e..accff16f3 100644 --- a/bindings/flutter/test/content_part_test.dart +++ b/bindings/flutter/test/content_part_test.dart @@ -1,222 +1,144 @@ -import 'package:test/test.dart'; +// Round trips for the message-content types against the generated wire shape: +// `bindings/node/src/types/{ContentPart,FileData,FileBytes,ToolResultOutput}.ts`. import 'package:aimux/types.dart'; +import 'package:test/test.dart'; void main() { - group('FileBytes round-trip', () { - test('Binary variant', () { - final original = FileBytesBinary(data: [104, 105]); - final json = original.toJson(); - expect(json, {'Binary': [104, 105]}); - final decoded = FileBytes.fromJson(json); - expect(decoded, isA()); - expect((decoded as FileBytesBinary).data, [104, 105]); + group('FileBytes (untagged: number[] | string)', () { + test('binary is a bare array', () { + expect(FileBytesBinary(data: [104, 105]).toJson(), [104, 105]); + expect(FileBytes.fromJson([104, 105]), isA()); }); - test('Base64 variant', () { - final original = FileBytesBase64(data: 'aGk='); - final json = original.toJson(); - expect(json, {'Base64': 'aGk='}); - final decoded = FileBytes.fromJson(json); - expect(decoded, isA()); - expect((decoded as FileBytesBase64).data, 'aGk='); + test('base64 is a bare string', () { + expect(FileBytesBase64(data: 'aGk=').toJson(), 'aGk='); + expect((FileBytes.fromJson('aGk=') as FileBytesBase64).data, 'aGk='); }); }); - group('FileData round-trip', () { - test('Data variant', () { - final original = FileDataData(data: FileBytesBase64(data: 'aGk=')); - final json = original.toJson(); - expect(json, { - 'Data': {'data': {'Base64': 'aGk='}} - }); - final decoded = FileData.fromJson(json); - expect(decoded, isA()); - final d = decoded as FileDataData; - expect(d.data, isA()); - expect((d.data as FileBytesBase64).data, 'aGk='); - }); - - test('Url variant', () { - final original = FileDataUrl(url: 'https://example.com/f.png'); - final json = original.toJson(); - expect(json, { - 'Url': {'url': 'https://example.com/f.png'} - }); - final decoded = FileData.fromJson(json); - expect(decoded, isA()); - expect((decoded as FileDataUrl).url, 'https://example.com/f.png'); - }); - - test('Reference variant', () { - final original = FileDataReference(reference: {'openai': 'file-abc'}); - final json = original.toJson(); - expect(json, { - 'Reference': { - 'reference': {'openai': 'file-abc'} - } - }); - final decoded = FileData.fromJson(json); - expect(decoded, isA()); - expect((decoded as FileDataReference).reference, {'openai': 'file-abc'}); - }); - - test('Text variant', () { - final original = FileDataText(text: 'hello world'); - final json = original.toJson(); - expect(json, { - 'Text': {'text': 'hello world'} - }); - final decoded = FileData.fromJson(json); - expect(decoded, isA()); - expect((decoded as FileDataText).text, 'hello world'); + group('FileData ("type"-tagged)', () { + test('data / url / reference / text round-trip', () { + final cases = >[ + {'type': 'data', 'data': 'aGk='}, + {'type': 'url', 'url': 'https://example.com/f.png', 'originalUrl': 'x'}, + {'type': 'reference', 'reference': {'openai': 'file-abc'}}, + {'type': 'text', 'text': 'hello world'}, + ]; + for (final json in cases) { + expect(FileData.fromJson(json).toJson(), json); + } + expect(FileData.fromJson(cases[0]), isA()); + expect(FileData.fromJson(cases[1]), isA()); + expect(FileData.fromJson(cases[2]), isA()); + expect(FileData.fromJson(cases[3]), isA()); }); }); group('ContentPart round-trip', () { - test('Text variant', () { - final original = ContentPartText(text: 'hello'); - final json = original.toJson(); - expect(json['type'], 'text'); - expect(json['text'], 'hello'); - final decoded = ContentPart.fromJson(json); - expect(decoded, isA()); - expect((decoded as ContentPartText).text, 'hello'); - }); - - test('Image variant', () { - final original = ContentPartImage(image: [1, 2, 3], mediaType: 'image/png'); - final json = original.toJson(); - expect(json['type'], 'image'); - expect(json['image'], [1, 2, 3]); - expect(json['media_type'], 'image/png'); - final decoded = ContentPart.fromJson(json); - expect(decoded, isA()); - final d = decoded as ContentPartImage; - expect(d.image, [1, 2, 3]); - expect(d.mediaType, 'image/png'); - }); - - test('File variant — has filename', () { - final original = ContentPartFile( - data: [104, 105], - mediaType: 'application/pdf', - filename: 'doc.pdf', - ); - final json = original.toJson(); - expect(json['type'], 'file'); - expect(json, contains('filename')); - expect(json['filename'], 'doc.pdf'); - final decoded = ContentPart.fromJson(json); - expect(decoded, isA()); - final d = decoded as ContentPartFile; - expect(d.data, [104, 105]); - expect(d.mediaType, 'application/pdf'); - expect(d.filename, 'doc.pdf'); - }); - - test('FileBase64 variant', () { - final original = ContentPartFileBase64( - data: 'aGk=', - mediaType: 'image/png', - filename: 'img.png', - ); - final json = original.toJson(); - expect(json['type'], 'file_base64'); - expect(json['data'], 'aGk='); - final decoded = ContentPart.fromJson(json); - expect(decoded, isA()); - expect((decoded as ContentPartFileBase64).data, 'aGk='); - }); - - test('FileUrl variant', () { - final original = ContentPartFileUrl( - url: 'https://example.com/f.png', - mediaType: 'image/png', - ); - final json = original.toJson(); - expect(json['type'], 'file_url'); - expect(json['url'], 'https://example.com/f.png'); - final decoded = ContentPart.fromJson(json); - expect(decoded, isA()); - expect((decoded as ContentPartFileUrl).url, 'https://example.com/f.png'); - }); - - test('FileReference variant', () { - final original = ContentPartFileReference( - mediaType: 'application/pdf', - reference: {'openai': 'file-abc'}, - filename: 'doc.pdf', - ); - final json = original.toJson(); - expect(json['type'], 'file_reference'); - expect(json['reference'], {'openai': 'file-abc'}); - final decoded = ContentPart.fromJson(json); - expect(decoded, isA()); - expect((decoded as ContentPartFileReference).reference, {'openai': 'file-abc'}); - }); - - test('Reasoning variant', () { - final original = ContentPartReasoning(text: 'thinking...', signature: 'sig123'); - final json = original.toJson(); - expect(json['type'], 'reasoning'); - expect(json['text'], 'thinking...'); - expect(json['signature'], 'sig123'); - final decoded = ContentPart.fromJson(json); - expect(decoded, isA()); - expect((decoded as ContentPartReasoning).signature, 'sig123'); - }); - - test('ToolCall variant', () { - final original = ContentPartToolCall( - toolCallId: 'call_1', - toolName: 'get_weather', - input: {'location': 'Tokyo'}, - providerExecuted: true, - ); - final json = original.toJson(); - expect(json['type'], 'tool_call'); - expect(json['tool_call_id'], 'call_1'); - expect(json['tool_name'], 'get_weather'); - expect(json['provider_executed'], true); - final decoded = ContentPart.fromJson(json); - expect(decoded, isA()); - final d = decoded as ContentPartToolCall; - expect(d.toolCallId, 'call_1'); - expect(d.toolName, 'get_weather'); - expect(d.input, {'location': 'Tokyo'}); - expect(d.providerExecuted, true); - }); + // One wire document per variant; optional fields are absent, not null. + final cases = >{ + 'text': {'type': 'text', 'text': 'hello'}, + 'image': {'type': 'image', 'image': [1, 2, 3], 'mediaType': 'image/png'}, + 'file': { + 'type': 'file', + 'data': [104, 105], + 'mediaType': 'application/pdf', + 'filename': 'doc.pdf', + }, + 'file-base64': { + 'type': 'file-base64', + 'data': 'aGk=', + 'mediaType': 'image/png', + }, + 'file-url': { + 'type': 'file-url', + 'url': 'https://example.com/f.png', + 'mediaType': 'image/png', + }, + 'file-reference': { + 'type': 'file-reference', + 'mediaType': 'application/pdf', + 'reference': {'openai': 'file-abc'}, + }, + 'reasoning': {'type': 'reasoning', 'text': 'thinking...', 'signature': 'sig'}, + 'reasoning-file': { + 'type': 'reasoning-file', + 'data': {'type': 'data', 'data': 'aGk='}, + 'mediaType': 'image/png', + }, + 'custom': { + 'type': 'custom', + 'kind': 'k', + 'providerOptions': {'a': {'b': 1}}, + }, + 'tool-approval-request': { + 'type': 'tool-approval-request', + 'approvalId': 'ap1', + 'toolCallId': 'call_1', + 'isAutomatic': true, + }, + 'tool-call': { + 'type': 'tool-call', + 'toolCallId': 'call_1', + 'toolName': 'get_weather', + 'input': {'location': 'Tokyo'}, + 'providerExecuted': true, + }, + 'tool-result': { + 'type': 'tool-result', + 'toolCallId': 'call_1', + 'toolName': 'get_weather', + 'output': { + 'type': 'json', + 'value': {'temp': 22}, + }, + }, + }; + + for (final entry in cases.entries) { + test(entry.key, () { + final part = ContentPart.fromJson(entry.value); + expect(part, isNot(isA())); + expect(part.toJson(), entry.value); + }); + } - test('ToolResult variant — uses result, not output', () { - final original = ContentPartToolResult( + test('tool-result carries a typed output, not result/isError', () { + final part = ContentPartToolResult( toolCallId: 'call_1', - result: {'temp': 22}, toolName: 'get_weather', - isError: false, - preliminary: null, - isDynamic: null, + output: ToolResultOutput.errorText('boom'), ); - final json = original.toJson(); - expect(json['type'], 'tool_result'); - expect(json, contains('result')); - expect(json, isNot(contains('output'))); - expect(json['tool_name'], 'get_weather'); - expect(json['is_error'], false); - final decoded = ContentPart.fromJson(json); - expect(decoded, isA()); - final d = decoded as ContentPartToolResult; - expect(d.result, {'temp': 22}); - expect(d.toolName, 'get_weather'); - expect(d.isError, false); - }); - - test('Unknown variant — forward compat', () { - final json = {'type': 'future_variant', 'data': 'something'}; - final decoded = ContentPart.fromJson(json); - expect(decoded, isA()); - final unknown = decoded as ContentPartUnknown; - expect(unknown.tag, 'future_variant'); - expect(unknown.data['data'], 'something'); + final json = part.toJson(); + expect(json, isNot(contains('result'))); + expect(json, isNot(contains('isError'))); + expect(json['output'], {'type': 'error-text', 'value': 'boom'}); + }); + + test('ToolResultOutput variants', () { + for (final json in >[ + {'type': 'text', 'value': 'ok'}, + {'type': 'json', 'value': {'a': 1}}, + {'type': 'execution-denied', 'reason': 'no'}, + {'type': 'execution-denied'}, + {'type': 'error-text', 'value': 'e'}, + {'type': 'error-json', 'value': [1]}, + { + 'type': 'content', + 'value': [ + {'type': 'text', 'text': 't'} + ] + }, + ]) { + expect(ToolResultOutput.fromJson(json).toJson(), json); + } + }); + + test('unknown type passes through verbatim', () { + final json = {'type': 'future-variant', 'data': 'something'}; + final part = ContentPart.fromJson(json); + expect(part, isA()); + expect(part.toJson(), json); }); }); } diff --git a/bindings/flutter/test/errors_test.dart b/bindings/flutter/test/errors_test.dart index bd9546cf3..6e801cabb 100644 --- a/bindings/flutter/test/errors_test.dart +++ b/bindings/flutter/test/errors_test.dart @@ -90,10 +90,10 @@ void main() { test('unknown code is rejected with StateError', () { // A code outside the published table is an ABI mismatch, not an error // kind. 1 is AIMUX_E_OTHER now because Other inherited the old UNKNOWN - // slot, so it resolves; 4 is retired and 20 is the first unassigned value. + // slot, so it resolves; 4 is retired and 21 is the first unassigned value. expect(() => AimuxException.fromCode(999, 'future'), throwsStateError); expect(() => AimuxException.fromCode(4, 'retired'), throwsStateError); - expect(() => AimuxException.fromCode(20, 'unassigned'), throwsStateError); + expect(() => AimuxException.fromCode(21, 'unassigned'), throwsStateError); }); test('bare retry code synthesizes a single-attempt RetryError', () { diff --git a/bindings/flutter/test/multimodal_e2e_test.dart b/bindings/flutter/test/multimodal_e2e_test.dart index 9737941ea..0bab55efe 100644 --- a/bindings/flutter/test/multimodal_e2e_test.dart +++ b/bindings/flutter/test/multimodal_e2e_test.dart @@ -118,7 +118,7 @@ Future startMockServer( // the worker so the main isolate's event loop stays free to serve the mock. // // Call options that the Rust core deserializes (speech/image/rerank/search) -// always carry `"provider_options":{}` — matching the Go wire format, where the +// always carry `"providerOptions":{}` — matching the Go wire format, where the // SharedProviderOptions map must be present (not null). embed/transcription/ // uploadFile pass no options (null pointer), matching the Go nil-opts path. @@ -251,7 +251,7 @@ const String speechAudioBody = 'SGVsbG8gd29ybGQ='; /// Parsed-only fixture for the video result wire format. const String videoResultJson = r''' -{"videos":[{"Url":{"url":"https://example.com/v.mp4","media_type":"video/mp4"}}]} +{"videos":[{"type":"url","url":"https://example.com/v.mp4","mediaType":"video/mp4"}]} '''; // ───────────────────────────────────────────────────────────────────────────── @@ -293,8 +293,8 @@ void main() { final optsJson = jsonEncode({ 'text': 'Hi', 'voice': 'alloy', - 'output_format': 'mp3', - 'provider_options': {}, + 'outputFormat': 'mp3', + 'providerOptions': {}, }); final result = @@ -302,9 +302,8 @@ void main() { final decoded = jsonDecode(result) as Map; expect(decoded, contains('audio')); - final audio = decoded['audio'] as Map; - expect(audio, contains('Binary')); - final binary = audio['Binary'] as List; + // AudioData is untagged: a base64 string or a byte array. + final binary = decoded['audio'] as List; expect(binary, isNotEmpty); expect(server.recorded, hasLength(1)); @@ -320,7 +319,7 @@ void main() { final optsJson = jsonEncode({ 'prompt': 'otter', 'n': 1, - 'provider_options': {}, + 'providerOptions': {}, }); final result = @@ -328,9 +327,8 @@ void main() { final decoded = jsonDecode(result) as Map; expect(decoded, contains('images')); - final images = decoded['images'] as Map; - expect(images, contains('Base64')); - final base64List = images['Base64'] as List; + // ImageOutputs is untagged: an array of base64 strings or of byte arrays. + final base64List = decoded['images'] as List; expect(base64List, hasLength(1)); expect(base64List[0], 'aW1hZ2Ux'); @@ -362,9 +360,9 @@ void main() { final optsJson = jsonEncode({ 'query': 'which?', - 'documents': {'Text': {'values': ['doc1', 'doc2']}}, - 'top_n': 2, - 'provider_options': {}, + 'documents': {'type': 'text', 'values': ['doc1', 'doc2']}, + 'topN': 2, + 'providerOptions': {}, }); final result = @@ -374,7 +372,7 @@ void main() { expect(decoded, contains('ranking')); final ranking = decoded['ranking'] as List; expect(ranking, hasLength(2)); - expect((ranking[0] as Map)['relevance_score'], 0.95); + expect((ranking[0] as Map)['relevanceScore'], 0.95); expect((ranking[0] as Map)['index'], 1); expect(server.recorded, hasLength(1)); @@ -389,8 +387,8 @@ void main() { final optsJson = jsonEncode({ 'query': 'What is Rust?', - 'max_results': 5, - 'provider_options': {}, + 'maxResults': 5, + 'providerOptions': {}, }); final result = await runSearch(server.baseUrl, apiKey, optsJson); @@ -417,8 +415,8 @@ void main() { server.baseUrl, apiKey, 'dGVzdA==', 'application/pdf'); final decoded = jsonDecode(result) as Map; - expect(decoded, contains('provider_reference')); - final providerRef = decoded['provider_reference'] as Map; + expect(decoded, contains('providerReference')); + final providerRef = decoded['providerReference'] as Map; expect(providerRef['openai'], 'file-abc'); expect(server.recorded, hasLength(1)); @@ -435,8 +433,9 @@ void main() { expect(decoded, contains('videos')); final videos = decoded['videos'] as List; expect(videos, hasLength(1)); - final urlData = (videos[0] as Map)['Url'] as Map; - expect(urlData['url'], 'https://example.com/v.mp4'); - expect(urlData['media_type'], 'video/mp4'); + final video = videos[0] as Map; + expect(video['type'], 'url'); + expect(video['url'], 'https://example.com/v.mp4'); + expect(video['mediaType'], 'video/mp4'); }); } diff --git a/bindings/flutter/test/structured_e2e_test.dart b/bindings/flutter/test/structured_e2e_test.dart index d5a9a3fba..b1ce1bf4b 100644 --- a/bindings/flutter/test/structured_e2e_test.dart +++ b/bindings/flutter/test/structured_e2e_test.dart @@ -28,9 +28,10 @@ import 'dart:convert'; import 'dart:io'; import 'dart:isolate'; -import 'package:aimux/aimux.dart'; import 'package:test/test.dart'; +import 'typed_model_test.dart' show mockChatModel; + // ───────────────────────────────────────────────────────────────────────────── // Mock server // ───────────────────────────────────────────────────────────────────────────── @@ -115,7 +116,7 @@ class _GenerateArgs { /// Run a non-streaming `generateText` in a worker isolate. Future> runGenerateText(_GenerateArgs args) { return Isolate.run(() { - final model = Model.openai(args.apiKey, args.modelId, baseUrl: args.baseUrl); + final model = mockChatModel(args.baseUrl, args.apiKey, args.modelId); try { return model.generateText(args.prompt, args.options); } finally { @@ -127,7 +128,7 @@ Future> runGenerateText(_GenerateArgs args) { /// Run a streaming `streamText` in a worker isolate, returning all parts. Future>> runStreamText(_GenerateArgs args) { return Isolate.run(() async { - final model = Model.openai(args.apiKey, args.modelId, baseUrl: args.baseUrl); + final model = mockChatModel(args.baseUrl, args.apiKey, args.modelId); try { // streamText blocks synchronously until the stream completes, then // returns an already-closed stream containing the buffered parts. @@ -217,7 +218,7 @@ Map _weatherTool() { return { 'type': 'function', 'name': 'get_weather', - 'input_schema': { + 'inputSchema': { 'type': 'object', 'properties': { 'location': {'type': 'string'}, @@ -235,7 +236,7 @@ void main() { const apiKey = 'sk-test-mock-key'; const modelId = 'gpt-4o'; - test('generateText parses tool_calls', () async { + test('generateText parses toolCalls', () async { final server = await startMockServer([ {'body': toolCallOpenAIResponse}, ]); @@ -255,18 +256,18 @@ void main() { expect(server.recorded.first.path, '/chat/completions'); // Tool call parsed into the user-facing result. - expect(result, contains('tool_calls')); - final toolCalls = result['tool_calls'] as List; + expect(result, contains('toolCalls')); + final toolCalls = result['toolCalls'] as List; expect(toolCalls, hasLength(1)); - expect(toolCalls[0]['tool_name'], 'get_weather'); - expect(toolCalls[0]['tool_call_id'], 'call_abc'); + expect(toolCalls[0]['toolName'], 'get_weather'); + expect(toolCalls[0]['toolCallId'], 'call_abc'); expect((toolCalls[0]['input'] as Map)['location'], 'Tokyo'); // The raw provider result contains a ToolCall content variant. final raw = result['raw'] as Map; final content = raw['content'] as List; final hasToolCallVariant = - content.any((c) => (c as Map).containsKey('ToolCall')); + content.any((c) => (c as Map)['type'] == 'tool-call'); expect(hasToolCallVariant, isTrue); }); @@ -317,7 +318,7 @@ void main() { 'What is the weather in Tokyo?', { 'tools': [_weatherTool()], - 'tool_choice': 'required', + 'toolChoice': 'required', }, )); @@ -350,18 +351,17 @@ void main() { // The stream delivered tool-call parts. expect(parts, isNotEmpty); final hasToolInputDelta = - parts.any((p) => (p as Map).containsKey('ToolInputDelta')); + parts.any((p) => (p as Map)['type'] == 'tool-input-delta'); final hasToolCall = - parts.any((p) => (p as Map).containsKey('ToolCall')); + parts.any((p) => (p as Map)['type'] == 'tool-call'); expect(hasToolInputDelta || hasToolCall, isTrue, reason: 'expected a tool-call stream part; got: $parts'); // The assembled ToolCall should carry the get_weather tool + Tokyo input. - final toolCallPart = parts - .cast?>() - .firstWhere((p) => p!.containsKey('ToolCall')); - final tc = toolCallPart!['ToolCall'] as Map; - expect(tc['tool_name'], 'get_weather'); + final tc = parts + .cast>() + .firstWhere((p) => p['type'] == 'tool-call'); + expect(tc['toolName'], 'get_weather'); expect((tc['input'] as Map)['location'], 'Tokyo'); }); @@ -394,11 +394,11 @@ void main() { {'tools': [_weatherTool()]}, )); - expect(firstResult, contains('tool_calls')); - final toolCalls = firstResult['tool_calls'] as List; + expect(firstResult, contains('toolCalls')); + final toolCalls = firstResult['toolCalls'] as List; expect(toolCalls, hasLength(1)); - expect(toolCalls[0]['tool_name'], 'get_weather'); - expect(toolCalls[0]['tool_call_id'], 'call_abc'); + expect(toolCalls[0]['toolName'], 'get_weather'); + expect(toolCalls[0]['toolCallId'], 'call_abc'); // 2. Build the follow-up messages: original user question, the assistant's // tool call, and the tool result we "executed" and are filling back in. @@ -408,9 +408,9 @@ void main() { 'role': 'assistant', 'content': [ { - 'type': 'tool_call', - 'tool_call_id': 'call_abc', - 'tool_name': 'get_weather', + 'type': 'tool-call', + 'toolCallId': 'call_abc', + 'toolName': 'get_weather', 'input': {'location': 'Tokyo'}, } ], @@ -419,9 +419,13 @@ void main() { 'role': 'tool', 'content': [ { - 'type': 'tool_result', - 'tool_call_id': 'call_abc', - 'output': {'temperature': 22, 'condition': 'sunny'}, + 'type': 'tool-result', + 'toolCallId': 'call_abc', + 'toolName': 'get_weather', + 'output': { + 'type': 'json', + 'value': {'temperature': 22, 'condition': 'sunny'}, + }, } ], }, diff --git a/bindings/flutter/test/tool_call_repair_typed_test.dart b/bindings/flutter/test/tool_call_repair_typed_test.dart index 181cb5c5b..273e6438c 100644 --- a/bindings/flutter/test/tool_call_repair_typed_test.dart +++ b/bindings/flutter/test/tool_call_repair_typed_test.dart @@ -10,13 +10,12 @@ import 'dart:convert'; import 'dart:isolate'; -import 'package:aimux/aimux.dart'; import 'package:aimux/typed_model.dart'; import 'package:aimux/types.dart'; import 'package:test/test.dart'; import 'typed_model_test.dart' - show buildToolCallSse, startMockServer, toolCallOpenAIResponse; + show buildToolCallSse, mockChatModel, startMockServer, toolCallOpenAIResponse; /// Accepts `city` only, so the canned `{"location":"Tokyo"}` is invalid. GenerateTextOptions _strictOptions() => GenerateTextOptions( @@ -48,7 +47,7 @@ void main() { final baseUrl = server.baseUrl; final result = await Isolate.run(() { - final typed = TypedModel(Model.openai('sk-test', 'gpt-4o', baseUrl: baseUrl)); + final typed = TypedModel(mockChatModel(baseUrl, 'sk-test', 'gpt-4o')); try { return jsonDecode(jsonEncode(typed.generateText('weather?', _strictOptions()))) as Map; @@ -57,13 +56,13 @@ void main() { } }); - final call = (result['tool_calls'] as List).single as Map; + final call = (result['toolCalls'] as List).single as Map; expect(call['invalid'], isNull); expect(call['input'], {'city': 'Tokyo'}); - final part = (result['response_messages'] as List) + final part = (result['responseMessages'] as List) .expand((m) => (m as Map)['content'] as List) .whereType>() - .singleWhere((p) => p['type'] == 'tool_call'); + .singleWhere((p) => p['type'] == 'tool-call'); expect(part['input'], {'city': 'Tokyo'}); }); @@ -75,7 +74,7 @@ void main() { final baseUrl = server.baseUrl; final arguments = await Isolate.run(() { - final typed = TypedModel(Model.openai('sk-test', 'gpt-4o', baseUrl: baseUrl)); + final typed = TypedModel(mockChatModel(baseUrl, 'sk-test', 'gpt-4o')); try { final completion = typed.generateTextAsOpenAI('weather?', _strictOptions()); return completion.choices.first.message.toolCalls!.single.function.arguments; @@ -95,10 +94,10 @@ void main() { final baseUrl = server.baseUrl; final input = await Isolate.run(() async { - final typed = TypedModel(Model.openai('sk-test', 'gpt-4o', baseUrl: baseUrl)); + final typed = TypedModel(mockChatModel(baseUrl, 'sk-test', 'gpt-4o')); try { final parts = await typed.streamText('weather?', _strictOptions()).toList(); - final call = parts.whereType().single; + final call = parts.whereType().single.call; return {'invalid': call.invalid, 'input': call.input}; } finally { typed.close(); @@ -108,27 +107,4 @@ void main() { expect(input['invalid'], isNull); expect(input['input'], {'city': 'Tokyo'}); }); - - test('RawToolCall keeps every provider field through a repair', () { - final call = RawToolCall.fromJson({ - 'tool_call_id': 'call-1', - 'tool_name': 'weather', - 'input': '{"town":"Tokyo"}', - 'provider_executed': true, - 'dynamic': true, - 'provider_metadata': { - 'google': {'x': 1}, - }, - }); - expect(call.copyWith(input: '{"city":"Tokyo"}').toJson(), { - 'tool_call_id': 'call-1', - 'tool_name': 'weather', - 'input': '{"city":"Tokyo"}', - 'provider_executed': true, - 'dynamic': true, - 'provider_metadata': { - 'google': {'x': 1}, - }, - }); - }); } diff --git a/bindings/flutter/test/typed_model_test.dart b/bindings/flutter/test/typed_model_test.dart index 838deb5f3..0c52a9f2c 100644 --- a/bindings/flutter/test/typed_model_test.dart +++ b/bindings/flutter/test/typed_model_test.dart @@ -37,6 +37,12 @@ import 'package:test/test.dart'; // Mock server (mirrors structured_e2e_test.dart) // ───────────────────────────────────────────────────────────────────────────── +/// A chat-completions model against [baseUrl]: the registry's `groq` entry with +/// a `baseUrl` setting (`Model.openai` speaks the Responses API). +Model mockChatModel(String baseUrl, String apiKey, String modelId) => + Model.provider('groq', modelId, + apiKey: apiKey, configJson: jsonEncode({'baseUrl': baseUrl})); + class MockOpenAIServer { final HttpServer _server; final List> _responses; @@ -134,7 +140,7 @@ class _TypedArgs { /// instances (not plain maps), which cannot be re-parsed by `fromJson`. Future> runTypedGenerateText(_TypedArgs args) { return Isolate.run(() { - final model = Model.openai(args.apiKey, args.modelId, baseUrl: args.baseUrl); + final model = mockChatModel(args.baseUrl, args.apiKey, args.modelId); final typed = TypedModel(model); try { final hasOptions = args.tools != null || @@ -160,7 +166,7 @@ Future> runTypedGenerateText(_TypedArgs args) { /// Run a typed `generateTextMessages` (multi-turn) in a worker isolate. Future> runTypedGenerateTextMessages(_TypedArgs args) { return Isolate.run(() { - final model = Model.openai(args.apiKey, args.modelId, baseUrl: args.baseUrl); + final model = mockChatModel(args.baseUrl, args.apiKey, args.modelId); final typed = TypedModel(model); try { final messages = args.messages! @@ -177,7 +183,7 @@ Future> runTypedGenerateTextMessages(_TypedArgs args) { /// Run a typed `streamText` in a worker isolate, returning the parsed parts. Future> runTypedStreamText(_TypedArgs args) { return Isolate.run(() async { - final model = Model.openai(args.apiKey, args.modelId, baseUrl: args.baseUrl); + final model = mockChatModel(args.baseUrl, args.apiKey, args.modelId); final typed = TypedModel(model); try { final options = (args.tools != null || args.includeRawChunks != null) @@ -415,22 +421,21 @@ void main() { )); expect(parts, isNotEmpty); - // Every part is a single-key tagged union with a non-empty tag. + // Every part is a `type`-tagged union member the binding models. expect(parts.every((p) => p.type.isNotEmpty), isTrue); + expect(parts.whereType(), isEmpty); - // The ToolCall part is a typed subclass with named fields. + // The tool-call part carries the parsed call. final toolCall = parts.whereType().first; - expect(toolCall.type, 'ToolCall'); - expect(toolCall.toolName, 'get_weather'); - expect(toolCall.toolCallId, 'call_xyz'); - expect((toolCall.input as Map)['location'], 'Tokyo'); + expect(toolCall.type, 'tool-call'); + expect(toolCall.call.toolName, 'get_weather'); + expect(toolCall.call.toolCallId, 'call_xyz'); + expect((toolCall.call.input as Map)['location'], 'Tokyo'); // A Finish part carries typed usage + finish reason. final finish = parts.whereType().first; - expect(finish.type, 'Finish'); + expect(finish.type, 'finish'); expect(finish.usage, isA()); - expect(finish.usage.inputTokens.total, 5); - expect(finish.usage.outputTokens.total, 2); // The request was a streaming POST. expect(server.recorded, hasLength(1)); @@ -439,7 +444,7 @@ void main() { }); test('streamText emits Raw parts when includeRawChunks enabled', () async { - // RFC-0016 M2: include_raw_chunks surfaces one Raw part per JSON SSE + // RFC-0016 M2: includeRawChunks surfaces one Raw part per JSON SSE // event (parsed payload), before the parsed parts; [DONE] excluded. final server = await startMockServer([ {'sse': true, 'body': buildTextSse()}, @@ -476,8 +481,8 @@ void main() { expect(text, 'Hello world'); }); - test('streamText parses non-empty StreamStart warnings', () async { - // RFC-0016 M9: StreamStart carries a warnings array; a non-empty array + test('streamText decodes the stream-start part', () async { + // RFC-0016 M9: stream-start carries a warnings array; a non-empty array // (e.g. from a provider that rejects top_k) must decode without breaking // the stream. The openai full profile produces no warning, so the // assertion is decode-ability + field presence (the warning itself is @@ -495,7 +500,7 @@ void main() { )); final start = parts.whereType().first; - expect(start.type, 'StreamStart'); + expect(start.type, 'stream-start'); expect(start.warnings, isA>()); }); } diff --git a/bindings/flutter/test/typed_round_trip_test.dart b/bindings/flutter/test/typed_round_trip_test.dart index 807b2b39d..558ab2031 100644 --- a/bindings/flutter/test/typed_round_trip_test.dart +++ b/bindings/flutter/test/typed_round_trip_test.dart @@ -1,458 +1,270 @@ -// Round-trip serialization tests for the typed `GenerateContent` / -// `StreamPart` sealed class hierarchies in `types.dart`. -// -// These tests are pure Dart — no FFI, no Rust core, no mock server. They -// verify that every known variant (and the `Unknown` fallback) survives a -// `toJson()` -> `fromJson()` round-trip with all fields intact, and that the -// externally-tagged wire shape matches the Rust core contract: -// -// - `File` variants serialize `data` + `media_type` and **never** `filename`. -// - `ToolResult` variants use the `result` key, **not** `output`. -// - `Unknown` variants pass the tag + payload through verbatim. -// -// Run: -// export PATH="/home/eric8810/.local/dart-sdk/bin:$PATH" -// cd bindings/flutter && dart test test/typed_round_trip_test.dart - +// Pure-Dart round trips for the typed `GenerateContent` / `StreamPart` unions, +// tool calls, options and results against the generated wire shapes in +// `bindings/node/src/types/`: camelCase fields, `type`-tagged unions with +// kebab-case values, optional fields absent (never null). import 'dart:convert'; import 'package:aimux/types.dart'; import 'package:test/test.dart'; -/// Deep-flatten a `toJson()` result across the JSON boundary. -/// -/// Mirrors the workaround used in `typed_model_test.dart`: `Usage` / -/// `TokenUsage` are `@JsonSerializable()` with the default -/// `explicitToJson: false`, so their generated `toJson()` keeps nested -/// `TokenUsage` instances instead of recursing to plain maps. The only variant -/// affected is `StreamPartFinish` (the only one nesting `@JsonSerializable` -/// types). In the real pipeline the value always crosses a JSON-string -/// boundary (Rust core → JSON → Dart), which flattens it — so we do the same -/// here before re-parsing. -Map deepFlatten(Map json) => - jsonDecode(jsonEncode(json)) as Map; +const _meta = { + 'openai': {'itemId': 'item_1'} +}; +const _file = { + 'data': {'type': 'data', 'data': 'aGk='}, + 'mediaType': 'image/png', +}; +const _usage = { + 'inputTokens': {'total': 5, 'noCache': 5}, + 'outputTokens': {'total': 2}, +}; +const _finish = {'unified': 'stop', 'raw': 'stop'}; void main() { - group('ToolCall round-trip', () { - test('preserves provider_metadata', () { - final original = ToolCall( - toolCallId: 'call_1', - toolName: 'get_weather', - input: {'city': 'Paris'}, - providerMetadata: { - 'openai': {'itemId': 'item_1'}, - }, - ); - final json = original.toJson(); - expect(json['provider_metadata'], { - 'openai': {'itemId': 'item_1'}, - }); - - final decoded = ToolCall.fromJson(json); - expect(decoded.providerMetadata, original.providerMetadata); + group('ToolCall', () { + test('round-trips and omits absent optionals', () { + final json = { + 'toolCallId': 'call_1', + 'toolName': 'get_weather', + 'input': {'city': 'Paris'}, + 'providerExecuted': true, + 'dynamic': false, + 'providerMetadata': _meta, + 'invalid': true, + 'error': {'name': 'AI_NoSuchToolError', 'toolName': 'x'}, + }; + expect(ToolCall.fromJson(json).toJson(), json); + + final minimal = ToolCall(toolCallId: 'c', toolName: 't', input: const {}); + expect(minimal.toJson().keys, ['toolCallId', 'toolName', 'input']); }); - test('provider_metadata accepts arbitrary JSON and omits null', () { - final scalar = ToolCall( - toolCallId: 'call_scalar', - toolName: 'tool', - input: const {}, - providerMetadata: 'opaque-provider-token', - ); - expect(scalar.toJson()['provider_metadata'], 'opaque-provider-token'); - expect(ToolCall.fromJson(scalar.toJson()).providerMetadata, - 'opaque-provider-token'); - - final absent = ToolCall( - toolCallId: 'call_absent', - toolName: 'tool', - input: const {}, - ); - expect(absent.toJson(), isNot(contains('provider_metadata'))); + test('RawToolCall keeps every provider field through copyWith', () { + final call = RawToolCall.fromJson({ + 'toolCallId': 'call-1', + 'toolName': 'weather', + 'input': '{"town":"Tokyo"}', + 'providerExecuted': true, + 'dynamic': true, + 'providerMetadata': _meta, + }); + expect(call.copyWith(input: '{"city":"Tokyo"}').toJson(), { + 'toolCallId': 'call-1', + 'toolName': 'weather', + 'input': '{"city":"Tokyo"}', + 'providerExecuted': true, + 'dynamic': true, + 'providerMetadata': _meta, + }); }); }); - // ───────────────────────────────────────────────────────────────────────── - // GenerateContent (6 variants + Unknown) - // ───────────────────────────────────────────────────────────────────────── - group('GenerateContent round-trip', () { - test('Text variant', () { - final original = - GenerateContentText(text: 'hello', providerMetadata: null); - final decoded = GenerateContent.fromJson(original.toJson()); - - expect(decoded, isA()); - expect(decoded.tag, 'Text'); - final t = decoded as GenerateContentText; - expect(t.text, 'hello'); - expect(t.providerMetadata, isNull); - }); - - test('Text variant preserves provider_metadata', () { - final original = GenerateContentText( - text: 'hi', - providerMetadata: {'model': 'gpt-4o'}, - ); - final decoded = GenerateContent.fromJson(original.toJson()); - - final t = decoded as GenerateContentText; - expect(t.providerMetadata, {'model': 'gpt-4o'}); - }); - - test('ToolCall variant', () { - final original = GenerateContentToolCall( - toolCallId: 'call_1', - toolName: 'get_weather', - input: {'location': 'Tokyo'}, - providerExecuted: true, - isDynamic: false, - providerMetadata: null, - ); - final decoded = GenerateContent.fromJson(original.toJson()); - - expect(decoded, isA()); - expect(decoded.tag, 'ToolCall'); - final tc = decoded as GenerateContentToolCall; - expect(tc.toolCallId, 'call_1'); - expect(tc.toolName, 'get_weather'); - expect((tc.input as Map)['location'], 'Tokyo'); - expect(tc.providerExecuted, true); - expect(tc.isDynamic, false); - }); - - test('Reasoning variant', () { - final original = - GenerateContentReasoning(text: 'thinking...', providerMetadata: null); - final decoded = GenerateContent.fromJson(original.toJson()); - - expect(decoded, isA()); - expect(decoded.tag, 'Reasoning'); - final r = decoded as GenerateContentReasoning; - expect(r.text, 'thinking...'); - }); - - - - test('ToolResult variant — uses result, not output', () { - final original = GenerateContentToolResult( - toolCallId: 'call_1', - toolName: 'web_search', - result: {'count': 3, 'hits': ['a', 'b', 'c']}, - isError: false, - preliminary: true, - isDynamic: false, - providerMetadata: null, - ); - final json = original.toJson(); - - expect(json.keys, ['ToolResult']); - final payload = json['ToolResult'] as Map; - - // Contract: `result` key, never `output`. - expect(payload, contains('result')); - expect(payload, isNot(contains('output'))); - expect(payload, contains('tool_call_id')); - expect(payload, contains('tool_name')); - expect(payload, contains('is_error')); - expect(payload, contains('preliminary')); - expect(payload, contains('dynamic')); - - final decoded = GenerateContent.fromJson(json); - expect(decoded, isA()); - expect(decoded.tag, 'ToolResult'); - final tr = decoded as GenerateContentToolResult; - expect(tr.toolCallId, 'call_1'); - expect(tr.toolName, 'web_search'); - expect((tr.result as Map)['count'], 3); - expect(tr.isError, false); - expect(tr.preliminary, true); - expect(tr.isDynamic, false); - }); - - test('Unknown variant — tag + data pass through verbatim', () { - final original = GenerateContentUnknown( - tag: 'FutureVariant', - data: {'foo': 'bar', 'n': 42}, - ); - final json = original.toJson(); - - // Unknown re-encodes as `{tag: data}`. - expect(json.keys, ['FutureVariant']); - expect(json['FutureVariant'], {'foo': 'bar', 'n': 42}); - - final decoded = GenerateContent.fromJson(json); - expect(decoded, isA()); - final u = decoded as GenerateContentUnknown; - expect(u.tag, 'FutureVariant'); - expect(u.data, {'foo': 'bar', 'n': 42}); + group('GenerateContent', () { + final cases = >[ + {'type': 'text', 'text': 'hi', 'providerMetadata': _meta}, + { + 'type': 'tool-call', + 'toolCallId': 'c1', + 'toolName': 'w', + 'input': '{"a":1}', + 'dynamic': true, + }, + {'type': 'source', 'sourceType': 'url', 'id': 's', 'url': 'https://x'}, + { + 'type': 'source', + 'sourceType': 'document', + 'id': 's', + 'mediaType': 'application/pdf', + 'title': 'T', + 'filename': 'f.pdf', + }, + {'type': 'reasoning', 'text': 'hmm'}, + {'type': 'file', ..._file}, + {'type': 'reasoning-file', ..._file}, + {'type': 'custom', 'kind': 'k'}, + {'type': 'tool-approval-request', 'approvalId': 'a', 'toolCallId': 'c'}, + { + 'type': 'tool-result', + 'toolCallId': 'c1', + 'toolName': 'web_search', + 'result': {'count': 3}, + 'isError': false, + 'preliminary': true, + 'dynamic': false, + }, + ]; + for (final json in cases) { + test(json['type'] as String, () { + final c = GenerateContent.fromJson(json); + expect(c, isNot(isA())); + expect(c.tag, json['type']); + expect(c.toJson(), json); + }); + } + + test('unknown type passes through verbatim', () { + final json = {'type': 'future', 'n': 42}; + final c = GenerateContent.fromJson(json); + expect(c, isA()); + expect(c.tag, 'future'); + expect(c.toJson(), json); }); }); - // ───────────────────────────────────────────────────────────────────────── - // StreamPart (18 variants + Unknown) - // ───────────────────────────────────────────────────────────────────────── - group('StreamPart round-trip', () { - // ── Text variants ── - test('TextStart variant', () { - final original = StreamPartTextStart(id: 't1', providerMetadata: null); - final decoded = StreamPart.fromJson(original.toJson()); - - expect(decoded, isA()); - expect(decoded.type, 'TextStart'); - expect((decoded as StreamPartTextStart).id, 't1'); - }); - - test('TextDelta variant', () { - final original = - StreamPartTextDelta(id: 't1', delta: 'hel', providerMetadata: null); - final decoded = StreamPart.fromJson(original.toJson()); - - expect(decoded, isA()); - expect(decoded.type, 'TextDelta'); - final d = decoded as StreamPartTextDelta; - expect(d.id, 't1'); - expect(d.delta, 'hel'); - }); - - test('TextEnd variant', () { - final original = StreamPartTextEnd(id: 't1', providerMetadata: null); - final decoded = StreamPart.fromJson(original.toJson()); - - expect(decoded, isA()); - expect(decoded.type, 'TextEnd'); - expect((decoded as StreamPartTextEnd).id, 't1'); - }); - - // ── Stream lifecycle ── - test('StreamStart variant', () { - final original = StreamPartStreamStart(warnings: [ - {'code': 'deprecation', 'message': 'soon'}, - ]); - final decoded = StreamPart.fromJson(original.toJson()); - - expect(decoded, isA()); - expect(decoded.type, 'StreamStart'); - final s = decoded as StreamPartStreamStart; - expect(s.warnings, hasLength(1)); - expect(s.warnings.first['code'], 'deprecation'); - }); - - - - test('Error variant', () { - final original = StreamPartError(error: {'message': 'boom', 'code': 500}); - final decoded = StreamPart.fromJson(original.toJson()); - - expect(decoded, isA()); - expect(decoded.type, 'Error'); - final e = decoded as StreamPartError; - expect((e.error as Map)['message'], 'boom'); - }); - - // ── Tool calls ── - test('ToolInputStart variant — has title field', () { - final original = StreamPartToolInputStart( - id: 'tc1', - toolName: 'get_weather', - providerExecuted: true, - isDynamic: false, - title: 'Weather lookup', - providerMetadata: null, - ); - final json = original.toJson(); - - final payload = json['ToolInputStart'] as Map; - expect(payload, contains('title')); - expect(payload['title'], 'Weather lookup'); - - final decoded = StreamPart.fromJson(json); - expect(decoded, isA()); - expect(decoded.type, 'ToolInputStart'); - final t = decoded as StreamPartToolInputStart; - expect(t.id, 'tc1'); - expect(t.toolName, 'get_weather'); - expect(t.title, 'Weather lookup'); - expect(t.providerExecuted, true); - expect(t.isDynamic, false); - }); - - test('ToolInputDelta variant', () { - final original = - StreamPartToolInputDelta(id: 'tc1', delta: '{"loc', providerMetadata: null); - final decoded = StreamPart.fromJson(original.toJson()); - - expect(decoded, isA()); - expect(decoded.type, 'ToolInputDelta'); - final d = decoded as StreamPartToolInputDelta; - expect(d.id, 'tc1'); - expect(d.delta, '{"loc'); - }); - - test('ToolInputEnd variant', () { - final original = - StreamPartToolInputEnd(id: 'tc1', providerMetadata: null); - final decoded = StreamPart.fromJson(original.toJson()); - - expect(decoded, isA()); - expect(decoded.type, 'ToolInputEnd'); - expect((decoded as StreamPartToolInputEnd).id, 'tc1'); - }); - - test('ToolCall variant — has provider_metadata field', () { - final original = StreamPartToolCall( - toolCallId: 'call_1', - toolName: 'get_weather', - input: {'location': 'Tokyo'}, - providerExecuted: true, - isDynamic: false, - providerMetadata: {'provider': 'openai'}, - ); - final json = original.toJson(); - - final payload = json['ToolCall'] as Map; - expect(payload, contains('provider_metadata')); - expect(payload['provider_metadata'], {'provider': 'openai'}); - - final decoded = StreamPart.fromJson(json); - expect(decoded, isA()); - expect(decoded.type, 'ToolCall'); - final tc = decoded as StreamPartToolCall; - expect(tc.toolCallId, 'call_1'); - expect(tc.toolName, 'get_weather'); - expect((tc.input as Map)['location'], 'Tokyo'); - expect(tc.providerMetadata, {'provider': 'openai'}); - }); - - test('ToolResult variant — uses result, not output', () { - final original = StreamPartToolResult( - toolCallId: 'call_1', - toolName: 'web_search', - result: {'count': 3}, - isError: false, - preliminary: true, - isDynamic: false, - providerMetadata: null, - ); - final json = original.toJson(); - - final payload = json['ToolResult'] as Map; - expect(payload, contains('result')); - expect(payload, isNot(contains('output'))); - - final decoded = StreamPart.fromJson(json); - expect(decoded, isA()); - expect(decoded.type, 'ToolResult'); - final tr = decoded as StreamPartToolResult; - expect(tr.toolCallId, 'call_1'); - expect(tr.toolName, 'web_search'); - expect((tr.result as Map)['count'], 3); - expect(tr.isError, false); - expect(tr.preliminary, true); - expect(tr.isDynamic, false); - }); - - // ── File ── - - - // ── Reasoning ── - test('ReasoningStart variant', () { - final original = - StreamPartReasoningStart(id: 'r1', providerMetadata: null); - final decoded = StreamPart.fromJson(original.toJson()); - - expect(decoded, isA()); - expect(decoded.type, 'ReasoningStart'); - expect((decoded as StreamPartReasoningStart).id, 'r1'); - }); - - test('ReasoningDelta variant', () { - final original = - StreamPartReasoningDelta(id: 'r1', delta: 'thi', providerMetadata: null); - final decoded = StreamPart.fromJson(original.toJson()); - - expect(decoded, isA()); - expect(decoded.type, 'ReasoningDelta'); - final d = decoded as StreamPartReasoningDelta; - expect(d.id, 'r1'); - expect(d.delta, 'thi'); - }); - - test('ReasoningEnd variant', () { - final original = - StreamPartReasoningEnd(id: 'r1', providerMetadata: null); - final decoded = StreamPart.fromJson(original.toJson()); - - expect(decoded, isA()); - expect(decoded.type, 'ReasoningEnd'); - expect((decoded as StreamPartReasoningEnd).id, 'r1'); + group('StreamPart', () { + final call = { + 'toolCallId': 'c1', + 'toolName': 'w', + 'input': {'a': 1}, + 'invalid': true, + }; + final cases = >[ + {'type': 'text-start', 'id': 't'}, + {'type': 'text-delta', 'id': 't', 'delta': 'hel', 'providerMetadata': _meta}, + {'type': 'text-end', 'id': 't'}, + { + 'type': 'stream-start', + 'warnings': [ + {'type': 'other', 'message': 'm'} + ] + }, + {'type': 'finish', 'finishReason': _finish, 'usage': _usage}, + { + 'type': 'finish-step', + 'finishReason': _finish, + 'usage': _usage, + 'response': {'id': 'r', 'modelId': 'gpt-4o'}, + }, + { + 'type': 'error', + 'error': {'name': 'AI_APICallError', 'message': 'boom', 'isRetryable': false}, + }, + { + 'type': 'tool-input-start', + 'id': 'tc', + 'toolName': 'w', + 'providerExecuted': true, + 'dynamic': false, + 'title': 'Weather', + }, + {'type': 'tool-input-delta', 'id': 'tc', 'delta': '{"lo'}, + {'type': 'tool-input-end', 'id': 'tc'}, + {'type': 'tool-call', ...call}, + { + 'type': 'tool-result', + 'toolCallId': 'c1', + 'toolName': 'w', + 'result': {'count': 3}, + }, + {'type': 'file', ..._file}, + {'type': 'reasoning-file', 'file': _file}, + {'type': 'custom', 'kind': 'k'}, + { + 'type': 'tool-approval-request', + 'approvalId': 'a', + 'toolCall': call, + 'reason': 'r', + }, + {'type': 'reasoning-start', 'id': 'r1'}, + {'type': 'reasoning-delta', 'id': 'r1', 'delta': 'thi'}, + {'type': 'reasoning-end', 'id': 'r1'}, + {'type': 'source', 'sourceType': 'url', 'id': 's', 'url': 'https://x'}, + {'type': 'raw', 'rawValue': {'raw': 'chunk'}}, + ]; + for (final json in cases) { + test(json['type'] as String, () { + final p = StreamPart.fromJson(json); + expect(p, isNot(isA())); + expect(p.type, json['type']); + expect(p.toJson(), json); + }); + } + + test('unknown type passes through verbatim', () { + final json = {'type': 'future-part', 'n': 7}; + final p = StreamPart.fromJson(json); + expect(p, isA()); + expect(p.type, 'future-part'); + expect(p.toJson(), json); }); + }); - // ── Metadata / sources / raw ── - test('ResponseMetadata variant', () { - final original = StreamPartResponseMetadata( - id: 'resp_1', - timestamp: '2024-01-01T00:00:00Z', - modelId: 'gpt-4o', + group('options and results', () { + test('GenerateTextOptions uses camelCase keys and omits unset fields', () { + final options = GenerateTextOptions( + maxOutputTokens: 10, + stopSequences: ['x'], + toolChoice: ToolChoice.tool('w'), + timeout: TimeoutConfiguration(totalMs: 1000), + includeRawChunks: true, + sessionId: 'sess-1', + tools: [ + Tool.function(FunctionTool(name: 'w', inputSchema: {'type': 'object'})), + ], ); - final decoded = StreamPart.fromJson(original.toJson()); - - expect(decoded, isA()); - expect(decoded.type, 'ResponseMetadata'); - final m = decoded as StreamPartResponseMetadata; - expect(m.id, 'resp_1'); - expect(m.timestamp, '2024-01-01T00:00:00Z'); - expect(m.modelId, 'gpt-4o'); + final json = options.toJson(); + expect(json, { + 'maxOutputTokens': 10, + 'stopSequences': ['x'], + 'toolChoice': {'type': 'tool', 'toolName': 'w'}, + 'timeout': {'totalMs': 1000}, + 'includeRawChunks': true, + 'sessionId': 'sess-1', + 'tools': [ + {'type': 'function', 'name': 'w', 'inputSchema': {'type': 'object'}} + ], + }); + expect(GenerateTextOptions.fromJson(json).toJson(), json); + expect(GenerateTextOptions().toJson(), isEmpty); }); - test('Raw variant', () { - final original = StreamPartRaw(rawValue: {'raw': 'chunk'}); - final decoded = StreamPart.fromJson(original.toJson()); - - expect(decoded, isA()); - expect(decoded.type, 'Raw'); - final r = decoded as StreamPartRaw; - expect((r.rawValue as Map)['raw'], 'chunk'); + test('provider tool', () { + final json = { + 'type': 'provider', + 'id': 'anthropic.web_search_20250305', + 'name': 'web_search', + 'args': {'maxUses': 1}, + }; + expect(Tool.fromJson(json).toJson(), json); }); - test('Unknown variant — tag + data pass through verbatim', () { - final original = - StreamPartUnknown(tag: 'FuturePart', data: {'foo': 'bar', 'n': 7}); - final json = original.toJson(); - - expect(json.keys, ['FuturePart']); - expect(json['FuturePart'], {'foo': 'bar', 'n': 7}); - - final decoded = StreamPart.fromJson(json); - expect(decoded, isA()); - expect(decoded.type, 'FuturePart'); - final u = decoded as StreamPartUnknown; - expect(u.tag, 'FuturePart'); - expect(u.data, {'foo': 'bar', 'n': 7}); + test('GenerateTextResult round-trips', () { + final json = jsonDecode(jsonEncode({ + 'content': [ + {'type': 'text', 'text': 'hi'} + ], + 'text': 'hi', + 'toolCalls': [ + {'toolCallId': 'c', 'toolName': 't', 'input': {}} + ], + 'finishReason': _finish, + 'usage': _usage, + 'warnings': [], + 'raw': { + 'content': [ + {'type': 'text', 'text': 'hi'} + ], + 'finishReason': _finish, + 'usage': _usage, + 'warnings': [], + }, + 'reasoning': [], + 'reasoningText': '', + 'sources': [], + 'files': [], + 'responseMessages': [ + {'role': 'assistant', 'content': 'hi'} + ], + 'rawFinishReason': 'stop', + 'request': {}, + 'response': {'id': 'r'}, + 'totalUsage': _usage, + })) as Map; + final result = GenerateTextResult.fromJson(json); + expect(result.finishReason.unified, 'stop'); + expect(result.totalUsage.inputTokens.total, 5); + expect(result.raw.content.single, isA()); + expect(jsonDecode(jsonEncode(result)), json); }); }); - - test('GenerateTextOptions includeRawChunks round-trips', () { - // RFC-0016 M2 true-case through the Dart typed options. - final original = GenerateTextOptions(includeRawChunks: true); - final json = original.toJson(); - expect(json['include_raw_chunks'], isTrue); - final back = GenerateTextOptions.fromJson(json); - expect(back.includeRawChunks, isTrue); - - // Default omits the field (toJson only emits non-null). - final defaults = GenerateTextOptions().toJson(); - expect(defaults.containsKey('include_raw_chunks'), isFalse); - }); - - test('GenerateTextOptions sessionId round-trips', () { - // RFC-0024: explicit session_id crosses the wire as snake_case. - final original = GenerateTextOptions(sessionId: 'sess-1'); - final json = original.toJson(); - expect(json['session_id'], 'sess-1'); - final back = GenerateTextOptions.fromJson(json); - expect(back.sessionId, 'sess-1'); - - final defaults = GenerateTextOptions().toJson(); - expect(defaults.containsKey('session_id'), isFalse); - }); } diff --git a/bindings/go/README.md b/bindings/go/README.md index 56a7996c7..7db593930 100644 --- a/bindings/go/README.md +++ b/bindings/go/README.md @@ -43,14 +43,20 @@ func main() { } defer p.Close() - text, err := p.GenerateText(`"Explain Rust ownership in one sentence."`, "") + result, err := p.Generate("Explain Rust ownership in one sentence.", nil) if err != nil { log.Fatal(err) } - fmt.Println(text) + fmt.Println(result.Text) } ``` +`GenerateText` / `StreamText` take and return raw JSON strings; `Generate` / +`Stream` are the typed wrappers. The JSON is the AI SDK JSON: camelCase field +names, unions tagged by `type` (`"tool-call"`, `"text-delta"`, ...), optional +fields absent. A tool result is a `tool-result` content part +(`toolCallId`, `toolName`, `output`). See [docs/api/go.md](../../docs/api/go.md). + ## Features - Typed API for text, streaming, embeddings, tools, images, speech, and more diff --git a/bindings/go/aimux.go b/bindings/go/aimux.go index 3fb7f88cd..afbc66119 100644 --- a/bindings/go/aimux.go +++ b/bindings/go/aimux.go @@ -352,18 +352,6 @@ func MockReplay(recordingsJsonl string) (*Model, error) { return wrapHandle(&h, C.aimux_mock_replay_new(cJsonl, &h)) } -// RegisterProviders registers 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", ... } ] }. -func RegisterProviders(configJSON string) error { - if err := requireJSON("config_json", configJSON); err != nil { - return err - } - cJSON := C.CString(configJSON) - defer C.free(unsafe.Pointer(cJSON)) - return expectAimuxError(C.aimux_register_providers(cJSON)) -} - // InitProxy sets 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. configJSON is required (pass "{}" for @@ -384,7 +372,7 @@ func InitProxy(configJSON string) error { // on error (per configJSON). models must be non-empty. // // configJSON (optional): {"router": "rule"|"weighted", "weights": [...], -// "fallback": "on_error"|"none", "provider_name", "model_id"}. +// "fallback": "on_error"|"none", "providerName", "modelId"}. // // The same model may appear several times. A nil entry returns an error // ("aimux: router: models[i] is nil"), never a panic. Concurrent Close is @@ -661,13 +649,13 @@ func ProviderWithBase(name, apiKey, modelID, baseURL string) (*Model, error) { return ProviderWithConfig(name, apiKey, modelID, &ProviderConfig{BaseURL: baseURL}) } -// ProviderConfig mirrors the Rust ProviderOptions accepted by -// aimux_provider_new's config_json (RFC-0017 §3.4). Zero-value fields are -// omitted. Retry is a per-call setting (CallOptions.MaxRetries), and request-body -// overrides no longer exist: a config_json carrying max_retries or -// body_overrides is rejected as CodeInvalidArgument, so this struct has neither. +// ProviderConfig is the config_json accepted by aimux_provider_new and +// aimux_provider_handle_new (RFC-0017 §3.4). Zero-value fields are omitted. +// Retry is a per-call setting (GenerateTextOptions.MaxRetries), and request-body +// overrides no longer exist: any other key in config_json is rejected as +// CodeInvalidArgument, so this struct has neither. type ProviderConfig struct { - BaseURL string `json:"base_url,omitempty"` + BaseURL string `json:"baseUrl,omitempty"` Headers map[string]string `json:"headers,omitempty"` Organization string `json:"organization,omitempty"` Project string `json:"project,omitempty"` @@ -861,8 +849,8 @@ func (m *Model) GenerateText(promptJson, optsJson string) (string, error) { // GenerateObject generates a structured JSON object (M12, RFC-0016). // // Same signature as GenerateText; returns the JSON-serialized -// GenerateObjectResult. Pass response_format: { "Json": { ... } } via -// optsJson for schema control; the function applies JSON repair before +// GenerateObjectResult. Pass responseFormat: {"type":"json","schema":{...}} +// via optsJson for schema control; the function applies JSON repair before // parsing. func (m *Model) GenerateObject(promptJson, optsJson string) (string, error) { if err := checkPromptOpts(promptJson, optsJson); err != nil { @@ -1050,8 +1038,8 @@ func (m *Model) StreamTextContext(ctx context.Context, promptJson, optsJson stri // ChatCompletionChunk (OpenAI "chat.completion.chunk" object). Works with any // provider. // -// Opts may carry providerOptions.openai.stream_options with include_usage -// (bool, default true) and include_reasoning (bool, default true). +// Opts may carry providerOptions.openai.streamOptions with includeUsage +// (bool, default true) and includeReasoning (bool, default true). func (m *Model) StreamTextAsOpenAI(promptJson, optsJson string) *Stream { return m.StreamTextAsOpenAIContext(context.Background(), promptJson, optsJson) } diff --git a/bindings/go/aimux_test.go b/bindings/go/aimux_test.go index 804941e37..87fb53493 100644 --- a/bindings/go/aimux_test.go +++ b/bindings/go/aimux_test.go @@ -242,13 +242,19 @@ func TestProviderWithConfigParams(t *testing.T) { } // A registry provider with no key and no env var fails with CodeLoadAPIKey and -// names the variable it consulted. +// names the variable it consulted. The key is read when a call is made (as in +// the AI SDK), not when the model is built. func TestMissingAPIKeyIsLoadAPIKey(t *testing.T) { if old, ok := os.LookupEnv("GROQ_API_KEY"); ok { os.Unsetenv("GROQ_API_KEY") defer os.Setenv("GROQ_API_KEY", old) } - _, err := Provider("groq", "", "llama-3.3-70b") + m, err := Provider("groq", "", "llama-3.3-70b") + if err != nil { + t.Fatalf("Provider: %v", err) + } + defer m.Close() + _, err = m.GenerateText(`"hi"`, "") var e *Error if !errors.As(err, &e) || e.Code != CodeLoadAPIKey { t.Fatalf("want CodeLoadAPIKey, got %v", err) diff --git a/bindings/go/batch1_e2e_test.go b/bindings/go/batch1_e2e_test.go index 8cc575733..95ec964f7 100644 --- a/bindings/go/batch1_e2e_test.go +++ b/bindings/go/batch1_e2e_test.go @@ -1,7 +1,7 @@ package aimux // RFC-0016 第一批 e2e tests (Go shell): -// M2 include_raw_chunks -> StreamPart.Raw emission +// M2 includeRawChunks -> "raw" stream part emission // M10 Usage.raw preservation // M9 StreamStart non-empty warnings // @@ -25,7 +25,7 @@ func TestE2E_StreamTextEmitsRawWhenEnabled(t *testing.T) { srv.SetContentType("text/event-stream") srv.SetResponse(buildTextDeltaSSE()) - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) + m := chatModel(t, srv.URL) defer m.Close() stream := m.StreamText(`"Say hello"`, mustMarshalOptions(t, &GenerateTextOptions{ @@ -40,15 +40,15 @@ func TestE2E_StreamTextEmitsRawWhenEnabled(t *testing.T) { if err != nil { t.Fatalf("failed to parse: %v", err) } - switch sp.Tag { - case "Raw": - raws = append(raws, sp.Payload) + switch sp.Type { + case "raw": + raws = append(raws, sp.Raw) if !rawBeforeFirstDelta { rawBeforeFirstDelta = true } - case "TextDelta": + case "text-delta": var td TextDeltaPayload - json.Unmarshal(sp.Payload, &td) + json.Unmarshal(sp.Raw, &td) textBuilder.WriteString(td.Delta) } } @@ -65,10 +65,10 @@ func TestE2E_StreamTextEmitsRawWhenEnabled(t *testing.T) { if err := json.Unmarshal(raws[0], &first); err != nil { t.Fatalf("Raw payload not an object: %v", err) } - // Raw payload is wrapped: {"raw_value": {chunk}}. - inner, ok := first["raw_value"].(map[string]any) + // A raw part is {"type":"raw","rawValue": {chunk}}. + inner, ok := first["rawValue"].(map[string]any) if !ok { - t.Fatalf("Raw payload missing raw_value, got %#v", first) + t.Fatalf("raw part missing rawValue, got %#v", first) } delta, ok := inner["choices"].([]any)[0].(map[string]any)["delta"].(map[string]any)["content"].(string) if !ok || delta != "Hello" { @@ -88,12 +88,12 @@ func TestE2E_StreamStartCarriesNonEmptyWarnings(t *testing.T) { srv.SetContentType("text/event-stream") srv.SetResponse(buildTextDeltaSSE()) - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) + m := chatModel(t, srv.URL) defer m.Close() // top_k on a provider that supports it produces no warning on the openai // full profile; the Go assertion here is that a non-empty warnings array - // in StreamStart decodes without breaking the stream (the warning itself + // in stream-start decodes without breaking the stream (the warning itself // is produced by the groq profile — covered in groq_test.rs). stream := m.StreamText(`"Say hello"`, mustMarshalOptions(t, &GenerateTextOptions{ TopK: f64Ptr(0.5), @@ -105,13 +105,13 @@ func TestE2E_StreamStartCarriesNonEmptyWarnings(t *testing.T) { if err != nil { t.Fatalf("failed to parse: %v", err) } - if sp.Tag == "StreamStart" { + if sp.Type == "stream-start" { sawStreamStart = true var start struct { Warnings []json.RawMessage `json:"warnings"` } - if err := json.Unmarshal(sp.Payload, &start); err != nil { - t.Fatalf("failed to decode StreamStart payload: %v", err) + if err := json.Unmarshal(sp.Raw, &start); err != nil { + t.Fatalf("failed to decode stream-start part: %v", err) } // warnings may be empty on the full profile; the point is that a // non-empty array would decode here without error. @@ -122,7 +122,7 @@ func TestE2E_StreamStartCarriesNonEmptyWarnings(t *testing.T) { t.Fatalf("stream error: %v", err) } if !sawStreamStart { - t.Fatal("no StreamStart part received") + t.Fatal("no stream-start part received") } } diff --git a/bindings/go/e2e_test.go b/bindings/go/e2e_test.go index 40ef89b5c..c1e1c2f4b 100644 --- a/bindings/go/e2e_test.go +++ b/bindings/go/e2e_test.go @@ -1,18 +1,16 @@ // Structured end-to-end tests for the Go binding. // // These spin up a local mock HTTP server speaking the OpenAI chat-completions -// wire format, point an OpenAI Model at it via the base_url constructor, +// wire format, point a chat-completions Model at it via a base URL, // and assert that the binding correctly: -// 1. parses text from a provider response, -// 2. parses tool_calls out of a provider response, -// 3. forwards multi-role messages, -// 4. forwards tool_choice, -// 5. parses tool-call stream parts from an SSE stream, -// 6. yields text-delta stream parts. +// 1. parses text from a provider response, +// 2. parses tool_calls out of a provider response, +// 3. forwards multi-role messages, +// 4. forwards tool_choice, +// 5. parses tool-call stream parts from an SSE stream, +// 6. yields text-delta stream parts. // // No real network access is performed — every request hits 127.0.0.1. -// Mirrors the Kotlin StructuredE2ETest.kt. - package aimux import ( @@ -155,6 +153,18 @@ func waitForChannelFull[T any](t *testing.T, channel chan T) { } } +// chatModel is a model that speaks the OpenAI chat-completions wire format at +// baseURL: the registry's OpenAI-compatible preset (the OpenAI constructors +// talk to the Responses API). +func chatModel(t *testing.T, baseURL string) *Model { + t.Helper() + m, err := ProviderWithBase("deepseek", "sk-test-fake-key", "gpt-4o", baseURL) + if err != nil { + t.Fatalf("ProviderWithBase: %v", err) + } + return m +} + // ── Canned OpenAI responses ──────────────────────────────────────────────────── // plainOpenAIResponse is a plain OpenAI text response (no tool calls). @@ -194,7 +204,7 @@ func weatherToolsOpts(toolChoice string) string { { "type": "function", "name": "get_weather", - "input_schema": map[string]any{ + "inputSchema": map[string]any{ "type": "object", "properties": map[string]any{ "location": map[string]any{"type": "string"}, @@ -204,7 +214,7 @@ func weatherToolsOpts(toolChoice string) string { }, } if toolChoice != "" { - opts["tool_choice"] = toolChoice + opts["toolChoice"] = toolChoice } b, _ := json.Marshal(opts) return string(b) @@ -217,7 +227,7 @@ func TestE2E_GenerateTextParsesText(t *testing.T) { defer srv.Close() srv.SetResponse(plainOpenAIResponse) - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) + m := chatModel(t, srv.URL) defer m.Close() result, err := m.GenerateText(`"What is Rust?"`, "") @@ -243,7 +253,7 @@ func TestE2E_GenerateTextParsesToolCalls(t *testing.T) { defer srv.Close() srv.SetResponse(toolCallOpenAIResponse) - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) + m := chatModel(t, srv.URL) defer m.Close() result, err := m.GenerateText(`"What is the weather in Tokyo?"`, weatherToolsOpts("")) @@ -273,21 +283,23 @@ func TestE2E_GenerateTextParsesToolCalls(t *testing.T) { t.Errorf("expected location Tokyo, got %v", input["location"]) } - // raw.content should contain a "ToolCall" variant. + // raw.content should contain a "tool-call" part. var rawWrap struct { Raw struct { - Content []map[string]json.RawMessage `json:"content"` + Content []struct { + Type string `json:"type"` + } `json:"content"` } `json:"raw"` } json.Unmarshal([]byte(result), &rawWrap) - hasToolCallVariant := false + hasToolCallPart := false for _, part := range rawWrap.Raw.Content { - if _, ok := part["ToolCall"]; ok { - hasToolCallVariant = true + if part.Type == "tool-call" { + hasToolCallPart = true } } - if !hasToolCallVariant { - t.Error("expected raw.content to contain a ToolCall variant") + if !hasToolCallPart { + t.Error("expected raw.content to contain a tool-call part") } } @@ -296,7 +308,7 @@ func TestE2E_MultiRoleMessagesReachProvider(t *testing.T) { defer srv.Close() srv.SetResponse(plainOpenAIResponse) - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) + m := chatModel(t, srv.URL) defer m.Close() msgs := []ModelMessage{ @@ -343,7 +355,7 @@ func TestE2E_ToolChoiceReachesProvider(t *testing.T) { defer srv.Close() srv.SetResponse(toolCallOpenAIResponse) - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) + m := chatModel(t, srv.URL) defer m.Close() _, err := m.GenerateText(`"What is the weather in Tokyo?"`, weatherToolsOpts("required")) @@ -370,7 +382,7 @@ func TestE2E_StreamTextParsesToolCallStreamParts(t *testing.T) { sseResponse := buildToolCallSSE() srv.SetResponse(sseResponse) - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) + m := chatModel(t, srv.URL) defer m.Close() stream := m.StreamText(`"What is the weather in Tokyo?"`, weatherToolsOpts("")) @@ -386,17 +398,16 @@ func TestE2E_StreamTextParsesToolCallStreamParts(t *testing.T) { t.Fatal("expected at least one stream part") } - // The first part should be a StreamStart or ToolCall stream part. - // We just verify we got JSON objects with known tags. + // We just verify we got JSON objects tagged by "type". for _, p := range parts { sp, err := ParseStreamPart(p) if err != nil { t.Errorf("failed to parse stream part: %v", err) continue } - // Tags we expect: StreamStart, TextDelta, ToolCall, Finish, etc. - if sp.Tag == "" { - t.Error("expected non-empty stream part tag") + // Types we expect: stream-start, text-delta, tool-call, finish, etc. + if sp.Type == "" { + t.Error("expected non-empty stream part type") } } } @@ -410,7 +421,7 @@ func TestE2E_StreamTextYieldsTextDeltas(t *testing.T) { sseResponse := buildTextDeltaSSE() srv.SetResponse(sseResponse) - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) + m := chatModel(t, srv.URL) defer m.Close() stream := m.StreamText(`"Say hello"`, "") @@ -423,12 +434,12 @@ func TestE2E_StreamTextYieldsTextDeltas(t *testing.T) { t.Errorf("failed to parse: %v", err) continue } - switch sp.Tag { - case "TextDelta": + switch sp.Type { + case "text-delta": var td TextDeltaPayload - json.Unmarshal(sp.Payload, &td) + json.Unmarshal(sp.Raw, &td) textBuilder.WriteString(td.Delta) - case "Finish": + case "finish": gotFinish = true } } @@ -441,7 +452,7 @@ func TestE2E_StreamTextYieldsTextDeltas(t *testing.T) { t.Errorf("expected 'Hello world', got %q", text) } if !gotFinish { - t.Error("expected a Finish part") + t.Error("expected a finish part") } } @@ -464,7 +475,7 @@ func TestStreamTextContextCancelsInFlightRequest(t *testing.T) { })) defer srv.Close() - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) + m := chatModel(t, srv.URL) defer m.Close() ctx, cancel := context.WithCancel(context.Background()) stream := m.StreamTextContext(ctx, `"wait"`, "") @@ -501,7 +512,7 @@ func TestStreamCancelUnblocksFullPartsChannel(t *testing.T) { srv, allSent, requestDone := newBackpressureSSEServer(800) defer srv.Close() - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) + m := chatModel(t, srv.URL) defer m.Close() stream := m.StreamText(`"fill the channel"`, "") defer stream.Cancel() @@ -567,8 +578,7 @@ func buildToolCallSSE() string { // ── Tool-call full round-trip ───────────────────────────────────────────────── -// TestE2E_ToolCallFullRoundTrip mirrors Kotlin's `tool-call full round-trip` -// scenario: the model requests a tool call, the client sends the tool result +// TestE2E_ToolCallFullRoundTrip: the model requests a tool call, the client sends the tool result // back as a multi-part tool message, and the model produces a final answer. // This verifies ModelMessage multi-part content reaches the provider correctly. func TestE2E_ToolCallFullRoundTrip(t *testing.T) { @@ -578,7 +588,7 @@ func TestE2E_ToolCallFullRoundTrip(t *testing.T) { // Queue: first call returns a tool_call, second call returns the final text. srv.setResponses(toolCallOpenAIResponse, plainOpenAIResponse) - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) + m := chatModel(t, srv.URL) defer m.Close() // 1. First call: model requests get_weather tool call. @@ -596,15 +606,15 @@ func TestE2E_ToolCallFullRoundTrip(t *testing.T) { } // 2. Build a multi-part tool-result message using ModelMessage's Content - // (any) field — this is the path that was previously unsupported. + // (any) field. toolResultMsg := ModelMessage{ Role: RoleTool, Content: []map[string]any{ { - "type": "tool_result", - "tool_call_id": call.ToolCallID, - "tool_name": call.ToolName, - "result": map[string]any{"temperature": "20C"}, + "type": "tool-result", + "toolCallId": call.ToolCallID, + "toolName": call.ToolName, + "output": map[string]any{"type": "json", "value": map[string]any{"temperature": "20C"}}, }, }, } @@ -626,7 +636,7 @@ func TestE2E_ToolCallFullRoundTrip(t *testing.T) { } // Verify the second request carried the tool-result message correctly. - // The engine converts multi-part tool_result content into OpenAI's wire + // The engine converts a tool-result part into OpenAI's wire // format: {"role":"tool","tool_call_id":"...","content":""}. reqBody := srv.LastRequestBody() var body struct { diff --git a/bindings/go/error.go b/bindings/go/error.go index 048e7b441..ac33a330f 100644 --- a/bindings/go/error.go +++ b/bindings/go/error.go @@ -47,7 +47,7 @@ const ( CodeLoadAPIKey Code = 18 // CodeLoadSetting: a required provider setting was not passed and its // fallback environment variable is unset (LoadSettingError). - CodeLoadSetting Code = 19 + CodeLoadSetting Code = 19 CodeNoOutputGenerated Code = 20 ) diff --git a/bindings/go/error_test.go b/bindings/go/error_test.go index 5a9632d75..a12f88e63 100644 --- a/bindings/go/error_test.go +++ b/bindings/go/error_test.go @@ -160,7 +160,7 @@ func TestRetryableIsNotDerivedFromStatus(t *testing.T) { // Malformed raw JSON is caught by the binding before the C call: a plain // error naming the parameter, not an *Error. func TestInvalidJSONNamesParameter(t *testing.T) { - err := RegisterProviders("{not json") + err := InitProxy("{not json") if err == nil { t.Fatal("expected error for malformed config") } @@ -417,7 +417,7 @@ func TestMarshalledOptionsRejectRawPassThrough(t *testing.T) { // invalid UTF-8 becomes U+FFFD, NUL becomes a \u0000 escape. No error here. {"plain string field is sanitized, not rejected", func() error { s, e := marshalJSON("opts", &SpeechCallOptions{Text: "hi\xffthere\x00"}) - if e == nil && !strings.Contains(s, `"hi\ufffdthere\u0000"`) { + if e == nil && !(strings.Contains(s, `"hi\ufffdthere\u0000"`) || strings.Contains(s, "\"hi\uFFFDthere\\u0000\"")) { t.Errorf("expected U+FFFD / \\u0000 coercion, got %s", s) } return e @@ -460,13 +460,13 @@ func TestEmbedRejectsRawPassThroughOpts(t *testing.T) { if _, err := m.Embed([]string{"hi"}, &EmbeddingCallOptions{ ProviderOptions: jsonObj(`{"a":"\ud800"}`), }); err == nil || !strings.Contains(err.Error(), "opts: invalid JSON") { - t.Fatalf("Embed with a lone surrogate in provider_options: %v", err) + t.Fatalf("Embed with a lone surrogate in providerOptions: %v", err) } } func TestCodeFromCRejectsOutOfRange(t *testing.T) { - // 4 is retired; 14 is Retry; 15..17 are tool-call errors. - for _, bad := range []int{0, 4, 20, 999} { + // 4 is retired; 14 is Retry; 15..17 are tool-call errors; 20 is the last. + for _, bad := range []int{0, 4, 21, 999} { if _, ok := codeFromC(bad); ok { t.Fatalf("%d is not an AiMuxError variant", bad) } diff --git a/bindings/go/examples/generate/main.go b/bindings/go/examples/generate/main.go index 4dcf69335..010d7ff9e 100644 --- a/bindings/go/examples/generate/main.go +++ b/bindings/go/examples/generate/main.go @@ -41,9 +41,9 @@ func main() { log.Fatalf("stream failed: %v", err) } for part := range stream.Parts() { - if part.Tag == "TextDelta" { + if part.Type == "text-delta" { var td aimux.TextDeltaPayload - json.Unmarshal(part.Payload, &td) + json.Unmarshal(part.Raw, &td) fmt.Print(td.Delta) } } diff --git a/bindings/go/multimodal.go b/bindings/go/multimodal.go index 05417a26d..189ca5a22 100644 --- a/bindings/go/multimodal.go +++ b/bindings/go/multimodal.go @@ -779,19 +779,20 @@ type TranscriptionSession struct { } // InputAudioFormat is the input audio format for streaming transcription -// (RFC-0028): e.g. {"format_type": "audio/pcm", "rate": 24000}. +// (RFC-0028): e.g. {"formatType": "audio/pcm", "rate": 24000}. type InputAudioFormat struct { - FormatType string `json:"format_type"` + FormatType string `json:"formatType"` Rate *uint32 `json:"rate,omitempty"` } // TranscriptionSessionOpts are the optional session options (RFC-0028): -// input audio format, provider options, headers, include_raw_chunks. +// input audio format, provider options, headers, includeRawChunks, timeout. type TranscriptionSessionOpts struct { - InputAudioFormat *InputAudioFormat `json:"input_audio_format,omitempty"` - ProviderOptions map[string]json.RawMessage `json:"provider_options,omitempty"` + InputAudioFormat *InputAudioFormat `json:"inputAudioFormat,omitempty"` + ProviderOptions map[string]json.RawMessage `json:"providerOptions,omitempty"` Headers map[string]string `json:"headers,omitempty"` - IncludeRawChunks *bool `json:"include_raw_chunks,omitempty"` + IncludeRawChunks *bool `json:"includeRawChunks,omitempty"` + Timeout *TimeoutConfiguration `json:"timeout,omitempty"` } // ErrTranscriptionEnded is returned by NextPart when the stream ended diff --git a/bindings/go/multimodal_test.go b/bindings/go/multimodal_test.go index b695ab57b..4eb66f42e 100644 --- a/bindings/go/multimodal_test.go +++ b/bindings/go/multimodal_test.go @@ -134,7 +134,7 @@ func TestE2E_Embedding(t *testing.T) { if len(result.Embeddings[0]) != 3 { t.Errorf("expected 3 dimensions, got %d", len(result.Embeddings[0])) } - if result.Usage == nil || result.Usage.Tokens == nil || *result.Usage.Tokens != 5 { + if result.Usage == nil || result.Usage.Tokens != 5 { t.Error("usage tokens mismatch") } } @@ -143,15 +143,20 @@ func TestE2E_Embedding(t *testing.T) { func TestE2E_SpeechResultParsing(t *testing.T) { result, err := ParseSpeechResult(`{ - "audio": {"Base64": "aGVsbG8="}, + "audio": "aGVsbG8=", "warnings": [], - "response": {"id": "resp-1"} + "response": {"modelId": "tts-1"} }`) if err != nil { t.Fatalf("parse failed: %v", err) } - if result.Audio.Base64 == nil || *result.Audio.Base64 != "aGVsbG8=" { - t.Error("audio base64 mismatch") + if string(result.Audio) != "hello" { + t.Errorf("audio mismatch: %q", result.Audio) + } + // The byte-array form decodes to the same bytes. + result, err = ParseSpeechResult(`{"audio": [104,105], "warnings": [], "response": {}}`) + if err != nil || string(result.Audio) != "hi" { + t.Errorf("binary audio: %q, %v", result.Audio, err) } } @@ -159,18 +164,18 @@ func TestE2E_SpeechResultParsing(t *testing.T) { func TestE2E_ImageResultParsing(t *testing.T) { result, err := ParseImageResult(`{ - "images": {"Base64": ["aW1hZ2Ux"]}, + "images": ["aW1hZ2Ux"], "warnings": [], - "response": {"id": "resp-1"} + "response": {"modelId": "dall-e-3"} }`) if err != nil { t.Fatalf("parse failed: %v", err) } - if len(result.Images.Base64) != 1 { - t.Fatalf("expected 1 image, got %d", len(result.Images.Base64)) + if len(result.Images) != 1 { + t.Fatalf("expected 1 image, got %d", len(result.Images)) } - if result.Images.Base64[0] != "aW1hZ2Ux" { - t.Error("image base64 mismatch") + if string(result.Images[0]) != "image1" { + t.Error("image mismatch") } } @@ -179,7 +184,7 @@ func TestE2E_ImageResultParsing(t *testing.T) { func TestE2E_TranscriptionResultParsing(t *testing.T) { result, err := ParseTranscriptionResult(`{ "text": "Hello world", - "segments": [{"text": "Hello", "start": 0.0, "end": 1.0}], + "segments": [{"text": "Hello", "startSecond": 0.0, "endSecond": 1.0}], "language": "en", "warnings": [], "response": {"id": "resp-1"} @@ -206,8 +211,8 @@ func TestE2E_TranscriptionResultParsing(t *testing.T) { func TestE2E_RerankingResultParsing(t *testing.T) { result, err := ParseRerankingResult(`{ "ranking": [ - {"index": 1, "relevance_score": 0.95}, - {"index": 0, "relevance_score": 0.30} + {"index": 1, "relevanceScore": 0.95}, + {"index": 0, "relevanceScore": 0.30} ], "warnings": [] }`) @@ -229,9 +234,9 @@ func TestE2E_RerankingResultParsing(t *testing.T) { func TestE2E_VideoResultParsing(t *testing.T) { result, err := ParseVideoResult(`{ - "videos": [{"Url": {"url": "https://example.com/video.mp4", "media_type": "video/mp4"}}], + "videos": [{"type": "url", "url": "https://example.com/video.mp4", "mediaType": "video/mp4"}], "warnings": [], - "response": {"id": "resp-1"} + "response": {"modelId": "veo"} }`) if err != nil { t.Fatalf("parse failed: %v", err) @@ -239,7 +244,7 @@ func TestE2E_VideoResultParsing(t *testing.T) { if len(result.Videos) != 1 { t.Fatalf("expected 1 video, got %d", len(result.Videos)) } - if result.Videos[0].Url == nil || result.Videos[0].Url.URL != "https://example.com/video.mp4" { + if result.Videos[0].Type != "url" || result.Videos[0].URL != "https://example.com/video.mp4" { t.Error("video URL mismatch") } } @@ -266,7 +271,7 @@ func TestVideoPollOptionsWireFormat(t *testing.T) { if !ok { t.Fatalf("poll missing from wire format: %s", encoded) } - if poll["interval_ms"] != float64(intervalMS) || poll["timeout_ms"] != float64(timeoutMS) { + if poll["intervalMs"] != float64(intervalMS) || poll["timeoutMs"] != float64(timeoutMS) { t.Fatalf("unexpected poll wire format: %s", encoded) } } @@ -299,8 +304,8 @@ func TestE2E_SearchResultParsing(t *testing.T) { func TestE2E_UploadFileResultParsing(t *testing.T) { result, err := ParseUploadFileResult(`{ - "provider_reference": {"openai": "file-abc123"}, - "media_type": "application/pdf", + "providerReference": {"openai": "file-abc123"}, + "mediaType": "application/pdf", "filename": "doc.pdf", "warnings": [] }`) @@ -319,7 +324,7 @@ func TestE2E_UploadFileResultParsing(t *testing.T) { func TestE2E_RerankWithOptions(t *testing.T) { // Cohere reranking uses its own endpoint; we verify options marshaling. - docs := `{"Text":{"values":["doc1","doc2","doc3"]}}` + docs := `{"type":"text","values":["doc1","doc2","doc3"]}` opts := &RerankingCallOptions{ Documents: json.RawMessage(docs), Query: "which is most relevant?", @@ -336,8 +341,8 @@ func TestE2E_RerankWithOptions(t *testing.T) { if !strings.Contains(s, `"query"`) { t.Errorf("expected query field: %s", s) } - if !strings.Contains(s, `"top_n"`) { - t.Errorf("expected top_n field: %s", s) + if !strings.Contains(s, `"topN"`) { + t.Errorf("expected topN field: %s", s) } } diff --git a/bindings/go/multimodal_types.go b/bindings/go/multimodal_types.go index 3eb2eabf7..7e78abfbe 100644 --- a/bindings/go/multimodal_types.go +++ b/bindings/go/multimodal_types.go @@ -1,13 +1,41 @@ // Typed multimodal data structures mirroring the aimux-core wire format -// (same shapes as the ts-rs generated .ts types in bindings/node/src/types/). -// -// Field names use JSON tags matching the wire format's snake_case. These types -// are intentionally lenient on decode (unknown keys ignored, every field -// optional) so future engine additions don't break existing clients. +// (same shapes as the ts-rs generated .ts types in bindings/node/src/types/): +// camelCase field names, unions tagged by "type". These types are +// intentionally lenient on decode (unknown keys ignored, every field optional) +// so future engine additions don't break existing clients. package aimux -import "encoding/json" +import ( + "encoding/base64" + "encoding/json" + "fmt" +) + +// Bytes is binary data on the wire: a base64 string or an array of byte +// values. It decodes either form; it encodes as a base64 string. +type Bytes []byte + +func (b *Bytes) UnmarshalJSON(data []byte) error { + if len(data) > 0 && data[0] == '[' { + var values []uint8 + if err := json.Unmarshal(data, &values); err != nil { + return err + } + *b = values + return nil + } + var text string + if err := json.Unmarshal(data, &text); err != nil { + return fmt.Errorf("aimux: bytes must be a base64 string or an array of numbers: %w", err) + } + decoded, err := base64.StdEncoding.DecodeString(text) + if err != nil { + return fmt.Errorf("aimux: invalid base64: %w", err) + } + *b = decoded + return nil +} // ── Shared types ──────────────────────────────────────────────────────────── @@ -18,7 +46,7 @@ type Warning = json.RawMessage // EmbeddingUsage is token usage for an embedding call (input tokens only). type EmbeddingUsage struct { - Tokens *uint32 `json:"tokens,omitempty"` + Tokens uint32 `json:"tokens"` } // EmbeddingResponse is provider response metadata for embeddings. @@ -31,28 +59,25 @@ type EmbeddingResponse struct { type EmbeddingResult struct { Embeddings [][]float32 `json:"embeddings"` Usage *EmbeddingUsage `json:"usage,omitempty"` - ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` + ProviderMetadata json.RawMessage `json:"providerMetadata,omitempty"` Response *EmbeddingResponse `json:"response,omitempty"` Warnings []Warning `json:"warnings,omitempty"` } // EmbeddingCallOptions is the options for an embedding call. type EmbeddingCallOptions struct { - Values []string `json:"values,omitempty"` - MaxRetries *uint32 `json:"max_retries,omitempty"` + Values []string `json:"values"` + MaxRetries *uint32 `json:"maxRetries,omitempty"` Timeout *TimeoutConfiguration `json:"timeout,omitempty"` - ProviderOptions jsonObj `json:"provider_options"` + ProviderOptions jsonObj `json:"providerOptions"` Headers map[string]string `json:"headers,omitempty"` } // ── Speech (TTS) ────────────────────────────────────────────────────────────── -// AudioData is generated audio: base64 string or raw binary bytes. -// Wire format: {"Base64": "string"} | {"Binary": [numbers]} -type AudioData struct { - Base64 *string `json:"Base64,omitempty"` - Binary []uint8 `json:"Binary,omitempty"` -} +// AudioData is generated audio: a base64 string or an array of bytes on the +// wire, decoded to bytes. +type AudioData = Bytes // SpeechRequest is request metadata for speech generation. type SpeechRequest struct { @@ -64,7 +89,7 @@ type SpeechRequest struct { // SpeechResponse is provider response metadata for speech. type SpeechResponse struct { Timestamp *string `json:"timestamp,omitempty"` - ModelID *string `json:"model_id,omitempty"` + ModelID *string `json:"modelId,omitempty"` Headers map[string]string `json:"headers,omitempty"` Body any `json:"body,omitempty"` } @@ -75,42 +100,40 @@ type SpeechResult struct { Warnings []Warning `json:"warnings,omitempty"` Request *SpeechRequest `json:"request,omitempty"` Response SpeechResponse `json:"response"` - ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` + ProviderMetadata json.RawMessage `json:"providerMetadata,omitempty"` } // SpeechCallOptions is the options for speech generation. type SpeechCallOptions struct { Text string `json:"text"` Voice *string `json:"voice,omitempty"` - OutputFormat *string `json:"output_format,omitempty"` + OutputFormat *string `json:"outputFormat,omitempty"` Instructions *string `json:"instructions,omitempty"` Speed *float64 `json:"speed,omitempty"` Language *string `json:"language,omitempty"` - MaxRetries *uint32 `json:"max_retries,omitempty"` + MaxRetries *uint32 `json:"maxRetries,omitempty"` Timeout *TimeoutConfiguration `json:"timeout,omitempty"` - ProviderOptions jsonObj `json:"provider_options"` + ProviderOptions jsonObj `json:"providerOptions"` Headers map[string]string `json:"headers,omitempty"` } // ── Image ───────────────────────────────────────────────────────────────────── -// ImageOutputs is the generated images: all base64 or all binary. -type ImageOutputs struct { - Base64 []string `json:"Base64,omitempty"` - Binary [][]uint8 `json:"Binary,omitempty"` -} +// ImageOutputs is the generated images: base64 strings or byte arrays on the +// wire, decoded to bytes. +type ImageOutputs = []Bytes // ImageUsage is token usage for image generation (if reported). type ImageUsage struct { - InputTokens *uint32 `json:"input_tokens,omitempty"` - OutputTokens *uint32 `json:"output_tokens,omitempty"` - TotalTokens *uint32 `json:"total_tokens,omitempty"` + InputTokens *uint32 `json:"inputTokens,omitempty"` + OutputTokens *uint32 `json:"outputTokens,omitempty"` + TotalTokens *uint32 `json:"totalTokens,omitempty"` } // ImageResponse is provider response metadata for images. type ImageResponse struct { Timestamp *string `json:"timestamp,omitempty"` - ModelID *string `json:"model_id,omitempty"` + ModelID *string `json:"modelId,omitempty"` Headers map[string]string `json:"headers,omitempty"` } @@ -118,7 +141,7 @@ type ImageResponse struct { type ImageResult struct { Images ImageOutputs `json:"images"` Warnings []Warning `json:"warnings,omitempty"` - ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` + ProviderMetadata json.RawMessage `json:"providerMetadata,omitempty"` Response ImageResponse `json:"response"` Usage *ImageUsage `json:"usage,omitempty"` } @@ -126,15 +149,15 @@ type ImageResult struct { // ImageCallOptions is the options for image generation. type ImageCallOptions struct { Prompt *string `json:"prompt,omitempty"` - N *int `json:"n,omitempty"` - Size *string `json:"size,omitempty"` - AspectRatio *string `json:"aspect_ratio,omitempty"` + N int `json:"n"` + Size *string `json:"size,omitempty"` // "WxH", e.g. "1024x1024" + AspectRatio *string `json:"aspectRatio,omitempty"` // "W:H", e.g. "16:9" Seed *uint64 `json:"seed,omitempty"` Files []json.RawMessage `json:"files,omitempty"` Mask json.RawMessage `json:"mask,omitempty"` - MaxRetries *uint32 `json:"max_retries,omitempty"` + MaxRetries *uint32 `json:"maxRetries,omitempty"` Timeout *TimeoutConfiguration `json:"timeout,omitempty"` - ProviderOptions jsonObj `json:"provider_options"` + ProviderOptions jsonObj `json:"providerOptions"` Headers map[string]string `json:"headers,omitempty"` } @@ -145,8 +168,8 @@ type TranscriptionSegment struct { // StartSecond/EndSecond are required f64 in the core (not Option), so they // are values rather than pointers and must always be marshalled. Text string `json:"text"` - StartSecond float64 `json:"start_second"` - EndSecond float64 `json:"end_second"` + StartSecond float64 `json:"startSecond"` + EndSecond float64 `json:"endSecond"` } // TranscriptionRequest is request metadata for transcription. @@ -159,7 +182,7 @@ type TranscriptionRequest struct { // TranscriptionResponse is provider response metadata for transcription. type TranscriptionResponse struct { Timestamp *string `json:"timestamp,omitempty"` - ModelID *string `json:"model_id,omitempty"` + ModelID *string `json:"modelId,omitempty"` Headers map[string]string `json:"headers,omitempty"` Body any `json:"body,omitempty"` } @@ -169,20 +192,20 @@ type TranscriptionResult struct { Text string `json:"text"` Segments []TranscriptionSegment `json:"segments,omitempty"` Language *string `json:"language,omitempty"` - DurationInSeconds *float64 `json:"duration_in_seconds,omitempty"` + DurationInSeconds *float64 `json:"durationInSeconds,omitempty"` Warnings []Warning `json:"warnings,omitempty"` Request *TranscriptionRequest `json:"request,omitempty"` Response TranscriptionResponse `json:"response"` - ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` + ProviderMetadata json.RawMessage `json:"providerMetadata,omitempty"` } // TranscriptionCallOptions is the options for transcription. type TranscriptionCallOptions struct { - Audio json.RawMessage `json:"audio"` - MediaType string `json:"media_type"` - MaxRetries *uint32 `json:"max_retries,omitempty"` + Audio Bytes `json:"audio"` + MediaType string `json:"mediaType"` + MaxRetries *uint32 `json:"maxRetries,omitempty"` Timeout *TimeoutConfiguration `json:"timeout,omitempty"` - ProviderOptions jsonObj `json:"provider_options"` + ProviderOptions jsonObj `json:"providerOptions"` Headers map[string]string `json:"headers,omitempty"` } @@ -191,14 +214,14 @@ type TranscriptionCallOptions struct { // RerankingRank is a single reranked entry. type RerankingRank struct { Index int `json:"index"` - RelevanceScore float64 `json:"relevance_score"` + RelevanceScore float64 `json:"relevanceScore"` } // RerankingResponse is provider response metadata for reranking. type RerankingResponse struct { ID *string `json:"id,omitempty"` Timestamp *string `json:"timestamp,omitempty"` - ModelID *string `json:"model_id,omitempty"` + ModelID *string `json:"modelId,omitempty"` Headers map[string]string `json:"headers,omitempty"` Body any `json:"body,omitempty"` } @@ -206,7 +229,7 @@ type RerankingResponse struct { // RerankingResult is the result of a reranking call. type RerankingResult struct { Ranking []RerankingRank `json:"ranking"` - ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` + ProviderMetadata json.RawMessage `json:"providerMetadata,omitempty"` Warnings []Warning `json:"warnings,omitempty"` Response *RerankingResponse `json:"response,omitempty"` } @@ -215,45 +238,28 @@ type RerankingResult struct { type RerankingCallOptions struct { Documents json.RawMessage `json:"documents"` Query string `json:"query"` - TopN *int `json:"top_n,omitempty"` - MaxRetries *uint32 `json:"max_retries,omitempty"` + TopN *int `json:"topN,omitempty"` + MaxRetries *uint32 `json:"maxRetries,omitempty"` Timeout *TimeoutConfiguration `json:"timeout,omitempty"` - ProviderOptions jsonObj `json:"provider_options"` + ProviderOptions jsonObj `json:"providerOptions"` Headers map[string]string `json:"headers,omitempty"` } // ── Video ─────────────────────────────────────────────────────────────────── -// VideoData is generated video: URL, base64, or binary. -// Wire format: {"Url": {"url":"...", "media_type":"..."}} | {"Base64": {...}} | {"Binary": {...}} +// VideoData is generated video, tagged by Type: "url" (URL), "base64" or +// "binary" (Data, decoded to bytes). type VideoData struct { - Url *VideoUrlData `json:"Url,omitempty"` - Base64 *VideoBase64Data `json:"Base64,omitempty"` - Binary *VideoBinaryData `json:"Binary,omitempty"` -} - -// VideoUrlData is the URL variant of VideoData. -type VideoUrlData struct { - URL string `json:"url"` - MediaType string `json:"media_type,omitempty"` -} - -// VideoBase64Data is the base64 variant of VideoData. -type VideoBase64Data struct { - Data string `json:"data"` - MediaType string `json:"media_type,omitempty"` -} - -// VideoBinaryData is the binary variant of VideoData. -type VideoBinaryData struct { - Data []uint8 `json:"data"` - MediaType string `json:"media_type,omitempty"` + Type string `json:"type"` + URL string `json:"url,omitempty"` + Data Bytes `json:"data,omitempty"` + MediaType string `json:"mediaType"` } // VideoResponse is provider response metadata for video. type VideoResponse struct { Timestamp *string `json:"timestamp,omitempty"` - ModelID *string `json:"model_id,omitempty"` + ModelID *string `json:"modelId,omitempty"` Headers map[string]string `json:"headers,omitempty"` } @@ -261,27 +267,33 @@ type VideoResponse struct { type VideoResult struct { Videos []VideoData `json:"videos"` Warnings []Warning `json:"warnings,omitempty"` - ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` + ProviderMetadata json.RawMessage `json:"providerMetadata,omitempty"` Response VideoResponse `json:"response"` } // VideoPollOptions overrides Core's pacing for an asynchronous video job. type VideoPollOptions struct { - IntervalMS *uint64 `json:"interval_ms,omitempty"` - TimeoutMS *uint64 `json:"timeout_ms,omitempty"` + IntervalMS *uint64 `json:"intervalMs,omitempty"` + TimeoutMS *uint64 `json:"timeoutMs,omitempty"` } // VideoCallOptions is the options for video generation. type VideoCallOptions struct { Prompt *string `json:"prompt,omitempty"` N *int `json:"n,omitempty"` - AspectRatio *string `json:"aspect_ratio,omitempty"` - Resolution *string `json:"resolution,omitempty"` + AspectRatio *string `json:"aspectRatio,omitempty"` // "W:H", e.g. "16:9" + Resolution *string `json:"resolution,omitempty"` // "WxH", e.g. "1280x720" + Duration *float64 `json:"duration,omitempty"` + Fps *float64 `json:"fps,omitempty"` Seed *uint64 `json:"seed,omitempty"` - MaxRetries *uint32 `json:"max_retries,omitempty"` + Image json.RawMessage `json:"image,omitempty"` + FrameImages []json.RawMessage `json:"frameImages,omitempty"` + InputReferences []json.RawMessage `json:"inputReferences,omitempty"` + GenerateAudio *bool `json:"generateAudio,omitempty"` + MaxRetries *uint32 `json:"maxRetries,omitempty"` Poll *VideoPollOptions `json:"poll,omitempty"` Timeout *TimeoutConfiguration `json:"timeout,omitempty"` - ProviderOptions jsonObj `json:"provider_options"` + ProviderOptions jsonObj `json:"providerOptions"` Headers map[string]string `json:"headers,omitempty"` } @@ -292,7 +304,7 @@ type SearchResultItem struct { Title *string `json:"title,omitempty"` URL *string `json:"url,omitempty"` Content *string `json:"content,omitempty"` - RawContent *string `json:"raw_content,omitempty"` + RawContent *string `json:"rawContent,omitempty"` Score *float64 `json:"score,omitempty"` } @@ -306,7 +318,7 @@ type SearchResponse struct { type SearchResult struct { Results []SearchResultItem `json:"results"` Answer *string `json:"answer,omitempty"` - ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` + ProviderMetadata json.RawMessage `json:"providerMetadata,omitempty"` Warnings []Warning `json:"warnings,omitempty"` Response *SearchResponse `json:"response,omitempty"` } @@ -314,14 +326,14 @@ type SearchResult struct { // SearchCallOptions is the options for a search call. type SearchCallOptions struct { Query string `json:"query"` - MaxResults *int `json:"max_results,omitempty"` - IncludeRawContent *bool `json:"include_raw_content,omitempty"` - TimeRange *string `json:"time_range,omitempty"` - IncludeDomains []string `json:"include_domains,omitempty"` - ExcludeDomains []string `json:"exclude_domains,omitempty"` - MaxRetries *uint32 `json:"max_retries,omitempty"` + MaxResults *int `json:"maxResults,omitempty"` + IncludeRawContent *bool `json:"includeRawContent,omitempty"` + TimeRange *string `json:"timeRange,omitempty"` + IncludeDomains []string `json:"includeDomains,omitempty"` + ExcludeDomains []string `json:"excludeDomains,omitempty"` + MaxRetries *uint32 `json:"maxRetries,omitempty"` Timeout *TimeoutConfiguration `json:"timeout,omitempty"` - ProviderOptions jsonObj `json:"provider_options"` + ProviderOptions jsonObj `json:"providerOptions"` Headers map[string]string `json:"headers,omitempty"` } @@ -329,19 +341,20 @@ type SearchCallOptions struct { // UploadFileResult is the result of a file upload. type UploadFileResult struct { - ProviderReference map[string]string `json:"provider_reference"` - MediaType *string `json:"media_type,omitempty"` + ProviderReference map[string]string `json:"providerReference"` + MediaType *string `json:"mediaType,omitempty"` Filename *string `json:"filename,omitempty"` - ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` + ProviderMetadata json.RawMessage `json:"providerMetadata,omitempty"` Warnings []Warning `json:"warnings,omitempty"` } // UploadFileCallOptions is the options for a file upload. type UploadFileCallOptions struct { + // Data is {"type":"data","data":} or {"type":"text","text":...}. Data json.RawMessage `json:"data"` - MediaType string `json:"media_type"` + MediaType string `json:"mediaType"` Filename *string `json:"filename,omitempty"` - ProviderOptions jsonObj `json:"provider_options"` + ProviderOptions jsonObj `json:"providerOptions"` } // jsonObj is a json.RawMessage that serializes as {} when nil (instead of null). diff --git a/bindings/go/multimodal_withbase_test.go b/bindings/go/multimodal_withbase_test.go index 3c0c713f4..c7eacb1b2 100644 --- a/bindings/go/multimodal_withbase_test.go +++ b/bindings/go/multimodal_withbase_test.go @@ -166,8 +166,7 @@ func TestE2E_SpeechViaMock(t *testing.T) { if err != nil { t.Fatal(err) } - // Binary audio comes back as AudioData::Binary - if len(r.Audio.Binary) == 0 { + if len(r.Audio) == 0 { t.Error("empty audio") } } @@ -185,12 +184,12 @@ func TestE2E_ImageViaMock(t *testing.T) { prompt := "otter" n := 1 - r, err := ParseImageResult(must(m.Generate(&ImageCallOptions{Prompt: &prompt, N: &n}))) + r, err := ParseImageResult(must(m.Generate(&ImageCallOptions{Prompt: &prompt, N: n}))) if err != nil { t.Fatal(err) } - if len(r.Images.Base64) != 1 { - t.Fatalf("got %d images", len(r.Images.Base64)) + if len(r.Images) != 1 { + t.Fatalf("got %d images", len(r.Images)) } } @@ -269,7 +268,7 @@ func TestE2E_RerankingViaMock(t *testing.T) { topN := 2 r, err := ParseRerankingResult(must(m.Rerank(&RerankingCallOptions{ Query: "which?", - Documents: json.RawMessage(`{"Text":{"values":["doc1","doc2"]}}`), + Documents: json.RawMessage(`{"type":"text","values":["doc1","doc2"]}`), TopN: &topN, }))) if err != nil { @@ -290,11 +289,11 @@ func TestE2E_VideoViaMock(t *testing.T) { } defer m.Close() - r, err := ParseVideoResult(`{"videos":[{"Url":{"url":"https://example.com/v.mp4","media_type":"video/mp4"}}]}`) + r, err := ParseVideoResult(`{"videos":[{"type":"url","url":"https://example.com/v.mp4","mediaType":"video/mp4"}],"warnings":[],"response":{}}`) if err != nil { t.Fatal(err) } - if len(r.Videos) != 1 || r.Videos[0].Url == nil || r.Videos[0].Url.URL != "https://example.com/v.mp4" { + if len(r.Videos) != 1 || r.Videos[0].Type != "url" || r.Videos[0].URL != "https://example.com/v.mp4" { t.Fatal("video result parse mismatch") } } diff --git a/bindings/go/repair.go b/bindings/go/repair.go index 017eb7925..62a7f2c62 100644 --- a/bindings/go/repair.go +++ b/bindings/go/repair.go @@ -24,12 +24,12 @@ import ( // argument *text*, not a parsed object. It is what a repair function reads // and what it returns. type RawToolCall struct { - ToolCallID string `json:"tool_call_id"` - ToolName string `json:"tool_name"` + ToolCallID string `json:"toolCallId"` + ToolName string `json:"toolName"` Input string `json:"input"` - ProviderExecuted *bool `json:"provider_executed,omitempty"` + ProviderExecuted *bool `json:"providerExecuted,omitempty"` Dynamic *bool `json:"dynamic,omitempty"` - ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` + ProviderMetadata json.RawMessage `json:"providerMetadata,omitempty"` } // ToolCallRepairContext is the argument handed to a RepairToolCallFunc — the @@ -39,13 +39,13 @@ type RawToolCall struct { // engine from the prompt and options of the call being repaired. type ToolCallRepairContext struct { // ToolCall is the failed call, with its raw argument text. - ToolCall RawToolCall `json:"tool_call"` - // Error is the serialized AiMuxError the call carries (e.g. - // {"InvalidToolInput": {...}} or {"NoSuchTool": {...}}). + ToolCall RawToolCall `json:"toolCall"` + // Error is the serialized AiMuxError (AI SDK error JSON, tagged by "name" the call carries (e.g. + // {"name":"AI_InvalidToolInputError",...} or {"name":"AI_NoSuchToolError",...}). Error json.RawMessage `json:"error"` // InputSchema is the JSON schema of the named tool, or an empty-object // schema when the name does not resolve to a tool. - InputSchema json.RawMessage `json:"input_schema"` + InputSchema json.RawMessage `json:"inputSchema"` Tools []Tool `json:"tools"` Messages []ModelMessage `json:"messages"` Instructions *string `json:"instructions"` @@ -106,7 +106,7 @@ func applyToolCallRepairJSON(toolCallJSON, optsJSON, replyJSON string) (string, } // applyToolCallRepairToResultJSON patches a whole result document: both -// tool_calls and the matching response_messages tool-call part. +// toolCalls and the matching responseMessages tool-call part. func applyToolCallRepairToResultJSON(resultJSON, optsJSON, toolCallID, replyJSON string) (string, error) { cResult := C.CString(resultJSON) defer C.free(unsafe.Pointer(cResult)) @@ -148,7 +148,7 @@ func repairReply(repair RepairToolCallFunc, contextJSON string) (string, error) } reply, err := json.Marshal(struct { Type string `json:"type"` - ToolCall *RawToolCall `json:"tool_call"` + ToolCall *RawToolCall `json:"toolCall"` }{"repaired", replacement}) if err != nil { return "", fmt.Errorf("aimux: failed to encode repaired tool call: %w", err) @@ -158,14 +158,14 @@ func repairReply(repair RepairToolCallFunc, contextJSON string) (string, error) // invalidToolCalls lists the invalid tool calls of a result document, in // order, as (id, JSON) pairs. A GenerateObjectResult carries the text result -// under `raw`; a GenerateTextResult's own `raw` has no tool_calls, so the -// same lookup covers both — and StreamTextResultAggregated, whose tool_calls +// under `raw`; a GenerateTextResult's own `raw` has no toolCalls, so the +// same lookup covers both — and StreamTextResultAggregated, whose toolCalls // are top-level. func invalidToolCalls(resultJSON string) [][2]string { var doc struct { - ToolCalls []json.RawMessage `json:"tool_calls"` + ToolCalls []json.RawMessage `json:"toolCalls"` Raw struct { - ToolCalls []json.RawMessage `json:"tool_calls"` + ToolCalls []json.RawMessage `json:"toolCalls"` } `json:"raw"` } if json.Unmarshal([]byte(resultJSON), &doc) != nil { @@ -178,7 +178,7 @@ func invalidToolCalls(resultJSON string) [][2]string { var invalid [][2]string for _, call := range calls { var head struct { - ToolCallID string `json:"tool_call_id"` + ToolCallID string `json:"toolCallId"` Invalid *bool `json:"invalid"` } if json.Unmarshal(call, &head) != nil || head.Invalid == nil || !*head.Invalid { @@ -217,7 +217,7 @@ func repairResultJSON(resultJSON, promptJSON, optsJSON string, repair RepairTool } // repairStreamToolCall is the streaming counterpart: it resolves one -// {"ToolCall": …} stream part payload, returning the payload to deliver. +// {"type":"tool-call",…} stream part, returning the payload to deliver. // A valid call, or one made without a tool set, passes through untouched. // Tool-input delta parts never reach here — they are forwarded immediately, // as the AI SDK does. diff --git a/bindings/go/roundtrip_fields_test.go b/bindings/go/roundtrip_fields_test.go index 61bdd1294..8fe7b808e 100644 --- a/bindings/go/roundtrip_fields_test.go +++ b/bindings/go/roundtrip_fields_test.go @@ -4,7 +4,7 @@ // Go prompts are raw JSON, so the binding's job is to pass them through the // FFI into the Rust engine unchanged. This test proves the full pipe for the // extended-thinking input round-trip: an assistant reasoning part carrying a -// `signature` is echoed as an Anthropic thinking block on the wire when +// providerOptions.anthropic.signature is echoed as an Anthropic thinking block on the wire when // thinking is enabled. package aimux @@ -30,12 +30,12 @@ func TestE2E_AnthropicEchoesReasoningSignature(t *testing.T) { prompt := `[ {"role":"user","content":[{"type":"text","text":"hi"}]}, {"role":"assistant","content":[ - {"type":"reasoning","text":"pondering","signature":"sig-go-1"}, + {"type":"reasoning","text":"pondering","providerOptions":{"anthropic":{"signature":"sig-go-1"}}}, {"type":"text","text":"answer"} ]}, {"role":"user","content":[{"type":"text","text":"go on"}]} ]` - opts := `{"provider_options":{"anthropic":{"thinking":{"type":"enabled","budgetTokens":1024}}}}` + opts := `{"providerOptions":{"anthropic":{"thinking":{"type":"enabled","budgetTokens":1024}}}}` if _, err := m.GenerateText(prompt, opts); err != nil { t.Fatalf("GenerateText: %v", err) diff --git a/bindings/go/roundtrip_test.go b/bindings/go/roundtrip_test.go index 360e830d2..71e5bdf3a 100644 --- a/bindings/go/roundtrip_test.go +++ b/bindings/go/roundtrip_test.go @@ -1,9 +1,9 @@ // Round-trip (de)serialization tests for the typed wire-format types. // -// Mirrors Kotlin TypedModelRoundTripTest.kt: build a typed value, marshal to -// JSON, unmarshal back, assert equality. These pin down wire-format field -// names and tag spellings the typed wrappers rely on — without them, a rename -// in aimux-core could silently break Go clients. +// Build a typed value, marshal to JSON, unmarshal back, assert equality. These +// pin down wire-format field names and tag spellings the typed wrappers rely +// on (camelCase, the AI SDK JSON) — without them, a rename in aimux-core could +// silently break Go clients. // // Also includes regression tests for the two bugs found in code review: // - ToolChoiceTool JSON injection (now uses json.Marshal) @@ -122,9 +122,9 @@ func TestToolCallRoundTrip(t *testing.T) { if err != nil { t.Fatalf("marshal failed: %v", err) } - // Verify wire field names match Kotlin (snake_case). + // Verify wire field names (camelCase). s := string(b) - for _, want := range []string{`"tool_call_id"`, `"tool_name"`, `"input"`, `"provider_executed"`, `"dynamic"`, `"provider_metadata"`} { + for _, want := range []string{`"toolCallId"`, `"toolName"`, `"input"`, `"providerExecuted"`, `"dynamic"`, `"providerMetadata"`} { if !contains(s, want) { t.Errorf("expected %s in wire JSON, got %s", want, s) } @@ -134,19 +134,19 @@ func TestToolCallRoundTrip(t *testing.T) { t.Fatalf("unmarshal failed: %v", err) } if decoded.ToolCallID != original.ToolCallID { - t.Errorf("tool_call_id mismatch: got %s, want %s", decoded.ToolCallID, original.ToolCallID) + t.Errorf("toolCallId mismatch: got %s, want %s", decoded.ToolCallID, original.ToolCallID) } if decoded.ToolName != original.ToolName { - t.Errorf("tool_name mismatch: got %s, want %s", decoded.ToolName, original.ToolName) + t.Errorf("toolName mismatch: got %s, want %s", decoded.ToolName, original.ToolName) } if decoded.ProviderExecuted == nil || *decoded.ProviderExecuted != true { - t.Error("provider_executed did not round-trip") + t.Error("providerExecuted did not round-trip") } if decoded.Dynamic == nil || *decoded.Dynamic != false { t.Error("dynamic did not round-trip") } if string(decoded.ProviderMetadata) != string(original.ProviderMetadata) { - t.Errorf("provider_metadata mismatch: got %s, want %s", decoded.ProviderMetadata, original.ProviderMetadata) + t.Errorf("providerMetadata mismatch: got %s, want %s", decoded.ProviderMetadata, original.ProviderMetadata) } } @@ -179,9 +179,10 @@ func TestModelMessageMultiPartRoundTrip(t *testing.T) { Role: RoleTool, Content: []map[string]any{ { - "type": "tool_result", - "tool_call_id": "call_1", - "result": map[string]any{"temp": "20"}, + "type": "tool-result", + "toolCallId": "call_1", + "toolName": "get_weather", + "output": map[string]any{"type": "json", "value": map[string]any{"temp": "20"}}, }, }, } @@ -203,14 +204,14 @@ func TestModelMessageMultiPartRoundTrip(t *testing.T) { if len(raw.Content) != 1 { t.Fatalf("expected 1 content part, got %d", len(raw.Content)) } - // Verify the tool_result structure. + // Verify the tool-result structure. var part map[string]any json.Unmarshal(raw.Content[0], &part) - if part["type"] != "tool_result" { - t.Errorf("expected type 'tool_result', got %v", part["type"]) + if part["type"] != "tool-result" { + t.Errorf("expected type 'tool-result', got %v", part["type"]) } - if part["tool_call_id"] != "call_1" { - t.Errorf("expected tool_call_id 'call_1', got %v", part["tool_call_id"]) + if part["toolCallId"] != "call_1" { + t.Errorf("expected toolCallId 'call_1', got %v", part["toolCallId"]) } } @@ -239,11 +240,11 @@ func TestGenerateTextOptionsRoundTrip(t *testing.T) { if err != nil { t.Fatalf("marshal failed: %v", err) } - // Verify snake_case wire fields present. + // Verify camelCase wire fields present. s := string(b) for _, want := range []string{ - `"temperature"`, `"top_k"`, `"reasoning"`, `"instructions"`, - `"tool_choice"`, `"tools"`, `"input_schema"`, + `"temperature"`, `"topK"`, `"reasoning"`, `"instructions"`, + `"toolChoice"`, `"tools"`, `"inputSchema"`, } { if !contains(s, want) { t.Errorf("expected %s in wire JSON, got %s", want, s) @@ -275,22 +276,22 @@ func TestGenerateTextOptionsDefaultOmitsFields(t *testing.T) { } } -// RFC-0016 M2: include_raw_chunks round-trips through the typed options. +// includeRawChunks round-trips through the typed options. func TestGenerateTextOptionsIncludeRawChunksRoundTrip(t *testing.T) { original := GenerateTextOptions{IncludeRawChunks: boolPtr(true)} b, err := json.Marshal(original) if err != nil { t.Fatalf("marshal: %v", err) } - if !strings.Contains(string(b), `"include_raw_chunks":true`) { - t.Errorf("expected include_raw_chunks:true in %s", b) + if !strings.Contains(string(b), `"includeRawChunks":true`) { + t.Errorf("expected includeRawChunks:true in %s", b) } var back GenerateTextOptions if err := json.Unmarshal(b, &back); err != nil { t.Fatalf("unmarshal: %v", err) } if back.IncludeRawChunks == nil || *back.IncludeRawChunks != true { - t.Errorf("round-trip lost include_raw_chunks: %#v", back.IncludeRawChunks) + t.Errorf("round-trip lost includeRawChunks: %#v", back.IncludeRawChunks) } } @@ -318,11 +319,11 @@ func TestStreamPartParseRoundTrip(t *testing.T) { cases := []struct { name string json string - tag string + typ string }{ - {"TextDelta", `{"TextDelta":{"id":"tx1","delta":"Hello"}}`, "TextDelta"}, - {"Finish", `{"Finish":{"finish_reason":{"unified":{"stop":null}}}}`, "Finish"}, - {"StreamStart", `{"StreamStart":{"warnings":[]}}`, "StreamStart"}, + {"text-delta", `{"type":"text-delta","id":"tx1","delta":"Hello"}`, "text-delta"}, + {"finish", `{"type":"finish","finishReason":{"unified":"stop"},"usage":{"inputTokens":{},"outputTokens":{}}}`, "finish"}, + {"stream-start", `{"type":"stream-start","warnings":[]}`, "stream-start"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -330,34 +331,40 @@ func TestStreamPartParseRoundTrip(t *testing.T) { if err != nil { t.Fatalf("parse failed: %v", err) } - if sp.Tag != c.tag { - t.Errorf("tag mismatch: got %s, want %s", sp.Tag, c.tag) + if sp.Type != c.typ { + t.Errorf("type mismatch: got %s, want %s", sp.Type, c.typ) } - if len(sp.Payload) == 0 { - t.Error("expected non-empty payload") + if string(sp.Raw) != c.json { + t.Errorf("raw mismatch: got %s", sp.Raw) } }) } } func TestParseTextDeltaPayload(t *testing.T) { - sp, _ := ParseStreamPart(`{"TextDelta":{"id":"tx1","delta":"Hello"}}`) + sp, _ := ParseStreamPart(`{"type":"text-delta","id":"tx1","delta":"Hello"}`) var td TextDeltaPayload - json.Unmarshal(sp.Payload, &td) + json.Unmarshal(sp.Raw, &td) if td.ID != "tx1" || td.Delta != "Hello" { t.Errorf("got %+v", td) } } -// TestParseStreamPartRejectsInvalid validates the externally-tagged union -// enforcement: zero keys or multiple keys must produce an error. +func TestStreamPartSource(t *testing.T) { + sp, _ := ParseStreamPart(`{"type":"source","sourceType":"url","id":"s1","url":"https://example.com"}`) + src, err := sp.Source() + if err != nil || src.URL == nil || src.URL.URL != "https://example.com" { + t.Fatalf("source: %+v, %v", src, err) + } +} + +// TestParseStreamPartRejectsInvalid: a part without a "type" is an error. func TestParseStreamPartRejectsInvalid(t *testing.T) { cases := []struct { name string json string }{ {"empty_object", `{}`}, - {"multiple_keys", `{"TextDelta":{"delta":"a"},"Finish":{}}`}, {"not_object", `[]`}, {"invalid_json", `{`}, } diff --git a/bindings/go/trace_test.go b/bindings/go/trace_test.go index 6e0a05fa6..1e9a26a29 100644 --- a/bindings/go/trace_test.go +++ b/bindings/go/trace_test.go @@ -1,7 +1,7 @@ // trace_test.go — RFC-0015 wire-contract tests (pure JSON, no FFI). // // Verifies Go can parse the probe wire types exactly as Rust serializes them -// (snake_case fields, hex fingerprints, optional verdicts). +// (camelCase fields, hex fingerprints, optional verdicts). package aimux @@ -13,34 +13,34 @@ import ( const traceRecordJSON = `{ "provider": "openai", "model": "gpt-4o", - "request_id": "req-1", - "session_id": "sess-1", - "call_id": "trace-1", - "sent_at_unix_ms": 1785900000000, - "ttft_ms": 42, + "requestId": "req-1", + "sessionId": "sess-1", + "callId": "trace-1", + "sentAtUnixMs": 1785900000000, + "ttftMs": 42, "fingerprint": { - "body_hash": "0123456789abcdef0123456789abcdef", - "len_bytes": 10240, - "block_size": 4096, - "block_hashes": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "token_estimate": 2560 + "bodyHash": "0123456789abcdef0123456789abcdef", + "lenBytes": 10240, + "blockSize": 4096, + "blockHashes": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "tokenEstimate": 2560 }, "usage": { - "input_total": 2560, - "input_no_cache": 1536, - "cache_read": 1024, - "cache_write": 0, - "output_total": 10, - "output_text": 10 + "inputTotal": 2560, + "inputNoCache": 1536, + "cacheRead": 1024, + "cacheWrite": 0, + "outputTotal": 10, + "outputText": 10 }, - "request_cache_hints": {"requested_write": true}, + "requestCacheHints": {"requestedWrite": true}, "verdict": { "kind": "Trusted", "confidence": "High", "violated": [], - "expected_max": 1024, + "expectedMax": 1024, "claimed": 1024, - "lcp_bytes": 4096, + "lcpBytes": 4096, "notes": [] } }` @@ -54,19 +54,19 @@ func TestTraceRecordParses(t *testing.T) { t.Errorf("provider/model mismatch: %s / %s", rec.Provider, rec.Model) } if rec.SessionID == nil || *rec.SessionID != "sess-1" { - t.Errorf("session_id mismatch: %v", rec.SessionID) + t.Errorf("sessionId mismatch: %v", rec.SessionID) } if rec.Fingerprint.BodyHash != "0123456789abcdef0123456789abcdef" { - t.Errorf("body_hash mismatch: %s", rec.Fingerprint.BodyHash) + t.Errorf("bodyHash mismatch: %s", rec.Fingerprint.BodyHash) } if len(rec.Fingerprint.BlockHashes) != 2 { t.Errorf("expected 2 block hashes, got %d", len(rec.Fingerprint.BlockHashes)) } if rec.Usage.CacheRead == nil || *rec.Usage.CacheRead != 1024 { - t.Errorf("cache_read mismatch: %v", rec.Usage.CacheRead) + t.Errorf("cacheRead mismatch: %v", rec.Usage.CacheRead) } if rec.RequestCacheHints == nil || !rec.RequestCacheHints.RequestedWrite { - t.Error("request_cache_hints mismatch") + t.Error("requestCacheHints mismatch") } if rec.Verdict == nil { t.Fatal("verdict must be present") @@ -85,11 +85,11 @@ func TestTraceRecordParses(t *testing.T) { func TestTraceStatsAndChainParse(t *testing.T) { statsJSON := `{ "provider": "openai", "model": "gpt-4o", - "requests": 3, "input_tokens_total": 7680, - "claimed_cache_read_total": 2048, "claimed_cache_write_total": 0, - "reported_hit_rate": 0.26666666666666666, - "client_upper_bound_hit_rate": 0.4, - "verdict_counts": {"Trusted": 2, "SuspectOverclaim": 1}, + "requests": 3, "inputTokensTotal": 7680, + "claimedCacheReadTotal": 2048, "claimedCacheWriteTotal": 0, + "reportedHitRate": 0.26666666666666666, + "clientUpperBoundHitRate": 0.4, + "verdictCounts": {"Trusted": 2, "SuspectOverclaim": 1}, "errors": 0 }` var stats TraceStats @@ -100,16 +100,16 @@ func TestTraceStatsAndChainParse(t *testing.T) { t.Errorf("stats mismatch: %+v", stats) } if stats.VerdictCounts["Trusted"] != 2 { - t.Errorf("verdict_counts mismatch: %v", stats.VerdictCounts) + t.Errorf("verdictCounts mismatch: %v", stats.VerdictCounts) } chainJSON := `{ - "session_id": "sess-1", - "record_ids": ["trace-1", "trace-2"], - "prefix_stability": 0.8, + "sessionId": "sess-1", + "recordIds": ["trace-1", "trace-2"], + "prefixStability": 0.8, "breaks": [{ - "at_record_id": "trace-2", "prev_record_id": "trace-1", - "lcp_bytes": 1024, "expected_break": false, "kind": "Unknown" + "atRecordId": "trace-2", "prevRecordId": "trace-1", + "lcpBytes": 1024, "expectedBreak": false, "kind": "Unknown" }] }` var chain SessionChainView diff --git a/bindings/go/typed.go b/bindings/go/typed.go index d0d3f6d33..8298b31a6 100644 --- a/bindings/go/typed.go +++ b/bindings/go/typed.go @@ -71,7 +71,7 @@ func (m *Model) Generate(prompt any, opts *GenerateTextOptions) (*GenerateTextRe // GenerateObj is the typed object-generation method (M12, RFC-0016). // // Same signature as Generate; returns a typed *GenerateObjectResult. Pass -// response_format: { "Json": { ... } } via opts for schema control; the +// ResponseFormat {"type":"json","schema":{...}} via opts for schema control; the // engine applies JSON repair before parsing. func (m *Model) GenerateObj(prompt any, opts *GenerateTextOptions) (*GenerateObjectResult, error) { promptJSON, err := marshalPrompt(prompt) @@ -144,9 +144,9 @@ func (s *TypedStream) Cancel() { s.raw.Cancel() } // // stream, err := model.Stream("Write a haiku", nil) // for part := range stream.Parts() { -// if part.Tag == "TextDelta" { +// if part.Type == "text-delta" { // var td TextDeltaPayload -// json.Unmarshal(part.Payload, &td) +// json.Unmarshal(part.Raw, &td) // fmt.Print(td.Delta) // } // } @@ -198,14 +198,14 @@ func (m *Model) StreamContext( } // Only a complete tool call can be repaired; input deltas are // forwarded untouched, as the AI SDK does (RFC-0035 §4). - if sp.Tag == "ToolCall" && opts != nil && opts.RepairToolCall != nil { - payload, err := repairStreamToolCall(sp.Payload, promptJSON, optsJSON, opts.RepairToolCall) + if sp.Type == "tool-call" && opts != nil && opts.RepairToolCall != nil { + raw, err := repairStreamToolCall(sp.Raw, promptJSON, optsJSON, opts.RepairToolCall) if err != nil { ts.err = err rawStream.cancel(err) return } - sp.Payload = payload + sp.Raw = raw } select { case ts.parts <- sp: diff --git a/bindings/go/typed_test.go b/bindings/go/typed_test.go index 2852791cb..2d08f5807 100644 --- a/bindings/go/typed_test.go +++ b/bindings/go/typed_test.go @@ -18,7 +18,7 @@ func TestTypedGenerateString(t *testing.T) { defer srv.Close() srv.SetResponse(plainOpenAIResponse) - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) + m := chatModel(t, srv.URL) defer m.Close() // Typed call with plain string prompt. @@ -36,7 +36,7 @@ func TestTypedGenerateMessages(t *testing.T) { defer srv.Close() srv.SetResponse(plainOpenAIResponse) - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) + m := chatModel(t, srv.URL) defer m.Close() msgs := []ModelMessage{ @@ -65,7 +65,7 @@ func TestTypedGenerateWithOptions(t *testing.T) { defer srv.Close() srv.SetResponse(toolCallOpenAIResponse) - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) + m := chatModel(t, srv.URL) defer m.Close() temp := 0.7 @@ -111,7 +111,7 @@ func TestTypedStreamTextDeltas(t *testing.T) { srv.SetContentType("text/event-stream") srv.SetResponse(buildTextDeltaSSE()) - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) + m := chatModel(t, srv.URL) defer m.Close() stream, err := m.Stream("Say hello", nil) @@ -122,12 +122,12 @@ func TestTypedStreamTextDeltas(t *testing.T) { var textBuilder string var gotFinish bool for part := range stream.Parts() { - switch part.Tag { - case "TextDelta": + switch part.Type { + case "text-delta": var td TextDeltaPayload - json.Unmarshal(part.Payload, &td) + json.Unmarshal(part.Raw, &td) textBuilder += td.Delta - case "Finish": + case "finish": gotFinish = true } } @@ -148,7 +148,7 @@ func TestTypedStreamMessages(t *testing.T) { srv.SetContentType("text/event-stream") srv.SetResponse(buildTextDeltaSSE()) - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) + m := chatModel(t, srv.URL) defer m.Close() msgs := []ModelMessage{ @@ -207,7 +207,7 @@ func TestTypedStreamContextAlreadyCanceled(t *testing.T) { func TestTypedStreamCancelUnblocksFullPartsChannel(t *testing.T) { srv, allSent, requestDone := newBackpressureSSEServer(1200) defer srv.Close() - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) + m := chatModel(t, srv.URL) defer m.Close() stream, err := m.Stream("fill the typed channel", nil) diff --git a/bindings/go/types.go b/bindings/go/types.go index 0b919cf6d..77cf2c6ee 100644 --- a/bindings/go/types.go +++ b/bindings/go/types.go @@ -1,12 +1,13 @@ -// Typed data structures mirroring the aimux-core wire format (same shapes as -// the Kotlin Types.kt and the generated TypeScript .d.ts types). +// Typed data structures mirroring the aimux-core wire format: the JSON the +// Rust engine reads and writes, which is the AI SDK JSON (camelCase field +// names, unions tagged by a "type" key, absent rather than null for optional +// fields). The generated TypeScript types in bindings/node/src/types are the +// exact shape. // -// Field names use JSON tags matching the wire format's snake_case. The raw JSON -// boundary is handled by Model.GenerateText / Model.StreamText — this layer -// only provides typed parsing so callers don't manually dig through JSON. -// -// These types are intentionally lenient on decode (unknown keys ignored, every -// field optional) so future engine additions don't break existing clients. +// The raw JSON boundary is handled by Model.GenerateText / Model.StreamText — +// this layer only provides typed parsing so callers don't manually dig through +// JSON. Decoding is lenient (unknown keys ignored) so future engine additions +// don't break existing clients. package aimux @@ -38,7 +39,6 @@ const ( ) // ReasoningEffort controls how much reasoning effort the model spends. -// Mirrors Kotlin ReasoningEffort (Types.kt:72-81). type ReasoningEffort string const ( @@ -87,27 +87,27 @@ type URLSource struct { ID string `json:"id"` URL string `json:"url"` Title *string `json:"title,omitempty"` - ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` + ProviderMetadata json.RawMessage `json:"providerMetadata,omitempty"` } type DocumentSource struct { ID string `json:"id"` - MediaType string `json:"media_type"` + MediaType string `json:"mediaType"` Title string `json:"title"` Filename *string `json:"filename,omitempty"` - ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` + ProviderMetadata json.RawMessage `json:"providerMetadata,omitempty"` } func (s Source) MarshalJSON() ([]byte, error) { if s.URL != nil && s.Document == nil { return json.Marshal(struct { - SourceType string `json:"source_type"` + SourceType string `json:"sourceType"` *URLSource }{"url", s.URL}) } if s.Document != nil && s.URL == nil { return json.Marshal(struct { - SourceType string `json:"source_type"` + SourceType string `json:"sourceType"` *DocumentSource }{"document", s.Document}) } @@ -120,17 +120,17 @@ func (s *Source) UnmarshalJSON(data []byte) error { return err } var sourceType string - if err := json.Unmarshal(fields["source_type"], &sourceType); err != nil { - return fmt.Errorf("aimux: invalid source_type: %w", err) + if err := json.Unmarshal(fields["sourceType"], &sourceType); err != nil { + return fmt.Errorf("aimux: invalid sourceType: %w", err) } required := []string{"id"} switch sourceType { case "url": required = append(required, "url") case "document": - required = append(required, "media_type", "title") + required = append(required, "mediaType", "title") default: - return fmt.Errorf("aimux: unknown source_type %q", sourceType) + return fmt.Errorf("aimux: unknown sourceType %q", sourceType) } for _, name := range required { var value *string @@ -157,9 +157,9 @@ func (s *Source) UnmarshalJSON(data []byte) error { // InputTokenUsage is input token usage detail with cache breakdown. type InputTokenUsage struct { Total *uint32 `json:"total,omitempty"` - NoCache *uint32 `json:"no_cache,omitempty"` - CacheRead *uint32 `json:"cache_read,omitempty"` - CacheWrite *uint32 `json:"cache_write,omitempty"` + NoCache *uint32 `json:"noCache,omitempty"` + CacheRead *uint32 `json:"cacheRead,omitempty"` + CacheWrite *uint32 `json:"cacheWrite,omitempty"` } // OutputTokenUsage is output token usage detail. @@ -171,8 +171,8 @@ type OutputTokenUsage struct { // Usage is token usage statistics. type Usage struct { - InputTokens InputTokenUsage `json:"input_tokens,omitempty"` - OutputTokens OutputTokenUsage `json:"output_tokens,omitempty"` + InputTokens InputTokenUsage `json:"inputTokens,omitempty"` + OutputTokens OutputTokenUsage `json:"outputTokens,omitempty"` Raw map[string]json.RawMessage `json:"raw,omitempty"` } @@ -183,123 +183,120 @@ type FinishReason struct { } // ToolCall represents a tool call requested by the model. -// Mirrors Kotlin ToolCall (Types.kt:157-164). type ToolCall struct { - ToolCallID string `json:"tool_call_id"` - ToolName string `json:"tool_name"` + ToolCallID string `json:"toolCallId"` + ToolName string `json:"toolName"` Input json.RawMessage `json:"input,omitempty"` - ProviderExecuted *bool `json:"provider_executed,omitempty"` + ProviderExecuted *bool `json:"providerExecuted,omitempty"` Dynamic *bool `json:"dynamic,omitempty"` // ProviderMetadata carries provider-specific data associated with this call. - ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` + ProviderMetadata json.RawMessage `json:"providerMetadata,omitempty"` // Invalid is set by Core when the tool call stays invalid after optional repair. Invalid *bool `json:"invalid,omitempty"` // Error is the typed lookup, parse, schema, or repair failure for an invalid call. Error json.RawMessage `json:"error,omitempty"` } -// ContentPart is a single content part in the raw response. -// The wire format uses internally-tagged unions; we keep it as raw JSON -// for forward compatibility. +// ContentPart is a single content part, in a prompt message or a result: an +// object tagged by "type" ("text", "tool-call", "tool-result", "source", ...). +// It is kept as raw JSON for forward compatibility. A tool-result part is +// {"type":"tool-result","toolCallId":...,"toolName":...,"output":{...}} where +// output is {"type":"text"|"json"|"error-text"|"error-json","value":...}, +// {"type":"execution-denied","reason":...} or {"type":"content","value":[...]}. type ContentPart = json.RawMessage -// ResponseMetadata describes the provider HTTP response. -// Mirrors Kotlin ResponseMetadata (Types.kt:141-146). +// ResponseMetadata describes the provider response. type ResponseMetadata struct { ID *string `json:"id,omitempty"` Timestamp *string `json:"timestamp,omitempty"` - ModelID *string `json:"model_id,omitempty"` + ModelID *string `json:"modelId,omitempty"` } -// GenerateResponseMetadata is response metadata with optional HTTP headers and body. -type GenerateResponseMetadata struct { +// ResponseInfo is response metadata with optional HTTP headers and body. +type ResponseInfo struct { ResponseMetadata Headers map[string]string `json:"headers,omitempty"` Body json.RawMessage `json:"body,omitempty"` } -// GenerateRequest is provider request information with an optional HTTP body. -type GenerateRequest struct { +// RequestInfo is provider request information with an optional HTTP body. +type RequestInfo struct { Body json.RawMessage `json:"body,omitempty"` } // GenerateResult is the raw provider result. -// Mirrors Kotlin GenerateResult (Types.kt:853-871). type GenerateResult struct { - Content []ContentPart `json:"content,omitempty"` - FinishReason FinishReason `json:"finish_reason,omitempty"` - Usage Usage `json:"usage,omitempty"` - Warnings []json.RawMessage `json:"warnings,omitempty"` - ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` - Response *GenerateResponseMetadata `json:"response,omitempty"` - Request *GenerateRequest `json:"request,omitempty"` + Content []ContentPart `json:"content,omitempty"` + FinishReason FinishReason `json:"finishReason,omitempty"` + Usage Usage `json:"usage,omitempty"` + Warnings []json.RawMessage `json:"warnings,omitempty"` + ProviderMetadata json.RawMessage `json:"providerMetadata,omitempty"` + Response *ResponseInfo `json:"response,omitempty"` + Request *RequestInfo `json:"request,omitempty"` } // GenerateTextResult is the typed result of a GenerateText call. -// Mirrors Kotlin GenerateTextResult (Types.kt:878-886). type GenerateTextResult struct { Content []ContentPart `json:"content,omitempty"` Text string `json:"text"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - FinishReason FinishReason `json:"finish_reason,omitempty"` + ToolCalls []ToolCall `json:"toolCalls,omitempty"` + FinishReason FinishReason `json:"finishReason,omitempty"` Usage Usage `json:"usage,omitempty"` Warnings []json.RawMessage `json:"warnings,omitempty"` Raw GenerateResult `json:"raw"` Reasoning []json.RawMessage `json:"reasoning,omitempty"` - ReasoningText string `json:"reasoning_text,omitempty"` + ReasoningText string `json:"reasoningText,omitempty"` Sources []Source `json:"sources,omitempty"` Files []json.RawMessage `json:"files,omitempty"` - ResponseMessages []ModelMessage `json:"response_messages,omitempty"` - // RawFinishReason is the raw provider-specific finish reason string (M12). - RawFinishReason *string `json:"raw_finish_reason,omitempty"` + ResponseMessages []ModelMessage `json:"responseMessages,omitempty"` + // RawFinishReason is the raw provider-specific finish reason string. + RawFinishReason *string `json:"rawFinishReason,omitempty"` // ProviderMetadata is provider-specific metadata (e.g. Anthropic cache info). - // Mirrored from raw.provider_metadata for top-level convenience. Weak type - // (json.RawMessage) — same strategy as GenerateResult.ProviderMetadata. - ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` - // Response is the response metadata (id, timestamp, model_id), mirrored from - // raw.response for top-level convenience. - Response ResponseMetadata `json:"response,omitempty"` + // Mirrored from raw.providerMetadata for top-level convenience. + ProviderMetadata json.RawMessage `json:"providerMetadata,omitempty"` + // Request is the request information (body) from the provider. + Request RequestInfo `json:"request"` + // Response is the response metadata, headers and body from the provider. + Response ResponseInfo `json:"response"` // TotalUsage is total token usage across all steps. In single-step mode // (aimux's default), equals Usage. Provided for AI SDK parity. - TotalUsage Usage `json:"total_usage,omitempty"` + TotalUsage Usage `json:"totalUsage,omitempty"` } -// GenerateObjectResult is the typed result of a GenerateObject call (M12). -// Mirrors Rust GenerateObjectResult. `object` is an arbitrary JSON value -// (weak type — json.RawMessage) and `raw` is the full GenerateTextResult. +// GenerateObjectResult is the typed result of a GenerateObject call. +// `object` is an arbitrary JSON value and `raw` is the full GenerateTextResult. type GenerateObjectResult struct { Object json.RawMessage `json:"object,omitempty"` - FinishReason FinishReason `json:"finish_reason,omitempty"` - RawFinishReason *string `json:"raw_finish_reason,omitempty"` + FinishReason FinishReason `json:"finishReason,omitempty"` + RawFinishReason *string `json:"rawFinishReason,omitempty"` Usage Usage `json:"usage,omitempty"` Warnings []json.RawMessage `json:"warnings,omitempty"` Reasoning *string `json:"reasoning,omitempty"` - ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` + ProviderMetadata json.RawMessage `json:"providerMetadata,omitempty"` Response ResponseMetadata `json:"response,omitempty"` Raw GenerateTextResult `json:"raw"` } -// StreamTextResultAggregated is the aggregated result of stream_text (M11). -// Mirrors Rust StreamTextResultAggregated. reasoning/sources/files use weak -// types (json.RawMessage) — same strategy as GenerateTextResult. +// StreamTextResultAggregated is the aggregated result of a consumed stream. type StreamTextResultAggregated struct { Content []ContentPart `json:"content,omitempty"` Text string `json:"text"` Reasoning []json.RawMessage `json:"reasoning,omitempty"` - ReasoningText string `json:"reasoning_text,omitempty"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ReasoningText string `json:"reasoningText,omitempty"` + ToolCalls []ToolCall `json:"toolCalls,omitempty"` Sources []Source `json:"sources,omitempty"` Files []json.RawMessage `json:"files,omitempty"` - FinishReason FinishReason `json:"finish_reason,omitempty"` - RawFinishReason *string `json:"raw_finish_reason,omitempty"` + FinishReason FinishReason `json:"finishReason,omitempty"` + RawFinishReason *string `json:"rawFinishReason,omitempty"` Usage Usage `json:"usage,omitempty"` - TotalUsage Usage `json:"total_usage,omitempty"` + TotalUsage Usage `json:"totalUsage,omitempty"` Warnings []json.RawMessage `json:"warnings,omitempty"` - ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` - // Response is the response metadata (id, timestamp, model_id) if emitted by - // the stream. Nullable (streaming may have none). - Response *ResponseMetadata `json:"response,omitempty"` - ResponseMessages []ModelMessage `json:"response_messages,omitempty"` + ProviderMetadata json.RawMessage `json:"providerMetadata,omitempty"` + // Request is the request information from the provider. + Request RequestInfo `json:"request"` + // Response is the response metadata and headers from the last step. + Response ResponseInfo `json:"response"` + ResponseMessages []ModelMessage `json:"responseMessages,omitempty"` } // ParseGenerateTextResult parses the JSON string returned by Model.GenerateText @@ -313,7 +310,7 @@ func ParseGenerateTextResult(jsonStr string) (*GenerateTextResult, error) { } // ParseGenerateObjectResult parses the JSON string returned by -// Model.GenerateObject into a typed GenerateObjectResult (M12). +// Model.GenerateObject into a typed GenerateObjectResult. func ParseGenerateObjectResult(jsonStr string) (*GenerateObjectResult, error) { var r GenerateObjectResult if err := json.Unmarshal([]byte(jsonStr), &r); err != nil { @@ -323,7 +320,7 @@ func ParseGenerateObjectResult(jsonStr string) (*GenerateObjectResult, error) { } // ParseStreamTextResultAggregated parses the JSON string returned by -// Model.ConsumeStreamText into a typed StreamTextResultAggregated (M11). +// Model.ConsumeStreamText into a typed StreamTextResultAggregated. func ParseStreamTextResultAggregated(jsonStr string) (*StreamTextResultAggregated, error) { var r StreamTextResultAggregated if err := json.Unmarshal([]byte(jsonStr), &r); err != nil { @@ -336,8 +333,7 @@ func ParseStreamTextResultAggregated(jsonStr string) (*StreamTextResultAggregate // ModelMessage is a single message in a conversation. // Content is `any` so it can be a plain string (common case) or a slice of -// typed ContentParts (multi-part content, e.g. tool results). -// Mirrors Kotlin ModelMessage (Types.kt:523-548). +// ContentParts (multi-part content, e.g. tool results). type ModelMessage struct { Role Role `json:"role"` Content any `json:"content"` @@ -360,37 +356,35 @@ func MarshalMessages(msgs []ModelMessage) (string, error) { // ── GenerateTextOptions (for typed options building) ────────────────────────── // GenerateTextOptions is the typed options for text generation. -// All fields are optional (pointer types) to match the engine's schema. -// Mirrors Kotlin GenerateTextOptions (Types.kt:561-579). -// TimeoutConfiguration sets per-call timeouts (RFC-0016 H3). +// TimeoutConfiguration sets per-call timeouts. type TimeoutConfiguration struct { - TotalMs *uint64 `json:"total_ms,omitempty"` - StepMs *uint64 `json:"step_ms,omitempty"` - FirstChunkMs *uint64 `json:"first_chunk_ms,omitempty"` - ChunkMs *uint64 `json:"chunk_ms,omitempty"` + TotalMs *uint64 `json:"totalMs,omitempty"` + StepMs *uint64 `json:"stepMs,omitempty"` + FirstChunkMs *uint64 `json:"firstChunkMs,omitempty"` + ChunkMs *uint64 `json:"chunkMs,omitempty"` } -// GenerateTextOptions mirrors the shared wire options. +// GenerateTextOptions mirrors the shared wire options. All fields are optional. type GenerateTextOptions struct { - MaxOutputTokens *uint32 `json:"max_output_tokens,omitempty"` + MaxOutputTokens *uint32 `json:"maxOutputTokens,omitempty"` Temperature *float64 `json:"temperature,omitempty"` - StopSequences []string `json:"stop_sequences,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - TopK *float64 `json:"top_k,omitempty"` - PresencePenalty *float64 `json:"presence_penalty,omitempty"` - FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` - ResponseFormat json.RawMessage `json:"response_format,omitempty"` + StopSequences []string `json:"stopSequences,omitempty"` + TopP *float64 `json:"topP,omitempty"` + TopK *float64 `json:"topK,omitempty"` + PresencePenalty *float64 `json:"presencePenalty,omitempty"` + FrequencyPenalty *float64 `json:"frequencyPenalty,omitempty"` + ResponseFormat json.RawMessage `json:"responseFormat,omitempty"` Seed *uint64 `json:"seed,omitempty"` Tools []Tool `json:"tools,omitempty"` - ToolChoice ToolChoice `json:"tool_choice,omitempty"` + ToolChoice ToolChoice `json:"toolChoice,omitempty"` Headers map[string]string `json:"headers,omitempty"` - ProviderOptions json.RawMessage `json:"provider_options,omitempty"` + ProviderOptions json.RawMessage `json:"providerOptions,omitempty"` Reasoning *ReasoningEffort `json:"reasoning,omitempty"` Instructions *string `json:"instructions,omitempty"` - MaxRetries *uint32 `json:"max_retries,omitempty"` + MaxRetries *uint32 `json:"maxRetries,omitempty"` Timeout *TimeoutConfiguration `json:"timeout,omitempty"` - IncludeRawChunks *bool `json:"include_raw_chunks,omitempty"` - SessionID *string `json:"session_id,omitempty"` + IncludeRawChunks *bool `json:"includeRawChunks,omitempty"` + SessionID *string `json:"sessionId,omitempty"` // RepairToolCall repairs tool calls the model got wrong (RFC-0035). It // runs in Go, after generation, on every call the engine marked invalid — // never serialized into the options sent to the engine. @@ -406,16 +400,19 @@ type FunctionToolInputExample struct { Input map[string]json.RawMessage `json:"input"` } -// Tool is a function tool definition (the "function" variant). -// Mirrors Kotlin Tool.Function (Types.kt:209-230). +// Tool is a tool definition: a function tool (Type "function": Name, +// InputSchema, ...) or a provider-defined tool (Type "provider": ID, Name, Args). type Tool struct { - Type string `json:"type"` // always "function" + Type string `json:"type"` // "function" or "provider" Name string `json:"name"` Description *string `json:"description,omitempty"` - InputSchema json.RawMessage `json:"input_schema,omitempty"` + InputSchema json.RawMessage `json:"inputSchema,omitempty"` Strict *bool `json:"strict,omitempty"` - ProviderOptions map[string]json.RawMessage `json:"provider_options,omitempty"` - InputExamples []FunctionToolInputExample `json:"input_examples,omitempty"` + ProviderOptions map[string]json.RawMessage `json:"providerOptions,omitempty"` + InputExamples []FunctionToolInputExample `json:"inputExamples,omitempty"` + // ID and Args are the provider tool's id (".") and arguments. + ID string `json:"id,omitempty"` + Args map[string]json.RawMessage `json:"args,omitempty"` } // MarshalOptions serializes GenerateTextOptions to JSON. Returns "" for nil opts. @@ -432,45 +429,43 @@ func MarshalOptions(opts *GenerateTextOptions) (string, error) { // ── StreamPart parsing ───────────────────────────────────────────────────── -// StreamPart is a parsed stream part. The wire format uses externally-tagged -// JSON (e.g. {"TextDelta":{"delta":"..."}}). We decode the tag and payload. +// StreamPart is a parsed stream part. The wire format is an object tagged by +// its "type" key (e.g. {"type":"text-delta","id":"...","delta":"..."}). type StreamPart struct { - Tag string `json:"-"` - Payload json.RawMessage `json:"-"` + // Type is the part's "type" (e.g. "text-delta", "tool-call", "finish"). + Type string `json:"-"` + // Raw is the whole part object, "type" key included. Decode it into the + // struct of your choice, e.g. TextDeltaPayload. + Raw json.RawMessage `json:"-"` } // ParseStreamPart parses a StreamPart JSON string. func ParseStreamPart(jsonStr string) (*StreamPart, error) { - var raw map[string]json.RawMessage - if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil { - return nil, fmt.Errorf("aimux: failed to parse StreamPart: %w", err) - } - // Externally-tagged: the map must have exactly one key. - if len(raw) == 0 { - return nil, fmt.Errorf("aimux: StreamPart has no variant tag: %s", jsonStr) + var head struct { + Type string `json:"type"` } - if len(raw) > 1 { - return nil, fmt.Errorf("aimux: StreamPart has multiple variant tags (%d), expected 1: %s", len(raw), jsonStr) + if err := json.Unmarshal([]byte(jsonStr), &head); err != nil { + return nil, fmt.Errorf("aimux: failed to parse StreamPart: %w", err) } - for tag, payload := range raw { - return &StreamPart{Tag: tag, Payload: payload}, nil + if head.Type == "" { + return nil, fmt.Errorf("aimux: StreamPart has no type: %s", jsonStr) } - return &StreamPart{}, nil + return &StreamPart{Type: head.Type, Raw: json.RawMessage(jsonStr)}, nil } -// Source decodes the payload of a Source stream part. +// Source decodes a "source" stream part. func (p *StreamPart) Source() (*Source, error) { - if p.Tag != "Source" { - return nil, fmt.Errorf("aimux: expected Source stream part, got %s", p.Tag) + if p.Type != "source" { + return nil, fmt.Errorf("aimux: expected source stream part, got %s", p.Type) } var source Source - if err := json.Unmarshal(p.Payload, &source); err != nil { + if err := json.Unmarshal(p.Raw, &source); err != nil { return nil, err } return &source, nil } -// TextDeltaPayload is the payload of a {"TextDelta":{...}} stream part. +// TextDeltaPayload is a {"type":"text-delta",...} stream part. type TextDeltaPayload struct { ID string `json:"id,omitempty"` Delta string `json:"delta"` @@ -482,8 +477,8 @@ type TextDeltaPayload struct { type TraceFilter struct { Provider *string `json:"provider,omitempty"` Model *string `json:"model,omitempty"` - SessionID *string `json:"session_id,omitempty"` - SinceUnixMs *int64 `json:"since_unix_ms,omitempty"` + SessionID *string `json:"sessionId,omitempty"` + SinceUnixMs *int64 `json:"sinceUnixMs,omitempty"` } // TraceStats is one (provider, model) aggregation group. @@ -491,14 +486,14 @@ type TraceStats struct { Provider string `json:"provider"` Model string `json:"model"` Requests uint64 `json:"requests"` - InputTokensTotal uint64 `json:"input_tokens_total"` - ClaimedCacheReadTotal uint64 `json:"claimed_cache_read_total"` - ClaimedCacheWriteTotal uint64 `json:"claimed_cache_write_total"` - ReportedHitRate *float64 `json:"reported_hit_rate,omitempty"` - ClientUpperBoundHitRate *float64 `json:"client_upper_bound_hit_rate,omitempty"` - VerdictCounts map[string]uint64 `json:"verdict_counts"` - TTFTP50Ms *uint64 `json:"ttft_p50_ms,omitempty"` - TTFTP95Ms *uint64 `json:"ttft_p95_ms,omitempty"` + InputTokensTotal uint64 `json:"inputTokensTotal"` + ClaimedCacheReadTotal uint64 `json:"claimedCacheReadTotal"` + ClaimedCacheWriteTotal uint64 `json:"claimedCacheWriteTotal"` + ReportedHitRate *float64 `json:"reportedHitRate,omitempty"` + ClientUpperBoundHitRate *float64 `json:"clientUpperBoundHitRate,omitempty"` + VerdictCounts map[string]uint64 `json:"verdictCounts"` + TTFTP50Ms *uint64 `json:"ttftP50Ms,omitempty"` + TTFTP95Ms *uint64 `json:"ttftP95Ms,omitempty"` Errors uint64 `json:"errors"` } @@ -506,60 +501,60 @@ type TraceStats struct { type TraceRecord struct { Provider string `json:"provider"` Model string `json:"model"` - RequestID *string `json:"request_id,omitempty"` - SessionID *string `json:"session_id,omitempty"` - TraceID string `json:"call_id"` - SentAtUnixMs int64 `json:"sent_at_unix_ms"` - LCPTokenUpper *uint64 `json:"lcp_token_upper,omitempty"` - TTFTMs *uint64 `json:"ttft_ms,omitempty"` + RequestID *string `json:"requestId,omitempty"` + SessionID *string `json:"sessionId,omitempty"` + TraceID string `json:"callId"` + SentAtUnixMs int64 `json:"sentAtUnixMs"` + LCPTokenUpper *uint64 `json:"lcpTokenUpper,omitempty"` + TTFTMs *uint64 `json:"ttftMs,omitempty"` Fingerprint Fingerprint `json:"fingerprint"` Usage UsageSnapshot `json:"usage"` - ResponseCacheHeaders map[string]string `json:"response_cache_headers,omitempty"` - RequestCacheHints *RequestCacheHints `json:"request_cache_hints,omitempty"` + ResponseCacheHeaders map[string]string `json:"responseCacheHeaders,omitempty"` + RequestCacheHints *RequestCacheHints `json:"requestCacheHints,omitempty"` Verdict json.RawMessage `json:"verdict,omitempty"` Error *string `json:"error,omitempty"` } // RequestCacheHints is the best-effort request-side cache hint snapshot. type RequestCacheHints struct { - RequestedWrite bool `json:"requested_write"` + RequestedWrite bool `json:"requestedWrite"` } // Fingerprint is the block-hash chain of a denoised request body (hex). type Fingerprint struct { - BodyHash string `json:"body_hash"` - LenBytes uint64 `json:"len_bytes"` - BlockSize uint64 `json:"block_size"` - BlockHashes []string `json:"block_hashes"` - TokenEstimate uint64 `json:"token_estimate"` + BodyHash string `json:"bodyHash"` + LenBytes uint64 `json:"lenBytes"` + BlockSize uint64 `json:"blockSize"` + BlockHashes []string `json:"blockHashes"` + TokenEstimate uint64 `json:"tokenEstimate"` } // UsageSnapshot is the 7-field flat usage snapshot + raw passthrough. type UsageSnapshot struct { - InputTotal *uint64 `json:"input_total,omitempty"` - InputNoCache *uint64 `json:"input_no_cache,omitempty"` - CacheRead *uint64 `json:"cache_read,omitempty"` - CacheWrite *uint64 `json:"cache_write,omitempty"` - OutputTotal *uint64 `json:"output_total,omitempty"` - OutputText *uint64 `json:"output_text,omitempty"` - OutputReasoning *uint64 `json:"output_reasoning,omitempty"` + InputTotal *uint64 `json:"inputTotal,omitempty"` + InputNoCache *uint64 `json:"inputNoCache,omitempty"` + CacheRead *uint64 `json:"cacheRead,omitempty"` + CacheWrite *uint64 `json:"cacheWrite,omitempty"` + OutputTotal *uint64 `json:"outputTotal,omitempty"` + OutputText *uint64 `json:"outputText,omitempty"` + OutputReasoning *uint64 `json:"outputReasoning,omitempty"` Raw json.RawMessage `json:"raw,omitempty"` } // SessionChainView is the per-session ordered chain view. type SessionChainView struct { - SessionID string `json:"session_id"` - RecordIDs []string `json:"record_ids"` - PrefixStability float64 `json:"prefix_stability"` + SessionID string `json:"sessionId"` + RecordIDs []string `json:"recordIds"` + PrefixStability float64 `json:"prefixStability"` Breaks []PrefixBreak `json:"breaks"` } // PrefixBreak marks a prefix break between consecutive session records. type PrefixBreak struct { - AtRecordID string `json:"at_record_id"` - PrevRecordID string `json:"prev_record_id"` - LCPBytes uint64 `json:"lcp_bytes"` - ExpectedBreak bool `json:"expected_break"` + AtRecordID string `json:"atRecordId"` + PrevRecordID string `json:"prevRecordId"` + LCPBytes uint64 `json:"lcpBytes"` + ExpectedBreak bool `json:"expectedBreak"` Kind string `json:"kind"` } diff --git a/bindings/java/build.gradle.kts b/bindings/java/build.gradle.kts index c4fb8a511..7335bba18 100644 --- a/bindings/java/build.gradle.kts +++ b/bindings/java/build.gradle.kts @@ -36,8 +36,12 @@ dependencies { tasks.test { useJUnitPlatform() - // Point JNA at the freshly built native library (cargo build --release -p aimux-ffi). - systemProperty("jna.library.path", projectDir.resolve("../../target/release").canonicalPath) + // Point JNA at the freshly built native library (cargo build --release -p aimux-ffi), + // or at AIMUX_FFI_LIB_DIR when set (e.g. a debug build or a shared target dir). + systemProperty( + "jna.library.path", + System.getenv("AIMUX_FFI_LIB_DIR") ?: projectDir.resolve("../../target/release").canonicalPath + ) testLogging { events("passed", "skipped", "failed") } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/Aimux.java b/bindings/java/src/main/java/ai/arcships/aimux/Aimux.java index 6525e1c34..e6f00ed42 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/Aimux.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/Aimux.java @@ -130,26 +130,6 @@ public static void recordingTryFlush() { } } - /** - * Register external OpenAI-compatible providers from a JSON config string - * (RFC-0020). - * - *

{@code configJson} is {@code { "providers": [ { "name", "base_url", ... } ] }}. - * Entries override same-named built-ins or add new ones. Like - * {@link #initRecording}, this mutates process-global registry state. - * - * @param configJson Provider registry config JSON. - * @throws AimuxException if the registry rejects the config - * ({@link AimuxException.InvalidArgumentError}). - */ - public static void registerProviders(String configJson) { - AimuxResult.requireJsonNonNull(configJson, "configJson"); - Pointer e = AimuxFFI.INSTANCE.aimux_register_providers(configJson); - if (e != null) { - throw AimuxResult.expectAimuxError(e, "registerProviders"); - } - } - /** * Set the global proxy configuration (M6, RFC-0016). Must be called before * the first {@code generateText} / {@code streamText} call; a no-op if the diff --git a/bindings/java/src/main/java/ai/arcships/aimux/AimuxFFI.java b/bindings/java/src/main/java/ai/arcships/aimux/AimuxFFI.java index 80a192905..9d4a83d1e 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/AimuxFFI.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/AimuxFFI.java @@ -379,9 +379,6 @@ Pointer aimux_file_upload(long handle, String dataBase64, /** Create a mock replay model from recorded JSONL. Writes the handle to {@code outHandle}; returns the error or null. */ Pointer aimux_mock_replay_new(String recordingsJsonl, LongByReference outHandle); - /** Register external OpenAI-compatible providers from JSON config (RFC-0020). Returns the error or null. */ - Pointer aimux_register_providers(String configJson); - /** Set the global proxy configuration (M6, RFC-0016). Returns the error or null. */ Pointer aimux_init_proxy(String configJson); diff --git a/bindings/java/src/main/java/ai/arcships/aimux/Model.java b/bindings/java/src/main/java/ai/arcships/aimux/Model.java index 4755e3a2f..f28ba5d23 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/Model.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/Model.java @@ -368,9 +368,9 @@ public static Model azureWithBase(String apiKey, String baseUrl, String deployme * from the registry entry. * @param modelId Model id. * @param configJson Optional JSON object of ProviderOptions - * ({@code {"base_url": "...", "headers": {...}, "organization": "...", + * ({@code {"baseUrl": "...", "headers": {...}, "organization": "...", * "project": "...", "params": {...}}}); {@code null} for defaults. - * {@code max_retries} (a per-call option) and {@code body_overrides} + * {@code maxRetries} (a per-call option) and {@code bodyOverrides} * (removed) are rejected as {@link AimuxException.InvalidArgumentError}. * @return A new {@link Model}. * @throws AimuxException if the provider could not be constructed @@ -469,7 +469,7 @@ public static Model mockReplay(String recordingsJsonl) { * @param models child models (must be non-empty; closed models throw). * @param configJson optional config: {@code {"router": "rule"|"weighted", * "weights": [...], "fallback": "on_error"|"none", - * "provider_name", "model_id"}} — all optional. + * "providerName", "modelId"}} — all optional. * @return a new RouterModel wrapping the children. */ public static Model router(java.util.List models, String configJson) { @@ -493,9 +493,9 @@ public static Model router(java.util.List models, String configJson) { * * @param references reference models (may be null/empty — runs aggregator only). * @param aggregator the aggregator model (must be non-null and open). - * @param configJson optional MoaConfig: {@code {"provider_name", "model_id", - * "aggregator_instructions", "strip_reference_tools", - * "fail_mode": "best_effort"|"fail_fast"}}. + * @param configJson optional MoaConfig: {@code {"providerName", "modelId", + * "aggregatorInstructions", "stripReferenceTools", + * "failMode": "best_effort"|"fail_fast"}}. * @return a new MoaModel. */ public static Model moa(java.util.List references, Model aggregator, String configJson) { diff --git a/bindings/java/src/main/java/ai/arcships/aimux/MultimodalTypes.java b/bindings/java/src/main/java/ai/arcships/aimux/MultimodalTypes.java index 34fc9de76..4c6c52ad9 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/MultimodalTypes.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/MultimodalTypes.java @@ -31,8 +31,8 @@ * *

Port of the Kotlin binding's {@code MultimodalTypes.kt}. Same shapes as the * ts-rs generated {@code .ts} types in {@code bindings/node/src/types/}. Field - * names are camelCase in Java and map to the wire format's snake_case via - * {@link JsonProperty}. + * names are camelCase in Java and on the wire (the AI SDK JSON); {@link JsonProperty} + * pins them. * *

These types are intentionally lenient on decode (unknown keys ignored, * every field has a default) so future engine additions don't break existing @@ -44,12 +44,11 @@ * .readValue(jsonStr, MultimodalTypes.EmbeddingResult.class); * } * - *

The {@code Base64}/{@code Binary}/{@code Url} unions ({@link AudioData}, - * {@link ImageOutputs}, {@link VideoData}) are serde-style externally-tagged - * enums on the wire ({@code {"Base64": ...}} / {@code {"Binary": ...}} / - * {@code {"Url": ...}}), so each has a custom serializer — the same pattern as - * {@link Types.FileBytes} in {@code Types.java} and - * {@code StreamPartSerializer} in the Kotlin binding. + *

The byte unions ({@link AudioData}, {@link ImageOutputs}, {@link VideoFileData}) + * are untagged on the wire (a base64 string or an int array), and + * {@link VideoData} / {@link VideoFile} are tagged on {@code type}, so each has a + * custom serializer — the same pattern as {@link Types.FileBytes} in + * {@code Types.java}. */ public final class MultimodalTypes { private MultimodalTypes() {} @@ -187,7 +186,7 @@ public boolean equals(Object o) { public static class EmbeddingResult { @JsonProperty("embeddings") private List> embeddings = new ArrayList<>(); @JsonProperty("usage") private EmbeddingUsage usage; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonProperty("response") private EmbeddingResponse response; @JsonProperty("warnings") private List warnings = new ArrayList<>(); @@ -252,9 +251,9 @@ public boolean equals(Object o) { /** Options for an embedding call. */ public static class EmbeddingCallOptions { @JsonProperty("values") private List values = new ArrayList<>(); - @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonProperty("providerOptions") private JsonNode providerOptions; @JsonProperty("headers") private Map headers; - @JsonProperty("max_retries") private Integer maxRetries; + @JsonProperty("maxRetries") private Integer maxRetries; @JsonProperty("timeout") private Types.TimeoutConfiguration timeout; @JsonCreator @@ -325,8 +324,7 @@ public boolean equals(Object o) { /** * Generated audio: a base64 string or raw binary bytes. * - *

Wire format (serde externally-tagged): {@code {"Base64": "..."}} | - * {@code {"Binary": [n,...]}}. + *

Wire format (untagged): a base64 string | {@code [n,...]}. */ public abstract static class AudioData { private AudioData() {} @@ -382,15 +380,14 @@ public boolean equals(Object o) { } } - /** Custom (de)serializer for {@link AudioData}: {@code {"Base64": "..."}} | {@code {"Binary": [n,...]}}. */ + /** Untagged (de)serializer for {@link AudioData}: a base64 string or an int array. */ public static class AudioDataSerializer extends JsonSerializer { @Override public void serialize(AudioData value, JsonGenerator gen, SerializerProvider serializers) throws IOException { - gen.writeStartObject(); if (value instanceof AudioData.Base64) { - gen.writeStringField("Base64", ((AudioData.Base64) value).getValue()); + gen.writeString(((AudioData.Base64) value).getValue()); } else if (value instanceof AudioData.Binary) { - gen.writeArrayFieldStart("Binary"); + gen.writeStartArray(); for (Integer b : ((AudioData.Binary) value).getValue()) { gen.writeNumber(b); } @@ -398,7 +395,6 @@ public void serialize(AudioData value, JsonGenerator gen, SerializerProvider ser } else { throw new IOException("Unknown AudioData: " + value); } - gen.writeEndObject(); } } @@ -407,26 +403,17 @@ public static class AudioDataDeserializer extends JsonDeserializer { @Override public AudioData deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { JsonNode node = p.getCodec().readTree(p); - if (!node.isObject() || node.size() != 1) { - throw new IOException("AudioData must be a single-key externally-tagged object, got: " + node); + if (node.isTextual()) { + return new AudioData.Base64(node.asText()); } - String tag = node.fieldNames().next(); - JsonNode inner = node.get(tag); - switch (tag) { - case "Base64": - return new AudioData.Base64(inner.asText()); - case "Binary": { - List bytes = new ArrayList<>(); - if (inner.isArray()) { - for (JsonNode item : inner) { - bytes.add(item.asInt()); - } - } - return new AudioData.Binary(bytes); + if (node.isArray()) { + List bytes = new ArrayList<>(); + for (JsonNode item : node) { + bytes.add(item.asInt()); } - default: - throw new IOException("Unknown AudioData tag: '" + tag + "'"); + return new AudioData.Binary(bytes); } + throw new IOException("AudioData must be a base64 string or an int array, got: " + node); } } @@ -476,7 +463,7 @@ public boolean equals(Object o) { /** Provider response metadata for speech. */ public static class SpeechResponse { @JsonProperty("timestamp") private String timestamp; - @JsonProperty("model_id") private String modelId; + @JsonProperty("modelId") private String modelId; @JsonProperty("headers") private Map headers; @JsonProperty("body") private JsonNode body; @@ -541,7 +528,7 @@ public static class SpeechResult { @JsonProperty("warnings") private List warnings = new ArrayList<>(); @JsonProperty("request") private SpeechRequest request; @JsonProperty("response") private SpeechResponse response = new SpeechResponse(); - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonCreator SpeechResult() {} @@ -608,13 +595,13 @@ public boolean equals(Object o) { public static class SpeechCallOptions { @JsonProperty("text") private String text = ""; @JsonProperty("voice") private String voice; - @JsonProperty("output_format") private String outputFormat; + @JsonProperty("outputFormat") private String outputFormat; @JsonProperty("instructions") private String instructions; @JsonProperty("speed") private Double speed; @JsonProperty("language") private String language; - @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonProperty("providerOptions") private JsonNode providerOptions; @JsonProperty("headers") private Map headers; - @JsonProperty("max_retries") private Integer maxRetries; + @JsonProperty("maxRetries") private Integer maxRetries; @JsonProperty("timeout") private Types.TimeoutConfiguration timeout; @JsonCreator @@ -715,8 +702,7 @@ public int hashCode() { /** * Generated images: all base64 strings or all binary byte arrays. * - *

Wire format (serde externally-tagged): - * {@code {"Base64": ["...", ...]}} | {@code {"Binary": [[n,...], ...]}}. + *

Wire format (untagged): {@code ["...", ...]} | {@code [[n,...], ...]}. */ public abstract static class ImageOutputs { private ImageOutputs() {} @@ -772,19 +758,16 @@ public boolean equals(Object o) { } } - /** Custom (de)serializer for {@link ImageOutputs}: {@code {"Base64": [...]}} | {@code {"Binary": [[...],...]}}. */ + /** Untagged (de)serializer for {@link ImageOutputs}: an array of base64 strings or of int arrays. */ public static class ImageOutputsSerializer extends JsonSerializer { @Override public void serialize(ImageOutputs value, JsonGenerator gen, SerializerProvider serializers) throws IOException { - gen.writeStartObject(); + gen.writeStartArray(); if (value instanceof ImageOutputs.Base64) { - gen.writeArrayFieldStart("Base64"); for (String s : ((ImageOutputs.Base64) value).getValue()) { gen.writeString(s); } - gen.writeEndArray(); } else if (value instanceof ImageOutputs.Binary) { - gen.writeArrayFieldStart("Binary"); for (List row : ((ImageOutputs.Binary) value).getValue()) { gen.writeStartArray(); for (Integer b : row) { @@ -792,60 +775,45 @@ public void serialize(ImageOutputs value, JsonGenerator gen, SerializerProvider } gen.writeEndArray(); } - gen.writeEndArray(); } else { throw new IOException("Unknown ImageOutputs: " + value); } - gen.writeEndObject(); + gen.writeEndArray(); } } - /** Custom (de)serializer for {@link ImageOutputs}. */ + /** Custom (de)serializer for {@link ImageOutputs}; an empty array decodes as {@link ImageOutputs.Base64}. */ public static class ImageOutputsDeserializer extends JsonDeserializer { @Override public ImageOutputs deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { JsonNode node = p.getCodec().readTree(p); - if (!node.isObject() || node.size() != 1) { - throw new IOException("ImageOutputs must be a single-key externally-tagged object, got: " + node); + if (!node.isArray()) { + throw new IOException("ImageOutputs must be an array, got: " + node); } - String tag = node.fieldNames().next(); - JsonNode inner = node.get(tag); - switch (tag) { - case "Base64": { - List values = new ArrayList<>(); - if (inner.isArray()) { - for (JsonNode item : inner) { - values.add(item.asText()); - } - } - return new ImageOutputs.Base64(values); - } - case "Binary": { - List> rows = new ArrayList<>(); - if (inner.isArray()) { - for (JsonNode rowNode : inner) { - List row = new ArrayList<>(); - if (rowNode.isArray()) { - for (JsonNode item : rowNode) { - row.add(item.asInt()); - } - } - rows.add(row); - } + if (node.size() > 0 && node.get(0).isArray()) { + List> rows = new ArrayList<>(); + for (JsonNode rowNode : node) { + List row = new ArrayList<>(); + for (JsonNode item : rowNode) { + row.add(item.asInt()); } - return new ImageOutputs.Binary(rows); + rows.add(row); } - default: - throw new IOException("Unknown ImageOutputs tag: '" + tag + "'"); + return new ImageOutputs.Binary(rows); } + List values = new ArrayList<>(); + for (JsonNode item : node) { + values.add(item.asText()); + } + return new ImageOutputs.Base64(values); } } /** Token usage for image generation (if reported). */ public static class ImageUsage { - @JsonProperty("input_tokens") private Long inputTokens; - @JsonProperty("output_tokens") private Long outputTokens; - @JsonProperty("total_tokens") private Long totalTokens; + @JsonProperty("inputTokens") private Long inputTokens; + @JsonProperty("outputTokens") private Long outputTokens; + @JsonProperty("totalTokens") private Long totalTokens; @JsonCreator ImageUsage() {} @@ -898,7 +866,7 @@ public boolean equals(Object o) { /** Provider response metadata for images. */ public static class ImageResponse { @JsonProperty("timestamp") private String timestamp; - @JsonProperty("model_id") private String modelId; + @JsonProperty("modelId") private String modelId; @JsonProperty("headers") private Map headers; @JsonCreator @@ -953,7 +921,7 @@ public boolean equals(Object o) { public static class ImageResult { @JsonProperty("images") private ImageOutputs images = new ImageOutputs.Base64(new ArrayList()); @JsonProperty("warnings") private List warnings = new ArrayList<>(); - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonProperty("response") private ImageResponse response = new ImageResponse(); @JsonProperty("usage") private ImageUsage usage; @@ -1023,13 +991,13 @@ public static class ImageCallOptions { @JsonProperty("prompt") private String prompt; @JsonProperty("n") private Integer n; @JsonProperty("size") private String size; - @JsonProperty("aspect_ratio") private String aspectRatio; + @JsonProperty("aspectRatio") private String aspectRatio; @JsonProperty("seed") private Long seed; @JsonProperty("files") private List files = new ArrayList<>(); @JsonProperty("mask") private JsonNode mask; - @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonProperty("providerOptions") private JsonNode providerOptions; @JsonProperty("headers") private Map headers; - @JsonProperty("max_retries") private Integer maxRetries; + @JsonProperty("maxRetries") private Integer maxRetries; @JsonProperty("timeout") private Types.TimeoutConfiguration timeout; @JsonCreator @@ -1138,9 +1106,9 @@ public static class TranscriptionSegment { @JsonProperty("text") private String text = ""; // Core declares these as required f64 (TranscriptionSegment.ts), so they // are primitives: NON_NULL inclusion would drop boxed nulls and the core - // would reject the payload with `missing field start_second`. - @JsonProperty("start_second") private double startSecond; - @JsonProperty("end_second") private double endSecond; + // would reject the payload with `missing field startSecond`. + @JsonProperty("startSecond") private double startSecond; + @JsonProperty("endSecond") private double endSecond; @JsonCreator TranscriptionSegment() {} @@ -1236,7 +1204,7 @@ public boolean equals(Object o) { /** Provider response metadata for transcription. */ public static class TranscriptionResponse { @JsonProperty("timestamp") private String timestamp; - @JsonProperty("model_id") private String modelId; + @JsonProperty("modelId") private String modelId; @JsonProperty("headers") private Map headers; @JsonProperty("body") private JsonNode body; @@ -1300,11 +1268,11 @@ public static class TranscriptionResult { @JsonProperty("text") private String text = ""; @JsonProperty("segments") private List segments = new ArrayList<>(); @JsonProperty("language") private String language; - @JsonProperty("duration_in_seconds") private Double durationInSeconds; + @JsonProperty("durationInSeconds") private Double durationInSeconds; @JsonProperty("warnings") private List warnings = new ArrayList<>(); @JsonProperty("request") private TranscriptionRequest request; @JsonProperty("response") private TranscriptionResponse response = new TranscriptionResponse(); - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonCreator TranscriptionResult() {} @@ -1391,10 +1359,10 @@ public int hashCode() { /** Options for transcription. */ public static class TranscriptionCallOptions { @JsonProperty("audio") private JsonNode audio = JsonNodeFactory.instance.objectNode(); - @JsonProperty("media_type") private String mediaType = ""; - @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonProperty("mediaType") private String mediaType = ""; + @JsonProperty("providerOptions") private JsonNode providerOptions; @JsonProperty("headers") private Map headers; - @JsonProperty("max_retries") private Integer maxRetries; + @JsonProperty("maxRetries") private Integer maxRetries; @JsonProperty("timeout") private Types.TimeoutConfiguration timeout; @JsonCreator @@ -1471,7 +1439,7 @@ public boolean equals(Object o) { /** A single reranked entry. */ public static class RerankingRank { @JsonProperty("index") private int index; - @JsonProperty("relevance_score") private double relevanceScore; + @JsonProperty("relevanceScore") private double relevanceScore; @JsonCreator RerankingRank() {} @@ -1520,7 +1488,7 @@ public boolean equals(Object o) { public static class RerankingResponse { @JsonProperty("id") private String id; @JsonProperty("timestamp") private String timestamp; - @JsonProperty("model_id") private String modelId; + @JsonProperty("modelId") private String modelId; @JsonProperty("headers") private Map headers; @JsonProperty("body") private JsonNode body; @@ -1587,7 +1555,7 @@ public boolean equals(Object o) { /** Result of a reranking call. */ public static class RerankingResult { @JsonProperty("ranking") private List ranking = new ArrayList<>(); - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonProperty("warnings") private List warnings = new ArrayList<>(); @JsonProperty("response") private RerankingResponse response; @@ -1651,10 +1619,10 @@ public boolean equals(Object o) { public static class RerankingCallOptions { @JsonProperty("documents") private JsonNode documents = JsonNodeFactory.instance.arrayNode(); @JsonProperty("query") private String query = ""; - @JsonProperty("top_n") private Integer topN; - @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonProperty("topN") private Integer topN; + @JsonProperty("providerOptions") private JsonNode providerOptions; @JsonProperty("headers") private Map headers; - @JsonProperty("max_retries") private Integer maxRetries; + @JsonProperty("maxRetries") private Integer maxRetries; @JsonProperty("timeout") private Types.TimeoutConfiguration timeout; @JsonCreator @@ -1736,7 +1704,7 @@ public boolean equals(Object o) { /** The URL variant of {@link VideoData}. */ public static class VideoUrlData { @JsonProperty("url") private String url = ""; - @JsonProperty("media_type") private String mediaType = ""; + @JsonProperty("mediaType") private String mediaType = ""; @JsonCreator VideoUrlData() {} @@ -1784,7 +1752,7 @@ public boolean equals(Object o) { /** The base64 variant of {@link VideoData}. */ public static class VideoBase64Data { @JsonProperty("data") private String data = ""; - @JsonProperty("media_type") private String mediaType = ""; + @JsonProperty("mediaType") private String mediaType = ""; @JsonCreator VideoBase64Data() {} @@ -1832,7 +1800,7 @@ public boolean equals(Object o) { /** The binary variant of {@link VideoData}. */ public static class VideoBinaryData { @JsonProperty("data") private List data = new ArrayList<>(); - @JsonProperty("media_type") private String mediaType = ""; + @JsonProperty("mediaType") private String mediaType = ""; @JsonCreator VideoBinaryData() {} @@ -1880,8 +1848,8 @@ public boolean equals(Object o) { /** * Generated video: a URL, base64 string, or raw binary bytes. * - *

Wire format (serde externally-tagged): - * {@code {"Url": {...}}} | {@code {"Base64": {...}}} | {@code {"Binary": {...}}}. + *

Wire format (tagged on {@code type}): {@code {"type":"url",...}} | + * {@code {"type":"base64",...}} | {@code {"type":"binary",...}}. */ public abstract static class VideoData { private VideoData() {} @@ -1962,24 +1930,24 @@ public boolean equals(Object o) { } } - /** Custom (de)serializer for {@link VideoData}: {@code {"Url": {...}}} | {@code {"Base64": {...}}} | {@code {"Binary": {...}}}. */ + /** Custom (de)serializer for {@link VideoData} — internally tagged on `"type"` with the payload inlined. */ public static class VideoDataSerializer extends JsonSerializer { @Override public void serialize(VideoData value, JsonGenerator gen, SerializerProvider serializers) throws IOException { - gen.writeStartObject(); + ObjectNode out = JsonNodeFactory.instance.objectNode(); if (value instanceof VideoData.Url) { - gen.writeFieldName("Url"); - gen.writeTree(AimuxJson.MAPPER.valueToTree(((VideoData.Url) value).getValue())); + out.put("type", "url"); + out.setAll((ObjectNode) AimuxJson.MAPPER.valueToTree(((VideoData.Url) value).getValue())); } else if (value instanceof VideoData.Base64) { - gen.writeFieldName("Base64"); - gen.writeTree(AimuxJson.MAPPER.valueToTree(((VideoData.Base64) value).getValue())); + out.put("type", "base64"); + out.setAll((ObjectNode) AimuxJson.MAPPER.valueToTree(((VideoData.Base64) value).getValue())); } else if (value instanceof VideoData.Binary) { - gen.writeFieldName("Binary"); - gen.writeTree(AimuxJson.MAPPER.valueToTree(((VideoData.Binary) value).getValue())); + out.put("type", "binary"); + out.setAll((ObjectNode) AimuxJson.MAPPER.valueToTree(((VideoData.Binary) value).getValue())); } else { throw new IOException("Unknown VideoData: " + value); } - gen.writeEndObject(); + gen.writeTree(out); } } @@ -1988,23 +1956,16 @@ public static class VideoDataDeserializer extends JsonDeserializer { @Override public VideoData deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { JsonNode node = p.getCodec().readTree(p); - if (!node.isObject() || node.size() != 1) { - throw new IOException("VideoData must be a single-key externally-tagged object, got: " + node); - } - String tag = node.fieldNames().next(); - JsonNode inner = node.get(tag); - if (!inner.isObject()) { - inner = JsonNodeFactory.instance.objectNode(); - } + String tag = node.path("type").asText(); switch (tag) { - case "Url": - return new VideoData.Url(AimuxJson.MAPPER.treeToValue(inner, VideoUrlData.class)); - case "Base64": - return new VideoData.Base64(AimuxJson.MAPPER.treeToValue(inner, VideoBase64Data.class)); - case "Binary": - return new VideoData.Binary(AimuxJson.MAPPER.treeToValue(inner, VideoBinaryData.class)); + case "url": + return new VideoData.Url(AimuxJson.MAPPER.treeToValue(node, VideoUrlData.class)); + case "base64": + return new VideoData.Base64(AimuxJson.MAPPER.treeToValue(node, VideoBase64Data.class)); + case "binary": + return new VideoData.Binary(AimuxJson.MAPPER.treeToValue(node, VideoBinaryData.class)); default: - throw new IOException("Unknown VideoData tag: '" + tag + "'"); + throw new IOException("Unknown VideoData type: '" + tag + "'"); } } } @@ -2012,7 +1973,7 @@ public VideoData deserialize(JsonParser p, DeserializationContext ctxt) throws I /** Provider response metadata for video. */ public static class VideoResponse { @JsonProperty("timestamp") private String timestamp; - @JsonProperty("model_id") private String modelId; + @JsonProperty("modelId") private String modelId; @JsonProperty("headers") private Map headers; @JsonCreator @@ -2067,7 +2028,7 @@ public boolean equals(Object o) { public static class VideoResult { @JsonProperty("videos") private List videos = new ArrayList<>(); @JsonProperty("warnings") private List warnings = new ArrayList<>(); - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonProperty("response") private VideoResponse response = new VideoResponse(); @JsonCreator @@ -2129,8 +2090,7 @@ public boolean equals(Object o) { /** * File payload for a {@link VideoFile}: a base64 string or raw binary bytes. * - *

Wire format (serde externally-tagged): {@code {"Base64": "..."}} | - * {@code {"Binary": [n,...]}}. + *

Wire format (untagged): a base64 string | {@code [n,...]}. */ public abstract static class VideoFileData { private VideoFileData() {} @@ -2186,15 +2146,14 @@ public boolean equals(Object o) { } } - /** Custom (de)serializer for {@link VideoFileData}: {@code {"Base64": "..."}} | {@code {"Binary": [n,...]}}. */ + /** Untagged (de)serializer for {@link VideoFileData}: a base64 string or an int array. */ public static class VideoFileDataSerializer extends JsonSerializer { @Override public void serialize(VideoFileData value, JsonGenerator gen, SerializerProvider serializers) throws IOException { - gen.writeStartObject(); if (value instanceof VideoFileData.Base64) { - gen.writeStringField("Base64", ((VideoFileData.Base64) value).getValue()); + gen.writeString(((VideoFileData.Base64) value).getValue()); } else if (value instanceof VideoFileData.Binary) { - gen.writeArrayFieldStart("Binary"); + gen.writeStartArray(); for (Integer b : ((VideoFileData.Binary) value).getValue()) { gen.writeNumber(b); } @@ -2202,7 +2161,6 @@ public void serialize(VideoFileData value, JsonGenerator gen, SerializerProvider } else { throw new IOException("Unknown VideoFileData: " + value); } - gen.writeEndObject(); } } @@ -2211,32 +2169,23 @@ public static class VideoFileDataDeserializer extends JsonDeserializer bytes = new ArrayList<>(); - if (inner.isArray()) { - for (JsonNode item : inner) { - bytes.add(item.asInt()); - } - } - return new VideoFileData.Binary(bytes); + if (node.isArray()) { + List bytes = new ArrayList<>(); + for (JsonNode item : node) { + bytes.add(item.asInt()); } - default: - throw new IOException("Unknown VideoFileData tag: '" + tag + "'"); + return new VideoFileData.Binary(bytes); } + throw new IOException("VideoFileData must be a base64 string or an int array, got: " + node); } } /** The {@code File} variant payload of {@link VideoFile}. */ public static class VideoFileFileData { - @JsonProperty("media_type") private String mediaType = ""; + @JsonProperty("mediaType") private String mediaType = ""; @JsonProperty("data") private VideoFileData data = new VideoFileData.Base64(""); @JsonCreator @@ -2285,7 +2234,7 @@ public boolean equals(Object o) { /** The {@code Url} variant payload of {@link VideoFile}. */ public static class VideoFileUrlData { @JsonProperty("url") private String url = ""; - @JsonProperty("media_type") private String mediaType; + @JsonProperty("mediaType") private String mediaType; @JsonCreator VideoFileUrlData() {} @@ -2333,8 +2282,8 @@ public boolean equals(Object o) { /** * A video or image file used for video editing or image-to-video generation. * - *

Wire format (serde externally-tagged): {@code {"File": {...}}} | - * {@code {"Url": {...}}}. + *

Wire format (tagged on {@code type}): {@code {"type":"file",...}} | + * {@code {"type":"url",...}}. */ public abstract static class VideoFile { private VideoFile() {} @@ -2390,21 +2339,21 @@ public boolean equals(Object o) { } } - /** Custom (de)serializer for {@link VideoFile}: {@code {"File": {...}}} | {@code {"Url": {...}}}. */ + /** Custom (de)serializer for {@link VideoFile} — internally tagged on `"type"` with the payload inlined. */ public static class VideoFileSerializer extends JsonSerializer { @Override public void serialize(VideoFile value, JsonGenerator gen, SerializerProvider serializers) throws IOException { - gen.writeStartObject(); + ObjectNode out = JsonNodeFactory.instance.objectNode(); if (value instanceof VideoFile.File) { - gen.writeFieldName("File"); - gen.writeTree(AimuxJson.MAPPER.valueToTree(((VideoFile.File) value).getValue())); + out.put("type", "file"); + out.setAll((ObjectNode) AimuxJson.MAPPER.valueToTree(((VideoFile.File) value).getValue())); } else if (value instanceof VideoFile.Url) { - gen.writeFieldName("Url"); - gen.writeTree(AimuxJson.MAPPER.valueToTree(((VideoFile.Url) value).getValue())); + out.put("type", "url"); + out.setAll((ObjectNode) AimuxJson.MAPPER.valueToTree(((VideoFile.Url) value).getValue())); } else { throw new IOException("Unknown VideoFile: " + value); } - gen.writeEndObject(); + gen.writeTree(out); } } @@ -2413,35 +2362,28 @@ public static class VideoFileDeserializer extends JsonDeserializer { @Override public VideoFile deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { JsonNode node = p.getCodec().readTree(p); - if (!node.isObject() || node.size() != 1) { - throw new IOException("VideoFile must be a single-key externally-tagged object, got: " + node); - } - String tag = node.fieldNames().next(); - JsonNode inner = node.get(tag); - if (!inner.isObject()) { - inner = JsonNodeFactory.instance.objectNode(); - } + String tag = node.path("type").asText(); switch (tag) { - case "File": - return new VideoFile.File(AimuxJson.MAPPER.treeToValue(inner, VideoFileFileData.class)); - case "Url": - return new VideoFile.Url(AimuxJson.MAPPER.treeToValue(inner, VideoFileUrlData.class)); + case "file": + return new VideoFile.File(AimuxJson.MAPPER.treeToValue(node, VideoFileFileData.class)); + case "url": + return new VideoFile.Url(AimuxJson.MAPPER.treeToValue(node, VideoFileUrlData.class)); default: - throw new IOException("Unknown VideoFile tag: '" + tag + "'"); + throw new IOException("Unknown VideoFile type: '" + tag + "'"); } } } /** The role a frame image plays in video generation. */ public enum VideoFrameType { - @JsonProperty("FirstFrame") FIRST_FRAME, - @JsonProperty("LastFrame") LAST_FRAME, + @JsonProperty("first_frame") FIRST_FRAME, + @JsonProperty("last_frame") LAST_FRAME, } /** A role-tagged image input for image-to-video and first-last-frame generation. */ public static class VideoFrameImage { @JsonProperty("image") private VideoFile image = new VideoFile.File(new VideoFileFileData()); - @JsonProperty("frame_type") private VideoFrameType frameType = VideoFrameType.FIRST_FRAME; + @JsonProperty("frameType") private VideoFrameType frameType = VideoFrameType.FIRST_FRAME; @JsonCreator VideoFrameImage() {} @@ -2488,8 +2430,8 @@ public boolean equals(Object o) { /** Per-call pacing overrides for the Core-owned video status poll loop. */ public static class VideoPollOptions { - @JsonProperty("interval_ms") private Long intervalMs; - @JsonProperty("timeout_ms") private Long timeoutMs; + @JsonProperty("intervalMs") private Long intervalMs; + @JsonProperty("timeoutMs") private Long timeoutMs; @JsonCreator VideoPollOptions() {} @@ -2538,18 +2480,18 @@ public String toString() { public static class VideoCallOptions { @JsonProperty("prompt") private String prompt; @JsonProperty("n") private Integer n; - @JsonProperty("aspect_ratio") private String aspectRatio; + @JsonProperty("aspectRatio") private String aspectRatio; @JsonProperty("resolution") private String resolution; @JsonProperty("duration") private Long duration; @JsonProperty("fps") private Long fps; @JsonProperty("seed") private Long seed; @JsonProperty("image") private VideoFile image; - @JsonProperty("frame_images") private List frameImages; - @JsonProperty("input_references") private List inputReferences; - @JsonProperty("generate_audio") private Boolean generateAudio; - @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonProperty("frameImages") private List frameImages; + @JsonProperty("inputReferences") private List inputReferences; + @JsonProperty("generateAudio") private Boolean generateAudio; + @JsonProperty("providerOptions") private JsonNode providerOptions; @JsonProperty("headers") private Map headers; - @JsonProperty("max_retries") private Integer maxRetries; + @JsonProperty("maxRetries") private Integer maxRetries; @JsonProperty("poll") private VideoPollOptions poll; @JsonProperty("timeout") private Types.TimeoutConfiguration timeout; @@ -2690,7 +2632,7 @@ public static class SearchResultItem { @JsonProperty("title") private String title = ""; @JsonProperty("url") private String url = ""; @JsonProperty("content") private String content = ""; - @JsonProperty("raw_content") private String rawContent; + @JsonProperty("rawContent") private String rawContent; @JsonProperty("score") private Double score; @JsonCreator @@ -2806,7 +2748,7 @@ public boolean equals(Object o) { public static class SearchResult { @JsonProperty("results") private List results = new ArrayList<>(); @JsonProperty("answer") private String answer; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonProperty("warnings") private List warnings = new ArrayList<>(); @JsonProperty("response") private SearchResponse response; @@ -2874,14 +2816,14 @@ public boolean equals(Object o) { /** Options for a search call. */ public static class SearchCallOptions { @JsonProperty("query") private String query = ""; - @JsonProperty("max_results") private Integer maxResults; - @JsonProperty("include_raw_content") private Boolean includeRawContent; - @JsonProperty("time_range") private String timeRange; - @JsonProperty("include_domains") private List includeDomains = new ArrayList<>(); - @JsonProperty("exclude_domains") private List excludeDomains = new ArrayList<>(); - @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonProperty("maxResults") private Integer maxResults; + @JsonProperty("includeRawContent") private Boolean includeRawContent; + @JsonProperty("timeRange") private String timeRange; + @JsonProperty("includeDomains") private List includeDomains = new ArrayList<>(); + @JsonProperty("excludeDomains") private List excludeDomains = new ArrayList<>(); + @JsonProperty("providerOptions") private JsonNode providerOptions; @JsonProperty("headers") private Map headers; - @JsonProperty("max_retries") private Integer maxRetries; + @JsonProperty("maxRetries") private Integer maxRetries; @JsonProperty("timeout") private Types.TimeoutConfiguration timeout; @JsonCreator @@ -2984,10 +2926,10 @@ public int hashCode() { /** Result of a file upload. */ public static class UploadFileResult { - @JsonProperty("provider_reference") private Map providerReference = new HashMap<>(); - @JsonProperty("media_type") private String mediaType; + @JsonProperty("providerReference") private Map providerReference = new HashMap<>(); + @JsonProperty("mediaType") private String mediaType; @JsonProperty("filename") private String filename; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonProperty("warnings") private List warnings = new ArrayList<>(); @JsonCreator @@ -3056,9 +2998,9 @@ public int hashCode() { /** Options for a file upload. */ public static class UploadFileCallOptions { @JsonProperty("data") private JsonNode data = JsonNodeFactory.instance.objectNode(); - @JsonProperty("media_type") private String mediaType = ""; + @JsonProperty("mediaType") private String mediaType = ""; @JsonProperty("filename") private String filename; - @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonProperty("providerOptions") private JsonNode providerOptions; @JsonProperty("headers") private Map headers; @JsonCreator diff --git a/bindings/java/src/main/java/ai/arcships/aimux/ToolCallRepairs.java b/bindings/java/src/main/java/ai/arcships/aimux/ToolCallRepairs.java index 959caa3a8..fc560a916 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/ToolCallRepairs.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/ToolCallRepairs.java @@ -44,7 +44,7 @@ static String apply(String toolCallJson, String optsJson, String replyJson) { return AimuxResult.extractString(e, out, "apply_tool_call_repair"); } - /** Patch a whole result document (tool_calls and response_messages). */ + /** Patch a whole result document (toolCalls and responseMessages). */ static String applyToResult(String resultJson, String optsJson, String toolCallId, String replyJson) { PointerByReference out = new PointerByReference(); Pointer e = AimuxFFI.INSTANCE.aimux_apply_tool_call_repair_to_result( @@ -82,7 +82,7 @@ static String repairResult(String resultJson, String promptJson, String optsJson continue; // no tool set — never repaired (AI SDK rule) } patched = applyToResult(patched, optsJson, - call.path("tool_call_id").asText(), reply(hook, contextJson)); + call.path("toolCallId").asText(), reply(hook, contextJson)); } return patched; } @@ -102,8 +102,8 @@ static String repairStreamPart(String partJson, String promptJson, String optsJs return partJson; } JsonNode part = parse(partJson); - JsonNode call = part.path("ToolCall"); - if (!call.isObject() || !call.path("invalid").asBoolean(false)) { + JsonNode call = part; + if (!"tool-call".equals(part.path("type").asText()) || !call.path("invalid").asBoolean(false)) { return partJson; } String contextJson = context(call.toString(), promptJson, optsJson); @@ -112,7 +112,8 @@ static String repairStreamPart(String partJson, String promptJson, String optsJs } String repaired = apply(call.toString(), optsJson, reply(offCallbackThread(hook, model), contextJson)); ObjectNode out = Types.AimuxJson.MAPPER.createObjectNode(); - out.set("ToolCall", parse(repaired)); + out.put("type", "tool-call"); + out.setAll((ObjectNode) parse(repaired)); return out.toString(); } @@ -180,7 +181,7 @@ private static String reply(ToolCallRepair hook, String contextJson) { replacement = hook.repair(context); } catch (Exception e) { // The host's own failure is data, not a thrown error: it comes back - // as ToolCallRepair { original_error, cause }. + // as ToolCallRepair { originalError, cause }. ObjectNode failed = Types.AimuxJson.MAPPER.createObjectNode(); failed.put("type", "failed"); failed.put("message", e.getMessage() == null ? e.toString() : e.getMessage()); @@ -191,14 +192,14 @@ private static String reply(ToolCallRepair hook, String contextJson) { } ObjectNode repaired = Types.AimuxJson.MAPPER.createObjectNode(); repaired.put("type", "repaired"); - repaired.set("tool_call", Types.AimuxJson.MAPPER.valueToTree(replacement)); + repaired.set("toolCall", Types.AimuxJson.MAPPER.valueToTree(replacement)); return repaired.toString(); } - /** {@code tool_calls}, from a text result or from the {@code raw} of an object result. */ + /** {@code toolCalls}, from a text result or from the {@code raw} of an object result. */ private static JsonNode toolCalls(JsonNode result) { - JsonNode calls = result.path("tool_calls"); - return calls.isArray() ? calls : result.path("raw").path("tool_calls"); + JsonNode calls = result.path("toolCalls"); + return calls.isArray() ? calls : result.path("raw").path("toolCalls"); } private static boolean isNull(String json) { diff --git a/bindings/java/src/main/java/ai/arcships/aimux/Types.java b/bindings/java/src/main/java/ai/arcships/aimux/Types.java index ca6eb20e9..4082f820e 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/Types.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/Types.java @@ -39,8 +39,8 @@ * `bindings/kotlin` (which in turn mirrors the ts-rs output in * `bindings/node/src/types`). * - * Field names are camelCase in Java and mapped to the wire format's snake_case - * via {@link JsonProperty}. The raw JSON boundary is handled by + * Field names are camelCase in Java and on the wire (the AI SDK JSON), so + * {@link JsonProperty} only pins them. The raw JSON boundary is handled by * {@link TypedModel} — callers of this layer never parse JSON by hand. * * These types are intentionally lenient on decode (unknown keys ignored, every @@ -69,7 +69,7 @@ private AimuxJson() {} public static final ObjectMapper MAPPER = createMapper(); /** - * Inner mapper used by the externally-tagged polymorphic serializers + * Inner mapper used by the `type`-tagged polymorphic serializers * ({@link ContentPartSerializer}, {@link StreamPartSerializer}, * {@link GenerateContentSerializer}) to serialize the concrete variant * as a plain POJO. It deliberately does NOT register those four @@ -109,7 +109,7 @@ private static ObjectMapper configure(ObjectMapper m, boolean polymorphic) { module.addDeserializer(FileData.class, new FileDataDeserializer()); module.addSerializer(GeneratedFileData.class, new GeneratedFileDataSerializer()); module.addDeserializer(GeneratedFileData.class, new GeneratedFileDataDeserializer()); - // Externally-tagged polymorphic serializers — only on the public + // `type`-tagged polymorphic serializers — only on the public // mapper. They use INNER_MAPPER internally to avoid self-recursion. if (polymorphic) { module.addSerializer(MessageContent.class, new MessageContentSerializer()); @@ -162,9 +162,9 @@ public enum ReasoningEffort { public static class InputTokenUsage { @JsonProperty("total") private Long total; - @JsonProperty("no_cache") private Long noCache; - @JsonProperty("cache_read") private Long cacheRead; - @JsonProperty("cache_write") private Long cacheWrite; + @JsonProperty("noCache") private Long noCache; + @JsonProperty("cacheRead") private Long cacheRead; + @JsonProperty("cacheWrite") private Long cacheWrite; @JsonCreator InputTokenUsage() {} public InputTokenUsage(Long total, Long noCache, Long cacheRead, Long cacheWrite) { this.total = total; @@ -228,12 +228,12 @@ public static class Builder { /** * Token usage statistics. * - * Mirrors `Usage.ts`: `{ input_tokens: InputTokenUsage, output_tokens: OutputTokenUsage, - * raw?: JSONObject | null }`. + * Mirrors `Usage.ts`: `{ inputTokens: InputTokenUsage, outputTokens: OutputTokenUsage, + * raw?: JSONObject }`. */ public static class Usage { - @JsonProperty("input_tokens") private InputTokenUsage inputTokens = new InputTokenUsage(); - @JsonProperty("output_tokens") private OutputTokenUsage outputTokens = new OutputTokenUsage(); + @JsonProperty("inputTokens") private InputTokenUsage inputTokens = new InputTokenUsage(); + @JsonProperty("outputTokens") private OutputTokenUsage outputTokens = new OutputTokenUsage(); @JsonProperty("raw") private ObjectNode raw; @JsonCreator @@ -334,12 +334,12 @@ public int hashCode() { * Metadata about the API response. * * Mirrors `ResponseMetadata.ts`: `{ id: string | null, timestamp: string | null, - * model_id: string | null }`. + * modelId: string | null }`. */ public static class ResponseMetadata { @JsonProperty("id") private String id; @JsonProperty("timestamp") private String timestamp; - @JsonProperty("model_id") private String modelId; + @JsonProperty("modelId") private String modelId; @JsonCreator ResponseMetadata() {} @@ -387,20 +387,20 @@ public int hashCode() { /** * A tool call requested by the model. * - * Mirrors `ToolCall.ts`: `{ tool_call_id, tool_name, input: JsonValue, - * provider_executed?: bool | null, dynamic?: bool | null, - * provider_metadata?: JsonValue | null }`. + * Mirrors `ToolCall.ts`: `{ toolCallId, toolName, input: JsonValue, + * providerExecuted?: bool, dynamic?: bool, providerMetadata?: JsonValue, + * invalid?: bool, error?: AiMuxError }`. * * `input` is a {@link JsonNode} because it is usually an arbitrary JSON object * (the tool arguments) whose shape is tool-specific. */ public static class ToolCall { - @JsonProperty("tool_call_id") private String toolCallId = ""; - @JsonProperty("tool_name") private String toolName = ""; + @JsonProperty("toolCallId") private String toolCallId = ""; + @JsonProperty("toolName") private String toolName = ""; @JsonProperty("input") private JsonNode input = emptyObject(); - @JsonProperty("provider_executed") private Boolean providerExecuted; + @JsonProperty("providerExecuted") private Boolean providerExecuted; @JsonProperty("dynamic") private Boolean dynamic; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonProperty("invalid") private Boolean invalid; @JsonProperty("error") private JsonNode error; @@ -507,15 +507,15 @@ public boolean equals(Object o) { /** * A user-defined function tool. * - * Mirrors `FunctionTool.ts`. `input_schema` is a {@link JsonNode} (a JSON Schema). + * Mirrors `FunctionTool.ts`. `inputSchema` is a {@link JsonNode} (a JSON Schema). */ public static class FunctionTool { @JsonProperty("name") private String name = ""; @JsonProperty("description") private String description; - @JsonProperty("input_schema") private JsonNode inputSchema = emptyObject(); + @JsonProperty("inputSchema") private JsonNode inputSchema = emptyObject(); @JsonProperty("strict") private Boolean strict; - @JsonProperty("provider_options") private Map providerOptions; - @JsonProperty("input_examples") private List inputExamples; + @JsonProperty("providerOptions") private Map providerOptions; + @JsonProperty("inputExamples") private List inputExamples; @JsonCreator FunctionTool() {} @@ -649,10 +649,10 @@ private Tool() {} public static class Function extends Tool { @JsonProperty("name") private String name = ""; @JsonProperty("description") private String description; - @JsonProperty("input_schema") private JsonNode inputSchema = emptyObject(); + @JsonProperty("inputSchema") private JsonNode inputSchema = emptyObject(); @JsonProperty("strict") private Boolean strict; - @JsonProperty("provider_options") private Map providerOptions; - @JsonProperty("input_examples") private List inputExamples; + @JsonProperty("providerOptions") private Map providerOptions; + @JsonProperty("inputExamples") private List inputExamples; @JsonCreator Function() {} @@ -916,6 +916,67 @@ public ToolChoice deserialize(JsonParser p, DeserializationContext ctxt) throws } } + // ───────────────────────────────────────────────────────────────────────────── + // ToolResultOutput (the `output` of a prompt-side tool-result part). + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Output of a tool call in a prompt message. + * + * Mirrors `ToolResultOutput.ts`, tagged on `type`: `text` / `error-text` + * carry a string `value`, `json` / `error-json` a JSON `value`, `content` an + * array of `{type:"text"|"file"|"custom",...}` items, and `execution-denied` + * an optional `reason`. `providerOptions` is absent on `content`. + */ + public static class ToolResultOutput { + @JsonProperty("type") private String type = "text"; + @JsonProperty("value") private JsonNode value; + @JsonProperty("reason") private String reason; + @JsonProperty("providerOptions") private JsonNode providerOptions; + + @JsonCreator + ToolResultOutput() {} + + private ToolResultOutput(String type, JsonNode value, String reason) { + this.type = type; + this.value = value; + this.reason = reason; + } + + public static ToolResultOutput text(String value) { return new ToolResultOutput("text", TextNode.valueOf(value), null); } + public static ToolResultOutput json(JsonNode value) { return new ToolResultOutput("json", value, null); } + public static ToolResultOutput executionDenied(String reason) { return new ToolResultOutput("execution-denied", null, reason); } + public static ToolResultOutput errorText(String value) { return new ToolResultOutput("error-text", TextNode.valueOf(value), null); } + public static ToolResultOutput errorJson(JsonNode value) { return new ToolResultOutput("error-json", value, null); } + /** `items` is a JSON array of `text` / `file` / `custom` content items. */ + public static ToolResultOutput content(ArrayNode items) { return new ToolResultOutput("content", items, null); } + + /** One of `text`, `json`, `execution-denied`, `error-text`, `error-json`, `content`. */ + public String getType() { return type; } + public JsonNode getValue() { return value; } + public String getReason() { return reason; } + public JsonNode getProviderOptions() { return providerOptions; } + + /** Returns a copy carrying the given provider options (ignored by `content`). */ + public ToolResultOutput withProviderOptions(JsonNode options) { + ToolResultOutput copy = new ToolResultOutput(type, value, reason); + copy.providerOptions = options; + return copy; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ToolResultOutput)) return false; + ToolResultOutput that = (ToolResultOutput) o; + return Objects.equals(type, that.type) && Objects.equals(value, that.value) + && Objects.equals(reason, that.reason) && Objects.equals(providerOptions, that.providerOptions); + } + + @Override + public int hashCode() { return Objects.hash(type, value, reason, providerOptions); } + } + // ───────────────────────────────────────────────────────────────────────────── // ContentPart (multi-part message content). // @@ -930,7 +991,7 @@ public ToolChoice deserialize(JsonParser p, DeserializationContext ctxt) throws * Mirrors `ContentPart.ts` (internally tagged on `type`). Shared between * {@link ModelMessage} (user-facing) and the provider-facing prompt. Every * field has a default and unknown keys are ignored on decode, so future part - * additions do not break existing clients. `provider_options` is a + * additions do not break existing clients. `providerOptions` is a * {@link JsonNode} because it is an opaque `Record`. */ public abstract static class ContentPart { @@ -938,7 +999,7 @@ private ContentPart() {} public static class Text extends ContentPart { @JsonProperty("text") private String text = ""; - @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonProperty("providerOptions") private JsonNode providerOptions; @JsonCreator Text() {} @@ -977,8 +1038,8 @@ public boolean equals(Object o) { public static class Image extends ContentPart { @JsonProperty("image") private List image = new ArrayList<>(); - @JsonProperty("media_type") private String mediaType = ""; - @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonProperty("mediaType") private String mediaType = ""; + @JsonProperty("providerOptions") private JsonNode providerOptions; @JsonCreator Image() {} @@ -1023,9 +1084,9 @@ public boolean equals(Object o) { public static class File extends ContentPart { @JsonProperty("data") private List data = new ArrayList<>(); - @JsonProperty("media_type") private String mediaType = ""; + @JsonProperty("mediaType") private String mediaType = ""; @JsonProperty("filename") private String filename; - @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonProperty("providerOptions") private JsonNode providerOptions; @JsonCreator File() {} @@ -1075,9 +1136,9 @@ public boolean equals(Object o) { public static class FileBase64 extends ContentPart { @JsonProperty("data") private String data = ""; - @JsonProperty("media_type") private String mediaType = ""; + @JsonProperty("mediaType") private String mediaType = ""; @JsonProperty("filename") private String filename; - @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonProperty("providerOptions") private JsonNode providerOptions; @JsonCreator FileBase64() {} @@ -1127,8 +1188,8 @@ public boolean equals(Object o) { public static class FileUrl extends ContentPart { @JsonProperty("url") private String url = ""; - @JsonProperty("media_type") private String mediaType = ""; - @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonProperty("mediaType") private String mediaType = ""; + @JsonProperty("providerOptions") private JsonNode providerOptions; @JsonCreator FileUrl() {} @@ -1172,10 +1233,10 @@ public boolean equals(Object o) { } public static class FileReference extends ContentPart { - @JsonProperty("media_type") private String mediaType = ""; + @JsonProperty("mediaType") private String mediaType = ""; @JsonProperty("reference") private JsonNode reference = emptyObject(); @JsonProperty("filename") private String filename; - @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonProperty("providerOptions") private JsonNode providerOptions; @JsonCreator FileReference() {} @@ -1228,7 +1289,7 @@ public boolean equals(Object o) { public static class Reasoning extends ContentPart { @JsonProperty("text") private String text = ""; @JsonProperty("signature") private String signature; - @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonProperty("providerOptions") private JsonNode providerOptions; @JsonCreator Reasoning() {} @@ -1272,11 +1333,11 @@ public boolean equals(Object o) { } public static class ToolCall extends ContentPart { - @JsonProperty("tool_call_id") private String toolCallId = ""; - @JsonProperty("tool_name") private String toolName = ""; + @JsonProperty("toolCallId") private String toolCallId = ""; + @JsonProperty("toolName") private String toolName = ""; @JsonProperty("input") private JsonNode input = emptyObject(); - @JsonProperty("provider_executed") private Boolean providerExecuted; - @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonProperty("providerExecuted") private Boolean providerExecuted; + @JsonProperty("providerOptions") private JsonNode providerOptions; @JsonCreator ToolCall() {} @@ -1335,58 +1396,40 @@ public int hashCode() { } public static class ToolResult extends ContentPart { - @JsonProperty("tool_call_id") private String toolCallId = ""; - @JsonProperty("result") private JsonNode result = emptyObject(); - @JsonProperty("tool_name") private String toolName; - @JsonProperty("is_error") private Boolean isError; - @JsonProperty("preliminary") private Boolean preliminary; - @JsonProperty("dynamic") private Boolean dynamic; - @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonProperty("toolCallId") private String toolCallId = ""; + @JsonProperty("toolName") private String toolName = ""; + @JsonProperty("output") private ToolResultOutput output = ToolResultOutput.text(""); + @JsonProperty("providerOptions") private JsonNode providerOptions; @JsonCreator ToolResult() {} - private ToolResult(String toolCallId, JsonNode result, String toolName, Boolean isError, - Boolean preliminary, Boolean dynamic, JsonNode providerOptions) { + private ToolResult(String toolCallId, String toolName, ToolResultOutput output, JsonNode providerOptions) { this.toolCallId = toolCallId; - this.result = result; this.toolName = toolName; - this.isError = isError; - this.preliminary = preliminary; - this.dynamic = dynamic; + this.output = output; this.providerOptions = providerOptions; } public String getToolCallId() { return toolCallId; } - public JsonNode getResult() { return result; } public String getToolName() { return toolName; } - public Boolean getIsError() { return isError; } - public Boolean getPreliminary() { return preliminary; } - public Boolean getDynamic() { return dynamic; } + public ToolResultOutput getOutput() { return output; } public JsonNode getProviderOptions() { return providerOptions; } public static Builder builder() { return new Builder(); } public static class Builder { private String toolCallId = ""; - private JsonNode result = emptyObject(); - private String toolName; - private Boolean isError; - private Boolean preliminary; - private Boolean dynamic; + private String toolName = ""; + private ToolResultOutput output = ToolResultOutput.text(""); private JsonNode providerOptions; public Builder toolCallId(String v) { this.toolCallId = v; return this; } - public Builder result(JsonNode v) { this.result = v; return this; } public Builder toolName(String v) { this.toolName = v; return this; } - public Builder isError(Boolean v) { this.isError = v; return this; } - public Builder preliminary(Boolean v) { this.preliminary = v; return this; } - public Builder dynamic(Boolean v) { this.dynamic = v; return this; } + public Builder output(ToolResultOutput v) { this.output = v; return this; } public Builder providerOptions(JsonNode v) { this.providerOptions = v; return this; } - public ToolResult build() { - return new ToolResult(toolCallId, result, toolName, isError, preliminary, dynamic, providerOptions); - } + public ToolResult build() { return new ToolResult(toolCallId, toolName, output, providerOptions); } } @Override @@ -1395,22 +1438,17 @@ public boolean equals(Object o) { if (!(o instanceof ToolResult)) return false; ToolResult that = (ToolResult) o; return Objects.equals(toolCallId, that.toolCallId) - && Objects.equals(result, that.result) && Objects.equals(toolName, that.toolName) - && Objects.equals(isError, that.isError) - && Objects.equals(preliminary, that.preliminary) - && Objects.equals(dynamic, that.dynamic) + && Objects.equals(output, that.output) && Objects.equals(providerOptions, that.providerOptions); } @Override - public int hashCode() { - return Objects.hash(toolCallId, result, toolName, isError, preliminary, dynamic, providerOptions); - } + public int hashCode() { return Objects.hash(toolCallId, toolName, output, providerOptions); } } public static class Custom extends ContentPart { @JsonProperty("kind") private String kind; - @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonProperty("providerOptions") private JsonNode providerOptions; @JsonCreator Custom() {} public Custom(String kind, JsonNode providerOptions) { this.kind = kind; @@ -1435,8 +1473,8 @@ public static class Builder { } public static class ReasoningFile extends ContentPart { @JsonProperty("data") private GeneratedFileData data; - @JsonProperty("media_type") private String mediaType; - @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonProperty("mediaType") private String mediaType; + @JsonProperty("providerOptions") private JsonNode providerOptions; @JsonCreator ReasoningFile() {} public ReasoningFile(GeneratedFileData data, String mediaType, JsonNode providerOptions) { this.data = data; @@ -1464,12 +1502,12 @@ public static class Builder { @Override public int hashCode() { return Objects.hash(data, mediaType, providerOptions); } } public static class ToolApprovalRequest extends ContentPart { - @JsonProperty("approval_id") private String approvalId; - @JsonProperty("tool_call_id") private String toolCallId; + @JsonProperty("approvalId") private String approvalId; + @JsonProperty("toolCallId") private String toolCallId; @JsonProperty("reason") private String reason; - @JsonProperty("is_automatic") private Boolean isAutomatic; + @JsonProperty("isAutomatic") private Boolean isAutomatic; @JsonProperty("signature") private String signature; - @JsonProperty("input_schema_input") private JsonNode inputSchemaInput; + @JsonProperty("inputSchemaInput") private JsonNode inputSchemaInput; @JsonCreator ToolApprovalRequest() {} public ToolApprovalRequest(String approvalId, String toolCallId, String reason, Boolean isAutomatic, String signature, JsonNode inputSchemaInput) { this.approvalId = approvalId; @@ -1526,15 +1564,15 @@ private static String variantTag(ContentPart value) { if (value instanceof ContentPart.Text) return "text"; if (value instanceof ContentPart.Image) return "image"; if (value instanceof ContentPart.File) return "file"; - if (value instanceof ContentPart.FileBase64) return "file_base64"; - if (value instanceof ContentPart.FileUrl) return "file_url"; - if (value instanceof ContentPart.FileReference) return "file_reference"; + if (value instanceof ContentPart.FileBase64) return "file-base64"; + if (value instanceof ContentPart.FileUrl) return "file-url"; + if (value instanceof ContentPart.FileReference) return "file-reference"; if (value instanceof ContentPart.Reasoning) return "reasoning"; - if (value instanceof ContentPart.ToolCall) return "tool_call"; - if (value instanceof ContentPart.ToolResult) return "tool_result"; + if (value instanceof ContentPart.ToolCall) return "tool-call"; + if (value instanceof ContentPart.ToolResult) return "tool-result"; if (value instanceof ContentPart.Custom) return "custom"; - if (value instanceof ContentPart.ReasoningFile) return "reasoning_file"; - if (value instanceof ContentPart.ToolApprovalRequest) return "tool_approval_request"; + if (value instanceof ContentPart.ReasoningFile) return "reasoning-file"; + if (value instanceof ContentPart.ToolApprovalRequest) return "tool-approval-request"; throw new IllegalArgumentException("Unknown ContentPart: " + value); } } @@ -1552,15 +1590,15 @@ public ContentPart deserialize(JsonParser p, DeserializationContext ctxt) throws case "text": return AimuxJson.MAPPER.treeToValue(node, ContentPart.Text.class); case "image": return AimuxJson.MAPPER.treeToValue(node, ContentPart.Image.class); case "file": return AimuxJson.MAPPER.treeToValue(node, ContentPart.File.class); - case "file_base64": return AimuxJson.MAPPER.treeToValue(node, ContentPart.FileBase64.class); - case "file_url": return AimuxJson.MAPPER.treeToValue(node, ContentPart.FileUrl.class); - case "file_reference": return AimuxJson.MAPPER.treeToValue(node, ContentPart.FileReference.class); + case "file-base64": return AimuxJson.MAPPER.treeToValue(node, ContentPart.FileBase64.class); + case "file-url": return AimuxJson.MAPPER.treeToValue(node, ContentPart.FileUrl.class); + case "file-reference": return AimuxJson.MAPPER.treeToValue(node, ContentPart.FileReference.class); case "reasoning": return AimuxJson.MAPPER.treeToValue(node, ContentPart.Reasoning.class); - case "tool_call": return AimuxJson.MAPPER.treeToValue(node, ContentPart.ToolCall.class); - case "tool_result": return AimuxJson.MAPPER.treeToValue(node, ContentPart.ToolResult.class); + case "tool-call": return AimuxJson.MAPPER.treeToValue(node, ContentPart.ToolCall.class); + case "tool-result": return AimuxJson.MAPPER.treeToValue(node, ContentPart.ToolResult.class); case "custom": return AimuxJson.MAPPER.treeToValue(node, ContentPart.Custom.class); - case "reasoning_file": return AimuxJson.MAPPER.treeToValue(node, ContentPart.ReasoningFile.class); - case "tool_approval_request": return AimuxJson.MAPPER.treeToValue(node, ContentPart.ToolApprovalRequest.class); + case "reasoning-file": return AimuxJson.MAPPER.treeToValue(node, ContentPart.ReasoningFile.class); + case "tool-approval-request": return AimuxJson.MAPPER.treeToValue(node, ContentPart.ToolApprovalRequest.class); default: throw new IOException("Unknown ContentPart type: '" + type + "'"); } @@ -1757,10 +1795,10 @@ public int hashCode() { * backoff and the whole streamed response. */ public static class TimeoutConfiguration { - @JsonProperty("total_ms") private Long totalMs; - @JsonProperty("step_ms") private Long stepMs; - @JsonProperty("first_chunk_ms") private Long firstChunkMs; - @JsonProperty("chunk_ms") private Long chunkMs; + @JsonProperty("totalMs") private Long totalMs; + @JsonProperty("stepMs") private Long stepMs; + @JsonProperty("firstChunkMs") private Long firstChunkMs; + @JsonProperty("chunkMs") private Long chunkMs; @JsonCreator TimeoutConfiguration() {} @@ -1827,12 +1865,12 @@ public int hashCode() { * inspects and what it returns as the replacement. */ public static class RawToolCall { - @JsonProperty("tool_call_id") private String toolCallId = ""; - @JsonProperty("tool_name") private String toolName = ""; + @JsonProperty("toolCallId") private String toolCallId = ""; + @JsonProperty("toolName") private String toolName = ""; @JsonProperty("input") private String input = ""; - @JsonProperty("provider_executed") private Boolean providerExecuted; + @JsonProperty("providerExecuted") private Boolean providerExecuted; @JsonProperty("dynamic") private Boolean dynamic; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonCreator RawToolCall() {} @@ -1886,9 +1924,9 @@ public RawToolCall build() { * {@link #getMessages()} is the conversation the model actually saw. */ public static class ToolCallRepairContext { - @JsonProperty("tool_call") private RawToolCall toolCall; + @JsonProperty("toolCall") private RawToolCall toolCall; @JsonProperty("error") private JsonNode error; - @JsonProperty("input_schema") private JsonNode inputSchema; + @JsonProperty("inputSchema") private JsonNode inputSchema; @JsonProperty("tools") private List tools; @JsonProperty("messages") private List messages; @JsonProperty("instructions") private String instructions; @@ -1907,25 +1945,25 @@ public static class ToolCallRepairContext { } public static class GenerateTextOptions { - @JsonProperty("max_output_tokens") private Long maxOutputTokens; + @JsonProperty("maxOutputTokens") private Long maxOutputTokens; @JsonProperty("temperature") private Double temperature; - @JsonProperty("stop_sequences") private List stopSequences; - @JsonProperty("top_p") private Double topP; - @JsonProperty("top_k") private Double topK; - @JsonProperty("presence_penalty") private Double presencePenalty; - @JsonProperty("frequency_penalty") private Double frequencyPenalty; - @JsonProperty("response_format") private JsonNode responseFormat; + @JsonProperty("stopSequences") private List stopSequences; + @JsonProperty("topP") private Double topP; + @JsonProperty("topK") private Double topK; + @JsonProperty("presencePenalty") private Double presencePenalty; + @JsonProperty("frequencyPenalty") private Double frequencyPenalty; + @JsonProperty("responseFormat") private JsonNode responseFormat; @JsonProperty("seed") private Long seed; @JsonProperty("tools") private List tools; - @JsonProperty("tool_choice") private ToolChoice toolChoice; + @JsonProperty("toolChoice") private ToolChoice toolChoice; @JsonProperty("headers") private Map headers; - @JsonProperty("provider_options") private Map providerOptions; + @JsonProperty("providerOptions") private Map providerOptions; @JsonProperty("reasoning") private ReasoningEffort reasoning; @JsonProperty("instructions") private String instructions; - @JsonProperty("max_retries") private Long maxRetries; - @JsonProperty("include_raw_chunks") private Boolean includeRawChunks; + @JsonProperty("maxRetries") private Long maxRetries; + @JsonProperty("includeRawChunks") private Boolean includeRawChunks; @JsonProperty("timeout") private TimeoutConfiguration timeout; - @JsonProperty("session_id") private String sessionId; + @JsonProperty("sessionId") private String sessionId; // Host-side only (RFC-0035): a function cannot cross the C ABI, and the // native layer must never see this key. @JsonIgnore private ToolCallRepair repairToolCall; @@ -2099,15 +2137,15 @@ public int hashCode() { // ───────────────────────────────────────────────────────────────────────────── // File bytes / file data (shared V4 file types). // - // `FileBytes` and `GeneratedFileData` are externally-tagged enums - // (`{"Binary": [...]}`, `{"Data": {"data": ...}}`, ...). Each is modeled with a - // custom serializer that dispatches on the single tag key. + // `FileBytes` is untagged (an int array or a base64 string); `FileData` and + // `GeneratedFileData` are tagged on `type` (`{"type":"data","data":...}`, ...). + // Each is modeled with a custom serializer. // ───────────────────────────────────────────────────────────────────────────── /** * Either raw bytes or a base64-encoded string. * - * Mirrors `FileBytes.ts`: `{"Binary": Array} | {"Base64": string}`. + * Mirrors `FileBytes.ts`: `Array | string`. * The binary payload is a list of byte ints (0–255). */ public abstract static class FileBytes { @@ -2158,59 +2196,48 @@ public boolean equals(Object o) { } } - /** Custom (de)serializer for {@link FileBytes} — externally tagged. */ + /** Serializes {@link FileBytes} untagged: a JSON int array (binary) or a string (base64). */ public static class FileBytesSerializer extends JsonSerializer { @Override public void serialize(FileBytes value, JsonGenerator gen, SerializerProvider serializers) throws IOException { - gen.writeStartObject(); if (value instanceof FileBytes.Binary) { - gen.writeArrayFieldStart("Binary"); + gen.writeStartArray(); for (Integer b : ((FileBytes.Binary) value).getData()) { gen.writeNumber(b); } gen.writeEndArray(); } else if (value instanceof FileBytes.Base64) { - gen.writeStringField("Base64", ((FileBytes.Base64) value).getData()); + gen.writeString(((FileBytes.Base64) value).getData()); } else { throw new IOException("Unknown FileBytes: " + value); } - gen.writeEndObject(); } } - /** Custom (de)serializer for {@link FileBytes}. */ + /** Deserializes {@link FileBytes}: a JSON array is binary, a JSON string is base64. */ public static class FileBytesDeserializer extends JsonDeserializer { @Override public FileBytes deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { JsonNode node = p.getCodec().readTree(p); - if (!node.isObject() || node.size() != 1) { - throw new IOException("FileBytes must be a single-key externally-tagged object, got: " + node); - } - String tag = node.fieldNames().next(); - JsonNode inner = node.get(tag); - switch (tag) { - case "Binary": { - List data = new ArrayList<>(); - if (inner.isArray()) { - for (JsonNode item : inner) { - data.add(item.asInt()); - } - } - return new FileBytes.Binary(data); + if (node.isArray()) { + List data = new ArrayList<>(); + for (JsonNode item : node) { + data.add(item.asInt()); } - case "Base64": - return new FileBytes.Base64(inner.asText()); - default: - throw new IOException("Unknown FileBytes tag: '" + tag + "'"); + return new FileBytes.Binary(data); } + if (node.isTextual()) { + return new FileBytes.Base64(node.asText()); + } + throw new IOException("FileBytes must be an int array or a base64 string, got: " + node); } } /** * File data as a tagged discriminated union. * - * Mirrors `FileData.ts`: `{"Data": {"data": FileBytes}} | {"Url": {"url": ...}} - * | {"Reference": {"reference": {...}}} | {"Text": {"text": ...}}`. + * Mirrors `FileData.ts`: `{"type":"data","data":FileBytes} | {"type":"url","url":...} + * | {"type":"reference","reference":{...}} | {"type":"text","text":...}`. */ public abstract static class FileData { private FileData() {} @@ -2238,7 +2265,7 @@ public boolean equals(Object o) { public static class Url extends FileData { @JsonProperty("url") private String url; - @JsonProperty("original_url") private String originalUrl; + @JsonProperty("originalUrl") private String originalUrl; @JsonCreator Url() {} public Url(String url, String originalUrl) { this.url = url; @@ -2304,30 +2331,26 @@ public boolean equals(Object o) { } } - /** Custom (de)serializer for {@link FileData} — externally tagged. */ + /** Custom (de)serializer for {@link FileData} — internally tagged on `"type"`. */ public static class FileDataSerializer extends JsonSerializer { @Override public void serialize(FileData value, JsonGenerator gen, SerializerProvider serializers) throws IOException { gen.writeStartObject(); if (value instanceof FileData.Data) { - gen.writeObjectFieldStart("Data"); + gen.writeStringField("type", "data"); gen.writeFieldName("data"); gen.writeTree(AimuxJson.MAPPER.valueToTree(((FileData.Data) value).getData())); - gen.writeEndObject(); } else if (value instanceof FileData.Url) { - gen.writeObjectFieldStart("Url"); + gen.writeStringField("type", "url"); gen.writeStringField("url", ((FileData.Url) value).getUrl()); if (((FileData.Url) value).getOriginalUrl() != null) - gen.writeStringField("original_url", ((FileData.Url) value).getOriginalUrl()); - gen.writeEndObject(); + gen.writeStringField("originalUrl", ((FileData.Url) value).getOriginalUrl()); } else if (value instanceof FileData.Reference) { - gen.writeObjectFieldStart("Reference"); + gen.writeStringField("type", "reference"); gen.writeObjectField("reference", ((FileData.Reference) value).getReference()); - gen.writeEndObject(); } else if (value instanceof FileData.Text) { - gen.writeObjectFieldStart("Text"); + gen.writeStringField("type", "text"); gen.writeStringField("text", ((FileData.Text) value).getText()); - gen.writeEndObject(); } else { throw new IOException("Unknown FileData: " + value); } @@ -2340,23 +2363,18 @@ public static class FileDataDeserializer extends JsonDeserializer { @Override public FileData deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { JsonNode node = p.getCodec().readTree(p); - if (!node.isObject() || node.size() != 1) { - throw new IOException("FileData must be a single-key externally-tagged object, got: " + node); - } - String tag = node.fieldNames().next(); - JsonNode inner = node.get(tag); - JsonNode innerObj = inner.isObject() ? inner : AimuxJson.MAPPER.createObjectNode(); + String tag = node.path("type").asText(); switch (tag) { - case "Data": - return new FileData.Data(AimuxJson.MAPPER.treeToValue(innerObj.get("data"), FileBytes.class)); - case "Url": - return new FileData.Url(innerObj.path("url").asText(), innerObj.hasNonNull("original_url") ? innerObj.get("original_url").asText() : null); - case "Reference": - return new FileData.Reference(innerObj.get("reference") == null ? emptyObject() : innerObj.get("reference")); - case "Text": - return new FileData.Text(innerObj.path("text").asText()); + case "data": + return new FileData.Data(AimuxJson.MAPPER.treeToValue(node.get("data"), FileBytes.class)); + case "url": + return new FileData.Url(node.path("url").asText(), node.hasNonNull("originalUrl") ? node.get("originalUrl").asText() : null); + case "reference": + return new FileData.Reference(node.get("reference") == null ? emptyObject() : node.get("reference")); + case "text": + return new FileData.Text(node.path("text").asText()); default: - throw new IOException("Unknown FileData tag: '" + tag + "'"); + throw new IOException("Unknown FileData type: '" + tag + "'"); } } } @@ -2364,7 +2382,7 @@ public FileData deserialize(JsonParser p, DeserializationContext ctxt) throws IO /** * File data as a tagged discriminated union. * - * Mirrors `GeneratedFileData.ts`: `{"Data": {"data": FileBytes}} | {"Url": {"url": ...}} + * Mirrors `GeneratedFileData.ts`: `{"type":"data","data":FileBytes} | {"type":"url","url":...}`. * Generated files contain only data or a URL. */ public abstract static class GeneratedFileData { @@ -2393,7 +2411,7 @@ public boolean equals(Object o) { public static class Url extends GeneratedFileData { @JsonProperty("url") private String url; - @JsonProperty("original_url") private String originalUrl; + @JsonProperty("originalUrl") private String originalUrl; @JsonCreator Url() {} public Url(String url, String originalUrl) { this.url = url; @@ -2419,22 +2437,20 @@ public static class Builder { } - /** Custom (de)serializer for {@link GeneratedFileData} — externally tagged. */ + /** Custom (de)serializer for {@link GeneratedFileData} — internally tagged on `"type"`. */ public static class GeneratedFileDataSerializer extends JsonSerializer { @Override public void serialize(GeneratedFileData value, JsonGenerator gen, SerializerProvider serializers) throws IOException { gen.writeStartObject(); if (value instanceof GeneratedFileData.Data) { - gen.writeObjectFieldStart("Data"); + gen.writeStringField("type", "data"); gen.writeFieldName("data"); gen.writeTree(AimuxJson.MAPPER.valueToTree(((GeneratedFileData.Data) value).getData())); - gen.writeEndObject(); } else if (value instanceof GeneratedFileData.Url) { - gen.writeObjectFieldStart("Url"); + gen.writeStringField("type", "url"); gen.writeStringField("url", ((GeneratedFileData.Url) value).getUrl()); if (((GeneratedFileData.Url) value).getOriginalUrl() != null) - gen.writeStringField("original_url", ((GeneratedFileData.Url) value).getOriginalUrl()); - gen.writeEndObject(); + gen.writeStringField("originalUrl", ((GeneratedFileData.Url) value).getOriginalUrl()); } else { throw new IOException("Unknown GeneratedFileData: " + value); } @@ -2447,19 +2463,14 @@ public static class GeneratedFileDataDeserializer extends JsonDeserializer { @Override public void serialize(GenerateContent value, JsonGenerator gen, SerializerProvider serializers) throws IOException { - String tag = variantTag(value); if (value instanceof GenerateContent.Unknown) { - ObjectNode out = JsonNodeFactory.instance.objectNode(); - out.set(tag, ((GenerateContent.Unknown) value).getData()); - gen.writeTree(out); + gen.writeTree(((GenerateContent.Unknown) value).getData()); return; } ObjectNode node = (ObjectNode) AimuxJson.INNER_MAPPER.valueToTree( value instanceof GenerateContent.Source ? ((GenerateContent.Source) value).getSource() : value); ObjectNode out = JsonNodeFactory.instance.objectNode(); - out.set(tag, node); + out.put("type", variantTag(value)); + out.setAll(node); gen.writeTree(out); } } @@ -2993,23 +3002,21 @@ public static class GenerateContentDeserializer extends JsonDeserializer headers; @JsonProperty("body") private JsonNode body; @@ -3116,10 +3123,10 @@ public boolean equals(Object o) { */ public static class GenerateResult { @JsonProperty("content") private List content = new ArrayList<>(); - @JsonProperty("finish_reason") private FinishReason finishReason = new FinishReason(); + @JsonProperty("finishReason") private FinishReason finishReason = new FinishReason(); @JsonProperty("usage") private Usage usage = new Usage(); @JsonProperty("warnings") private List warnings = new ArrayList<>(); - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonProperty("response") private ResponseInfo response; @JsonProperty("request") private RequestInfo request; @@ -3146,7 +3153,7 @@ private GenerateResult(List content, FinishReason finishReason, public ResponseInfo getResponse() { return response; } public RequestInfo getRequest() { return request; } - /** Names of the variant tags present in {@link #getContent()} (e.g. "Text", "ToolCall"). */ + /** Wire `type` tags present in {@link #getContent()} (e.g. "text", "tool-call"). */ public List getContentVariantTags() { List tags = new ArrayList<>(); for (GenerateContent item : content) { @@ -3155,7 +3162,7 @@ public List getContentVariantTags() { return tags; } - /** `true` if any content item carries the given externally-tagged variant. */ + /** `true` if any content item carries the given `type` tag. */ public boolean hasContentVariant(String tag) { for (GenerateContent item : content) { if (variantTag(item).equals(tag)) return true; @@ -3216,20 +3223,21 @@ public int hashCode() { public static class GenerateTextResult { @JsonProperty("content") private List content = new ArrayList<>(); @JsonProperty("text") private String text = ""; - @JsonProperty("tool_calls") private List toolCalls = new ArrayList<>(); - @JsonProperty("finish_reason") private FinishReason finishReason = new FinishReason(); + @JsonProperty("toolCalls") private List toolCalls = new ArrayList<>(); + @JsonProperty("finishReason") private FinishReason finishReason = new FinishReason(); @JsonProperty("usage") private Usage usage = new Usage(); @JsonProperty("warnings") private List warnings = new ArrayList<>(); @JsonProperty("raw") private GenerateResult raw = new GenerateResult(); @JsonProperty("reasoning") private List reasoning = new ArrayList<>(); - @JsonProperty("reasoning_text") private String reasoningText = ""; + @JsonProperty("reasoningText") private String reasoningText = ""; @JsonProperty("sources") private List sources = new ArrayList<>(); @JsonProperty("files") private List files = new ArrayList<>(); - @JsonProperty("response_messages") private List responseMessages = new ArrayList<>(); - @JsonProperty("raw_finish_reason") private String rawFinishReason; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; - @JsonProperty("response") private ResponseMetadata response = new ResponseMetadata(); - @JsonProperty("total_usage") private Usage totalUsage = new Usage(); + @JsonProperty("responseMessages") private List responseMessages = new ArrayList<>(); + @JsonProperty("rawFinishReason") private String rawFinishReason; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; + @JsonProperty("request") private RequestInfo request = new RequestInfo(); + @JsonProperty("response") private ResponseInfo response = new ResponseInfo(); + @JsonProperty("totalUsage") private Usage totalUsage = new Usage(); @JsonCreator GenerateTextResult() {} @@ -3239,7 +3247,7 @@ private GenerateTextResult(String text, List toolCalls, FinishReason f List reasoning, String reasoningText, List sources, List files, List responseMessages, String rawFinishReason, - JsonNode providerMetadata, ResponseMetadata response, Usage totalUsage) { + JsonNode providerMetadata, ResponseInfo response, Usage totalUsage) { this.text = text; this.toolCalls = toolCalls; this.finishReason = finishReason; @@ -3271,7 +3279,8 @@ private GenerateTextResult(String text, List toolCalls, FinishReason f public List getResponseMessages() { return responseMessages; } public String getRawFinishReason() { return rawFinishReason; } public JsonNode getProviderMetadata() { return providerMetadata; } - public ResponseMetadata getResponse() { return response; } + public RequestInfo getRequest() { return request; } + public ResponseInfo getResponse() { return response; } public Usage getTotalUsage() { return totalUsage; } public static Builder builder() { return new Builder(); } @@ -3290,7 +3299,7 @@ public static class Builder { private List responseMessages = new ArrayList<>(); private String rawFinishReason; private JsonNode providerMetadata; - private ResponseMetadata response = new ResponseMetadata(); + private ResponseInfo response = new ResponseInfo(); private Usage totalUsage = new Usage(); public Builder text(String v) { this.text = v; return this; } @@ -3306,7 +3315,7 @@ public static class Builder { public Builder responseMessages(List v) { this.responseMessages = v; return this; } public Builder rawFinishReason(String v) { this.rawFinishReason = v; return this; } public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } - public Builder response(ResponseMetadata v) { this.response = v; return this; } + public Builder response(ResponseInfo v) { this.response = v; return this; } public Builder totalUsage(Usage v) { this.totalUsage = v; return this; } public GenerateTextResult build() { @@ -3356,12 +3365,12 @@ public int hashCode() { */ public static class GenerateObjectResult { @JsonProperty("object") private JsonNode object = emptyObject(); - @JsonProperty("finish_reason") private FinishReason finishReason = new FinishReason(); - @JsonProperty("raw_finish_reason") private String rawFinishReason; + @JsonProperty("finishReason") private FinishReason finishReason = new FinishReason(); + @JsonProperty("rawFinishReason") private String rawFinishReason; @JsonProperty("usage") private Usage usage = new Usage(); @JsonProperty("warnings") private List warnings = new ArrayList<>(); @JsonProperty("reasoning") private String reasoning; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonProperty("response") private ResponseMetadata response = new ResponseMetadata(); @JsonProperty("raw") private GenerateTextResult raw = new GenerateTextResult(); @@ -3457,18 +3466,19 @@ public static class StreamTextResultAggregated { @JsonProperty("content") private List content = new ArrayList<>(); @JsonProperty("text") private String text = ""; @JsonProperty("reasoning") private List reasoning = new ArrayList<>(); - @JsonProperty("reasoning_text") private String reasoningText = ""; - @JsonProperty("tool_calls") private List toolCalls = new ArrayList<>(); + @JsonProperty("reasoningText") private String reasoningText = ""; + @JsonProperty("toolCalls") private List toolCalls = new ArrayList<>(); @JsonProperty("sources") private List sources = new ArrayList<>(); @JsonProperty("files") private List files = new ArrayList<>(); - @JsonProperty("finish_reason") private FinishReason finishReason = new FinishReason(); - @JsonProperty("raw_finish_reason") private String rawFinishReason; + @JsonProperty("finishReason") private FinishReason finishReason = new FinishReason(); + @JsonProperty("rawFinishReason") private String rawFinishReason; @JsonProperty("usage") private Usage usage = new Usage(); - @JsonProperty("total_usage") private Usage totalUsage = new Usage(); + @JsonProperty("totalUsage") private Usage totalUsage = new Usage(); @JsonProperty("warnings") private List warnings = new ArrayList<>(); - @JsonProperty("provider_metadata") private JsonNode providerMetadata; - @JsonProperty("response") private ResponseMetadata response; - @JsonProperty("response_messages") private List responseMessages = new ArrayList<>(); + @JsonProperty("providerMetadata") private JsonNode providerMetadata; + @JsonProperty("request") private RequestInfo request = new RequestInfo(); + @JsonProperty("response") private ResponseInfo response; + @JsonProperty("responseMessages") private List responseMessages = new ArrayList<>(); @JsonCreator StreamTextResultAggregated() {} @@ -3477,7 +3487,7 @@ private StreamTextResultAggregated(String text, List reasoning, String List toolCalls, List sources, List files, FinishReason finishReason, String rawFinishReason, Usage usage, Usage totalUsage, List warnings, JsonNode providerMetadata, - ResponseMetadata response, List responseMessages) { + ResponseInfo response, List responseMessages) { this.text = text; this.reasoning = reasoning; this.reasoningText = reasoningText; @@ -3507,7 +3517,8 @@ private StreamTextResultAggregated(String text, List reasoning, String public Usage getTotalUsage() { return totalUsage; } public List getWarnings() { return warnings; } public JsonNode getProviderMetadata() { return providerMetadata; } - public ResponseMetadata getResponse() { return response; } + public RequestInfo getRequest() { return request; } + public ResponseInfo getResponse() { return response; } public List getResponseMessages() { return responseMessages; } public static Builder builder() { return new Builder(); } @@ -3525,7 +3536,7 @@ public static class Builder { private Usage totalUsage = new Usage(); private List warnings = new ArrayList<>(); private JsonNode providerMetadata; - private ResponseMetadata response; + private ResponseInfo response; private List responseMessages = new ArrayList<>(); public Builder text(String v) { this.text = v; return this; } @@ -3540,7 +3551,7 @@ public static class Builder { public Builder totalUsage(Usage v) { this.totalUsage = v; return this; } public Builder warnings(List v) { this.warnings = v; return this; } public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } - public Builder response(ResponseMetadata v) { this.response = v; return this; } + public Builder response(ResponseInfo v) { this.response = v; return this; } public Builder responseMessages(List v) { this.responseMessages = v; return this; } public StreamTextResultAggregated build() { @@ -3582,22 +3593,23 @@ public int hashCode() { // ───────────────────────────────────────────────────────────────────────────── // StreamPart (the streaming chunk type). // - // `StreamPart` is externally tagged (`{"TextDelta": {...}}`, `{"ToolCall": - // {...}}`, ...). Unrecognized variants fall back to StreamPart.Unknown for + // `StreamPart` is tagged on `type` (`{"type":"text-delta",...}`, + // `{"type":"tool-call",...}`, ...). It mirrors the TextStreamPart that + // `aimux_stream_text` yields. Unrecognized variants fall back to StreamPart.Unknown for // forward compatibility. // ───────────────────────────────────────────────────────────────────────────── /** * A single streaming chunk. * - * Mirrors `StreamPart.ts` (externally tagged). + * Mirrors `TextStreamPart.ts` (tagged on `type`). */ public abstract static class StreamPart { private StreamPart() {} public static class TextStart extends StreamPart { @JsonProperty("id") private String id = ""; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonCreator TextStart() {} @@ -3637,7 +3649,7 @@ public boolean equals(Object o) { public static class TextDelta extends StreamPart { @JsonProperty("id") private String id = ""; @JsonProperty("delta") private String delta = ""; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonCreator TextDelta() {} @@ -3682,7 +3694,7 @@ public boolean equals(Object o) { public static class TextEnd extends StreamPart { @JsonProperty("id") private String id = ""; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonCreator TextEnd() {} @@ -3751,9 +3763,9 @@ public boolean equals(Object o) { } public static class Finish extends StreamPart { - @JsonProperty("finish_reason") private FinishReason finishReason = new FinishReason(); + @JsonProperty("finishReason") private FinishReason finishReason = new FinishReason(); @JsonProperty("usage") private Usage usage = new Usage(); - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonCreator Finish() {} @@ -3796,13 +3808,43 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(finishReason, usage, providerMetadata); } } + /** End of one step, carrying the step's response (`finish-step`). */ + public static class FinishStep extends StreamPart { + @JsonProperty("finishReason") private FinishReason finishReason = new FinishReason(); + @JsonProperty("usage") private Usage usage = new Usage(); + @JsonProperty("providerMetadata") private JsonNode providerMetadata; + @JsonProperty("response") private ResponseInfo response = new ResponseInfo(); + + @JsonCreator + FinishStep() {} + + public FinishReason getFinishReason() { return finishReason; } + public Usage getUsage() { return usage; } + public JsonNode getProviderMetadata() { return providerMetadata; } + public ResponseInfo getResponse() { return response; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof FinishStep)) return false; + FinishStep that = (FinishStep) o; + return Objects.equals(finishReason, that.finishReason) + && Objects.equals(usage, that.usage) + && Objects.equals(providerMetadata, that.providerMetadata) + && Objects.equals(response, that.response); + } + + @Override + public int hashCode() { return Objects.hash(finishReason, usage, providerMetadata, response); } + } + public static class ToolInputStart extends StreamPart { @JsonProperty("id") private String id = ""; - @JsonProperty("tool_name") private String toolName = ""; - @JsonProperty("provider_executed") private Boolean providerExecuted; + @JsonProperty("toolName") private String toolName = ""; + @JsonProperty("providerExecuted") private Boolean providerExecuted; @JsonProperty("dynamic") private Boolean dynamic; @JsonProperty("title") private String title; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonCreator ToolInputStart() {} @@ -3868,7 +3910,7 @@ public int hashCode() { public static class ToolInputDelta extends StreamPart { @JsonProperty("id") private String id = ""; @JsonProperty("delta") private String delta = ""; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonCreator ToolInputDelta() {} @@ -3913,7 +3955,7 @@ public boolean equals(Object o) { public static class ToolInputEnd extends StreamPart { @JsonProperty("id") private String id = ""; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonCreator ToolInputEnd() {} @@ -3951,12 +3993,12 @@ public boolean equals(Object o) { } public static class ToolCall extends StreamPart { - @JsonProperty("tool_call_id") private String toolCallId = ""; - @JsonProperty("tool_name") private String toolName = ""; + @JsonProperty("toolCallId") private String toolCallId = ""; + @JsonProperty("toolName") private String toolName = ""; @JsonProperty("input") private JsonNode input = emptyObject(); - @JsonProperty("provider_executed") private Boolean providerExecuted; + @JsonProperty("providerExecuted") private Boolean providerExecuted; @JsonProperty("dynamic") private Boolean dynamic; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonProperty("invalid") private Boolean invalid; @JsonProperty("error") private JsonNode error; @@ -4037,13 +4079,13 @@ public int hashCode() { } public static class ToolResult extends StreamPart { - @JsonProperty("tool_call_id") private String toolCallId = ""; - @JsonProperty("tool_name") private String toolName = ""; + @JsonProperty("toolCallId") private String toolCallId = ""; + @JsonProperty("toolName") private String toolName = ""; @JsonProperty("result") private JsonNode result = emptyObject(); - @JsonProperty("is_error") private Boolean isError; + @JsonProperty("isError") private Boolean isError; @JsonProperty("preliminary") private Boolean preliminary; @JsonProperty("dynamic") private Boolean dynamic; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonCreator ToolResult() {} @@ -4114,8 +4156,8 @@ public int hashCode() { /** A file generated by the model (e.g. an image or document). */ public static class File extends StreamPart { @JsonProperty("data") private GeneratedFileData data = new GeneratedFileData.Data(new FileBytes.Base64("")); - @JsonProperty("media_type") private String mediaType = ""; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("mediaType") private String mediaType = ""; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonCreator File() {} @@ -4160,7 +4202,7 @@ public boolean equals(Object o) { public static class ReasoningStart extends StreamPart { @JsonProperty("id") private String id = ""; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonCreator ReasoningStart() {} @@ -4200,7 +4242,7 @@ public boolean equals(Object o) { public static class ReasoningDelta extends StreamPart { @JsonProperty("id") private String id = ""; @JsonProperty("delta") private String delta = ""; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonCreator ReasoningDelta() {} @@ -4245,7 +4287,7 @@ public boolean equals(Object o) { public static class ReasoningEnd extends StreamPart { @JsonProperty("id") private String id = ""; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonCreator ReasoningEnd() {} @@ -4285,7 +4327,7 @@ public boolean equals(Object o) { public static class ResponseMetadata extends StreamPart { @JsonProperty("id") private String id; @JsonProperty("timestamp") private String timestamp; - @JsonProperty("model_id") private String modelId; + @JsonProperty("modelId") private String modelId; @JsonCreator ResponseMetadata() {} @@ -4341,7 +4383,7 @@ public static class Source extends StreamPart { } public static class Raw extends StreamPart { - @JsonProperty("raw_value") private JsonNode rawValue = emptyObject(); + @JsonProperty("rawValue") private JsonNode rawValue = emptyObject(); @JsonCreator Raw() {} @@ -4404,7 +4446,7 @@ public boolean equals(Object o) { public static class Custom extends StreamPart { @JsonProperty("kind") private String kind; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonCreator Custom() {} public Custom(String kind, JsonNode providerMetadata) { this.kind = kind; @@ -4429,7 +4471,7 @@ public static class Builder { } public static class ReasoningFile extends StreamPart { @JsonProperty("file") private GenerateContent.File file; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonProperty("providerMetadata") private JsonNode providerMetadata; @JsonCreator ReasoningFile() {} public ReasoningFile(GenerateContent.File file, JsonNode providerMetadata) { this.file = file; @@ -4453,10 +4495,10 @@ public static class Builder { @Override public int hashCode() { return Objects.hash(file, providerMetadata); } } public static class ToolApprovalRequest extends StreamPart { - @JsonProperty("approval_id") private String approvalId; - @JsonProperty("tool_call") private Types.ToolCall toolCall; + @JsonProperty("approvalId") private String approvalId; + @JsonProperty("toolCall") private Types.ToolCall toolCall; @JsonProperty("reason") private String reason; - @JsonProperty("is_automatic") private Boolean isAutomatic; + @JsonProperty("isAutomatic") private Boolean isAutomatic; @JsonProperty("signature") private String signature; @JsonCreator ToolApprovalRequest() {} public ToolApprovalRequest(String approvalId, Types.ToolCall toolCall, String reason, Boolean isAutomatic, String signature) { @@ -4521,48 +4563,47 @@ public boolean equals(Object o) { } } - /** The externally-tagged variant name for this {@link StreamPart} (e.g. "TextDelta", "ToolCall"). */ + /** The wire `type` tag of this {@link StreamPart} (e.g. "text-delta", "tool-call"). */ public static String variantTag(StreamPart value) { - if (value instanceof StreamPart.TextStart) return "TextStart"; - if (value instanceof StreamPart.TextDelta) return "TextDelta"; - if (value instanceof StreamPart.TextEnd) return "TextEnd"; - if (value instanceof StreamPart.StreamStart) return "StreamStart"; - if (value instanceof StreamPart.Finish) return "Finish"; - if (value instanceof StreamPart.ToolInputStart) return "ToolInputStart"; - if (value instanceof StreamPart.ToolInputDelta) return "ToolInputDelta"; - if (value instanceof StreamPart.ToolInputEnd) return "ToolInputEnd"; - if (value instanceof StreamPart.ToolCall) return "ToolCall"; - if (value instanceof StreamPart.ToolResult) return "ToolResult"; - if (value instanceof StreamPart.File) return "File"; - if (value instanceof StreamPart.ReasoningStart) return "ReasoningStart"; - if (value instanceof StreamPart.ReasoningDelta) return "ReasoningDelta"; - if (value instanceof StreamPart.ReasoningEnd) return "ReasoningEnd"; - if (value instanceof StreamPart.ResponseMetadata) return "ResponseMetadata"; - if (value instanceof StreamPart.Source) return "Source"; - if (value instanceof StreamPart.Raw) return "Raw"; - if (value instanceof StreamPart.Error) return "Error"; - if (value instanceof StreamPart.Custom) return "Custom"; - if (value instanceof StreamPart.ReasoningFile) return "ReasoningFile"; - if (value instanceof StreamPart.ToolApprovalRequest) return "ToolApprovalRequest"; + if (value instanceof StreamPart.TextStart) return "text-start"; + if (value instanceof StreamPart.TextDelta) return "text-delta"; + if (value instanceof StreamPart.TextEnd) return "text-end"; + if (value instanceof StreamPart.StreamStart) return "stream-start"; + if (value instanceof StreamPart.Finish) return "finish"; + if (value instanceof StreamPart.FinishStep) return "finish-step"; + if (value instanceof StreamPart.ToolInputStart) return "tool-input-start"; + if (value instanceof StreamPart.ToolInputDelta) return "tool-input-delta"; + if (value instanceof StreamPart.ToolInputEnd) return "tool-input-end"; + if (value instanceof StreamPart.ToolCall) return "tool-call"; + if (value instanceof StreamPart.ToolResult) return "tool-result"; + if (value instanceof StreamPart.File) return "file"; + if (value instanceof StreamPart.ReasoningStart) return "reasoning-start"; + if (value instanceof StreamPart.ReasoningDelta) return "reasoning-delta"; + if (value instanceof StreamPart.ReasoningEnd) return "reasoning-end"; + if (value instanceof StreamPart.ResponseMetadata) return "response-metadata"; + if (value instanceof StreamPart.Source) return "source"; + if (value instanceof StreamPart.Raw) return "raw"; + if (value instanceof StreamPart.Error) return "error"; + if (value instanceof StreamPart.Custom) return "custom"; + if (value instanceof StreamPart.ReasoningFile) return "reasoning-file"; + if (value instanceof StreamPart.ToolApprovalRequest) return "tool-approval-request"; if (value instanceof StreamPart.Unknown) return ((StreamPart.Unknown) value).getTag(); throw new IllegalArgumentException("Unknown StreamPart: " + value); } - /** Custom (de)serializer for {@link StreamPart} — externally tagged. */ + /** Custom (de)serializer for {@link StreamPart} — internally tagged on `"type"`. */ public static class StreamPartSerializer extends JsonSerializer { @Override public void serialize(StreamPart value, JsonGenerator gen, SerializerProvider serializers) throws IOException { - String tag = variantTag(value); if (value instanceof StreamPart.Unknown) { - ObjectNode out = JsonNodeFactory.instance.objectNode(); - out.set(tag, ((StreamPart.Unknown) value).getData()); - gen.writeTree(out); + gen.writeTree(((StreamPart.Unknown) value).getData()); return; } ObjectNode node = (ObjectNode) AimuxJson.INNER_MAPPER.valueToTree( value instanceof StreamPart.Source ? ((StreamPart.Source) value).getSource() : value); ObjectNode out = JsonNodeFactory.instance.objectNode(); - out.set(tag, node); + out.put("type", variantTag(value)); + out.setAll(node); gen.writeTree(out); } } @@ -4572,35 +4613,34 @@ public static class StreamPartDeserializer extends JsonDeserializer @Override public StreamPart deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { JsonNode node = p.getCodec().readTree(p); - if (!node.isObject() || node.size() != 1) { - throw new IOException("StreamPart must be a single-key externally-tagged object, got: " + node); + if (!node.isObject()) { + throw new IOException("StreamPart must be a JSON object, got: " + node); } - String tag = node.fieldNames().next(); - JsonNode inner = node.get(tag); - JsonNode innerObj = inner.isObject() ? inner : AimuxJson.MAPPER.createObjectNode(); + String tag = node.path("type").asText(); switch (tag) { - case "TextStart": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.TextStart.class); - case "TextDelta": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.TextDelta.class); - case "TextEnd": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.TextEnd.class); - case "StreamStart": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.StreamStart.class); - case "Finish": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.Finish.class); - case "ToolInputStart": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.ToolInputStart.class); - case "ToolInputDelta": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.ToolInputDelta.class); - case "ToolInputEnd": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.ToolInputEnd.class); - case "ToolCall": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.ToolCall.class); - case "ToolResult": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.ToolResult.class); - case "File": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.File.class); - case "ReasoningStart": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.ReasoningStart.class); - case "ReasoningDelta": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.ReasoningDelta.class); - case "ReasoningEnd": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.ReasoningEnd.class); - case "ResponseMetadata": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.ResponseMetadata.class); - case "Source": return new StreamPart.Source(AimuxJson.MAPPER.treeToValue(innerObj, Types.Source.class)); - case "Raw": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.Raw.class); - case "Error": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.Error.class); - case "Custom": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.Custom.class); - case "ReasoningFile": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.ReasoningFile.class); - case "ToolApprovalRequest": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.ToolApprovalRequest.class); - default: return new StreamPart.Unknown(tag, inner); + case "text-start": return AimuxJson.MAPPER.treeToValue(node, StreamPart.TextStart.class); + case "text-delta": return AimuxJson.MAPPER.treeToValue(node, StreamPart.TextDelta.class); + case "text-end": return AimuxJson.MAPPER.treeToValue(node, StreamPart.TextEnd.class); + case "stream-start": return AimuxJson.MAPPER.treeToValue(node, StreamPart.StreamStart.class); + case "finish": return AimuxJson.MAPPER.treeToValue(node, StreamPart.Finish.class); + case "finish-step": return AimuxJson.MAPPER.treeToValue(node, StreamPart.FinishStep.class); + case "tool-input-start": return AimuxJson.MAPPER.treeToValue(node, StreamPart.ToolInputStart.class); + case "tool-input-delta": return AimuxJson.MAPPER.treeToValue(node, StreamPart.ToolInputDelta.class); + case "tool-input-end": return AimuxJson.MAPPER.treeToValue(node, StreamPart.ToolInputEnd.class); + case "tool-call": return AimuxJson.MAPPER.treeToValue(node, StreamPart.ToolCall.class); + case "tool-result": return AimuxJson.MAPPER.treeToValue(node, StreamPart.ToolResult.class); + case "file": return AimuxJson.MAPPER.treeToValue(node, StreamPart.File.class); + case "reasoning-start": return AimuxJson.MAPPER.treeToValue(node, StreamPart.ReasoningStart.class); + case "reasoning-delta": return AimuxJson.MAPPER.treeToValue(node, StreamPart.ReasoningDelta.class); + case "reasoning-end": return AimuxJson.MAPPER.treeToValue(node, StreamPart.ReasoningEnd.class); + case "response-metadata": return AimuxJson.MAPPER.treeToValue(node, StreamPart.ResponseMetadata.class); + case "source": return new StreamPart.Source(AimuxJson.MAPPER.treeToValue(node, Types.Source.class)); + case "raw": return AimuxJson.MAPPER.treeToValue(node, StreamPart.Raw.class); + case "error": return AimuxJson.MAPPER.treeToValue(node, StreamPart.Error.class); + case "custom": return AimuxJson.MAPPER.treeToValue(node, StreamPart.Custom.class); + case "reasoning-file": return AimuxJson.MAPPER.treeToValue(node, StreamPart.ReasoningFile.class); + case "tool-approval-request": return AimuxJson.MAPPER.treeToValue(node, StreamPart.ToolApprovalRequest.class); + default: return new StreamPart.Unknown(tag, node); } } } @@ -4613,7 +4653,8 @@ private static ObjectNode emptyObject() { // OpenAI Chat Completions output (RFC-0026). // // Mirrors `aimux-core::openai_output`. Field names are camelCase in Java and - // mapped to the wire's snake_case via @JsonProperty. The `type` field is JSON + // mapped to the OpenAI wire's snake_case via @JsonProperty (this vendor shape is + // not part of the AI SDK JSON). The `type` field is JSON // `"type"` (Rust `#[serde(rename = "type")]`) → `toolType`. Arbitrary-JSON // fields (`logprobs`, `annotations`) are JsonNode. // ───────────────────────────────────────────────────────────────────────────── diff --git a/bindings/java/src/test/java/ai/arcships/aimux/AimuxExceptionTest.java b/bindings/java/src/test/java/ai/arcships/aimux/AimuxExceptionTest.java index b3d493d49..dd4f5ba41 100644 --- a/bindings/java/src/test/java/ai/arcships/aimux/AimuxExceptionTest.java +++ b/bindings/java/src/test/java/ai/arcships/aimux/AimuxExceptionTest.java @@ -88,7 +88,7 @@ void deadHandleIsBindingInvariant() { @Test void ffiInvalidWireJsonIsBindingInvariant() { - Pointer eh = AimuxFFI.INSTANCE.aimux_register_providers("{not json"); + Pointer eh = AimuxFFI.INSTANCE.aimux_init_proxy("{not json"); assertThat(eh).isNotNull(); assertThat(AimuxFFI.INSTANCE.aimux_error_code(eh)).isEqualTo(202); RuntimeException e = AimuxResult.expectAimuxError(eh, "ctx"); @@ -112,19 +112,19 @@ void expectRecordingErrorRejectsAnAiMuxErrorCode() { @Test void trailingGarbageJsonIsIllegalArgument() { - assertThatThrownBy(() -> Aimux.registerProviders("{} garbage")) + assertThatThrownBy(() -> Aimux.initProxy("{} garbage")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("configJson"); - assertThatThrownBy(() -> Aimux.registerProviders("")) + assertThatThrownBy(() -> Aimux.initProxy("")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("configJson: invalid JSON: empty"); - assertThatThrownBy(() -> Aimux.registerProviders(null)) + assertThatThrownBy(() -> Aimux.initProxy(null)) .isInstanceOf(NullPointerException.class); } @Test void malformedConfigJsonIsIllegalArgument() { - assertThatThrownBy(() -> Aimux.registerProviders("{not json")) + assertThatThrownBy(() -> Aimux.initProxy("{not json")) .isInstanceOf(IllegalArgumentException.class) .isNotInstanceOf(AimuxException.class) .hasMessageContaining("configJson"); @@ -248,12 +248,13 @@ void loadErrorsHaveTheirOwnCodesNamesAndNoEnvVarLocally() { @Test void codesOutsideTheRustEnumAreRejected() { - // Tool errors occupy 15..17 and 18 / 19 are the missing key / setting; - // retired code 4 and values beyond 19 are header/library mismatches. + // Tool errors occupy 15..17, 18 / 19 are the missing key / setting and 20 + // is NoOutputGenerated; retired code 4 and values beyond 20 are + // header/library mismatches. assertThat(AimuxException.of(15, "")).isInstanceOf(AimuxException.NoSuchToolError.class); assertThatThrownBy(() -> AimuxException.of(4, "")) .isInstanceOf(IllegalStateException.class); - assertThatThrownBy(() -> AimuxException.of(20, "")) + assertThatThrownBy(() -> AimuxException.of(21, "")) .isInstanceOf(IllegalStateException.class); assertThatThrownBy(() -> AimuxException.of(999, "m")) .isInstanceOf(IllegalStateException.class); diff --git a/bindings/java/src/test/java/ai/arcships/aimux/MockProviderServer.java b/bindings/java/src/test/java/ai/arcships/aimux/MockProviderServer.java index 599e35663..576058d44 100644 --- a/bindings/java/src/test/java/ai/arcships/aimux/MockProviderServer.java +++ b/bindings/java/src/test/java/ai/arcships/aimux/MockProviderServer.java @@ -105,6 +105,14 @@ public String baseUrl() { return "http://127.0.0.1:" + port(); } + /** + * Provider config for an OpenAI-compatible chat-completions model pointed + * at this server (the OpenAI factory itself speaks the Responses API). + */ + public String chatConfig() { + return "{\"baseUrl\":\"" + baseUrl() + "\"}"; + } + public String lastRequestBody() { return lastRequestBody; } diff --git a/bindings/java/src/test/java/ai/arcships/aimux/ModelTest.java b/bindings/java/src/test/java/ai/arcships/aimux/ModelTest.java index 0edddeec5..9ce6409ba 100644 --- a/bindings/java/src/test/java/ai/arcships/aimux/ModelTest.java +++ b/bindings/java/src/test/java/ai/arcships/aimux/ModelTest.java @@ -65,7 +65,7 @@ void providerCreatesModelInstanceFromRegistry() { @Test void generateTextReturnsJsonString() { try (MockProviderServer server = new MockProviderServer(); - Model model = Model.openaiWithBase("sk-test-fake-key", "gpt-4o-mini", server.baseUrl())) { + Model model = Model.provider("deepseek", "sk-test-fake-key", "gpt-4o-mini", server.chatConfig())) { server.setResponseBody(plainOpenAiResponse()); String resultJson = model.generateText("\"Hello\""); @@ -105,7 +105,7 @@ void closeThenCallThrows() { @Test void streamTextInvokesOnDone() { try (MockProviderServer server = new MockProviderServer(); - Model model = Model.openaiWithBase("sk-test-fake-key", "gpt-4o-mini", server.baseUrl())) { + Model model = Model.provider("deepseek", "sk-test-fake-key", "gpt-4o-mini", server.chatConfig())) { server.setContentType("text/event-stream"); server.setResponseBody(openAiSseResponse()); @@ -146,7 +146,7 @@ void streamTextInvokesOnDone() { @Test void streamTextStreamIteratesParts() { try (MockProviderServer server = new MockProviderServer(); - Model model = Model.openaiWithBase("sk-test-fake-key", "gpt-4o-mini", server.baseUrl())) { + Model model = Model.provider("deepseek", "sk-test-fake-key", "gpt-4o-mini", server.chatConfig())) { server.setContentType("text/event-stream"); server.setResponseBody(openAiSseResponse()); @@ -184,7 +184,7 @@ void streamTextStreamReturnsAStream() { @Test void concurrentCloseAndGenerateDoesNotCrash() throws InterruptedException { try (MockProviderServer server = new MockProviderServer(); - Model model = Model.openaiWithBase("sk-test-fake-key", "gpt-4o-mini", server.baseUrl())) { + Model model = Model.provider("deepseek", "sk-test-fake-key", "gpt-4o-mini", server.chatConfig())) { server.setResponseBody(plainOpenAiResponse()); final int iterations = 300; diff --git a/bindings/java/src/test/java/ai/arcships/aimux/MultimodalCoverageTest.java b/bindings/java/src/test/java/ai/arcships/aimux/MultimodalCoverageTest.java index b5e01c8c3..ba1e75df1 100644 --- a/bindings/java/src/test/java/ai/arcships/aimux/MultimodalCoverageTest.java +++ b/bindings/java/src/test/java/ai/arcships/aimux/MultimodalCoverageTest.java @@ -158,8 +158,8 @@ void searchParsesAnswerField() { try (SearchModel model = SearchModel.tavilyWithBase("sk-test", server.baseUrl())) { JSONObject opts = new JSONObject() .put("query", "What is Rust?") - .put("max_results", 5) - .put("provider_options", new JSONObject()); + .put("maxResults", 5) + .put("providerOptions", new JSONObject()); JSONObject result = new JSONObject(model.search(opts.toString())); assertThat(result.getJSONArray("results").length()).isEqualTo(1); @@ -176,7 +176,7 @@ void deepseekGenerateTextViaMock() { + "\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"Hi\"},\"finish_reason\":\"stop\"}]," + "\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}"); - try (Model model = Model.openaiWithBase("sk-test", "deepseek-chat", server.baseUrl())) { + try (Model model = Model.provider("deepseek", "sk-test-fake-key", "deepseek-chat", server.chatConfig())) { String result = model.generateText("\"Say hi\""); assertThat(new JSONObject(result).getString("text")).isEqualTo("Hi"); } diff --git a/bindings/java/src/test/java/ai/arcships/aimux/MultimodalE2ETest.java b/bindings/java/src/test/java/ai/arcships/aimux/MultimodalE2ETest.java index 7ccfdb3de..7680fb087 100644 --- a/bindings/java/src/test/java/ai/arcships/aimux/MultimodalE2ETest.java +++ b/bindings/java/src/test/java/ai/arcships/aimux/MultimodalE2ETest.java @@ -76,15 +76,13 @@ void speechGenerateReturnsBinaryAudio() { JSONObject opts = new JSONObject() .put("text", "Hi") .put("voice", "alloy") - .put("output_format", "mp3") - .put("provider_options", new JSONObject()); + .put("outputFormat", "mp3") + .put("providerOptions", new JSONObject()); JSONObject result = new JSONObject(model.generate(opts.toString())); - // Wire format: {"audio":{"Binary":[]}, ...} - JSONObject audio = result.getJSONObject("audio"); - assertThat(audio.has("Binary")).isTrue(); - assertThat(audio.getJSONArray("Binary").length()).isGreaterThan(0); + // Wire format: {"audio":[], ...} (untagged: int array = binary) + assertThat(result.getJSONArray("audio").length()).isGreaterThan(0); } } @@ -99,12 +97,12 @@ void imageGenerateParsesBase64Images() { JSONObject opts = new JSONObject() .put("prompt", "otter") .put("n", 1) - .put("provider_options", new JSONObject()); + .put("providerOptions", new JSONObject()); JSONObject result = new JSONObject(model.generate(opts.toString())); - // Wire format: {"images":{"Base64":["aW1hZ2Ux"]}, ...} - JSONArray base64 = result.getJSONObject("images").getJSONArray("Base64"); + // Wire format: {"images":["aW1hZ2Ux"], ...} (untagged: strings = base64) + JSONArray base64 = result.getJSONArray("images"); assertThat(base64.length()).isEqualTo(1); assertThat(base64.getString(0)).isEqualTo("aW1hZ2Ux"); } @@ -137,18 +135,18 @@ void rerankParsesRanking() { "sk-test", "rerank-v3.0", server.baseUrl())) { JSONObject opts = new JSONObject() .put("query", "which?") - .put("documents", new JSONObject("{\"Text\":{\"values\":[\"doc1\",\"doc2\"]}}")) - .put("top_n", 2) - .put("provider_options", new JSONObject()); + .put("documents", new JSONObject("{\"type\":\"text\",\"values\":[\"doc1\",\"doc2\"]}")) + .put("topN", 2) + .put("providerOptions", new JSONObject()); JSONObject result = new JSONObject(model.rerank(opts.toString())); - // Wire format: {"ranking":[{"index":1,"relevance_score":0.95}, ...], ...} + // Wire format: {"ranking":[{"index":1,"relevanceScore":0.95}, ...], ...} JSONArray ranking = result.getJSONArray("ranking"); assertThat(ranking.length()).isEqualTo(2); JSONObject first = ranking.getJSONObject(0); assertThat(first.getInt("index")).isEqualTo(1); - assertThat(first.getDouble("relevance_score")).isEqualTo(0.95); + assertThat(first.getDouble("relevanceScore")).isEqualTo(0.95); } } @@ -165,8 +163,8 @@ void searchParsesResults() { "sk-test", server.baseUrl())) { JSONObject opts = new JSONObject() .put("query", "What is Rust?") - .put("max_results", 5) - .put("provider_options", new JSONObject()); + .put("maxResults", 5) + .put("providerOptions", new JSONObject()); JSONObject result = new JSONObject(model.search(opts.toString())); @@ -189,8 +187,8 @@ void uploadFileParsesProviderReference() { try (Files files = Files.openaiWithBase("sk-test", server.baseUrl())) { JSONObject result = new JSONObject(files.uploadFile("dGVzdA==", "application/pdf")); - // Wire format: {"provider_reference":{"openai":"file-abc"}, ...} - assertThat(result.getJSONObject("provider_reference").getString("openai")) + // Wire format: {"providerReference":{"openai":"file-abc"}, ...} + assertThat(result.getJSONObject("providerReference").getString("openai")) .isEqualTo("file-abc"); } } @@ -211,13 +209,13 @@ void videoConstructionAndResultParsing() { assertThat(model).isNotNull(); } - // Wire format: {"videos":[{"Url":{"url":"...","media_type":"..."}}], ...} + // Wire format: {"videos":[{"type":"url","url":"...","mediaType":"..."}], ...} JSONObject parsed = new JSONObject( - "{\"videos\":[{\"Url\":{\"url\":\"https://example.com/v.mp4\"," - + "\"media_type\":\"video/mp4\"}}]}"); + "{\"videos\":[{\"type\":\"url\",\"url\":\"https://example.com/v.mp4\"," + + "\"mediaType\":\"video/mp4\"}]}"); JSONArray videos = parsed.getJSONArray("videos"); assertThat(videos.length()).isEqualTo(1); - assertThat(videos.getJSONObject(0).getJSONObject("Url").getString("url")) + assertThat(videos.getJSONObject(0).getString("url")) .isEqualTo("https://example.com/v.mp4"); } } diff --git a/bindings/java/src/test/java/ai/arcships/aimux/MultimodalTypesTest.java b/bindings/java/src/test/java/ai/arcships/aimux/MultimodalTypesTest.java index 99f709af1..19b4799ea 100644 --- a/bindings/java/src/test/java/ai/arcships/aimux/MultimodalTypesTest.java +++ b/bindings/java/src/test/java/ai/arcships/aimux/MultimodalTypesTest.java @@ -26,11 +26,10 @@ * *

Special attention is paid to the three hand-written * {@code JsonSerializer}/{@code JsonDeserializer} pairs — - * {@link MultimodalTypes.AudioData} (Base64/Binary), - * {@link MultimodalTypes.ImageOutputs} (Base64/Binary) and - * {@link MultimodalTypes.VideoData} (Url/Base64/Binary) — which implement the - * serde-style externally-tagged enum wire format ({@code {"Base64": ...}}) and - * are the most bug-prone part of the file. Dedicated tests below exercise every + * {@link MultimodalTypes.AudioData} (Base64/Binary, untagged), + * {@link MultimodalTypes.ImageOutputs} (Base64/Binary, untagged) and + * {@link MultimodalTypes.VideoData} (Url/Base64/Binary, tagged on {@code type}) + * — which are the most bug-prone part of the file. Dedicated tests below exercise every * variant of each union. * *

These are pure serialization tests: no native library is loaded — the @@ -79,7 +78,7 @@ void embeddingResultRoundTrip() throws Exception { @Test void speechResultRoundTrip() throws Exception { // Same shape as Go ParseSpeechResult — exercises AudioData.Base64. - String json = "{\"audio\":{\"Base64\":\"aGVsbG8=\"}," + String json = "{\"audio\":\"aGVsbG8=\"," + "\"warnings\":[],\"response\":{\"id\":\"resp-1\"}}"; MultimodalTypes.SpeechResult result = @@ -95,9 +94,9 @@ void speechResultRoundTrip() throws Exception { M.readValue(out, MultimodalTypes.SpeechResult.class); assertThat(rt).isEqualTo(result); - // The custom AudioData serializer re-emits the {"Base64": "..."} tag. + // The custom AudioData serializer re-emits the bare base64 string. assertThat(M.readTree(out)).as("wire form: %s", out).isEqualTo(M.readTree( - "{\"audio\":{\"Base64\":\"aGVsbG8=\"},\"warnings\":[],\"response\":{}}")); + "{\"audio\":\"aGVsbG8=\",\"warnings\":[],\"response\":{}}")); } // ── Image ────────────────────────────────────────────────────────────── @@ -105,7 +104,7 @@ void speechResultRoundTrip() throws Exception { @Test void imageResultRoundTrip() throws Exception { // Same shape as Go ParseImageResult — exercises ImageOutputs.Base64. - String json = "{\"images\":{\"Base64\":[\"aW1hZ2Ux\"]}," + String json = "{\"images\":[\"aW1hZ2Ux\"]," + "\"warnings\":[],\"response\":{\"id\":\"resp-1\"}}"; MultimodalTypes.ImageResult result = @@ -123,9 +122,9 @@ void imageResultRoundTrip() throws Exception { M.readValue(out, MultimodalTypes.ImageResult.class); assertThat(rt).isEqualTo(result); - // The custom ImageOutputs serializer re-emits the {"Base64": [...]} tag. + // The custom ImageOutputs serializer re-emits the bare string array. assertThat(M.readTree(out)).as("wire form: %s", out).isEqualTo(M.readTree( - "{\"images\":{\"Base64\":[\"aW1hZ2Ux\"]},\"warnings\":[],\"response\":{}}")); + "{\"images\":[\"aW1hZ2Ux\"],\"warnings\":[],\"response\":{}}")); } // ── Transcription (STT) ──────────────────────────────────────────────── @@ -133,9 +132,9 @@ void imageResultRoundTrip() throws Exception { @Test void transcriptionResultRoundTrip() throws Exception { // Same shape as Go ParseTranscriptionResult. - // Timing keys are `start_second`/`end_second` — see TranscriptionSegment.ts. + // Timing keys are `startSecond`/`endSecond` — see TranscriptionSegment.ts. String json = "{\"text\":\"Hello world\"," - + "\"segments\":[{\"text\":\"Hello\",\"start_second\":0.0,\"end_second\":1.0}]," + + "\"segments\":[{\"text\":\"Hello\",\"startSecond\":0.0,\"endSecond\":1.0}]," + "\"language\":\"en\",\"warnings\":[],\"response\":{\"id\":\"resp-1\"}}"; MultimodalTypes.TranscriptionResult result = @@ -161,11 +160,11 @@ void transcriptionResultRoundTrip() throws Exception { assertThat(rt.getText()).isEqualTo("Hello world"); assertThat(rt.getLanguage()).isEqualTo("en"); - // Wire form retains start_second:0.0 and the empty `warnings` list + // Wire form retains startSecond:0.0 and the empty `warnings` list // (NON_NULL suppresses only null, not zero values or empty collections). assertThat(M.readTree(out)).as("wire form: %s", out).isEqualTo(M.readTree( "{\"text\":\"Hello world\",\"segments\":" - + "[{\"text\":\"Hello\",\"start_second\":0.0,\"end_second\":1.0}]," + + "[{\"text\":\"Hello\",\"startSecond\":0.0,\"endSecond\":1.0}]," + "\"language\":\"en\",\"warnings\":[],\"response\":{}}")); } @@ -175,8 +174,8 @@ void transcriptionResultRoundTrip() throws Exception { void rerankingResultRoundTrip() throws Exception { // Same shape as Go ParseRerankingResult. String json = "{\"ranking\":[" - + "{\"index\":1,\"relevance_score\":0.95}," - + "{\"index\":0,\"relevance_score\":0.30}" + + "{\"index\":1,\"relevanceScore\":0.95}," + + "{\"index\":0,\"relevanceScore\":0.30}" + "],\"warnings\":[]}"; MultimodalTypes.RerankingResult result = @@ -198,8 +197,8 @@ void rerankingResultRoundTrip() throws Exception { // int is never null, so it is not suppressed) and the empty `warnings` // list; it round-trips losslessly either way. assertThat(M.readTree(out)).as("wire form: %s", out).isEqualTo(M.readTree( - "{\"ranking\":[{\"index\":1,\"relevance_score\":0.95}," - + "{\"index\":0,\"relevance_score\":0.3}],\"warnings\":[]}")); + "{\"ranking\":[{\"index\":1,\"relevanceScore\":0.95}," + + "{\"index\":0,\"relevanceScore\":0.3}],\"warnings\":[]}")); } // ── Video ────────────────────────────────────────────────────────────── @@ -207,8 +206,8 @@ void rerankingResultRoundTrip() throws Exception { @Test void videoResultRoundTrip() throws Exception { // Same shape as Go ParseVideoResult — exercises VideoData.Url. - String json = "{\"videos\":[{\"Url\":" - + "{\"url\":\"https://example.com/video.mp4\",\"media_type\":\"video/mp4\"}}]," + String json = "{\"videos\":[{\"type\":\"url\"," + + "\"url\":\"https://example.com/video.mp4\",\"mediaType\":\"video/mp4\"}]," + "\"warnings\":[],\"response\":{\"id\":\"resp-1\"}}"; MultimodalTypes.VideoResult result = @@ -227,10 +226,10 @@ void videoResultRoundTrip() throws Exception { M.readValue(out, MultimodalTypes.VideoResult.class); assertThat(rt).isEqualTo(result); - // The custom VideoData serializer re-emits the {"Url": {...}} tag. + // The custom VideoData serializer re-emits the {"type":"url",...} tag. assertThat(M.readTree(out)).as("wire form: %s", out).isEqualTo(M.readTree( - "{\"videos\":[{\"Url\":{\"url\":\"https://example.com/video.mp4\"," - + "\"media_type\":\"video/mp4\"}}],\"warnings\":[],\"response\":{}}")); + "{\"videos\":[{\"type\":\"url\",\"url\":\"https://example.com/video.mp4\"," + + "\"mediaType\":\"video/mp4\"}],\"warnings\":[],\"response\":{}}")); } @Test @@ -244,8 +243,8 @@ void videoPollOptionsRoundTrip() throws Exception { .build(); String json = M.writeValueAsString(options); - assertThat(M.readTree(json).path("poll").path("interval_ms").asLong()).isEqualTo(1_000L); - assertThat(M.readTree(json).path("poll").path("timeout_ms").asLong()).isEqualTo(120_000L); + assertThat(M.readTree(json).path("poll").path("intervalMs").asLong()).isEqualTo(1_000L); + assertThat(M.readTree(json).path("poll").path("timeoutMs").asLong()).isEqualTo(120_000L); MultimodalTypes.VideoCallOptions decoded = M.readValue(json, MultimodalTypes.VideoCallOptions.class); @@ -301,13 +300,13 @@ void searchResultRoundTrip() throws Exception { @Test void uploadFileResultRoundTrip() throws Exception { // Same shape as Go ParseUploadFileResult. - String json = "{\"provider_reference\":{\"openai\":\"file-abc123\"}," - + "\"media_type\":\"application/pdf\",\"filename\":\"doc.pdf\",\"warnings\":[]}"; + String json = "{\"providerReference\":{\"openai\":\"file-abc123\"}," + + "\"mediaType\":\"application/pdf\",\"filename\":\"doc.pdf\",\"warnings\":[]}"; MultimodalTypes.UploadFileResult result = M.readValue(json, MultimodalTypes.UploadFileResult.class); - // Decode (mirrors Go: provider_reference.openai, media_type). + // Decode (mirrors Go: providerReference.openai, mediaType). assertThat(result.getProviderReference()).containsEntry("openai", "file-abc123"); assertThat(result.getMediaType()).isEqualTo("application/pdf"); assertThat(result.getFilename()).isEqualTo("doc.pdf"); @@ -318,8 +317,8 @@ void uploadFileResultRoundTrip() throws Exception { assertThat(rt).isEqualTo(result); assertThat(M.readTree(out)).as("wire form: %s", out).isEqualTo(M.readTree( - "{\"provider_reference\":{\"openai\":\"file-abc123\"}," - + "\"media_type\":\"application/pdf\",\"filename\":\"doc.pdf\",\"warnings\":[]}")); + "{\"providerReference\":{\"openai\":\"file-abc123\"}," + + "\"mediaType\":\"application/pdf\",\"filename\":\"doc.pdf\",\"warnings\":[]}")); } // ── Custom serializer focus: the three sealed unions ─────────────────── @@ -331,7 +330,7 @@ void audioDataSerializerAllVariants() throws Exception { // Base64 variant. MultimodalTypes.AudioData base64 = new MultimodalTypes.AudioData.Base64("aGVsbG8="); String base64Json = M.writeValueAsString(base64); - assertThat(base64Json).isEqualTo("{\"Base64\":\"aGVsbG8=\"}"); + assertThat(base64Json).isEqualTo("\"aGVsbG8=\""); MultimodalTypes.AudioData base64Back = M.readValue(base64Json, MultimodalTypes.AudioData.class); assertThat(base64Back).isInstanceOf(MultimodalTypes.AudioData.Base64.class); @@ -342,14 +341,14 @@ void audioDataSerializerAllVariants() throws Exception { MultimodalTypes.AudioData binary = new MultimodalTypes.AudioData.Binary(Arrays.asList(72, 101, 108, 108, 111)); String binaryJson = M.writeValueAsString(binary); - assertThat(binaryJson).isEqualTo("{\"Binary\":[72,101,108,108,111]}"); + assertThat(binaryJson).isEqualTo("[72,101,108,108,111]"); MultimodalTypes.AudioData binaryBack = M.readValue(binaryJson, MultimodalTypes.AudioData.class); assertThat(binaryBack).isInstanceOf(MultimodalTypes.AudioData.Binary.class); assertThat(((MultimodalTypes.AudioData.Binary) binaryBack).getValue()) .isEqualTo(Arrays.asList(72, 101, 108, 108, 111)); - // Unknown tag → the deserializer rejects it. + // Neither a string nor an array → the deserializer rejects it. assertThatThrownBy(() -> M.readValue("{\"Quux\":\"x\"}", MultimodalTypes.AudioData.class)) .isInstanceOf(IOException.class); } @@ -360,7 +359,7 @@ void imageOutputsSerializerAllVariants() throws Exception { MultimodalTypes.ImageOutputs base64 = new MultimodalTypes.ImageOutputs.Base64(Arrays.asList("aW1hZ2Ux", "aW1hZ2Uy")); String base64Json = M.writeValueAsString(base64); - assertThat(base64Json).isEqualTo("{\"Base64\":[\"aW1hZ2Ux\",\"aW1hZ2Uy\"]}"); + assertThat(base64Json).isEqualTo("[\"aW1hZ2Ux\",\"aW1hZ2Uy\"]"); MultimodalTypes.ImageOutputs base64Back = M.readValue(base64Json, MultimodalTypes.ImageOutputs.class); assertThat(base64Back).isInstanceOf(MultimodalTypes.ImageOutputs.Base64.class); @@ -371,7 +370,7 @@ void imageOutputsSerializerAllVariants() throws Exception { MultimodalTypes.ImageOutputs binary = new MultimodalTypes.ImageOutputs.Binary( Arrays.asList(Arrays.asList(1, 2, 3), Arrays.asList(4, 5, 6))); String binaryJson = M.writeValueAsString(binary); - assertThat(binaryJson).isEqualTo("{\"Binary\":[[1,2,3],[4,5,6]]}"); + assertThat(binaryJson).isEqualTo("[[1,2,3],[4,5,6]]"); MultimodalTypes.ImageOutputs binaryBack = M.readValue(binaryJson, MultimodalTypes.ImageOutputs.class); assertThat(binaryBack).isInstanceOf(MultimodalTypes.ImageOutputs.Binary.class); @@ -389,7 +388,7 @@ void videoDataSerializerAllVariants() throws Exception { MultimodalTypes.VideoUrlData.of("https://example.com/v.mp4", "video/mp4")); String urlJson = M.writeValueAsString(url); assertThat(urlJson).isEqualTo( - "{\"Url\":{\"url\":\"https://example.com/v.mp4\",\"media_type\":\"video/mp4\"}}"); + "{\"type\":\"url\",\"url\":\"https://example.com/v.mp4\",\"mediaType\":\"video/mp4\"}"); MultimodalTypes.VideoData urlBack = M.readValue(urlJson, MultimodalTypes.VideoData.class); assertThat(urlBack).isInstanceOf(MultimodalTypes.VideoData.Url.class); assertThat(((MultimodalTypes.VideoData.Url) urlBack).getValue().getUrl()) @@ -400,7 +399,7 @@ void videoDataSerializerAllVariants() throws Exception { MultimodalTypes.VideoBase64Data.of("AAAA", "video/mp4")); String base64Json = M.writeValueAsString(base64); assertThat(base64Json).isEqualTo( - "{\"Base64\":{\"data\":\"AAAA\",\"media_type\":\"video/mp4\"}}"); + "{\"type\":\"base64\",\"data\":\"AAAA\",\"mediaType\":\"video/mp4\"}"); MultimodalTypes.VideoData base64Back = M.readValue(base64Json, MultimodalTypes.VideoData.class); assertThat(base64Back).isInstanceOf(MultimodalTypes.VideoData.Base64.class); @@ -412,14 +411,14 @@ void videoDataSerializerAllVariants() throws Exception { MultimodalTypes.VideoBinaryData.of(Arrays.asList(1, 2, 3), "video/mp4")); String binaryJson = M.writeValueAsString(binary); assertThat(binaryJson).isEqualTo( - "{\"Binary\":{\"data\":[1,2,3],\"media_type\":\"video/mp4\"}}"); + "{\"type\":\"binary\",\"data\":[1,2,3],\"mediaType\":\"video/mp4\"}"); MultimodalTypes.VideoData binaryBack = M.readValue(binaryJson, MultimodalTypes.VideoData.class); assertThat(binaryBack).isInstanceOf(MultimodalTypes.VideoData.Binary.class); assertThat(((MultimodalTypes.VideoData.Binary) binaryBack).getValue().getData()) .isEqualTo(Arrays.asList(1, 2, 3)); - assertThatThrownBy(() -> M.readValue("{\"Quux\":{}}", MultimodalTypes.VideoData.class)) + assertThatThrownBy(() -> M.readValue("{\"type\":\"quux\"}", MultimodalTypes.VideoData.class)) .isInstanceOf(IOException.class); } } diff --git a/bindings/java/src/test/java/ai/arcships/aimux/StructuredE2ETest.java b/bindings/java/src/test/java/ai/arcships/aimux/StructuredE2ETest.java index ddd322506..a8c8a40ac 100644 --- a/bindings/java/src/test/java/ai/arcships/aimux/StructuredE2ETest.java +++ b/bindings/java/src/test/java/ai/arcships/aimux/StructuredE2ETest.java @@ -20,14 +20,14 @@ * the Kotlin binding's StructuredE2ETest.kt). * *

These spin up a local mock HTTP server speaking the OpenAI - * chat-completions wire format, point an OpenAI {@code Model} at it via the - * base-url constructor, and assert that the binding correctly: + * chat-completions wire format, point an OpenAI-compatible {@code Model} at it + * through the provider registry, and assert that the binding correctly: *

    - *
  1. parses tool_calls out of a provider response,
  2. + *
  3. parses toolCalls out of a provider response,
  4. *
  5. forwards multi-role messages,
  6. - *
  7. forwards {@code tool_choice},
  8. + *
  9. forwards {@code toolChoice},
  10. *
  11. parses tool-call stream parts from an SSE stream,
  12. - *
  13. runs a full tool-call round-trip (tool_call + tool_result + final answer).
  14. + *
  15. runs a full tool-call round-trip (tool-call + tool-result + final answer).
  16. *
* * No real network access is performed — every request hits 127.0.0.1. @@ -160,12 +160,12 @@ private static String weatherToolsOpts(String includeToolChoice) { new JSONObject() .put("type", "function") .put("name", "get_weather") - .put("input_schema", new JSONObject() + .put("inputSchema", new JSONObject() .put("type", "object") .put("properties", new JSONObject() .put("location", new JSONObject().put("type", "string")))))); if (includeToolChoice != null) { - opts.put("tool_choice", includeToolChoice); + opts.put("toolChoice", includeToolChoice); } return opts.toString(); } @@ -180,23 +180,23 @@ private static String weatherToolsOpts() { void generateTextParsesToolCalls() { server.setResponseBody(toolCallOpenAiResponse()); - try (Model model = Model.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { + try (Model model = Model.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { String resultJson = model.generateText("\"What is the weather in Tokyo?\"", weatherToolsOpts()); JSONObject result = new JSONObject(resultJson); - // tool_calls[0].tool_name == "get_weather" - JSONArray toolCalls = result.getJSONArray("tool_calls"); + // toolCalls[0].toolName == "get_weather" + JSONArray toolCalls = result.getJSONArray("toolCalls"); assertThat(toolCalls.length()).isGreaterThan(0); JSONObject firstCall = toolCalls.getJSONObject(0); - assertThat(firstCall.getString("tool_name")).isEqualTo("get_weather"); - assertThat(firstCall.getString("tool_call_id")).isEqualTo("call_abc"); + assertThat(firstCall.getString("toolName")).isEqualTo("get_weather"); + assertThat(firstCall.getString("toolCallId")).isEqualTo("call_abc"); assertThat(firstCall.getJSONObject("input").getString("location")).isEqualTo("Tokyo"); - // raw.content contains a "ToolCall" variant. + // raw.content contains a "tool-call" variant. JSONArray rawContent = result.getJSONObject("raw").getJSONArray("content"); boolean hasToolCallVariant = false; for (int i = 0; i < rawContent.length(); i++) { - if (rawContent.getJSONObject(i).has("ToolCall")) { + if ("tool-call".equals(rawContent.getJSONObject(i).optString("type"))) { hasToolCallVariant = true; break; } @@ -215,7 +215,7 @@ void multiRoleMessagesReachProvider() { .put(new JSONObject().put("role", "system").put("content", "You are a helpful assistant.")) .put(new JSONObject().put("role", "user").put("content", "What is Rust?")); - try (Model model = Model.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { + try (Model model = Model.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { String resultJson = model.generateText(prompt.toString()); JSONObject result = new JSONObject(resultJson); assertThat(result.getString("text")) @@ -235,7 +235,7 @@ void multiRoleMessagesReachProvider() { void toolChoiceReachesProvider() { server.setResponseBody(toolCallOpenAiResponse()); - try (Model model = Model.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { + try (Model model = Model.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { model.generateText("\"What is the weather in Tokyo?\"", weatherToolsOpts("required")); JSONObject reqBody = new JSONObject(server.lastRequestBody()); @@ -251,7 +251,7 @@ void streamTextParsesToolCallStreamParts() { server.setContentType("text/event-stream"); server.setResponseBody(toolCallSseResponse()); - try (Model model = Model.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { + try (Model model = Model.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { // Run the blocking FFI stream call on a separate thread so the JNA // callbacks (which attach to the calling thread) don't deadlock with // tokio's block_on. The main thread collects parts via a queue. @@ -286,24 +286,24 @@ void streamTextParsesToolCallStreamParts() { } assertThat(collected).isNotEmpty(); - // Stream must surface tool-call activity: either a complete ToolCall - // part or ToolInputDelta deltas (or both). + // Stream must surface tool-call activity: either a complete tool-call + // part or tool-input-delta deltas (or both). boolean hasToolPart = false; for (String part : collected) { - if (part.contains("\"ToolCall\"") || part.contains("\"ToolInputDelta\"")) { + if (part.contains("\"type\":\"tool-call\"") || part.contains("\"type\":\"tool-input-delta\"")) { hasToolPart = true; break; } } assertThat(hasToolPart) - .as("expected a ToolCall or ToolInputDelta stream part; got: %s", collected) + .as("expected a tool-call or tool-input-delta stream part; got: %s", collected) .isTrue(); // The finish part should also be present, carrying the tool-calls // finish reason. boolean hasFinish = false; for (String part : collected) { - if (part.contains("\"Finish\"")) { + if (part.contains("\"type\":\"finish\"")) { hasFinish = true; break; } @@ -320,43 +320,45 @@ void toolCallFullRoundTrip() { // second (FIFO queue). server.setResponses(toolCallOpenAiResponse(), finalTextResponse()); - try (Model model = Model.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { + try (Model model = Model.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { // 1st call: provider asks to call get_weather. String firstResultJson = model.generateText("\"What's the weather in Tokyo?\"", weatherToolsOpts()); JSONObject firstResult = new JSONObject(firstResultJson); - JSONArray toolCalls = firstResult.getJSONArray("tool_calls"); + JSONArray toolCalls = firstResult.getJSONArray("toolCalls"); assertThat(toolCalls.length()).isGreaterThan(0); JSONObject firstCall = toolCalls.getJSONObject(0); - assertThat(firstCall.getString("tool_name")).isEqualTo("get_weather"); - assertThat(firstCall.getString("tool_call_id")).isEqualTo("call_abc"); + assertThat(firstCall.getString("toolName")).isEqualTo("get_weather"); + assertThat(firstCall.getString("toolCallId")).isEqualTo("call_abc"); assertThat(firstCall.getJSONObject("input").getString("location")) .isEqualTo("Tokyo"); // 2nd call: echo the full conversation back, including the - // assistant's tool_call and the ToolResult we synthesised. - // Input uses the engine's ContentPart variants (tool_call / - // tool_result); the engine converts these to the OpenAI wire - // format on the outbound request. NOTE: the tool_result part uses - // the `result` field (never `output`) — the engine's - // ContentPart::ToolResult requires it (aimux-core/content.rs). + // assistant's tool-call and the tool-result we synthesised. + // Input uses the AI SDK ContentPart variants (tool-call / + // tool-result); the engine converts these to the OpenAI wire + // format on the outbound request. The tool-result part carries + // `output` (a ToolResultOutput union), never `result`. JSONArray messages = new JSONArray() .put(new JSONObject().put("role", "user").put("content", "What's the weather in Tokyo?")) .put(new JSONObject().put("role", "assistant") .put("content", new JSONArray().put( new JSONObject() - .put("type", "tool_call") - .put("tool_call_id", "call_abc") - .put("tool_name", "get_weather") + .put("type", "tool-call") + .put("toolCallId", "call_abc") + .put("toolName", "get_weather") .put("input", new JSONObject().put("location", "Tokyo"))))) .put(new JSONObject().put("role", "tool") .put("content", new JSONArray().put( new JSONObject() - .put("type", "tool_result") - .put("tool_call_id", "call_abc") - .put("result", new JSONObject() - .put("temperature", 22) - .put("condition", "sunny"))))); + .put("type", "tool-result") + .put("toolCallId", "call_abc") + .put("toolName", "get_weather") + .put("output", new JSONObject() + .put("type", "json") + .put("value", new JSONObject() + .put("temperature", 22) + .put("condition", "sunny")))))); String secondResultJson = model.generateText(messages.toString(), weatherToolsOpts()); JSONObject secondResult = new JSONObject(secondResultJson); diff --git a/bindings/java/src/test/java/ai/arcships/aimux/TypedModelTest.java b/bindings/java/src/test/java/ai/arcships/aimux/TypedModelTest.java index 6ffbb0bc4..5decbd6f3 100644 --- a/bindings/java/src/test/java/ai/arcships/aimux/TypedModelTest.java +++ b/bindings/java/src/test/java/ai/arcships/aimux/TypedModelTest.java @@ -167,7 +167,7 @@ void topLevelToolCallProviderMetadataRoundTrips() throws Exception { String json = Types.AimuxJson.MAPPER.writeValueAsString(original); Types.ToolCall decoded = Types.AimuxJson.MAPPER.readValue(json, Types.ToolCall.class); - assertThat(json).contains("\"provider_metadata\""); + assertThat(json).contains("\"providerMetadata\""); assertThat(decoded.getProviderMetadata()).isEqualTo(metadata); assertThat(decoded).isEqualTo(original); } @@ -177,7 +177,7 @@ void generateTextReturnsTypedGenerateTextResultWithTextAndRawContent() { server.setResponseBody(plainOpenAiResponse()); try (TypedModel model = - TypedModel.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { + TypedModel.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { Types.GenerateTextResult result = model.generateText("What is Rust?"); // No manual parsing: the wrapper returned a typed object. @@ -204,7 +204,7 @@ void generateTextParsesToolCallsIntoTypedObjects() { .build(); try (TypedModel model = - TypedModel.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { + TypedModel.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { Types.GenerateTextResult result = model.generateText("What is the weather in Tokyo?", options); @@ -216,8 +216,8 @@ void generateTextParsesToolCallsIntoTypedObjects() { // input is a JsonNode (tool arguments); inspect it directly. assertThat(call.getInput().get("location").asText()).isEqualTo("Tokyo"); - // .raw.content carries the ToolCall variant tag. - assertThat(result.getRaw().hasContentVariant("ToolCall")).isTrue(); + // .raw.content carries the tool-call variant tag. + assertThat(result.getRaw().hasContentVariant("tool-call")).isTrue(); } } @@ -231,11 +231,11 @@ void typedToolsAndToolChoiceReachProvider() { .build(); try (TypedModel model = - TypedModel.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { + TypedModel.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { model.generateText("What is the weather in Tokyo?", options); // The serialized options crossed the JSON boundary as the engine's - // snake_case shape with only the fields the caller set. + // camelCase shape with only the fields the caller set. JSONObject reqBody = new JSONObject(server.lastRequestBody()); assertThat(reqBody.getString("tool_choice")).isEqualTo("required"); assertThat(reqBody.has("tools")).isTrue(); @@ -260,7 +260,7 @@ void multiRoleModelMessageListReachesProvider() { Types.ModelMessage.text(Types.Role.USER, "What is Rust?")); try (TypedModel model = - TypedModel.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { + TypedModel.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { Types.GenerateTextResult result = model.generateText(messages); assertThat(result.getText()).isEqualTo("Rust is a systems programming language."); @@ -289,7 +289,7 @@ void streamTextStreamDecodesTypedParts() { .build(); try (TypedModel model = - TypedModel.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { + TypedModel.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { List parts = assertTimeout(Duration.ofSeconds(30), () -> model.streamTextStream("What is the weather in Tokyo?", options) .collect(Collectors.toList())); @@ -326,7 +326,7 @@ void streamTextCallbackDeliversTypedParts() { java.util.concurrent.atomic.AtomicBoolean done = new java.util.concurrent.atomic.AtomicBoolean(false); try (TypedModel model = - TypedModel.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { + TypedModel.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { assertTimeout(Duration.ofSeconds(30), () -> model.streamText("What is the weather in Tokyo?", options, collected::add, // onPart — typed StreamPart @@ -346,7 +346,7 @@ void streamTextCallbackDeliversTypedParts() { void ofModelDoesNotOwnHandle() { server.setResponseBody(plainOpenAiResponse()); - Model raw = Model.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl()); + Model raw = Model.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig()); // of() wraps without owning — closing the TypedModel must NOT close the raw Model. TypedModel typed = TypedModel.of(raw); typed.close(); @@ -370,7 +370,7 @@ void streamTextStreamFromMessageList() { .build(); try (TypedModel model = - TypedModel.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { + TypedModel.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { List parts = assertTimeout(Duration.ofSeconds(30), () -> model.streamTextStream(messages, options).collect(Collectors.toList())); @@ -386,7 +386,7 @@ void generateTextInvalidPromptThrowsAimuxException() { // The typed layer JSON-encodes the prompt, so any string is valid wire // JSON; the failure is the mock provider's response → AimuxException. try (TypedModel model = - TypedModel.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { + TypedModel.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { assertThatThrownBy(() -> model.generateText("not-valid-json")) .isInstanceOf(AimuxException.class); } diff --git a/bindings/kotlin/build.gradle.kts b/bindings/kotlin/build.gradle.kts index 003e4e0a6..e2fa32782 100644 --- a/bindings/kotlin/build.gradle.kts +++ b/bindings/kotlin/build.gradle.kts @@ -33,7 +33,12 @@ kotlin { tasks.test { useJUnitPlatform() - systemProperty("jna.library.path", rootProject.projectDir.resolve("../../target/release").canonicalPath) + // AIMUX_FFI_LIB_DIR points at the directory holding libaimux_ffi (e.g. a + // custom CARGO_TARGET_DIR/debug); the default is the in-tree release build. + systemProperty( + "jna.library.path", + System.getenv("AIMUX_FFI_LIB_DIR") ?: rootProject.projectDir.resolve("../../target/release").canonicalPath, + ) // Gradle prints only "there were failing tests" by default; the names live // in an HTML report that CI logs never surface. Without this, a red build // tells you nothing about which test broke. diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Errors.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Errors.kt index 5de65650c..ec4012c92 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Errors.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Errors.kt @@ -522,7 +522,7 @@ class ToolCallRepairError( cause: Throwable? = null, retryable: Boolean = false, /** - * The original lookup/parse/validation error as externally-tagged wire + * The original lookup/parse/validation error as name-tagged wire * JSON (the same encoding as [ToolCall.error]); null when synthesized * locally. */ diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Model.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Model.kt index 5c9561869..5f5fd6acb 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Model.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Model.kt @@ -221,9 +221,6 @@ internal interface AimuxFFI : Library { fun aimux_recording_try_flush(): Pointer? fun aimux_mock_replay_new(recordingsJsonl: String, outHandle: LongByReference): Pointer? - // [AiMuxError] Register external providers from JSON config (RFC-0020). - fun aimux_register_providers(configJson: String): Pointer? - // [AiMuxError] Set the global proxy configuration (M6, RFC-0016). fun aimux_init_proxy(configJson: String): Pointer? @@ -534,7 +531,7 @@ class Model internal constructor(handle: Long) : Closeable { * Generate a structured JSON object (M12, RFC-0016). * * Same signature as [generateText]; returns a JSON-serialized - * `GenerateObjectResult`. Pass `response_format: { "Json": { ... } }` via + * `GenerateObjectResult`. Pass `responseFormat: { "type": "json", "schema": { ... } }` via * [optsJson] for schema control; aimux-core applies JSON repair before * parsing. * @@ -581,7 +578,7 @@ class Model internal constructor(handle: Long) : Closeable { * Stream text from the model. * * Blocks the calling thread until the stream completes. Recoverable - * frame errors (a malformed SSE frame) arrive as `StreamPart::Error` + * frame errors (a malformed SSE frame) arrive as `error` stream parts * data parts and the stream continues; only transport/Core failures * throw [AimuxException] (on_done is not invoked on failure), after any * parts already delivered to [onPart]. @@ -893,7 +890,9 @@ class Model internal constructor(handle: Long) : Closeable { * @param apiKey API key, or null to read the provider's env var from * the registry entry. * @param modelId Model id. - * @param configJson Optional JSON object of ProviderOptions; null for defaults. + * @param configJson Optional JSON object of settings (`baseUrl`, `headers`, + * `organization`, `project`, `params`); any other key is + * rejected as an invalid argument. Null for defaults. */ fun provider(name: String, apiKey: String? = null, modelId: String, configJson: String? = null): Model { requireJson("configJson", configJson) @@ -943,7 +942,7 @@ class Model internal constructor(handle: Long) : Closeable { * @param models child models (must be non-empty). * @param configJson optional config: `{"router": "rule"|"weighted", * "weights": [...], "fallback": "on_error"|"none", - * "provider_name", "model_id"}` — all optional. + * "providerName", "modelId"}` — all optional. * @throws IllegalArgumentException if `configJson` is malformed JSON. * @throws AimuxException on AiMuxError. */ @@ -982,22 +981,6 @@ class Model internal constructor(handle: Long) : Closeable { }) } - /** - * Register external OpenAI-compatible providers from a JSON config string - * (RFC-0020). - * - * `configJson` is `{ "providers": [ { "name", "base_url", ... } ] }`. - * Entries override same-named built-ins or add new ones. Like - * `initRecording`, this mutates process-global registry state. - * - * @throws IllegalArgumentException if `configJson` is blank or malformed JSON. - * @throws AimuxException on AiMuxError (bad schema / unknown protocol). - */ - fun registerProviders(configJson: String) { - requireJsonRequired("configJson", configJson) - FFI.lib.aimux_register_providers(configJson)?.let { throw expectAimuxError(it, "registerProviders") } - } - /** * Set the global proxy configuration (M6, RFC-0016). Must be called * before the first `generateText` / `streamText` call; a no-op if the diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Multimodal.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Multimodal.kt index eeaa2fae1..ef60b6ba9 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Multimodal.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Multimodal.kt @@ -234,8 +234,8 @@ class TranscriptionModel private constructor(handle: Long) : Closeable { * Start a streaming transcription session (RFC-0028) on this model. * Requires a model that supports streaming (realtime models). * - * @param optsJson optional session options JSON (`input_audio_format` / - * `provider_options` / `headers` / `include_raw_chunks`). + * @param optsJson optional session options JSON (`inputAudioFormat` / + * `providerOptions` / `headers` / `includeRawChunks`). * @param abortHandle abort handle (from `Model.abortSignalNew()`), or 0. * @return a new live session. */ diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/MultimodalTypes.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/MultimodalTypes.kt index 3f547296f..8fb8fdedf 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/MultimodalTypes.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/MultimodalTypes.kt @@ -1,31 +1,34 @@ /** * aimux — typed multimodal data structures mirroring the aimux-core wire format. * - * Same shapes as the ts-rs generated `.ts` types in `bindings/node/src/types/` and - * the Go structs in `bindings/go/multimodal_types.go`. Field names use camelCase - * in Kotlin and map to the wire format's snake_case via [kotlinx.serialization.SerialName]. + * Same shapes as the ts-rs generated `.ts` types in `bindings/node/src/types/`: + * camelCase field names (Kotlin property names equal the wire names), unions + * tagged on `type`, optional fields absent rather than null. * - * These types are intentionally lenient on decode (unknown keys ignored, every - * field has a default) so future engine additions don't break existing clients. - * The serialization config lives in [AimuxJson]. Decode a JSON string returned - * by a [Multimodal][aimux] model with, for example: + * Decode is lenient (unknown keys ignored, every result field has a default) + * so future engine additions don't break existing clients. The serialization + * config lives in [AimuxJson]. Decode a JSON string returned by a + * [Multimodal][aimux] model with, for example: * * ```kotlin * val result: EmbeddingResult = AimuxJson.decodeFromString(EmbeddingResult.serializer(), jsonStr) * ``` * - * The `Base64`/`Binary`/`Url` unions ([AudioData], [ImageOutputs], [VideoData]) - * are serde-style externally-tagged enums on the wire - * (`{"Base64": ...}` / `{"Binary": ...}` / `{"Url": ...}`), so each has a custom - * serializer (see [StreamPartSerializer] in `Types.kt` for the same pattern). + * [AudioData] and [ImageOutputs] are untagged unions on the wire (a string is + * base64, an array is binary), so each has a small custom serializer; the + * `Array | string` input fields reuse [FileBytes]. */ package ai.arcships.aimux +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.encoding.Decoder @@ -34,18 +37,45 @@ import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonEncoder -import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive // ───────────────────────────────────────────────────────────────────────────── // Shared types. // ───────────────────────────────────────────────────────────────────────────── -/** A provider warning (arbitrary JSON). Mirrors Go's `Warning = json.RawMessage`. */ -typealias Warning = JsonElement +/** A pixel size. Mirrors `Size.ts`: serialized as the string `"WxH"` (e.g. `"1024x1024"`). */ +@Serializable(with = SizeSerializer::class) +data class Size(val width: Int, val height: Int) + +/** An aspect ratio. Mirrors `AspectRatio.ts`: serialized as the string `"W:H"` (e.g. `"16:9"`). */ +@Serializable(with = AspectRatioSerializer::class) +data class AspectRatio(val width: Int, val height: Int) + +private fun parsePair(kind: String, sep: Char, text: String): Pair { + val parts = text.split(sep) + val w = parts.getOrNull(0)?.toIntOrNull() + val h = parts.getOrNull(1)?.toIntOrNull() + if (parts.size != 2 || w == null || h == null) { + throw SerializationException("$kind must look like \"W${sep}H\", got: \"$text\"") + } + return w to h +} + +object SizeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("aimux.Size", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): Size = + parsePair("Size", 'x', decoder.decodeString()).let { Size(it.first, it.second) } + override fun serialize(encoder: Encoder, value: Size) = encoder.encodeString("${value.width}x${value.height}") +} + +object AspectRatioSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("aimux.AspectRatio", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): AspectRatio = + parsePair("AspectRatio", ':', decoder.decodeString()).let { AspectRatio(it.first, it.second) } + override fun serialize(encoder: Encoder, value: AspectRatio) = encoder.encodeString("${value.width}:${value.height}") +} // ───────────────────────────────────────────────────────────────────────────── // Embedding @@ -69,7 +99,7 @@ data class EmbeddingResponse( data class EmbeddingResult( val embeddings: List> = emptyList(), val usage: EmbeddingUsage? = null, - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + val providerMetadata: ProviderMetadata? = null, val response: EmbeddingResponse? = null, val warnings: List = emptyList(), ) @@ -78,9 +108,9 @@ data class EmbeddingResult( @Serializable data class EmbeddingCallOptions( val values: List = emptyList(), - @SerialName("max_retries") val maxRetries: Long? = null, + val maxRetries: Long? = null, val timeout: TimeoutConfiguration? = null, - @SerialName("provider_options") val providerOptions: JsonElement? = null, + val providerOptions: ProviderOptions? = null, val headers: Map? = null, ) @@ -91,7 +121,7 @@ data class EmbeddingCallOptions( /** * Generated audio: a base64 string or raw binary bytes. * - * Wire format (serde externally-tagged): `{"Base64": "..."}` | `{"Binary": [n,...]}`. + * Wire format (untagged, `AudioData.ts`): `"..."` (base64) | `[n,...]` (binary). */ @Serializable(with = AudioDataSerializer::class) sealed interface AudioData { @@ -112,7 +142,7 @@ data class SpeechRequest( @Serializable data class SpeechResponse( val timestamp: String? = null, - @SerialName("model_id") val modelId: String? = null, + val modelId: String? = null, val headers: Map? = null, val body: JsonElement? = null, ) @@ -124,7 +154,7 @@ data class SpeechResult( val warnings: List = emptyList(), val request: SpeechRequest? = null, val response: SpeechResponse = SpeechResponse(), - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + val providerMetadata: ProviderMetadata? = null, ) /** Options for speech generation. */ @@ -132,13 +162,13 @@ data class SpeechResult( data class SpeechCallOptions( val text: String = "", val voice: String? = null, - @SerialName("output_format") val outputFormat: String? = null, + val outputFormat: String? = null, val instructions: String? = null, val speed: Double? = null, val language: String? = null, - @SerialName("max_retries") val maxRetries: Long? = null, + val maxRetries: Long? = null, val timeout: TimeoutConfiguration? = null, - @SerialName("provider_options") val providerOptions: JsonElement? = null, + val providerOptions: ProviderOptions? = null, val headers: Map? = null, ) @@ -149,8 +179,7 @@ data class SpeechCallOptions( /** * Generated images: all base64 strings or all binary byte arrays. * - * Wire format (serde externally-tagged): - * `{"Base64": ["...", ...]}` | `{"Binary": [[n,...], ...]}`. + * Wire format (untagged, `ImageOutputs.ts`): `["...", ...]` | `[[n,...], ...]`. */ @Serializable(with = ImageOutputsSerializer::class) sealed interface ImageOutputs { @@ -164,16 +193,16 @@ sealed interface ImageOutputs { /** Token usage for image generation (if reported). */ @Serializable data class ImageUsage( - @SerialName("input_tokens") val inputTokens: Long? = null, - @SerialName("output_tokens") val outputTokens: Long? = null, - @SerialName("total_tokens") val totalTokens: Long? = null, + val inputTokens: Long? = null, + val outputTokens: Long? = null, + val totalTokens: Long? = null, ) /** Provider response metadata for images. */ @Serializable data class ImageResponse( val timestamp: String? = null, - @SerialName("model_id") val modelId: String? = null, + val modelId: String? = null, val headers: Map? = null, ) @@ -182,24 +211,40 @@ data class ImageResponse( data class ImageResult( val images: ImageOutputs = ImageOutputs.Base64(emptyList()), val warnings: List = emptyList(), - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + val providerMetadata: ProviderMetadata? = null, val response: ImageResponse = ImageResponse(), val usage: ImageUsage? = null, ) -/** Options for image generation. */ +/** An input image (edit source or mask), tagged on `type`. Mirrors `ImageFile.ts`. */ +@Serializable +sealed interface ImageFile { + @Serializable + @SerialName("file") + data class File(val mediaType: String, val data: FileBytes) : ImageFile + + @Serializable + @SerialName("url") + data class Url(val url: String) : ImageFile +} + +/** + * Options for image generation. `n` and `providerOptions` are required on the + * wire, so they are always encoded (defaults: 1 image, no options). + */ +@OptIn(ExperimentalSerializationApi::class) @Serializable data class ImageCallOptions( val prompt: String? = null, - val n: Int? = null, - val size: String? = null, - @SerialName("aspect_ratio") val aspectRatio: String? = null, + @EncodeDefault val n: Int = 1, + val size: Size? = null, + val aspectRatio: AspectRatio? = null, val seed: Long? = null, - val files: List = emptyList(), - val mask: JsonElement? = null, - @SerialName("max_retries") val maxRetries: Long? = null, + val files: List? = null, + val mask: ImageFile? = null, + @EncodeDefault val providerOptions: ProviderOptions = emptyMap(), + val maxRetries: Long? = null, val timeout: TimeoutConfiguration? = null, - @SerialName("provider_options") val providerOptions: JsonElement? = null, val headers: Map? = null, ) @@ -210,26 +255,22 @@ data class ImageCallOptions( /** A transcript segment with timing. */ @Serializable data class TranscriptionSegment( - // Required f64 in the core (no Option) — see TranscriptionSegment.ts — so - // non-nullable here; the defaults follow the file's lenient-decode contract. - // Decode-only in practice: segments arrive from the core, which always emits - // all three fields, and nothing here sends them back. val text: String = "", - @SerialName("start_second") val startSecond: Double = 0.0, - @SerialName("end_second") val endSecond: Double = 0.0, + val startSecond: Double = 0.0, + val endSecond: Double = 0.0, ) /** Request metadata for transcription. */ @Serializable data class TranscriptionRequest( - @SerialName("body") val body: String? = null, + val body: String? = null, ) /** Provider response metadata for transcription. */ @Serializable data class TranscriptionResponse( val timestamp: String? = null, - @SerialName("model_id") val modelId: String? = null, + val modelId: String? = null, val headers: Map? = null, val body: JsonElement? = null, ) @@ -240,21 +281,21 @@ data class TranscriptionResult( val text: String = "", val segments: List = emptyList(), val language: String? = null, - @SerialName("duration_in_seconds") val durationInSeconds: Double? = null, + val durationInSeconds: Double? = null, val warnings: List = emptyList(), val request: TranscriptionRequest? = null, val response: TranscriptionResponse = TranscriptionResponse(), - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + val providerMetadata: ProviderMetadata? = null, ) -/** Options for transcription. */ +/** Options for transcription. `audio` is raw bytes or a base64 string. */ @Serializable data class TranscriptionCallOptions( - val audio: JsonElement = JsonObject(emptyMap()), - @SerialName("media_type") val mediaType: String = "", - @SerialName("max_retries") val maxRetries: Long? = null, + val audio: FileBytes = FileBytes.Base64(""), + val mediaType: String = "", + val maxRetries: Long? = null, val timeout: TimeoutConfiguration? = null, - @SerialName("provider_options") val providerOptions: JsonElement? = null, + val providerOptions: ProviderOptions? = null, val headers: Map? = null, ) @@ -266,7 +307,7 @@ data class TranscriptionCallOptions( @Serializable data class RerankingRank( val index: Int = 0, - @SerialName("relevance_score") val relevanceScore: Double = 0.0, + val relevanceScore: Double = 0.0, ) /** Provider response metadata for reranking. */ @@ -274,7 +315,7 @@ data class RerankingRank( data class RerankingResponse( val id: String? = null, val timestamp: String? = null, - @SerialName("model_id") val modelId: String? = null, + val modelId: String? = null, val headers: Map? = null, val body: JsonElement? = null, ) @@ -283,20 +324,32 @@ data class RerankingResponse( @Serializable data class RerankingResult( val ranking: List = emptyList(), - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, - val warnings: List = emptyList(), + val providerMetadata: ProviderMetadata? = null, + val warnings: List? = null, val response: RerankingResponse? = null, ) +/** The documents to rerank, tagged on `type`. Mirrors `RerankingDocuments.ts`. */ +@Serializable +sealed interface RerankingDocuments { + @Serializable + @SerialName("text") + data class Text(val values: List) : RerankingDocuments + + @Serializable + @SerialName("object") + data class Objects(val values: List) : RerankingDocuments +} + /** Options for reranking. */ @Serializable data class RerankingCallOptions( - val documents: JsonElement = JsonArray(emptyList()), + val documents: RerankingDocuments, val query: String = "", - @SerialName("top_n") val topN: Int? = null, - @SerialName("max_retries") val maxRetries: Long? = null, + val topN: Int? = null, + val maxRetries: Long? = null, val timeout: TimeoutConfiguration? = null, - @SerialName("provider_options") val providerOptions: JsonElement? = null, + val providerOptions: ProviderOptions? = null, val headers: Map? = null, ) @@ -304,50 +357,54 @@ data class RerankingCallOptions( // Video // ───────────────────────────────────────────────────────────────────────────── -/** The URL variant of [VideoData]. */ -@Serializable -data class VideoUrlData( - val url: String = "", - @SerialName("media_type") val mediaType: String = "", -) - -/** The base64 variant of [VideoData]. */ -@Serializable -data class VideoBase64Data( - val data: String = "", - @SerialName("media_type") val mediaType: String = "", -) - -/** The binary variant of [VideoData]. */ +/** Generated video, tagged on `type`. Mirrors `VideoData.ts`. */ @Serializable -data class VideoBinaryData( - val data: List = emptyList(), - @SerialName("media_type") val mediaType: String = "", -) - -/** - * Generated video: a URL, base64 string, or raw binary bytes. - * - * Wire format (serde externally-tagged): - * `{"Url": {...}}` | `{"Base64": {...}}` | `{"Binary": {...}}`. - */ -@Serializable(with = VideoDataSerializer::class) sealed interface VideoData { + val mediaType: String + /** A URL pointing at the generated video. */ - data class Url(val value: VideoUrlData) : VideoData + @Serializable + @SerialName("url") + data class Url(val url: String, override val mediaType: String) : VideoData /** Base64-encoded video. */ - data class Base64(val value: VideoBase64Data) : VideoData + @Serializable + @SerialName("base64") + data class Base64(val data: String, override val mediaType: String) : VideoData /** Raw binary video bytes (each element is a 0–255 byte value). */ - data class Binary(val value: VideoBinaryData) : VideoData + @Serializable + @SerialName("binary") + data class Binary(val data: List, override val mediaType: String) : VideoData +} + +/** An input video/image, tagged on `type`. Mirrors `VideoFile.ts`. */ +@Serializable +sealed interface VideoFile { + @Serializable + @SerialName("file") + data class File(val mediaType: String, val data: FileBytes) : VideoFile + + @Serializable + @SerialName("url") + data class Url(val url: String, val mediaType: String? = null) : VideoFile } +@Serializable +enum class VideoFrameType { + @SerialName("first_frame") FIRST_FRAME, + @SerialName("last_frame") LAST_FRAME, +} + +/** An image pinned to a frame of the video. Mirrors `VideoFrameImage.ts`. */ +@Serializable +data class VideoFrameImage(val image: VideoFile, val frameType: VideoFrameType) + /** Provider response metadata for video. */ @Serializable data class VideoResponse( val timestamp: String? = null, - @SerialName("model_id") val modelId: String? = null, + val modelId: String? = null, val headers: Map? = null, ) @@ -356,29 +413,36 @@ data class VideoResponse( data class VideoResult( val videos: List = emptyList(), val warnings: List = emptyList(), - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + val providerMetadata: ProviderMetadata? = null, val response: VideoResponse = VideoResponse(), ) /** Per-call pacing overrides for the Core-owned video status poll loop. */ @Serializable data class VideoPollOptions( - @SerialName("interval_ms") val intervalMs: Long? = null, - @SerialName("timeout_ms") val timeoutMs: Long? = null, + val intervalMs: Long? = null, + val timeoutMs: Long? = null, ) -/** Options for video generation. */ +/** Options for video generation. `n` and `providerOptions` are always encoded. */ +@OptIn(ExperimentalSerializationApi::class) @Serializable data class VideoCallOptions( val prompt: String? = null, - val n: Int? = null, - @SerialName("aspect_ratio") val aspectRatio: String? = null, - val resolution: String? = null, + @EncodeDefault val n: Int = 1, + val aspectRatio: AspectRatio? = null, + val resolution: Size? = null, + val duration: Double? = null, + val fps: Double? = null, val seed: Long? = null, - @SerialName("max_retries") val maxRetries: Long? = null, + val image: VideoFile? = null, + val frameImages: List? = null, + val inputReferences: List? = null, + val generateAudio: Boolean? = null, + @EncodeDefault val providerOptions: ProviderOptions = emptyMap(), + val maxRetries: Long? = null, val poll: VideoPollOptions? = null, val timeout: TimeoutConfiguration? = null, - @SerialName("provider_options") val providerOptions: JsonElement? = null, val headers: Map? = null, ) @@ -392,8 +456,9 @@ data class SearchResultItem( val title: String? = null, val url: String? = null, val content: String? = null, - @SerialName("raw_content") val rawContent: String? = null, + val rawContent: String? = null, val score: Double? = null, + val providerMetadata: ProviderMetadata? = null, ) /** Provider response metadata for search. */ @@ -408,7 +473,7 @@ data class SearchResponse( data class SearchResult( val results: List = emptyList(), val answer: String? = null, - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + val providerMetadata: ProviderMetadata? = null, val warnings: List = emptyList(), val response: SearchResponse? = null, ) @@ -417,14 +482,14 @@ data class SearchResult( @Serializable data class SearchCallOptions( val query: String = "", - @SerialName("max_results") val maxResults: Int? = null, - @SerialName("include_raw_content") val includeRawContent: Boolean? = null, - @SerialName("time_range") val timeRange: String? = null, - @SerialName("include_domains") val includeDomains: List = emptyList(), - @SerialName("exclude_domains") val excludeDomains: List = emptyList(), - @SerialName("max_retries") val maxRetries: Long? = null, + val maxResults: Int? = null, + val includeRawContent: Boolean? = null, + val timeRange: String? = null, + val includeDomains: List? = null, + val excludeDomains: List? = null, + val maxRetries: Long? = null, val timeout: TimeoutConfiguration? = null, - @SerialName("provider_options") val providerOptions: JsonElement? = null, + val providerOptions: ProviderOptions? = null, val headers: Map? = null, ) @@ -435,114 +500,88 @@ data class SearchCallOptions( /** Result of a file upload. */ @Serializable data class UploadFileResult( - @SerialName("provider_reference") val providerReference: Map = emptyMap(), - @SerialName("media_type") val mediaType: String? = null, + val providerReference: Map = emptyMap(), + val mediaType: String? = null, val filename: String? = null, - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + val providerMetadata: ProviderMetadata? = null, val warnings: List = emptyList(), ) +/** Upload payload, tagged on `type`. Mirrors `UploadFileData.ts`. */ +@Serializable +sealed interface UploadFileData { + @Serializable + @SerialName("data") + data class Data(val data: FileBytes) : UploadFileData + + @Serializable + @SerialName("text") + data class Text(val text: String) : UploadFileData +} + /** Options for a file upload. */ @Serializable data class UploadFileCallOptions( - val data: JsonElement = JsonObject(emptyMap()), - @SerialName("media_type") val mediaType: String = "", + val data: UploadFileData = UploadFileData.Data(FileBytes.Base64("")), + val mediaType: String = "", val filename: String? = null, - @SerialName("provider_options") val providerOptions: JsonElement? = null, + val providerOptions: ProviderOptions? = null, ) // ───────────────────────────────────────────────────────────────────────────── -// Custom serializers for the serde-style externally-tagged unions. -// (Same approach as [StreamPartSerializer] in `Types.kt`.) +// Custom serializers for the untagged unions. // ───────────────────────────────────────────────────────────────────────────── -/** (De)serializer for [AudioData]: `{"Base64": "..."}` | `{"Binary": [n,...]}`. */ +/** (De)serializer for [AudioData]: `"..."` | `[n,...]`. */ object AudioDataSerializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("aimux.AudioData") override fun deserialize(decoder: Decoder): AudioData { val json = decoder as? JsonDecoder ?: throw SerializationException("AudioData can only be decoded from JSON") - val obj = json.decodeJsonElement().jsonObject - require(obj.size == 1) { "AudioData must be a single-key object, got: $obj" } - val (tag, inner) = obj.entries.single() - return when (tag) { - "Base64" -> AudioData.Base64(inner.jsonPrimitive.content) - "Binary" -> AudioData.Binary(inner.jsonArray.map { it.jsonPrimitive.content.toInt() }) - else -> throw SerializationException("Unknown AudioData variant: '$tag'") + return when (val el = json.decodeJsonElement()) { + is JsonArray -> AudioData.Binary(el.map { it.jsonPrimitive.content.toInt() }) + is JsonPrimitive -> AudioData.Base64(el.content) + else -> throw SerializationException("AudioData must be a base64 string or a byte array, got: $el") } } override fun serialize(encoder: Encoder, value: AudioData) { val json = encoder as? JsonEncoder ?: throw SerializationException("AudioData can only be encoded to JSON") - val (tag, inner) = when (value) { - is AudioData.Base64 -> "Base64" to JsonPrimitive(value.value) - is AudioData.Binary -> "Binary" to JsonArray(value.value.map { JsonPrimitive(it) }) - } - json.encodeJsonElement(JsonObject(mapOf(tag to inner))) + json.encodeJsonElement( + when (value) { + is AudioData.Base64 -> JsonPrimitive(value.value) + is AudioData.Binary -> JsonArray(value.value.map { JsonPrimitive(it) }) + } + ) } } -/** (De)serializer for [ImageOutputs]: `{"Base64": [...]}` | `{"Binary": [[...],...]}`. */ +/** (De)serializer for [ImageOutputs]: `["...",...]` | `[[n,...],...]`. */ object ImageOutputsSerializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("aimux.ImageOutputs") override fun deserialize(decoder: Decoder): ImageOutputs { val json = decoder as? JsonDecoder ?: throw SerializationException("ImageOutputs can only be decoded from JSON") - val obj = json.decodeJsonElement().jsonObject - require(obj.size == 1) { "ImageOutputs must be a single-key object, got: $obj" } - val (tag, inner) = obj.entries.single() - return when (tag) { - "Base64" -> ImageOutputs.Base64(inner.jsonArray.map { it.jsonPrimitive.content }) - "Binary" -> ImageOutputs.Binary( - inner.jsonArray.map { row -> row.jsonArray.map { it.jsonPrimitive.content.toInt() } }, - ) - else -> throw SerializationException("Unknown ImageOutputs variant: '$tag'") + val items = json.decodeJsonElement().jsonArray + // An empty list carries no evidence either way; treat it as base64. + return if (items.firstOrNull() is JsonArray) { + ImageOutputs.Binary(items.map { row -> row.jsonArray.map { it.jsonPrimitive.content.toInt() } }) + } else { + ImageOutputs.Base64(items.map { it.jsonPrimitive.content }) } } override fun serialize(encoder: Encoder, value: ImageOutputs) { val json = encoder as? JsonEncoder ?: throw SerializationException("ImageOutputs can only be encoded to JSON") - val (tag, inner) = when (value) { - is ImageOutputs.Base64 -> "Base64" to JsonArray(value.value.map { JsonPrimitive(it) }) - is ImageOutputs.Binary -> "Binary" to JsonArray(value.value.map { row -> JsonArray(row.map { JsonPrimitive(it) }) }) - } - json.encodeJsonElement(JsonObject(mapOf(tag to inner))) - } -} - -/** (De)serializer for [VideoData]: `{"Url": {...}}` | `{"Base64": {...}}` | `{"Binary": {...}}`. */ -object VideoDataSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("aimux.VideoData") - - override fun deserialize(decoder: Decoder): VideoData { - val json = decoder as? JsonDecoder - ?: throw SerializationException("VideoData can only be decoded from JSON") - val obj = json.decodeJsonElement().jsonObject - require(obj.size == 1) { "VideoData must be a single-key object, got: $obj" } - val (tag, inner) = obj.entries.single() - val innerObj = inner as? JsonObject ?: JsonObject(emptyMap()) - val ctx = json.json - return when (tag) { - "Url" -> VideoData.Url(ctx.decodeFromJsonElement(VideoUrlData.serializer(), innerObj)) - "Base64" -> VideoData.Base64(ctx.decodeFromJsonElement(VideoBase64Data.serializer(), innerObj)) - "Binary" -> VideoData.Binary(ctx.decodeFromJsonElement(VideoBinaryData.serializer(), innerObj)) - else -> throw SerializationException("Unknown VideoData variant: '$tag'") - } - } - - override fun serialize(encoder: Encoder, value: VideoData) { - val json = encoder as? JsonEncoder - ?: throw SerializationException("VideoData can only be encoded to JSON") - val ctx = json.json - val (tag, inner) = when (value) { - is VideoData.Url -> "Url" to ctx.encodeToJsonElement(VideoUrlData.serializer(), value.value) - is VideoData.Base64 -> "Base64" to ctx.encodeToJsonElement(VideoBase64Data.serializer(), value.value) - is VideoData.Binary -> "Binary" to ctx.encodeToJsonElement(VideoBinaryData.serializer(), value.value) - } - json.encodeJsonElement(JsonObject(mapOf(tag to inner))) + json.encodeJsonElement( + when (value) { + is ImageOutputs.Base64 -> JsonArray(value.value.map { JsonPrimitive(it) }) + is ImageOutputs.Binary -> JsonArray(value.value.map { row -> JsonArray(row.map { JsonPrimitive(it) }) }) + } + ) } } diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/ToolCallRepair.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/ToolCallRepair.kt index dd913f937..3cae4e4c8 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/ToolCallRepair.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/ToolCallRepair.kt @@ -12,7 +12,6 @@ package ai.arcships.aimux -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -40,19 +39,19 @@ import java.util.concurrent.FutureTask */ @Serializable data class RawToolCall( - @SerialName("tool_call_id") val toolCallId: String, - @SerialName("tool_name") val toolName: String, + val toolCallId: String, + val toolName: String, val input: String, - @SerialName("provider_executed") val providerExecuted: Boolean? = null, + val providerExecuted: Boolean? = null, val dynamic: Boolean? = null, - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + val providerMetadata: ProviderMetadata? = null, ) /** * Everything a repair function is given about one invalid tool call. * * Mirrors the AI SDK `repairToolCall` argument. [error] is the serialized - * `AiMuxError` that made the call invalid (e.g. `{"InvalidToolInput": …}`), + * `AiMuxError` that made the call invalid (e.g. `{"name":"AI_InvalidToolInputError",…}`), * carried verbatim from the call rather than re-derived. [inputSchema] is the * JSON Schema of the named function tool, or the AI SDK's empty-object schema * when the name resolves to no function tool. [messages] and [instructions] @@ -60,9 +59,9 @@ data class RawToolCall( */ @Serializable data class ToolCallRepairContext( - @SerialName("tool_call") val toolCall: RawToolCall, + val toolCall: RawToolCall, val error: JsonElement, - @SerialName("input_schema") val inputSchema: JsonElement, + val inputSchema: JsonElement, val tools: List = emptyList(), val messages: List = emptyList(), val instructions: String? = null, @@ -88,7 +87,7 @@ typealias RepairToolCall = (ToolCallRepairContext) -> RawToolCall? /** * Build the repair argument for one invalid tool call. * - * @param toolCallJson One `tool_calls` entry with `"invalid": true`. + * @param toolCallJson One `toolCalls` entry with `"invalid": true`. * @param promptJson The prompt the call was generated with (the same string * passed to `generate_text` / `stream_text`). * @param optsJson The options the call was generated with (same rule). @@ -108,7 +107,7 @@ fun toolCallRepairContext(toolCallJson: String, promptJson: String, optsJson: St * Resolve one invalid tool call against a repair reply, returning the final * `ToolCall` JSON — valid, or invalid carrying a nested `ToolCallRepair` error. * - * @param replyJson `{"type":"repaired","tool_call":…}` | `{"type":"unchanged"}` + * @param replyJson `{"type":"repaired","toolCall":…}` | `{"type":"unchanged"}` * | `{"type":"failed","message":…}`. * @throws InvalidArgumentError when the options carry no tools, or the call is * not an invalid one. @@ -126,7 +125,7 @@ fun applyToolCallRepair(toolCallJson: String, optsJson: String?, replyJson: Stri * Apply a repair reply to a whole serialized result (`GenerateTextResult`, * `GenerateObjectResult` via its nested `raw`, or `StreamTextResultAggregated`). * - * Rewrites both `tool_calls` and the matching `response_messages` tool-call + * Rewrites both `toolCalls` and the matching `responseMessages` tool-call * part — the transcript must agree with the repaired call, or the next turn * sends the model the unrepaired arguments again. * @@ -167,7 +166,7 @@ private fun repairReply(repair: RepairToolCall, contextJson: String): String { val replacement = repair(context) ?: return REPLY_UNCHANGED buildJsonObject { put("type", "repaired") - put("tool_call", AimuxJson.encodeToJsonElement(RawToolCall.serializer(), replacement)) + put("toolCall", AimuxJson.encodeToJsonElement(RawToolCall.serializer(), replacement)) }.toString() } catch (e: Exception) { // A host exception has no typed counterpart in core; only its message @@ -197,8 +196,8 @@ internal fun repairToolCallsInResult( ): String { val root = AimuxJson.parseToJsonElement(resultJson) as? JsonObject ?: return resultJson // GenerateObjectResult nests the text result under `raw`. - val target = if (root.containsKey("tool_calls")) root else root["raw"] as? JsonObject ?: return resultJson - val invalid = (target["tool_calls"] as? JsonArray) + val target = if (root.containsKey("toolCalls")) root else root["raw"] as? JsonObject ?: return resultJson + val invalid = (target["toolCalls"] as? JsonArray) ?.mapNotNull { it as? JsonObject } ?.filter(::isInvalid) ?: return resultJson @@ -207,7 +206,7 @@ internal fun repairToolCallsInResult( for (call in invalid) { val contextJson = toolCallRepairContext(call.toString(), promptJson, optsJson) if (AimuxJson.parseToJsonElement(contextJson) is JsonNull) continue - val id = call["tool_call_id"]?.jsonPrimitive?.content ?: continue + val id = call["toolCallId"]?.jsonPrimitive?.content ?: continue patched = applyToolCallRepairToResult(patched, optsJson, id, repairReply(repair, contextJson)) } return patched @@ -250,7 +249,7 @@ internal fun offCallbackThread(model: Model, repair: RepairToolCall): RepairTool } /** - * Repair an invalid `ToolCall` stream part, returning the replacement part + * Repair an invalid `tool-call` stream part, returning the replacement part * JSON. Any other part (tool-input deltas included) is returned untouched, so * deltas still reach the caller immediately — AI SDK behaviour. */ @@ -260,13 +259,12 @@ internal fun repairToolCallStreamPart( optsJson: String?, repair: RepairToolCall, ): String { - // StreamPart is externally tagged: {"ToolCall": {…}}. + // A tool-call part is the ToolCall itself plus `"type": "tool-call"`. val part = AimuxJson.parseToJsonElement(partJson) as? JsonObject ?: return partJson - val call = part["ToolCall"] as? JsonObject ?: return partJson - if (!isInvalid(call)) return partJson + if ((part["type"] as? JsonPrimitive)?.content != "tool-call" || !isInvalid(part)) return partJson - val contextJson = toolCallRepairContext(call.toString(), promptJson, optsJson) + val contextJson = toolCallRepairContext(part.toString(), promptJson, optsJson) if (AimuxJson.parseToJsonElement(contextJson) is JsonNull) return partJson - val repaired = applyToolCallRepair(call.toString(), optsJson, repairReply(repair, contextJson)) - return JsonObject(part + ("ToolCall" to AimuxJson.parseToJsonElement(repaired))).toString() + val repaired = applyToolCallRepair(part.toString(), optsJson, repairReply(repair, contextJson)) + return JsonObject(mapOf("type" to JsonPrimitive("tool-call")) + (AimuxJson.parseToJsonElement(repaired) as JsonObject)).toString() } diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/TypedModel.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/TypedModel.kt index 8d5f2d3ed..6b2fac6db 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/TypedModel.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/TypedModel.kt @@ -265,7 +265,7 @@ class TypedModel(private val raw: Model, private val ownsModel: Boolean = false) rawPartJson } try { - onPart(AimuxJson.decodeFromString(StreamPartSerializer, partJson)) + onPart(AimuxJson.decodeFromString(StreamPart.serializer(), partJson)) } catch (error: Exception) { onError( "failed to decode StreamPart: ${error.message ?: error::class.simpleName}", diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt index 1d0e0b3ff..fb4d77b2b 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt @@ -2,13 +2,15 @@ * aimux — typed data classes mirroring the ts-rs output in `bindings/node/src/types` * (the generated TypeScript types). * - * Field names use camelCase in Kotlin and are mapped to the wire format's - * snake_case via [kotlinx.serialization.SerialName]. The raw JSON boundary is - * handled by [TypedModel] — callers of this layer never parse JSON by hand. + * The wire JSON is the AI SDK's: camelCase field names, unions tagged with a + * `type` key (a few use `role`, `name` or `sourceType`), optional fields + * absent rather than null. Kotlin property names equal the wire names, so no + * [kotlinx.serialization.SerialName] is needed on fields. The raw JSON boundary + * is handled by [TypedModel] — callers of this layer never parse JSON by hand. * - * These types intentionally lenient on decode (unknown keys ignored, every - * field has a default) so that future provider/engine additions do not break - * existing clients. The serialization config lives in [AimuxJson]. + * Decode is lenient (unknown keys ignored, every result field has a default) + * so engine additions do not break existing clients. The serialization config + * lives in [AimuxJson]. */ package ai.arcships.aimux @@ -31,17 +33,18 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive // ───────────────────────────────────────────────────────────────────────────── // Shared Json instance. // // - ignoreUnknownKeys : tolerate forward-compatible fields from the engine. -// - explicitNulls=false: omit null fields when encoding (so GenerateTextOptions -// only carries fields the caller actually set, matching -// the engine's optional-everything schema). +// - explicitNulls=false: omit null fields when encoding (optional fields are +// absent on the wire, never null). // - encodeDefaults=false: do not encode default values (keeps payloads small). +// +// Unions tagged with `type` use kotlinx's default sealed-class polymorphism +// (`classDiscriminator` stays "type"). // ───────────────────────────────────────────────────────────────────────────── val AimuxJson: Json = Json { @@ -50,6 +53,12 @@ val AimuxJson: Json = Json { encodeDefaults = false } +/** Provider-specific options / metadata: `{ providerName: { key: value } }`. */ +typealias ProviderOptions = Map> + +/** Provider-specific metadata on a result (same shape as [ProviderOptions]). */ +typealias ProviderMetadata = Map> + // ───────────────────────────────────────────────────────────────────────────── // Enums (simple string enums on the wire). // ───────────────────────────────────────────────────────────────────────────── @@ -90,9 +99,9 @@ enum class ReasoningEffort { @Serializable data class InputTokenUsage( val total: Long? = null, - @SerialName("no_cache") val noCache: Long? = null, - @SerialName("cache_read") val cacheRead: Long? = null, - @SerialName("cache_write") val cacheWrite: Long? = null, + val noCache: Long? = null, + val cacheRead: Long? = null, + val cacheWrite: Long? = null, ) @Serializable @@ -102,16 +111,11 @@ data class OutputTokenUsage( val reasoning: Long? = null, ) -/** - * Token usage statistics. - * - * Mirrors `Usage.ts`: `{ input_tokens: InputTokenUsage, output_tokens: OutputTokenUsage, - * raw?: JsonValue | null }`. - */ +/** Token usage statistics. Mirrors `Usage.ts`. */ @Serializable data class Usage( - @SerialName("input_tokens") val inputTokens: InputTokenUsage = InputTokenUsage(), - @SerialName("output_tokens") val outputTokens: OutputTokenUsage = OutputTokenUsage(), + val inputTokens: InputTokenUsage = InputTokenUsage(), + val outputTokens: OutputTokenUsage = OutputTokenUsage(), val raw: JsonObject? = null, ) { companion object { @@ -121,35 +125,27 @@ data class Usage( } } -/** - * Unified finish reason. - * - * Mirrors `FinishReason.ts`: `{ unified: FinishReasonUnified, raw: string | null }`. - */ +/** Unified finish reason. Mirrors `FinishReason.ts`: `raw` is absent when the provider sent none. */ @Serializable data class FinishReason( val unified: FinishReasonUnified = FinishReasonUnified.OTHER, val raw: String? = null, ) -/** - * Metadata about the API response. - * - * Mirrors `ResponseMetadata.ts`: `{ id: string | null, timestamp: string | null, - * model_id: string | null }`. - */ +/** Mirrors `ResponseMetadata.ts`. */ @Serializable data class ResponseMetadata( val id: String? = null, val timestamp: String? = null, - @SerialName("model_id") val modelId: String? = null, + val modelId: String? = null, ) +/** Mirrors `ResponseInfo.ts`: [ResponseMetadata] plus the HTTP headers and body. */ @Serializable data class ResponseInfo( val id: String? = null, val timestamp: String? = null, - @SerialName("model_id") val modelId: String? = null, + val modelId: String? = null, val headers: Map? = null, val body: JsonElement? = null, ) @@ -158,26 +154,21 @@ data class ResponseInfo( data class RequestInfo(val body: JsonElement? = null) /** - * A tool call requested by the model. - * - * Mirrors `ToolCall.ts`: `{ tool_call_id, tool_name, input: JsonValue, - * provider_executed?: bool | null, dynamic?: bool | null, - * provider_metadata?: JsonValue | null }`. + * A tool call requested by the model. Mirrors `ToolCall.ts`. * - * `input` is a [JsonElement] because it is usually an arbitrary JSON object - * (the tool arguments) whose shape is tool-specific. - * - * `invalid` is set by Core when the tool call stays invalid after optional - * repair; `error` is the typed lookup, parse, schema, or repair failure. + * `input` is the parsed argument value (usually an object). `invalid` is set + * by Core when the call stays invalid after optional repair; `error` is the + * `name`-tagged `AiMuxError` JSON of the lookup, parse, schema, or repair + * failure (`{"name":"AI_NoSuchToolError",...}`). */ @Serializable data class ToolCall( - @SerialName("tool_call_id") val toolCallId: String, - @SerialName("tool_name") val toolName: String, + val toolCallId: String, + val toolName: String, val input: JsonElement = JsonObject(emptyMap()), - @SerialName("provider_executed") val providerExecuted: Boolean? = null, - @SerialName("dynamic") val dynamic: Boolean? = null, - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + val providerExecuted: Boolean? = null, + val dynamic: Boolean? = null, + val providerMetadata: ProviderMetadata? = null, val invalid: Boolean? = null, val error: JsonElement? = null, ) @@ -185,34 +176,24 @@ data class ToolCall( // ───────────────────────────────────────────────────────────────────────────── // Tools (input side of GenerateTextOptions). // -// `Tool` is an internally-tagged union on `type` (`"function" | "provider"`), -// which matches kotlinx.serialization's default sealed-class polymorphism with -// the `"type"` discriminator — no custom serializer needed. +// `Tool` is an internally-tagged union on `type` (`"function" | "provider"`). // ───────────────────────────────────────────────────────────────────────────── @Serializable data class FunctionToolInputExample(val input: JsonObject) -/** - * A user-defined function tool. - * - * Mirrors `FunctionTool.ts`. `input_schema` is a [JsonElement] (a JSON Schema). - */ +/** A user-defined function tool. Mirrors `FunctionTool.ts`; `inputSchema` is a JSON Schema. */ @Serializable data class FunctionTool( val name: String, val description: String? = null, - @SerialName("input_schema") val inputSchema: JsonElement, + val inputSchema: JsonElement, val strict: Boolean? = null, - @SerialName("provider_options") val providerOptions: Map? = null, - @SerialName("input_examples") val inputExamples: List? = null, + val providerOptions: ProviderOptions? = null, + val inputExamples: List? = null, ) -/** - * A provider-defined tool (e.g. `anthropic.web_search_20250305`). - * - * Mirrors `ProviderTool.ts`. - */ +/** A provider-defined tool (e.g. `anthropic.web_search_20250305`). Mirrors `ProviderTool.ts`. */ @Serializable data class ProviderTool( val id: String, @@ -221,10 +202,8 @@ data class ProviderTool( ) /** - * A tool that can be either a function tool or a provider tool. - * - * Mirrors `Tool.ts`. Serialized as `{"type":"function", ...}` / - * `{"type":"provider", ...}` (internal `"type"` discriminator). + * A function tool or a provider tool. Mirrors `Tool.ts`; serialized as + * `{"type":"function", ...}` / `{"type":"provider", ...}`. */ @Serializable sealed interface Tool { @@ -233,10 +212,10 @@ sealed interface Tool { data class Function( val name: String, val description: String? = null, - @SerialName("input_schema") val inputSchema: JsonElement, + val inputSchema: JsonElement, val strict: Boolean? = null, - @SerialName("provider_options") val providerOptions: Map? = null, - @SerialName("input_examples") val inputExamples: List? = null, + val providerOptions: ProviderOptions? = null, + val inputExamples: List? = null, ) : Tool { companion object { /** Convenience constructor from a [FunctionTool]. */ @@ -268,8 +247,8 @@ sealed interface Tool { * How the model should choose tools. * * Mirrors `ToolChoice.ts`: `"auto" | "none" | "required" | { type: "tool", - * toolName: "..." }`. This is a mixed tagged/untagged shape (bare strings plus a - * tagged object), so a custom serializer handles the two forms. + * toolName: "..." }`. This is a mixed untagged/tagged shape (bare strings plus + * a tagged object), so a custom serializer handles the two forms. */ @Serializable(with = ToolChoiceSerializer::class) sealed interface ToolChoice { @@ -329,170 +308,254 @@ object ToolChoiceSerializer : KSerializer { } // ───────────────────────────────────────────────────────────────────────────── -// ContentPart (multi-part message content). -// -// `ContentPart` is an internally-tagged enum (`{"type": "text", "text": ...}`, -// `{"type": "image", "image": [...], ...}`). A custom serializer dispatches on -// the `"type"` key and re-injects it on encode. +// File bytes / file data. // ───────────────────────────────────────────────────────────────────────────── /** - * A part of a multi-part message. + * Raw bytes or a base64 string. Mirrors `FileBytes.ts`: `Array | string` + * (untagged — a JSON array is binary, a JSON string is base64). Also the shape + * of every `Array | string` field (audio input, image / video file data). + */ +@Serializable(with = FileBytesSerializer::class) +sealed interface FileBytes { + + /** Raw binary bytes (a JSON array of 0–255 ints on the wire). */ + data class Binary(val data: List = emptyList()) : FileBytes + + /** A base64-encoded string. */ + data class Base64(val data: String = "") : FileBytes +} + +object FileBytesSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("aimux.FileBytes") + + override fun deserialize(decoder: Decoder): FileBytes { + val json = decoder as? JsonDecoder + ?: throw SerializationException("FileBytes can only be decoded from JSON") + return when (val el = json.decodeJsonElement()) { + is JsonArray -> FileBytes.Binary(el.map { it.jsonPrimitive.content.toInt() }) + is JsonPrimitive -> FileBytes.Base64(el.content) + else -> throw SerializationException("FileBytes must be a byte array or a base64 string, got: $el") + } + } + + override fun serialize(encoder: Encoder, value: FileBytes) { + val json = encoder as? JsonEncoder + ?: throw SerializationException("FileBytes can only be encoded to JSON") + json.encodeJsonElement( + when (value) { + is FileBytes.Binary -> JsonArray(value.data.map { JsonPrimitive(it) }) + is FileBytes.Base64 -> JsonPrimitive(value.data) + } + ) + } +} + +/** File data, tagged on `type`. Mirrors `FileData.ts`. */ +@Serializable +sealed interface FileData { + + @Serializable + @SerialName("data") + data class Data(val data: FileBytes = FileBytes.Base64("")) : FileData + + @Serializable + @SerialName("url") + data class Url(val url: String = "", val originalUrl: String? = null) : FileData + + @Serializable + @SerialName("reference") + data class Reference(val reference: Map = emptyMap()) : FileData + + @Serializable + @SerialName("text") + data class Text(val text: String = "") : FileData +} + +/** Generated file data, tagged on `type`. Mirrors `GeneratedFileData.ts`. */ +@Serializable +sealed interface GeneratedFileData { + + @Serializable + @SerialName("data") + data class Data(val data: FileBytes = FileBytes.Base64("")) : GeneratedFileData + + @Serializable + @SerialName("url") + data class Url(val url: String = "", val originalUrl: String? = null) : GeneratedFileData +} + +/** A file produced by the model. Mirrors `GeneratedFile.ts`. */ +@Serializable +data class GeneratedFile( + val data: GeneratedFileData = GeneratedFileData.Data(), + val mediaType: String = "", + val providerMetadata: ProviderMetadata? = null, +) + +// ───────────────────────────────────────────────────────────────────────────── +// Tool results inside a prompt (ContentPart `tool-result`). +// ───────────────────────────────────────────────────────────────────────────── + +/** One element of a `content` tool output. Mirrors `ToolResultContent.ts`. */ +@Serializable +sealed interface ToolResultContent { + @Serializable + @SerialName("text") + data class Text(val text: String = "", val providerOptions: ProviderOptions? = null) : ToolResultContent + + @Serializable + @SerialName("file") + data class File( + val data: FileData = FileData.Data(), + val mediaType: String = "", + val filename: String? = null, + val providerOptions: ProviderOptions? = null, + ) : ToolResultContent + + @Serializable + @SerialName("custom") + data class Custom(val providerOptions: ProviderOptions? = null) : ToolResultContent +} + +/** The output of a tool call, tagged on `type`. Mirrors `ToolResultOutput.ts`. */ +@Serializable +sealed interface ToolResultOutput { + @Serializable + @SerialName("text") + data class Text(val value: String, val providerOptions: ProviderOptions? = null) : ToolResultOutput + + @Serializable + @SerialName("json") + data class JsonValue(val value: JsonElement, val providerOptions: ProviderOptions? = null) : ToolResultOutput + + @Serializable + @SerialName("execution-denied") + data class ExecutionDenied(val reason: String? = null, val providerOptions: ProviderOptions? = null) : ToolResultOutput + + @Serializable + @SerialName("error-text") + data class ErrorText(val value: String, val providerOptions: ProviderOptions? = null) : ToolResultOutput + + @Serializable + @SerialName("error-json") + data class ErrorJson(val value: JsonElement, val providerOptions: ProviderOptions? = null) : ToolResultOutput + + @Serializable + @SerialName("content") + data class Content(val value: List) : ToolResultOutput +} + +// ───────────────────────────────────────────────────────────────────────────── +// ContentPart (multi-part message content), tagged on `type`. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * A part of a multi-part message. Mirrors `ContentPart.ts`. * - * Mirrors `ContentPart.ts` (internally tagged on `type`). Shared between - * [ModelMessage] (user-facing) and the provider-facing prompt. Every field has - * a default and unknown keys are ignored on decode (see [AimuxJson]), so future - * part additions do not break existing clients. `provider_options` is a - * [JsonElement] because it is an opaque `Record`. + * Shared by [ModelMessage] (user-facing) and the provider-facing prompt. A + * tool result carries a [ToolResultOutput] (`output`), not a bare value. */ -@Serializable(with = ContentPartSerializer::class) +@Serializable sealed interface ContentPart { @Serializable - data class Custom(val kind: String, @SerialName("provider_options") val providerOptions: JsonElement? = null) : ContentPart + @SerialName("text") + data class Text(val text: String = "", val providerOptions: ProviderOptions? = null) : ContentPart @Serializable - data class ReasoningFile(val data: GeneratedFileData, @SerialName("media_type") val mediaType: String, @SerialName("provider_options") val providerOptions: JsonElement? = null) : ContentPart + @SerialName("custom") + data class Custom(val kind: String, val providerOptions: ProviderOptions? = null) : ContentPart @Serializable - data class ToolApprovalRequest(@SerialName("approval_id") val approvalId: String, @SerialName("tool_call_id") val toolCallId: String, val reason: String? = null, @SerialName("is_automatic") val isAutomatic: Boolean? = null, val signature: String? = null, @SerialName("input_schema_input") val inputSchemaInput: JsonElement? = null) : ContentPart - + @SerialName("reasoning-file") + data class ReasoningFile( + val data: GeneratedFileData, + val mediaType: String, + val providerOptions: ProviderOptions? = null, + ) : ContentPart @Serializable - data class Text( - val text: String = "", - @SerialName("provider_options") val providerOptions: JsonElement? = null, + @SerialName("tool-approval-request") + data class ToolApprovalRequest( + val approvalId: String, + val toolCallId: String, + val reason: String? = null, + val isAutomatic: Boolean? = null, + val signature: String? = null, + val inputSchemaInput: JsonElement? = null, ) : ContentPart @Serializable + @SerialName("image") data class Image( val image: List = emptyList(), - @SerialName("media_type") val mediaType: String = "", - @SerialName("provider_options") val providerOptions: JsonElement? = null, + val mediaType: String = "", + val providerOptions: ProviderOptions? = null, ) : ContentPart @Serializable + @SerialName("file") data class File( val data: List = emptyList(), - @SerialName("media_type") val mediaType: String = "", + val mediaType: String = "", val filename: String? = null, - @SerialName("provider_options") val providerOptions: JsonElement? = null, + val providerOptions: ProviderOptions? = null, ) : ContentPart @Serializable + @SerialName("file-base64") data class FileBase64( val data: String = "", - @SerialName("media_type") val mediaType: String = "", + val mediaType: String = "", val filename: String? = null, - @SerialName("provider_options") val providerOptions: JsonElement? = null, + val providerOptions: ProviderOptions? = null, ) : ContentPart @Serializable + @SerialName("file-url") data class FileUrl( val url: String = "", - @SerialName("media_type") val mediaType: String = "", - @SerialName("provider_options") val providerOptions: JsonElement? = null, + val mediaType: String = "", + val providerOptions: ProviderOptions? = null, ) : ContentPart @Serializable + @SerialName("file-reference") data class FileReference( - @SerialName("media_type") val mediaType: String = "", + val mediaType: String = "", val reference: JsonElement = JsonObject(emptyMap()), val filename: String? = null, - @SerialName("provider_options") val providerOptions: JsonElement? = null, + val providerOptions: ProviderOptions? = null, ) : ContentPart @Serializable + @SerialName("reasoning") data class Reasoning( val text: String = "", val signature: String? = null, - @SerialName("provider_options") val providerOptions: JsonElement? = null, + val providerOptions: ProviderOptions? = null, ) : ContentPart @Serializable + @SerialName("tool-call") data class ToolCall( - @SerialName("tool_call_id") val toolCallId: String = "", - @SerialName("tool_name") val toolName: String = "", + val toolCallId: String = "", + val toolName: String = "", val input: JsonElement = JsonObject(emptyMap()), - @SerialName("provider_options") val providerOptions: JsonElement? = null, - @SerialName("provider_executed") val providerExecuted: Boolean? = null, + val providerExecuted: Boolean? = null, + val providerOptions: ProviderOptions? = null, ) : ContentPart @Serializable + @SerialName("tool-result") data class ToolResult( - @SerialName("tool_call_id") val toolCallId: String = "", - val result: JsonElement = JsonObject(emptyMap()), - @SerialName("tool_name") val toolName: String? = null, - @SerialName("is_error") val isError: Boolean? = null, - @SerialName("preliminary") val preliminary: Boolean? = null, - @SerialName("dynamic") val dynamic: Boolean? = null, - @SerialName("provider_options") val providerOptions: JsonElement? = null, + val toolCallId: String, + val toolName: String, + val output: ToolResultOutput, + val providerOptions: ProviderOptions? = null, ) : ContentPart } -/** - * Custom (de)serializer for [ContentPart]. - * - * The wire format is internally tagged: each part is a JSON object whose - * `"type"` key selects the variant. On decode the `"type"` key is read and the - * matching variant's generated serializer decodes the object (the `"type"` key - * is ignored thanks to `ignoreUnknownKeys`). On encode the variant is encoded - * and the `"type"` key is re-injected. - */ -object ContentPartSerializer : KSerializer { - override val descriptor: SerialDescriptor = - buildClassSerialDescriptor("aimux.ContentPart") - - override fun deserialize(decoder: Decoder): ContentPart { - val json = decoder as? JsonDecoder - ?: throw SerializationException("ContentPart can only be decoded from JSON") - val element = json.decodeJsonElement() - val obj = element.jsonObject - val type = obj["type"]?.jsonPrimitive?.content - ?: throw SerializationException("ContentPart is missing the 'type' discriminator: $element") - val ctx = json.json - return when (type) { - "custom" -> ctx.decodeFromJsonElement(ContentPart.Custom.serializer(), obj) - "reasoning_file" -> ctx.decodeFromJsonElement(ContentPart.ReasoningFile.serializer(), obj) - "tool_approval_request" -> ctx.decodeFromJsonElement(ContentPart.ToolApprovalRequest.serializer(), obj) - "text" -> ctx.decodeFromJsonElement(ContentPart.Text.serializer(), obj) - "image" -> ctx.decodeFromJsonElement(ContentPart.Image.serializer(), obj) - "file" -> ctx.decodeFromJsonElement(ContentPart.File.serializer(), obj) - "file_base64" -> ctx.decodeFromJsonElement(ContentPart.FileBase64.serializer(), obj) - "file_url" -> ctx.decodeFromJsonElement(ContentPart.FileUrl.serializer(), obj) - "file_reference" -> ctx.decodeFromJsonElement(ContentPart.FileReference.serializer(), obj) - "reasoning" -> ctx.decodeFromJsonElement(ContentPart.Reasoning.serializer(), obj) - "tool_call" -> ctx.decodeFromJsonElement(ContentPart.ToolCall.serializer(), obj) - "tool_result" -> ctx.decodeFromJsonElement(ContentPart.ToolResult.serializer(), obj) - else -> throw SerializationException("Unknown ContentPart type: '$type'") - } - } - - override fun serialize(encoder: Encoder, value: ContentPart) { - val json = encoder as? JsonEncoder - ?: throw SerializationException("ContentPart can only be encoded to JSON") - val ctx = json.json - val (typeTag, inner) = when (value) { - is ContentPart.Custom -> "custom" to ctx.encodeToJsonElement(ContentPart.Custom.serializer(), value) - is ContentPart.ReasoningFile -> "reasoning_file" to ctx.encodeToJsonElement(ContentPart.ReasoningFile.serializer(), value) - is ContentPart.ToolApprovalRequest -> "tool_approval_request" to ctx.encodeToJsonElement(ContentPart.ToolApprovalRequest.serializer(), value) - is ContentPart.Text -> "text" to ctx.encodeToJsonElement(ContentPart.Text.serializer(), value) - is ContentPart.Image -> "image" to ctx.encodeToJsonElement(ContentPart.Image.serializer(), value) - is ContentPart.File -> "file" to ctx.encodeToJsonElement(ContentPart.File.serializer(), value) - is ContentPart.FileBase64 -> "file_base64" to ctx.encodeToJsonElement(ContentPart.FileBase64.serializer(), value) - is ContentPart.FileUrl -> "file_url" to ctx.encodeToJsonElement(ContentPart.FileUrl.serializer(), value) - is ContentPart.FileReference -> "file_reference" to ctx.encodeToJsonElement(ContentPart.FileReference.serializer(), value) - is ContentPart.Reasoning -> "reasoning" to ctx.encodeToJsonElement(ContentPart.Reasoning.serializer(), value) - is ContentPart.ToolCall -> "tool_call" to ctx.encodeToJsonElement(ContentPart.ToolCall.serializer(), value) - is ContentPart.ToolResult -> "tool_result" to ctx.encodeToJsonElement(ContentPart.ToolResult.serializer(), value) - } - val merged = JsonObject(buildMap { - put("type", JsonPrimitive(typeTag)) - putAll(inner as? JsonObject ?: JsonObject(emptyMap())) - }) - json.encodeJsonElement(merged) - } -} - // ───────────────────────────────────────────────────────────────────────────── // ModelMessage (prompt side). // ───────────────────────────────────────────────────────────────────────────── @@ -500,9 +563,8 @@ object ContentPartSerializer : KSerializer { /** * Message body: either a simple string or multi-part content. * - * Mirrors `MessageContent.ts`: `string | Array`. Modeled as a - * sealed union with a custom (untagged) serializer: a JSON string decodes to - * [MessageContent.Text], a JSON array decodes to [MessageContent.Parts]. + * Mirrors `MessageContent.ts`: `string | Array` (untagged): a JSON + * string decodes to [MessageContent.Text], a JSON array to [MessageContent.Parts]. */ @Serializable(with = MessageContentSerializer::class) sealed interface MessageContent { @@ -514,9 +576,6 @@ sealed interface MessageContent { data class Parts(val parts: List = emptyList()) : MessageContent } -/** - * Custom (de)serializer for the untagged [MessageContent] union. - */ object MessageContentSerializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("aimux.MessageContent") @@ -552,10 +611,8 @@ object MessageContentSerializer : KSerializer { } /** - * A single user-facing chat message. - * - * Mirrors `ModelMessage.ts`: `{ role: Role, content: MessageContent }` where - * [MessageContent] is a string-or-parts union. Use [contentString] / + * A single user-facing chat message. Mirrors `ModelMessage.ts`: + * `{ role: Role, content: MessageContent }`. Use [contentString] / * [contentParts] for ergonomic access, or the companion factories to build one. */ @Serializable @@ -590,24 +647,39 @@ data class ModelMessage( // ───────────────────────────────────────────────────────────────────────────── /** - * Per-call timeout configuration. - * - * Mirrors `TimeoutConfiguration.ts`. All values are milliseconds; `null` - * disables the corresponding limit. A `total` timeout also covers retry - * backoff and the whole streamed response. + * Per-call timeout configuration. Mirrors `TimeoutConfiguration.ts`. All values + * are milliseconds; `null` disables the corresponding limit. A `total` timeout + * also covers retry backoff and the whole streamed response. */ @Serializable data class TimeoutConfiguration( /** Overall timeout for the entire call (including retries and, for streaming, the whole stream), in milliseconds. */ - @SerialName("total_ms") val totalMs: Long? = null, + val totalMs: Long? = null, /** Timeout for one model operation attempt, in milliseconds. */ - @SerialName("step_ms") val stepMs: Long? = null, + val stepMs: Long? = null, /** Timeout waiting for the first stream chunk (streaming only). */ - @SerialName("first_chunk_ms") val firstChunkMs: Long? = null, + val firstChunkMs: Long? = null, /** Maximum idle time between consecutive stream chunks (streaming only). */ - @SerialName("chunk_ms") val chunkMs: Long? = null, + val chunkMs: Long? = null, ) +/** Response format, tagged on `type`. Mirrors `ResponseFormat.ts`. */ +@Serializable +sealed interface ResponseFormat { + @Serializable + @SerialName("text") + data object Text : ResponseFormat + + /** JSON output; `schema` is a JSON Schema for structured output. */ + @Serializable + @SerialName("json") + data class Json( + val schema: JsonElement? = null, + val name: String? = null, + val description: String? = null, + ) : ResponseFormat +} + /** * User-facing options for `generate_text` / `stream_text`. * @@ -617,30 +689,29 @@ data class TimeoutConfiguration( */ @Serializable data class GenerateTextOptions( - @SerialName("max_output_tokens") val maxOutputTokens: Long? = null, + val maxOutputTokens: Long? = null, val temperature: Double? = null, - @SerialName("stop_sequences") val stopSequences: List? = null, - @SerialName("top_p") val topP: Double? = null, - @SerialName("top_k") val topK: Double? = null, - @SerialName("presence_penalty") val presencePenalty: Double? = null, - @SerialName("frequency_penalty") val frequencyPenalty: Double? = null, - /** Response format. Untyped ([JsonElement]) — use a `ResponseFormat` JSON object if needed. */ - @SerialName("response_format") val responseFormat: JsonElement? = null, + val stopSequences: List? = null, + val topP: Double? = null, + val topK: Double? = null, + val presencePenalty: Double? = null, + val frequencyPenalty: Double? = null, + val responseFormat: ResponseFormat? = null, val seed: Long? = null, val tools: List? = null, - @SerialName("tool_choice") val toolChoice: ToolChoice? = null, + val toolChoice: ToolChoice? = null, val headers: Map? = null, - @SerialName("provider_options") val providerOptions: Map? = null, + val providerOptions: ProviderOptions? = null, val reasoning: ReasoningEffort? = null, val instructions: String? = null, /** Per-call retry count (0 = disable retries). */ - @SerialName("max_retries") val maxRetries: Long? = null, - /** Emit raw provider stream chunks as `StreamPart.Raw` (debugging; OpenAI-compatible family only). */ - @SerialName("include_raw_chunks") val includeRawChunks: Boolean? = null, + val maxRetries: Long? = null, + /** Emit raw provider stream chunks as `raw` stream parts (debugging; OpenAI-compatible family only). */ + val includeRawChunks: Boolean? = null, /** Per-call timeout configuration (overall / first chunk / inter-chunk idle, in ms). */ - @SerialName("timeout") val timeout: TimeoutConfiguration? = null, + val timeout: TimeoutConfiguration? = null, /** Session identifier (RFC-0024): groups consecutive calls into a session. */ - @SerialName("session_id") val sessionId: String? = null, + val sessionId: String? = null, /** * One repair attempt per invalid tool call (RFC-0035), run on the JVM after * the call returns. Mirrors AI SDK `repairToolCall`. @@ -670,205 +741,16 @@ data class GenerateTextOptions( ) // ───────────────────────────────────────────────────────────────────────────── -// File bytes / file data (shared V4 file types). -// -// `FileBytes` and `FileData` are externally-tagged enums -// (`{"Binary": [...]}`, `{"Data": {"data": ...}}`, ...). Each is modeled with a -// custom serializer that dispatches on the single tag key. -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Either raw bytes or a base64-encoded string. - * - * Mirrors `FileBytes.ts`: `{"Binary": Array} | {"Base64": string}`. - * The binary payload is a list of byte ints (serde's default `Vec` encoding). - */ -@Serializable(with = FileBytesSerializer::class) -sealed interface FileBytes { - - /** Raw binary bytes (a JSON array of 0–255 ints on the wire). */ - data class Binary(val data: List = emptyList()) : FileBytes - - /** A base64-encoded string. */ - data class Base64(val data: String = "") : FileBytes -} - -/** - * Custom (de)serializer for [FileBytes]. - * - * Externally tagged: `{"Binary": [..]}` / `{"Base64": "..."}`. The inner value - * is the raw array/string (not an object), so it is read/written directly. - */ -object FileBytesSerializer : KSerializer { - override val descriptor: SerialDescriptor = - buildClassSerialDescriptor("aimux.FileBytes") - - override fun deserialize(decoder: Decoder): FileBytes { - val json = decoder as? JsonDecoder - ?: throw SerializationException("FileBytes can only be decoded from JSON") - val element = json.decodeJsonElement() - val obj = element.jsonObject - require(obj.size == 1) { - "FileBytes must be a single-key externally-tagged object, got: $element" - } - val (tag, inner) = obj.entries.single() - return when (tag) { - "Binary" -> { - val arr = (inner as? JsonArray) ?: JsonArray(emptyList()) - FileBytes.Binary(arr.map { it.jsonPrimitive.content.toInt() }) - } - "Base64" -> FileBytes.Base64(inner.jsonPrimitive.content) - else -> throw SerializationException("Unknown FileBytes tag: '$tag'") - } - } - - override fun serialize(encoder: Encoder, value: FileBytes) { - val json = encoder as? JsonEncoder - ?: throw SerializationException("FileBytes can only be encoded to JSON") - val (tag, inner) = when (value) { - is FileBytes.Binary -> "Binary" to JsonArray(value.data.map { JsonPrimitive(it) }) - is FileBytes.Base64 -> "Base64" to JsonPrimitive(value.data) - } - json.encodeJsonElement(JsonObject(mapOf(tag to inner))) - } -} - -/** - * File data as a tagged discriminated union. - * - * Mirrors `FileData.ts`: `{"Data": {"data": FileBytes}} | {"Url": {"url": ...}} - * | {"Reference": {"reference": {...}}} | {"Text": {"text": ...}}`. - */ -@Serializable(with = FileDataSerializer::class) -sealed interface FileData { - - @Serializable - data class Data(val data: FileBytes = FileBytes.Base64("")) : FileData - - @Serializable - data class Url(val url: String = "", @SerialName("original_url") val originalUrl: String? = null) : FileData - - @Serializable - data class Reference(val reference: JsonElement = JsonObject(emptyMap())) : FileData - - @Serializable - data class Text(val text: String = "") : FileData -} - -/** - * Custom (de)serializer for [FileData]. - * - * Externally tagged: each variant is a single-key object whose value is the - * variant's inner object; delegation goes to the matching variant's generated - * serializer. - */ -object FileDataSerializer : KSerializer { - override val descriptor: SerialDescriptor = - buildClassSerialDescriptor("aimux.FileData") - - override fun deserialize(decoder: Decoder): FileData { - val json = decoder as? JsonDecoder - ?: throw SerializationException("FileData can only be decoded from JSON") - val element = json.decodeJsonElement() - val obj = element.jsonObject - require(obj.size == 1) { - "FileData must be a single-key externally-tagged object, got: $element" - } - val (tag, inner) = obj.entries.single() - val innerObj = inner as? JsonObject ?: JsonObject(emptyMap()) - val ctx = json.json - return when (tag) { - "Data" -> ctx.decodeFromJsonElement(FileData.Data.serializer(), innerObj) - "Url" -> ctx.decodeFromJsonElement(FileData.Url.serializer(), innerObj) - "Reference" -> ctx.decodeFromJsonElement(FileData.Reference.serializer(), innerObj) - "Text" -> ctx.decodeFromJsonElement(FileData.Text.serializer(), innerObj) - else -> throw SerializationException("Unknown FileData tag: '$tag'") - } - } - - override fun serialize(encoder: Encoder, value: FileData) { - val json = encoder as? JsonEncoder - ?: throw SerializationException("FileData can only be encoded to JSON") - val ctx = json.json - val (tag, inner) = when (value) { - is FileData.Data -> "Data" to ctx.encodeToJsonElement(FileData.Data.serializer(), value) - is FileData.Url -> "Url" to ctx.encodeToJsonElement(FileData.Url.serializer(), value) - is FileData.Reference -> "Reference" to ctx.encodeToJsonElement(FileData.Reference.serializer(), value) - is FileData.Text -> "Text" to ctx.encodeToJsonElement(FileData.Text.serializer(), value) - } - json.encodeJsonElement(JsonObject(mapOf(tag to inner))) - } -} - -@Serializable(with = GeneratedFileDataSerializer::class) -sealed interface GeneratedFileData { - - @Serializable - data class Data(val data: FileBytes = FileBytes.Base64("")) : GeneratedFileData - - @Serializable - data class Url(val url: String = "", @SerialName("original_url") val originalUrl: String? = null) : GeneratedFileData - - -} - -/** - * Custom (de)serializer for [GeneratedFileData]. - * - * Externally tagged: each variant is a single-key object whose value is the - * variant's inner object; delegation goes to the matching variant's generated - * serializer. - */ -object GeneratedFileDataSerializer : KSerializer { - override val descriptor: SerialDescriptor = - buildClassSerialDescriptor("aimux.GeneratedFileData") - - override fun deserialize(decoder: Decoder): GeneratedFileData { - val json = decoder as? JsonDecoder - ?: throw SerializationException("GeneratedFileData can only be decoded from JSON") - val element = json.decodeJsonElement() - val obj = element.jsonObject - require(obj.size == 1) { - "GeneratedFileData must be a single-key externally-tagged object, got: $element" - } - val (tag, inner) = obj.entries.single() - val innerObj = inner as? JsonObject ?: JsonObject(emptyMap()) - val ctx = json.json - return when (tag) { - "Data" -> ctx.decodeFromJsonElement(GeneratedFileData.Data.serializer(), innerObj) - "Url" -> ctx.decodeFromJsonElement(GeneratedFileData.Url.serializer(), innerObj) - else -> throw SerializationException("Unknown GeneratedFileData tag: '$tag'") - } - } - - override fun serialize(encoder: Encoder, value: GeneratedFileData) { - val json = encoder as? JsonEncoder - ?: throw SerializationException("GeneratedFileData can only be encoded to JSON") - val ctx = json.json - val (tag, inner) = when (value) { - is GeneratedFileData.Data -> "Data" to ctx.encodeToJsonElement(GeneratedFileData.Data.serializer(), value) - is GeneratedFileData.Url -> "Url" to ctx.encodeToJsonElement(GeneratedFileData.Url.serializer(), value) - } - json.encodeJsonElement(JsonObject(mapOf(tag to inner))) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// GenerateContent / GenerateResult (the `raw` field of GenerateTextResult). -// -// `GenerateContent` is an externally-tagged enum -// (`{"Text": {...}}`, `{"ToolCall": {...}}`, ...). It is modeled as a sealed -// interface with a custom serializer; unrecognized variants fall back to -// [GenerateContent.Unknown] for forward compatibility (mirroring [StreamPart]). +// Results. // ───────────────────────────────────────────────────────────────────────────── -/** A URL or document used as a source for the response. */ +/** A URL or document used as a source for the response. Mirrors `Source.ts` (tagged on `sourceType`). */ @OptIn(ExperimentalSerializationApi::class) @Serializable -@JsonClassDiscriminator("source_type") +@JsonClassDiscriminator("sourceType") sealed interface Source { val id: String - val providerMetadata: JsonElement? + val providerMetadata: ProviderMetadata? @Serializable @SerialName("url") @@ -876,549 +758,368 @@ sealed interface Source { override val id: String, val url: String, val title: String? = null, - @SerialName("provider_metadata") override val providerMetadata: JsonElement? = null, + override val providerMetadata: ProviderMetadata? = null, ) : Source @Serializable @SerialName("document") data class Document( override val id: String, - @SerialName("media_type") val mediaType: String, + val mediaType: String, val title: String, val filename: String? = null, - @SerialName("provider_metadata") override val providerMetadata: JsonElement? = null, + override val providerMetadata: ProviderMetadata? = null, ) : Source } +/** A provider warning, tagged on `type`. Mirrors `Warning.ts`. */ +@Serializable +sealed interface Warning { + @Serializable + @SerialName("unsupported") + data class Unsupported(val feature: String, val details: String? = null) : Warning + + @Serializable + @SerialName("compatibility") + data class Compatibility(val feature: String, val details: String? = null) : Warning + + @Serializable + @SerialName("deprecated") + data class Deprecated(val setting: String, val message: String) : Warning + + @Serializable + @SerialName("other") + data class Other(val message: String) : Warning +} + /** - * A content item in the generation result. - * - * Mirrors `GenerateContent.ts` (externally tagged). `provider_metadata` is a - * [JsonElement] (`ProviderMetadata = serde_json::Value`). Every field has a - * default; the `File` variant has no `filename` (matching Rust). + * A content item of the provider-level result (`GenerateResult.content`), + * tagged on `type`. Mirrors `GenerateContent`: a tool call's `input` is the raw argument text, and an + * approval request carries just the ids. */ -@Serializable(with = GenerateContentSerializer::class) +@Serializable sealed interface GenerateContent { @Serializable - data class Custom( - val kind: String, - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, - ) : GenerateContent + @SerialName("text") + data class Text(val text: String = "", val providerMetadata: ProviderMetadata? = null) : GenerateContent @Serializable - data class ReasoningFile( - val data: GeneratedFileData, - @SerialName("media_type") val mediaType: String, - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + @SerialName("tool-call") + data class ToolCall( + val toolCallId: String = "", + val toolName: String = "", + val input: String = "", + val providerExecuted: Boolean? = null, + val dynamic: Boolean? = null, + val providerMetadata: ProviderMetadata? = null, ) : GenerateContent @Serializable - data class ToolApprovalRequest( - @SerialName("approval_id") val approvalId: String, - @SerialName("tool_call_id") val toolCallId: String, - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + @SerialName("source") + data class Source( + val sourceType: String, + val id: String, + val url: String? = null, + val mediaType: String? = null, + val title: String? = null, + val filename: String? = null, + val providerMetadata: ProviderMetadata? = null, ) : GenerateContent + @Serializable + @SerialName("reasoning") + data class Reasoning(val text: String = "", val providerMetadata: ProviderMetadata? = null) : GenerateContent @Serializable - data class Text( - val text: String = "", - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + @SerialName("file") + data class File( + val data: GeneratedFileData = GeneratedFileData.Data(), + val mediaType: String = "", + val providerMetadata: ProviderMetadata? = null, ) : GenerateContent @Serializable - data class ToolCall( - @SerialName("tool_call_id") val toolCallId: String = "", - @SerialName("tool_name") val toolName: String = "", - val input: JsonElement = JsonObject(emptyMap()), - @SerialName("provider_executed") val providerExecuted: Boolean? = null, - @SerialName("dynamic") val dynamic: Boolean? = null, - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + @SerialName("reasoning-file") + data class ReasoningFile( + val data: GeneratedFileData = GeneratedFileData.Data(), + val mediaType: String = "", + val providerMetadata: ProviderMetadata? = null, ) : GenerateContent - data class Source(val source: ai.arcships.aimux.Source) : GenerateContent - @Serializable - data class Reasoning( - val text: String = "", - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, - ) : GenerateContent + @SerialName("custom") + data class Custom(val kind: String, val providerMetadata: ProviderMetadata? = null) : GenerateContent @Serializable - data class File( - val data: GeneratedFileData = GeneratedFileData.Data(), - @SerialName("media_type") val mediaType: String = "", - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + @SerialName("tool-approval-request") + data class ToolApprovalRequest( + val approvalId: String, + val toolCallId: String, + val providerMetadata: ProviderMetadata? = null, ) : GenerateContent @Serializable + @SerialName("tool-result") data class ToolResult( - @SerialName("tool_call_id") val toolCallId: String = "", - @SerialName("tool_name") val toolName: String = "", + val toolCallId: String = "", + val toolName: String = "", val result: JsonElement = JsonObject(emptyMap()), - @SerialName("is_error") val isError: Boolean? = null, - @SerialName("preliminary") val preliminary: Boolean? = null, - @SerialName("dynamic") val dynamic: Boolean? = null, - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + val isError: Boolean? = null, + val preliminary: Boolean? = null, + val dynamic: Boolean? = null, + val providerMetadata: ProviderMetadata? = null, ) : GenerateContent - - /** Fallback for variants introduced after this wrapper was written. */ - data class Unknown(val tag: String, val data: JsonElement) : GenerateContent } -/** The externally-tagged variant name for this [GenerateContent] (e.g. "Text", "ToolCall"). */ -val GenerateContent.variantTag: String - get() = when (this) { - is GenerateContent.Custom -> "Custom" - is GenerateContent.ReasoningFile -> "ReasoningFile" - is GenerateContent.ToolApprovalRequest -> "ToolApprovalRequest" - is GenerateContent.Text -> "Text" - is GenerateContent.ToolCall -> "ToolCall" - is GenerateContent.Source -> "Source" - is GenerateContent.Reasoning -> "Reasoning" - is GenerateContent.File -> "File" - is GenerateContent.ToolResult -> "ToolResult" - is GenerateContent.Unknown -> tag - } - -/** - * Custom (de)serializer for [GenerateContent]. - * - * The wire format is externally tagged: each item is a single-key JSON object - * `{"": { ...inner... }}`. This serializer reads the tag, then - * delegates to the matching variant's generated serializer. Unknown tags become - * [GenerateContent.Unknown]. - */ -object GenerateContentSerializer : KSerializer { - override val descriptor: SerialDescriptor = - buildClassSerialDescriptor("aimux.GenerateContent") - - override fun deserialize(decoder: Decoder): GenerateContent { - val json = decoder as? JsonDecoder - ?: throw SerializationException("GenerateContent can only be decoded from JSON") - val element = json.decodeJsonElement() - val obj = element.jsonObject - require(obj.size == 1) { - "GenerateContent must be a single-key externally-tagged object, got: $element" - } - val (tag, inner) = obj.entries.single() - val innerObj = inner as? JsonObject ?: JsonObject(emptyMap()) - val ctx = json.json - return when (tag) { - "Custom" -> ctx.decodeFromJsonElement(GenerateContent.Custom.serializer(), innerObj) - "ReasoningFile" -> ctx.decodeFromJsonElement(GenerateContent.ReasoningFile.serializer(), innerObj) - "ToolApprovalRequest" -> ctx.decodeFromJsonElement(GenerateContent.ToolApprovalRequest.serializer(), innerObj) - "Text" -> ctx.decodeFromJsonElement(GenerateContent.Text.serializer(), innerObj) - "ToolCall" -> ctx.decodeFromJsonElement(GenerateContent.ToolCall.serializer(), innerObj) - "Source" -> GenerateContent.Source(ctx.decodeFromJsonElement(Source.serializer(), innerObj)) - "Reasoning" -> ctx.decodeFromJsonElement(GenerateContent.Reasoning.serializer(), innerObj) - "File" -> ctx.decodeFromJsonElement(GenerateContent.File.serializer(), innerObj) - "ToolResult" -> ctx.decodeFromJsonElement(GenerateContent.ToolResult.serializer(), innerObj) - else -> GenerateContent.Unknown(tag, inner) - } - } - - override fun serialize(encoder: Encoder, value: GenerateContent) { - val json = encoder as? JsonEncoder - ?: throw SerializationException("GenerateContent can only be encoded to JSON") - val ctx = json.json - val (tag, inner) = when (value) { - is GenerateContent.Custom -> "Custom" to ctx.encodeToJsonElement(GenerateContent.Custom.serializer(), value) - is GenerateContent.ReasoningFile -> "ReasoningFile" to ctx.encodeToJsonElement(GenerateContent.ReasoningFile.serializer(), value) - is GenerateContent.ToolApprovalRequest -> "ToolApprovalRequest" to ctx.encodeToJsonElement(GenerateContent.ToolApprovalRequest.serializer(), value) - is GenerateContent.Text -> "Text" to ctx.encodeToJsonElement(GenerateContent.Text.serializer(), value) - is GenerateContent.ToolCall -> "ToolCall" to ctx.encodeToJsonElement(GenerateContent.ToolCall.serializer(), value) - is GenerateContent.Source -> "Source" to ctx.encodeToJsonElement(Source.serializer(), value.source) - is GenerateContent.Reasoning -> "Reasoning" to ctx.encodeToJsonElement(GenerateContent.Reasoning.serializer(), value) - is GenerateContent.File -> "File" to ctx.encodeToJsonElement(GenerateContent.File.serializer(), value) - is GenerateContent.ToolResult -> "ToolResult" to ctx.encodeToJsonElement(GenerateContent.ToolResult.serializer(), value) - is GenerateContent.Unknown -> value.tag to value.data - } - json.encodeJsonElement(JsonObject(mapOf(tag to inner))) - } -} - -/** - * Result of `LanguageModel::do_generate` (non-streaming) — the raw provider - * result surfaced via `GenerateTextResult.raw`. - * - * Mirrors `GenerateResult.ts`. [content] holds typed [GenerateContent] items. - */ +/** Result of `LanguageModel::do_generate`, surfaced as `GenerateTextResult.raw`. Mirrors `GenerateResult.ts`. */ @Serializable data class GenerateResult( val content: List = emptyList(), - @SerialName("finish_reason") val finishReason: FinishReason = FinishReason(), + val finishReason: FinishReason = FinishReason(), val usage: Usage = Usage(), - val warnings: List = emptyList(), - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, - val response: ResponseInfo? = null, + val warnings: List = emptyList(), + val providerMetadata: ProviderMetadata? = null, val request: RequestInfo? = null, -) { - /** Names of the variant tags present in [content] (e.g. "Text", "ToolCall"). */ - val contentVariantTags: List - get() = content.map { it.variantTag } - - /** `true` if any content item carries the given externally-tagged variant. */ - fun hasContentVariant(tag: String): Boolean = - content.any { it.variantTag == tag } -} + val response: ResponseInfo? = null, +) /** - * Result of `generate_text` (user-facing). + * Result of `generate_text` (user-facing). Mirrors `GenerateTextResult.ts`. * - * Mirrors `GenerateTextResult.ts`. + * `content`, `reasoning` and the tool-call items are kept as raw [JsonElement]s + * (their element types are generic over the call layer). */ @Serializable data class GenerateTextResult( + val content: List? = null, val text: String = "", - val content: List = emptyList(), - @SerialName("tool_calls") val toolCalls: List = emptyList(), - @SerialName("finish_reason") val finishReason: FinishReason = FinishReason(), + val toolCalls: List = emptyList(), + val finishReason: FinishReason = FinishReason(), val usage: Usage = Usage(), - val warnings: List = emptyList(), + val warnings: List = emptyList(), val raw: GenerateResult = GenerateResult(), - // M7: top-level aggregation fields val reasoning: List = emptyList(), - @SerialName("reasoning_text") val reasoningText: String = "", + val reasoningText: String = "", val sources: List = emptyList(), - val files: List = emptyList(), - @SerialName("response_messages") val responseMessages: List = emptyList(), - // M12: raw provider-specific finish reason string. - @SerialName("raw_finish_reason") val rawFinishReason: String? = null, - // Provider-specific metadata (e.g. Anthropic cache info). Mirrored from - // raw.provider_metadata for top-level convenience. Weak type (JsonElement?). - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, - // Response metadata (id, timestamp, model_id). Mirrored from raw.response. - val response: ResponseMetadata = ResponseMetadata(), - // Total token usage across all steps. In single-step mode (aimux's - // default), equals usage. Provided for AI SDK parity. - @SerialName("total_usage") val totalUsage: Usage = Usage(), + val files: List = emptyList(), + val responseMessages: List = emptyList(), + val rawFinishReason: String? = null, + val providerMetadata: ProviderMetadata? = null, + val request: RequestInfo = RequestInfo(), + val response: ResponseInfo = ResponseInfo(), + /** Total token usage across all steps (equals [usage] in single-step mode). */ + val totalUsage: Usage = Usage(), ) /** - * Result of `generate_object` (user-facing, M12). The parsed JSON object plus - * convenience fields from the underlying `generate_text` call. - * - * Mirrors `GenerateObjectResult.ts`. `object` is a [JsonElement] (arbitrary - * JSON value, weak type). + * Result of `generate_object` (user-facing). The parsed JSON object plus + * convenience fields from the underlying `generate_text` call. Mirrors + * `GenerateObjectResult.ts`; `object` is an arbitrary JSON value. */ @Serializable data class GenerateObjectResult( - // `object` is an arbitrary JSON value — weak type (JsonElement). val `object`: JsonElement, - @SerialName("finish_reason") val finishReason: FinishReason = FinishReason(), - @SerialName("raw_finish_reason") val rawFinishReason: String? = null, + val finishReason: FinishReason = FinishReason(), + val rawFinishReason: String? = null, val usage: Usage = Usage(), - val warnings: List = emptyList(), - // Concatenated reasoning text (if the model produced reasoning/thinking). + val warnings: List = emptyList(), + /** Concatenated reasoning text, if the model produced reasoning. */ val reasoning: String? = null, - // Provider-specific metadata (e.g. Anthropic cache info). Weak type. - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, - // Response metadata (id, timestamp, model_id). + val providerMetadata: ProviderMetadata? = null, val response: ResponseMetadata = ResponseMetadata(), val raw: GenerateTextResult = GenerateTextResult(), ) /** - * Aggregated result of `stream_text().consume()` (M11). Mirrors - * `GenerateTextResult`'s user-facing fields (without `raw`, since streaming - * has no `GenerateResult` equivalent). - * - * Mirrors `StreamTextResultAggregated.ts`. reasoning/sources/files use weak - * types (JsonElement) — same strategy as [GenerateTextResult]. + * Aggregated result of `stream_text().consume()`. Mirrors + * `StreamTextResultAggregated.ts` (`GenerateTextResult` without `raw`). */ @Serializable data class StreamTextResultAggregated( + val content: List? = null, val text: String = "", - val content: List = emptyList(), - // reasoning/sources/files use weak types (JsonElement). val reasoning: List = emptyList(), - @SerialName("reasoning_text") val reasoningText: String = "", - @SerialName("tool_calls") val toolCalls: List = emptyList(), + val reasoningText: String = "", + val toolCalls: List = emptyList(), val sources: List = emptyList(), - val files: List = emptyList(), - @SerialName("finish_reason") val finishReason: FinishReason = FinishReason(), - @SerialName("raw_finish_reason") val rawFinishReason: String? = null, + val files: List = emptyList(), + val finishReason: FinishReason = FinishReason(), + val rawFinishReason: String? = null, val usage: Usage = Usage(), - // Total token usage across all steps. In single-step mode (aimux's - // default), equals usage. Provided for AI SDK parity. - @SerialName("total_usage") val totalUsage: Usage = Usage(), - val warnings: List = emptyList(), - // Provider-specific metadata from the Finish chunk. Weak type. - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, - // Response metadata (id, timestamp, model_id) if emitted by the stream. - val response: ResponseMetadata? = null, - @SerialName("response_messages") val responseMessages: List = emptyList(), + val totalUsage: Usage = Usage(), + val warnings: List = emptyList(), + val providerMetadata: ProviderMetadata? = null, + val request: RequestInfo = RequestInfo(), + val response: ResponseInfo = ResponseInfo(), + val responseMessages: List = emptyList(), ) // ───────────────────────────────────────────────────────────────────────────── -// StreamPart (the streaming chunk type). +// StreamPart (the streaming chunk type), tagged on `type`. // -// `StreamPart` is an externally-tagged enum -// (`{"TextDelta": {...}}`, `{"ToolCall": {...}}`, ...). It is modeled as a -// sealed class with a custom deserializer that dispatches on the single tag -// key; unrecognized variants fall back to [StreamPart.Unknown] for forward -// compatibility. +// Mirrors `TextStreamPart.ts`, the part type `aimux_stream_text` emits. An +// unknown `type` fails decoding ([TypedModel] reports it through `onError`). // ───────────────────────────────────────────────────────────────────────────── -@Serializable(with = StreamPartSerializer::class) +@Serializable sealed interface StreamPart { @Serializable - data class Custom( - val kind: String, - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, - ) : StreamPart - - @Serializable - data class ReasoningFile( - val file: GenerateContent.File, - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, - ) : StreamPart - - @Serializable - data class ToolApprovalRequest( - @SerialName("approval_id") val approvalId: String, - @SerialName("tool_call") val toolCall: ai.arcships.aimux.ToolCall, - val reason: String? = null, - @SerialName("is_automatic") val isAutomatic: Boolean? = null, - val signature: String? = null, - ) : StreamPart - - - @Serializable - data class TextStart( - val id: String = "", - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, - ) : StreamPart + @SerialName("text-start") + data class TextStart(val id: String = "", val providerMetadata: ProviderMetadata? = null) : StreamPart @Serializable + @SerialName("text-delta") data class TextDelta( val id: String = "", val delta: String = "", - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + val providerMetadata: ProviderMetadata? = null, ) : StreamPart @Serializable - data class TextEnd( - val id: String = "", - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, - ) : StreamPart + @SerialName("text-end") + data class TextEnd(val id: String = "", val providerMetadata: ProviderMetadata? = null) : StreamPart @Serializable - data class StreamStart(val warnings: List = emptyList()) : StreamPart + @SerialName("stream-start") + data class StreamStart(val warnings: List = emptyList()) : StreamPart @Serializable + @SerialName("finish") data class Finish( - @SerialName("finish_reason") val finishReason: FinishReason = FinishReason(), + val finishReason: FinishReason = FinishReason(), + val usage: Usage = Usage(), + val providerMetadata: ProviderMetadata? = null, + ) : StreamPart + + @Serializable + @SerialName("finish-step") + data class FinishStep( + val finishReason: FinishReason = FinishReason(), val usage: Usage = Usage(), - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + val providerMetadata: ProviderMetadata? = null, + val response: ResponseInfo = ResponseInfo(), ) : StreamPart + /** `error` is the `name`-tagged `AiMuxError` JSON. */ + @Serializable + @SerialName("error") + data class Error(val error: JsonElement = JsonObject(emptyMap())) : StreamPart + @Serializable + @SerialName("tool-input-start") data class ToolInputStart( val id: String = "", - @SerialName("tool_name") val toolName: String = "", - @SerialName("provider_executed") val providerExecuted: Boolean? = null, - @SerialName("dynamic") val dynamic: Boolean? = null, + val toolName: String = "", + val providerExecuted: Boolean? = null, + val dynamic: Boolean? = null, val title: String? = null, - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + val providerMetadata: ProviderMetadata? = null, ) : StreamPart @Serializable + @SerialName("tool-input-delta") data class ToolInputDelta( val id: String = "", val delta: String = "", - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + val providerMetadata: ProviderMetadata? = null, ) : StreamPart @Serializable - data class ToolInputEnd( - val id: String = "", - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, - ) : StreamPart + @SerialName("tool-input-end") + data class ToolInputEnd(val id: String = "", val providerMetadata: ProviderMetadata? = null) : StreamPart /** * `invalid` is set by Core when the tool call stays invalid after optional * repair; `error` is the typed lookup, parse, schema, or repair failure. */ @Serializable + @SerialName("tool-call") data class ToolCall( - @SerialName("tool_call_id") val toolCallId: String = "", - @SerialName("tool_name") val toolName: String = "", + val toolCallId: String = "", + val toolName: String = "", val input: JsonElement = JsonObject(emptyMap()), - @SerialName("provider_executed") val providerExecuted: Boolean? = null, - @SerialName("dynamic") val dynamic: Boolean? = null, - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + val providerExecuted: Boolean? = null, + val dynamic: Boolean? = null, + val providerMetadata: ProviderMetadata? = null, val invalid: Boolean? = null, val error: JsonElement? = null, ) : StreamPart @Serializable + @SerialName("tool-result") data class ToolResult( - @SerialName("tool_call_id") val toolCallId: String = "", - @SerialName("tool_name") val toolName: String = "", + val toolCallId: String = "", + val toolName: String = "", val result: JsonElement = JsonObject(emptyMap()), - @SerialName("is_error") val isError: Boolean? = null, - @SerialName("preliminary") val preliminary: Boolean? = null, - @SerialName("dynamic") val dynamic: Boolean? = null, - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + val isError: Boolean? = null, + val preliminary: Boolean? = null, + val dynamic: Boolean? = null, + val providerMetadata: ProviderMetadata? = null, ) : StreamPart - // ── P2: file ── /** A file generated by the model (e.g. an image or document). */ @Serializable + @SerialName("file") data class File( - val data: JsonElement = JsonObject(emptyMap()), - @SerialName("media_type") val mediaType: String = "", - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + val data: GeneratedFileData = GeneratedFileData.Data(), + val mediaType: String = "", + val providerMetadata: ProviderMetadata? = null, ) : StreamPart @Serializable - data class ReasoningStart( - val id: String = "", - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + @SerialName("reasoning-file") + data class ReasoningFile( + val file: GeneratedFile = GeneratedFile(), + val providerMetadata: ProviderMetadata? = null, ) : StreamPart @Serializable - data class ReasoningDelta( - val id: String = "", - val delta: String = "", - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, - ) : StreamPart + @SerialName("custom") + data class Custom(val kind: String, val providerMetadata: ProviderMetadata? = null) : StreamPart @Serializable - data class ReasoningEnd( - val id: String = "", - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + @SerialName("tool-approval-request") + data class ToolApprovalRequest( + val approvalId: String, + val toolCall: ai.arcships.aimux.ToolCall, + val reason: String? = null, + val isAutomatic: Boolean? = null, + val signature: String? = null, ) : StreamPart @Serializable - data class ResponseMetadata( - val id: String? = null, - val timestamp: String? = null, - @SerialName("model_id") val modelId: String? = null, - ) : StreamPart - - data class Source(val source: ai.arcships.aimux.Source) : StreamPart + @SerialName("reasoning-start") + data class ReasoningStart(val id: String = "", val providerMetadata: ProviderMetadata? = null) : StreamPart @Serializable - data class Raw( - @SerialName("raw_value") val rawValue: JsonElement = JsonObject(emptyMap()), + @SerialName("reasoning-delta") + data class ReasoningDelta( + val id: String = "", + val delta: String = "", + val providerMetadata: ProviderMetadata? = null, ) : StreamPart @Serializable - data class Error(val error: JsonElement = JsonObject(emptyMap())) : StreamPart - - /** Fallback for variants introduced after this wrapper was written. */ - data class Unknown(val tag: String, val data: JsonElement) : StreamPart -} - -/** The externally-tagged variant name for this [StreamPart] (e.g. "TextDelta", "ToolCall"). */ -val StreamPart.variantTag: String - get() = when (this) { - is StreamPart.Custom -> "Custom" - is StreamPart.ReasoningFile -> "ReasoningFile" - is StreamPart.ToolApprovalRequest -> "ToolApprovalRequest" - is StreamPart.TextStart -> "TextStart" - is StreamPart.TextDelta -> "TextDelta" - is StreamPart.TextEnd -> "TextEnd" - is StreamPart.StreamStart -> "StreamStart" - is StreamPart.Finish -> "Finish" - is StreamPart.ToolInputStart -> "ToolInputStart" - is StreamPart.ToolInputDelta -> "ToolInputDelta" - is StreamPart.ToolInputEnd -> "ToolInputEnd" - is StreamPart.ToolCall -> "ToolCall" - is StreamPart.ToolResult -> "ToolResult" - is StreamPart.File -> "File" - is StreamPart.ReasoningStart -> "ReasoningStart" - is StreamPart.ReasoningDelta -> "ReasoningDelta" - is StreamPart.ReasoningEnd -> "ReasoningEnd" - is StreamPart.ResponseMetadata -> "ResponseMetadata" - is StreamPart.Source -> "Source" - is StreamPart.Raw -> "Raw" - is StreamPart.Error -> "Error" - is StreamPart.Unknown -> tag - } - -/** - * Custom (de)serializer for [StreamPart]. - * - * The wire format is externally tagged: each part is a single-key JSON object - * `{"": { ...inner... }}`. This serializer reads the tag, then - * delegates to the matching variant's generated serializer. Unknown tags become - * [StreamPart.Unknown]. - */ -object StreamPartSerializer : KSerializer { - override val descriptor: SerialDescriptor = - buildClassSerialDescriptor("aimux.StreamPart") + @SerialName("reasoning-end") + data class ReasoningEnd(val id: String = "", val providerMetadata: ProviderMetadata? = null) : StreamPart - override fun deserialize(decoder: Decoder): StreamPart { - val json = decoder as? JsonDecoder - ?: throw SerializationException("StreamPart can only be decoded from JSON") - val element = json.decodeJsonElement() - val obj = element.jsonObject - require(obj.size == 1) { - "StreamPart must be a single-key externally-tagged object, got: $element" - } - val (tag, inner) = obj.entries.single() - val innerObj = inner as? JsonObject ?: JsonObject(emptyMap()) - val ctx = json.json - return when (tag) { - "Custom" -> ctx.decodeFromJsonElement(StreamPart.Custom.serializer(), innerObj) - "ReasoningFile" -> ctx.decodeFromJsonElement(StreamPart.ReasoningFile.serializer(), innerObj) - "ToolApprovalRequest" -> ctx.decodeFromJsonElement(StreamPart.ToolApprovalRequest.serializer(), innerObj) - "TextStart" -> ctx.decodeFromJsonElement(StreamPart.TextStart.serializer(), innerObj) - "TextDelta" -> ctx.decodeFromJsonElement(StreamPart.TextDelta.serializer(), innerObj) - "TextEnd" -> ctx.decodeFromJsonElement(StreamPart.TextEnd.serializer(), innerObj) - "StreamStart" -> ctx.decodeFromJsonElement(StreamPart.StreamStart.serializer(), innerObj) - "Finish" -> ctx.decodeFromJsonElement(StreamPart.Finish.serializer(), innerObj) - "ToolInputStart" -> ctx.decodeFromJsonElement(StreamPart.ToolInputStart.serializer(), innerObj) - "ToolInputDelta" -> ctx.decodeFromJsonElement(StreamPart.ToolInputDelta.serializer(), innerObj) - "ToolInputEnd" -> ctx.decodeFromJsonElement(StreamPart.ToolInputEnd.serializer(), innerObj) - "ToolCall" -> ctx.decodeFromJsonElement(StreamPart.ToolCall.serializer(), innerObj) - "ToolResult" -> ctx.decodeFromJsonElement(StreamPart.ToolResult.serializer(), innerObj) - "File" -> ctx.decodeFromJsonElement(StreamPart.File.serializer(), innerObj) - "ReasoningStart" -> ctx.decodeFromJsonElement(StreamPart.ReasoningStart.serializer(), innerObj) - "ReasoningDelta" -> ctx.decodeFromJsonElement(StreamPart.ReasoningDelta.serializer(), innerObj) - "ReasoningEnd" -> ctx.decodeFromJsonElement(StreamPart.ReasoningEnd.serializer(), innerObj) - "ResponseMetadata" -> ctx.decodeFromJsonElement(StreamPart.ResponseMetadata.serializer(), innerObj) - "Source" -> StreamPart.Source(ctx.decodeFromJsonElement(Source.serializer(), innerObj)) - "Raw" -> ctx.decodeFromJsonElement(StreamPart.Raw.serializer(), innerObj) - "Error" -> ctx.decodeFromJsonElement(StreamPart.Error.serializer(), innerObj) - else -> StreamPart.Unknown(tag, inner) - } - } + /** A source cited by the response (flat: `type: "source"` plus the [Source] fields). */ + @Serializable + @SerialName("source") + data class Source( + val sourceType: String, + val id: String, + val url: String? = null, + val mediaType: String? = null, + val title: String? = null, + val filename: String? = null, + val providerMetadata: ProviderMetadata? = null, + ) : StreamPart - override fun serialize(encoder: Encoder, value: StreamPart) { - val json = encoder as? JsonEncoder - ?: throw SerializationException("StreamPart can only be encoded to JSON") - val ctx = json.json - val (tag, inner) = when (value) { - is StreamPart.Custom -> "Custom" to ctx.encodeToJsonElement(StreamPart.Custom.serializer(), value) - is StreamPart.ReasoningFile -> "ReasoningFile" to ctx.encodeToJsonElement(StreamPart.ReasoningFile.serializer(), value) - is StreamPart.ToolApprovalRequest -> "ToolApprovalRequest" to ctx.encodeToJsonElement(StreamPart.ToolApprovalRequest.serializer(), value) - is StreamPart.TextStart -> "TextStart" to ctx.encodeToJsonElement(StreamPart.TextStart.serializer(), value) - is StreamPart.TextDelta -> "TextDelta" to ctx.encodeToJsonElement(StreamPart.TextDelta.serializer(), value) - is StreamPart.TextEnd -> "TextEnd" to ctx.encodeToJsonElement(StreamPart.TextEnd.serializer(), value) - is StreamPart.StreamStart -> "StreamStart" to ctx.encodeToJsonElement(StreamPart.StreamStart.serializer(), value) - is StreamPart.Finish -> "Finish" to ctx.encodeToJsonElement(StreamPart.Finish.serializer(), value) - is StreamPart.ToolInputStart -> "ToolInputStart" to ctx.encodeToJsonElement(StreamPart.ToolInputStart.serializer(), value) - is StreamPart.ToolInputDelta -> "ToolInputDelta" to ctx.encodeToJsonElement(StreamPart.ToolInputDelta.serializer(), value) - is StreamPart.ToolInputEnd -> "ToolInputEnd" to ctx.encodeToJsonElement(StreamPart.ToolInputEnd.serializer(), value) - is StreamPart.ToolCall -> "ToolCall" to ctx.encodeToJsonElement(StreamPart.ToolCall.serializer(), value) - is StreamPart.ToolResult -> "ToolResult" to ctx.encodeToJsonElement(StreamPart.ToolResult.serializer(), value) - is StreamPart.File -> "File" to ctx.encodeToJsonElement(StreamPart.File.serializer(), value) - is StreamPart.ReasoningStart -> "ReasoningStart" to ctx.encodeToJsonElement(StreamPart.ReasoningStart.serializer(), value) - is StreamPart.ReasoningDelta -> "ReasoningDelta" to ctx.encodeToJsonElement(StreamPart.ReasoningDelta.serializer(), value) - is StreamPart.ReasoningEnd -> "ReasoningEnd" to ctx.encodeToJsonElement(StreamPart.ReasoningEnd.serializer(), value) - is StreamPart.ResponseMetadata -> "ResponseMetadata" to ctx.encodeToJsonElement(StreamPart.ResponseMetadata.serializer(), value) - is StreamPart.Source -> "Source" to ctx.encodeToJsonElement(Source.serializer(), value.source) - is StreamPart.Raw -> "Raw" to ctx.encodeToJsonElement(StreamPart.Raw.serializer(), value) - is StreamPart.Error -> "Error" to ctx.encodeToJsonElement(StreamPart.Error.serializer(), value) - is StreamPart.Unknown -> value.tag to value.data - } - json.encodeJsonElement(JsonObject(mapOf(tag to inner))) - } + @Serializable + @SerialName("raw") + data class Raw(val rawValue: JsonElement = JsonObject(emptyMap())) : StreamPart } // ───────────────────────────────────────────────────────────────────────────── diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ErrorsTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ErrorsTest.kt index 0263ae286..2d34d74e5 100644 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ErrorsTest.kt +++ b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ErrorsTest.kt @@ -47,22 +47,6 @@ class ErrorsTest { .hasMessageContaining("api_key") } - /** Malformed JSON text that reaches C → invariant (the public API rejects it before C). */ - @Test - fun `malformed wire JSON is a C ABI failure naming the parameter`() { - val e = FFI.lib.aimux_register_providers("{not json") - assertThat(e).isNotNull - assertThat(FFI.lib.aimux_error_code(e)).isEqualTo(202) - assertThatThrownBy { throw expectAimuxError(e!!) } - .isInstanceOf(IllegalStateException::class.java) - .isNotInstanceOf(AimuxException::class.java) - .hasMessageContaining("config_json") - // The public API never lets it reach C. - assertThatThrownBy { Model.registerProviders("{not json") } - .isInstanceOf(IllegalArgumentException::class.java) - .hasMessageContaining("configJson") - } - /** A C-ABI-only utility decodes through expectFfiError. */ @Test fun `expectFfiError reads the message and frees`() { @@ -237,8 +221,8 @@ class ErrorsTest { fun `createByCode rejects codes outside the enum`() { assertThatThrownBy { AimuxException.createByCode(999, "?") } .isInstanceOf(IllegalStateException::class.java) - // 20 is the first unassigned value and is rejected. - assertThatThrownBy { AimuxException.createByCode(20, "?") } + // 21 is the first unassigned value and is rejected. + assertThatThrownBy { AimuxException.createByCode(21, "?") } .isInstanceOf(IllegalStateException::class.java) assertThatThrownBy { AimuxException.createByCode(AIMUX_OK, "?") } .isInstanceOf(IllegalStateException::class.java) diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/MultimodalE2ETest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/MultimodalE2ETest.kt index ea99780d6..24423630b 100644 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/MultimodalE2ETest.kt +++ b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/MultimodalE2ETest.kt @@ -78,19 +78,16 @@ class MultimodalE2ETest { server.responseBody = "SGVsbG8gd29ybGQ=" SpeechModel.openai("sk-test", "tts-1", server.baseUrl).use { model -> - val opts = JSONObject().apply { - put("text", "Hi") - put("voice", "alloy") - put("output_format", "mp3") - put("provider_options", JSONObject()) - }.toString() - - val result = JSONObject(model.generate(opts)) - - // Wire format: {"audio":{"Binary":[]}, ...} - val audio = result.getJSONObject("audio") - assertThat(audio.has("Binary")).isTrue() - assertThat(audio.getJSONArray("Binary").length()).isGreaterThan(0) + val opts = AimuxJson.encodeToString( + SpeechCallOptions.serializer(), + SpeechCallOptions(text = "Hi", voice = "alloy", outputFormat = "mp3"), + ) + + val result = AimuxJson.decodeFromString(SpeechResult.serializer(), model.generate(opts)) + + // Wire format: {"audio":[], ...} (untagged: an array is binary). + val audio = result.audio as AudioData.Binary + assertThat(audio.value).isNotEmpty() } } @@ -101,18 +98,20 @@ class MultimodalE2ETest { server.responseBody = """{"data":[{"b64_json":"aW1hZ2Ux"}]}""" ImageModel.openai("sk-test", "dall-e-3", server.baseUrl).use { model -> - val opts = JSONObject().apply { - put("prompt", "otter") - put("n", 1) - put("provider_options", JSONObject()) - }.toString() - - val result = JSONObject(model.generate(opts)) - - // Wire format: {"images":{"Base64":["aW1hZ2Ux"]}, ...} - val base64 = result.getJSONObject("images").getJSONArray("Base64") - assertThat(base64.length()).isEqualTo(1) - assertThat(base64.getString(0)).isEqualTo("aW1hZ2Ux") + // `n` and `providerOptions` are required on the wire and always encoded. + val opts = AimuxJson.encodeToString( + ImageCallOptions.serializer(), + ImageCallOptions(prompt = "otter", size = Size(1024, 1024), aspectRatio = AspectRatio(16, 9)), + ) + assertThat(JSONObject(opts).getString("size")).isEqualTo("1024x1024") + assertThat(JSONObject(opts).getString("aspectRatio")).isEqualTo("16:9") + assertThat(JSONObject(opts).has("n")).isTrue() + assertThat(JSONObject(opts).has("providerOptions")).isTrue() + + val result = AimuxJson.decodeFromString(ImageResult.serializer(), model.generate(opts)) + + // Wire format: {"images":["aW1hZ2Ux"], ...} (untagged: strings are base64). + assertThat(result.images).isEqualTo(ImageOutputs.Base64(listOf("aW1hZ2Ux"))) } } @@ -138,21 +137,21 @@ class MultimodalE2ETest { """{"results":[{"index":1,"relevance_score":0.95},{"index":0,"relevance_score":0.3}]}""" RerankingModel.cohere("sk-test", "rerank-v3.0", server.baseUrl).use { model -> - val opts = JSONObject().apply { - put("query", "which?") - put("documents", JSONObject("""{"Text":{"values":["doc1","doc2"]}}""")) - put("top_n", 2) - put("provider_options", JSONObject()) - }.toString() - - val result = JSONObject(model.rerank(opts)) - - // Wire format: {"ranking":[{"index":1,"relevance_score":0.95}, ...], ...} - val ranking = result.getJSONArray("ranking") - assertThat(ranking.length()).isEqualTo(2) - val first = ranking.getJSONObject(0) - assertThat(first.getInt("index")).isEqualTo(1) - assertThat(first.getDouble("relevance_score")).isEqualTo(0.95) + val opts = AimuxJson.encodeToString( + RerankingCallOptions.serializer(), + RerankingCallOptions( + documents = RerankingDocuments.Text(listOf("doc1", "doc2")), + query = "which?", + topN = 2, + ), + ) + assertThat(JSONObject(opts).getJSONObject("documents").getString("type")).isEqualTo("text") + + val result = AimuxJson.decodeFromString(RerankingResult.serializer(), model.rerank(opts)) + + // Wire format: {"ranking":[{"index":1,"relevanceScore":0.95}, ...], ...} + assertThat(result.ranking).hasSize(2) + assertThat(result.ranking[0]).isEqualTo(RerankingRank(index = 1, relevanceScore = 0.95)) } } @@ -164,11 +163,10 @@ class MultimodalE2ETest { """{"results":[{"title":"Rust","url":"https://rust-lang.org","content":"Rust is..."}],"answer":"Rust is a systems language."}""" SearchModel.tavily("sk-test", server.baseUrl).use { model -> - val opts = JSONObject().apply { - put("query", "What is Rust?") - put("max_results", 5) - put("provider_options", JSONObject()) - }.toString() + val opts = AimuxJson.encodeToString( + SearchCallOptions.serializer(), + SearchCallOptions(query = "What is Rust?", maxResults = 5), + ) val result = JSONObject(model.search(opts)) @@ -189,8 +187,8 @@ class MultimodalE2ETest { Files.openai("sk-test", server.baseUrl).use { model -> val result = JSONObject(model.uploadFile("dGVzdA==", "application/pdf")) - // Wire format: {"provider_reference":{"openai":"file-abc"}, ...} - assertThat(result.getJSONObject("provider_reference").getString("openai")) + // Wire format: {"providerReference":{"openai":"file-abc"}, ...} + assertThat(result.getJSONObject("providerReference").getString("openai")) .isEqualTo("file-abc") } } @@ -209,13 +207,11 @@ class MultimodalE2ETest { assertThat(model).isNotNull() } - // Wire format: {"videos":[{"Url":{"url":"...","media_type":"..."}}], ...} - val parsed = JSONObject( - """{"videos":[{"Url":{"url":"https://example.com/v.mp4","media_type":"video/mp4"}}]}""", + // Wire format: {"videos":[{"type":"url","url":"...","mediaType":"..."}], ...} + val parsed = AimuxJson.decodeFromString( + VideoResult.serializer(), + """{"videos":[{"type":"url","url":"https://example.com/v.mp4","mediaType":"video/mp4"}]}""", ) - val videos = parsed.getJSONArray("videos") - assertThat(videos.length()).isEqualTo(1) - assertThat(videos.getJSONObject(0).getJSONObject("Url").getString("url")) - .isEqualTo("https://example.com/v.mp4") + assertThat(parsed.videos).containsExactly(VideoData.Url("https://example.com/v.mp4", "video/mp4")) } } diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/StructuredE2ETest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/StructuredE2ETest.kt index 7cf05c114..680302a4b 100644 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/StructuredE2ETest.kt +++ b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/StructuredE2ETest.kt @@ -15,16 +15,24 @@ import java.nio.charset.StandardCharsets // Structured end-to-end tests for the Kotlin/JVM binding. // // These spin up a local mock HTTP server speaking the OpenAI chat-completions -// wire format, point an OpenAI `Model` at it via the `base_url` constructor, +// wire format, point an OpenAI `Model` at it via the `baseUrl` constructor, // and assert that the binding correctly: -// 1. parses tool_calls out of a provider response, +// 1. parses toolCalls out of a provider response, // 2. forwards multi-role messages, -// 3. forwards `tool_choice`, +// 3. forwards `toolChoice`, // 4. parses tool-call stream parts from an SSE stream. // // No real network access is performed — every request hits 127.0.0.1. // ───────────────────────────────────────────────────────────────────────────── +/** + * A chat-completions model pointed at [baseUrl]. `Model.openai` speaks the + * Responses API, so these tests reach the OpenAI-compatible chat wire through + * a registry provider instead. + */ +internal fun chatCompletionsModel(baseUrl: String): Model = + Model.provider("deepseek", "sk-test-fake-key", "gpt-4o", """{"baseUrl":"$baseUrl"}""") + /// A tiny mock HTTP server that records the last request body and replays a /// preset response. Listens on a random loopback port. internal class MockProviderServer { @@ -206,7 +214,7 @@ class StructuredE2ETest { put("type", "function") put("name", "get_weather") put( - "input_schema", + "inputSchema", JSONObject().apply { put("type", "object") put( @@ -221,7 +229,7 @@ class StructuredE2ETest { ), ) if (includeToolChoice != null) { - put("tool_choice", includeToolChoice) + put("toolChoice", includeToolChoice) } }.toString() @@ -231,22 +239,22 @@ class StructuredE2ETest { fun `generateText parses tool_calls`() { server.responseBody = toolCallOpenAiResponse - Model.openai("sk-test-fake-key", "gpt-4o", server.baseUrl).use { model -> + chatCompletionsModel(server.baseUrl).use { model -> val resultJson = model.generateText("\"What is the weather in Tokyo?\"", weatherToolsOpts()) val result = JSONObject(resultJson) - // tool_calls[0].tool_name == "get_weather" - val toolCalls = result.getJSONArray("tool_calls") + // toolCalls[0].toolName == "get_weather" + val toolCalls = result.getJSONArray("toolCalls") assertThat(toolCalls.length()).isGreaterThan(0) val firstCall = toolCalls.getJSONObject(0) - assertThat(firstCall.getString("tool_name")).isEqualTo("get_weather") - assertThat(firstCall.getString("tool_call_id")).isEqualTo("call_abc") + assertThat(firstCall.getString("toolName")).isEqualTo("get_weather") + assertThat(firstCall.getString("toolCallId")).isEqualTo("call_abc") assertThat(firstCall.getJSONObject("input").getString("location")).isEqualTo("Tokyo") - // raw.content contains a "ToolCall" variant. + // raw.content contains a `tool-call` item. val rawContent = result.getJSONObject("raw").getJSONArray("content") val hasToolCallVariant = (0 until rawContent.length()).any { - rawContent.getJSONObject(it).has("ToolCall") + rawContent.getJSONObject(it).getString("type") == "tool-call" } assertThat(hasToolCallVariant).isTrue() } @@ -269,7 +277,7 @@ class StructuredE2ETest { }) }.toString() - Model.openai("sk-test-fake-key", "gpt-4o", server.baseUrl).use { model -> + chatCompletionsModel(server.baseUrl).use { model -> val resultJson = model.generateText(promptJson) val result = JSONObject(resultJson) assertThat(result.getString("text")) @@ -289,7 +297,7 @@ class StructuredE2ETest { fun `tool_choice reaches provider`() { server.responseBody = toolCallOpenAiResponse - Model.openai("sk-test-fake-key", "gpt-4o", server.baseUrl).use { model -> + chatCompletionsModel(server.baseUrl).use { model -> model.generateText("\"What is the weather in Tokyo?\"", weatherToolsOpts(includeToolChoice = "required")) val reqBody = JSONObject(server.lastRequestBody) @@ -404,7 +412,7 @@ class StructuredE2ETest { append("data: [DONE]\n\n") } - Model.openai("sk-test-fake-key", "gpt-4o", server.baseUrl).use { model -> + chatCompletionsModel(server.baseUrl).use { model -> // Run the blocking FFI stream call on a separate thread so the JNA // callbacks (which attach to the calling thread) don't deadlock with // tokio's block_on. The main thread collects parts via a queue. @@ -428,18 +436,18 @@ class StructuredE2ETest { streamThread.join(5000) assertThat(collected).isNotEmpty() - // Stream must surface tool-call activity: either a complete ToolCall - // part or ToolInputDelta deltas (or both). + // Stream must surface tool-call activity: either a complete tool-call + // part or tool-input-delta deltas (or both). val hasToolPart = collected.any { - it.contains("\"ToolCall\"") || it.contains("\"ToolInputDelta\"") + it.contains("\"type\":\"tool-call\"") || it.contains("\"type\":\"tool-input-delta\"") } assertThat(hasToolPart) - .`as`("expected a ToolCall or ToolInputDelta stream part; got: $collected") + .`as`("expected a tool-call or tool-input-delta stream part; got: $collected") .isTrue() // The finish part should also be present, carrying the tool-calls // finish reason. - val hasFinish = collected.any { it.contains("\"Finish\"") } + val hasFinish = collected.any { it.contains("\"type\":\"finish\"") } assertThat(hasFinish).isTrue() } } @@ -480,23 +488,23 @@ class StructuredE2ETest { server.setResponses(toolCallOpenAiResponse, finalTextResponse) - Model.openai("sk-test-fake-key", "gpt-4o", server.baseUrl).use { model -> + chatCompletionsModel(server.baseUrl).use { model -> // 1st call: provider asks to call get_weather. val firstResultJson = model.generateText("\"What's the weather in Tokyo?\"", weatherToolsOpts()) val firstResult = JSONObject(firstResultJson) - val toolCalls = firstResult.getJSONArray("tool_calls") + val toolCalls = firstResult.getJSONArray("toolCalls") assertThat(toolCalls.length()).isGreaterThan(0) val firstCall = toolCalls.getJSONObject(0) - assertThat(firstCall.getString("tool_name")).isEqualTo("get_weather") - assertThat(firstCall.getString("tool_call_id")).isEqualTo("call_abc") + assertThat(firstCall.getString("toolName")).isEqualTo("get_weather") + assertThat(firstCall.getString("toolCallId")).isEqualTo("call_abc") assertThat(firstCall.getJSONObject("input").getString("location")) .isEqualTo("Tokyo") // 2nd call: echo the full conversation back, including the - // assistant's tool_call and the ToolResult we synthesised. - // Input uses the engine's ContentPart variants (tool_call / - // tool_result); the engine converts these to the OpenAI wire + // assistant's tool call and the tool result we synthesised. + // Input uses the engine's ContentPart variants (tool-call / + // tool-result); the engine converts these to the OpenAI wire // format on the outbound request. val messages = JSONArray().apply { put(JSONObject().apply { @@ -509,9 +517,9 @@ class StructuredE2ETest { "content", JSONArray().put( JSONObject().apply { - put("type", "tool_call") - put("tool_call_id", "call_abc") - put("tool_name", "get_weather") + put("type", "tool-call") + put("toolCallId", "call_abc") + put("toolName", "get_weather") put("input", JSONObject().put("location", "Tokyo")) }, ), @@ -523,11 +531,15 @@ class StructuredE2ETest { "content", JSONArray().put( JSONObject().apply { - put("type", "tool_result") - put("tool_call_id", "call_abc") + put("type", "tool-result") + put("toolCallId", "call_abc") + put("toolName", "get_weather") put( "output", - JSONObject().put("temperature", 22).put("condition", "sunny"), + JSONObject().put("type", "json").put( + "value", + JSONObject().put("temperature", 22).put("condition", "sunny"), + ), ) }, ), diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt index 83704cc46..28357c75a 100644 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt +++ b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt @@ -18,7 +18,7 @@ import org.junit.jupiter.api.Test // (OpenAI chat-completions wire format) and assert that the typed wrapper: // 1. returns a [GenerateTextResult] object (no manual JSON parsing), // 2. surfaces `.text`, `.toolCalls[0].toolName`, and `.raw.content`, -// 3. forwards typed `tools` / `tool_choice` onto the provider request, +// 3. forwards typed `tools` / `toolChoice` onto the provider request, // 4. forwards multi-role [ModelMessage] conversations. // // No real network access — every request hits 127.0.0.1. @@ -140,7 +140,7 @@ class TypedModelTest { fun `generateText returns a typed GenerateTextResult with text and raw content`() { server.responseBody = plainOpenAiResponse - TypedModel.openai("sk-test-fake-key", "gpt-4o", server.baseUrl).use { model -> + TypedModel(chatCompletionsModel(server.baseUrl), ownsModel = true).use { model -> val result = model.generateText("What is Rust?") // No manual parsing: the wrapper returned a typed object. @@ -166,7 +166,7 @@ class TypedModelTest { toolChoice = ToolChoice.AUTO, ) - TypedModel.openai("sk-test-fake-key", "gpt-4o", server.baseUrl).use { model -> + TypedModel(chatCompletionsModel(server.baseUrl), ownsModel = true).use { model -> val result = model.generateText("What is the weather in Tokyo?", options) // .toolCalls[0].toolName / .toolCallId / .input — all typed. @@ -178,8 +178,8 @@ class TypedModelTest { assertThat(call.input.jsonObject["location"]!!.jsonPrimitive.content) .isEqualTo("Tokyo") - // .raw.content carries the ToolCall variant tag. - assertThat(result.raw.hasContentVariant("ToolCall")).isTrue() + // .raw.content carries the tool-call item (input is the raw argument text). + assertThat(result.raw.content.any { it is GenerateContent.ToolCall }).isTrue() } } @@ -192,11 +192,11 @@ class TypedModelTest { toolChoice = ToolChoice.REQUIRED, ) - TypedModel.openai("sk-test-fake-key", "gpt-4o", server.baseUrl).use { model -> + TypedModel(chatCompletionsModel(server.baseUrl), ownsModel = true).use { model -> model.generateText("What is the weather in Tokyo?", options) - // The serialized options crossed the JSON boundary as the engine's - // snake_case shape with only the fields the caller set. + // The serialized options crossed the JSON boundary in the engine's + // camelCase shape with only the fields the caller set. val reqBody = JSONObject(server.lastRequestBody) assertThat(reqBody.getString("tool_choice")).isEqualTo("required") assertThat(reqBody.has("tools")).isTrue() @@ -221,7 +221,7 @@ class TypedModelTest { ModelMessage.text(Role.USER, "What is Rust?"), ) - TypedModel.openai("sk-test-fake-key", "gpt-4o", server.baseUrl).use { model -> + TypedModel(chatCompletionsModel(server.baseUrl), ownsModel = true).use { model -> val result = model.generateText(messages) assertThat(result.text).isEqualTo("Rust is a systems programming language.") @@ -248,11 +248,9 @@ class TypedModelTest { // asserts the round-tripped value equals the original. They also pin down the // wire-format quirks the typed wrappers rely on: // - [GenerateContent.File] carries no `filename` (unlike [ContentPart.File]), -// - [GenerateContent.ToolResult] / [ContentPart.ToolResult] use the `result` -// field (never `output`) and carry `tool_name` / `is_error` / `preliminary` -// / `dynamic`, -// - [GenerateContent.Unknown] preserves an unrecognized external tag (forward -// compatibility). +// - [GenerateContent.ToolResult] keeps the executed-tool `result` shape, while +// [ContentPart.ToolResult] carries a [ToolResultOutput] under `output`, +// - every union is tagged on `type` with the AI SDK's kebab-case names. // ───────────────────────────────────────────────────────────────────────────── class TypedModelRoundTripTest { @@ -262,17 +260,15 @@ class TypedModelRoundTripTest { toolCallId = "call_1", toolName = "get_weather", input = JsonObject(mapOf("city" to JsonPrimitive("Paris"))), - providerMetadata = JsonObject( - mapOf("openai" to JsonObject(mapOf("itemId" to JsonPrimitive("item_1")))), - ), + providerMetadata = mapOf("openai" to mapOf("itemId" to JsonPrimitive("item_1"))), ) val json = AimuxJson.encodeToString(ToolCall.serializer(), original) - assertThat(json).contains("\"provider_metadata\"") + assertThat(json).contains("\"providerMetadata\"") val decoded = AimuxJson.decodeFromString(ToolCall.serializer(), json) assertThat(decoded).isEqualTo(original) } - // ── GenerateContent (externally tagged) ────────────────────────────── + // ── GenerateContent (tagged on `type`) ────────────────────────────── @Test fun `GenerateContent Text round-trips`() { @@ -287,12 +283,13 @@ class TypedModelRoundTripTest { val original = GenerateContent.ToolCall( toolCallId = "call_1", toolName = "get_weather", - input = JsonObject(mapOf("location" to JsonPrimitive("Tokyo"))), + input = """{"location":"Tokyo"}""", providerExecuted = false, dynamic = true, providerMetadata = null, ) val json = AimuxJson.encodeToString(GenerateContent.serializer(), original) + assertThat(json).contains("\"type\":\"tool-call\"") val decoded = AimuxJson.decodeFromString(GenerateContent.serializer(), json) assertThat(decoded).isEqualTo(original) } @@ -325,23 +322,7 @@ class TypedModelRoundTripTest { assertThat(decoded).isEqualTo(original) } - @Test - fun `GenerateContent Unknown preserves an unrecognized external tag`() { - val payload = JsonObject(mapOf("future" to JsonPrimitive("data"))) - val original = GenerateContent.Unknown(tag = "FutureVariant", data = payload) - val json = AimuxJson.encodeToString(GenerateContent.serializer(), original) - val decoded = AimuxJson.decodeFromString(GenerateContent.serializer(), json) - - // The whole value round-trips intact. - assertThat(decoded).isEqualTo(original) - // The unrecognized variant survives as Unknown and keeps its tag. - assertThat(decoded).isInstanceOf(GenerateContent.Unknown::class.java) - assertThat((decoded as GenerateContent.Unknown).tag).isEqualTo("FutureVariant") - assertThat(decoded.variantTag).isEqualTo("FutureVariant") - assertThat(json).contains("\"FutureVariant\"") - } - - // ── ContentPart (internally tagged on `type`) ─────────────────────── + // ── ContentPart (tagged on `type`) ─────────────────────── @Test fun `ContentPart Text round-trips`() { @@ -364,36 +345,30 @@ class TypedModelRoundTripTest { val json = AimuxJson.encodeToString(ContentPart.serializer(), original) val decoded = AimuxJson.decodeFromString(ContentPart.serializer(), json) assertThat(decoded).isEqualTo(original) - assertThat(json).contains("\"tool_call\"") - assertThat(json).contains("\"provider_executed\":true") + assertThat(json).contains("\"type\":\"tool-call\"") + assertThat(json).contains("\"providerExecuted\":true") } @Test - fun `ContentPart ToolResult round-trips with result not output`() { + fun `ContentPart ToolResult round-trips with a typed output`() { val original = ContentPart.ToolResult( toolCallId = "call_1", - result = JsonObject(mapOf("temp" to JsonPrimitive("20"))), toolName = "get_weather", - isError = true, - preliminary = true, - dynamic = false, + output = ToolResultOutput.JsonValue(JsonObject(mapOf("temp" to JsonPrimitive("20")))), providerOptions = null, ) val json = AimuxJson.encodeToString(ContentPart.serializer(), original) - // `result` field, never `output`; carries the full ToolResult field set. - assertThat(json).contains("\"result\"").doesNotContain("\"output\"") - assertThat(json).contains("\"tool_name\"") - assertThat(json).contains("\"is_error\"") - assertThat(json).contains("\"preliminary\"") - assertThat(json).contains("\"dynamic\"") - assertThat(json).contains("\"tool_result\"") + // `output` is a tagged union; the old result / isError fields are gone. + assertThat(json).contains("\"type\":\"tool-result\"") + assertThat(json).contains("\"output\":{\"type\":\"json\",\"value\":{\"temp\":\"20\"}}") + assertThat(json).doesNotContain("\"result\"").doesNotContain("\"isError\"") val decoded = AimuxJson.decodeFromString(ContentPart.serializer(), json) assertThat(decoded).isEqualTo(original) } - // ── FileBytes / FileData (externally tagged) ──────────────────────── + // ── FileBytes (untagged) / FileData (tagged on `type`) ──────────────────────── @Test fun `FileBytes Binary round-trips`() { @@ -401,7 +376,7 @@ class TypedModelRoundTripTest { val json = AimuxJson.encodeToString(FileBytes.serializer(), original) val decoded = AimuxJson.decodeFromString(FileBytes.serializer(), json) assertThat(decoded).isEqualTo(original) - assertThat(json).contains("\"Binary\"") + assertThat(json).isEqualTo("[1,2,3,255]") } @Test @@ -410,7 +385,7 @@ class TypedModelRoundTripTest { val json = AimuxJson.encodeToString(FileBytes.serializer(), original) val decoded = AimuxJson.decodeFromString(FileBytes.serializer(), json) assertThat(decoded).isEqualTo(original) - assertThat(json).contains("\"Base64\"") + assertThat(json).isEqualTo("\"aGVsbG8=\"") } @Test @@ -419,7 +394,7 @@ class TypedModelRoundTripTest { val json = AimuxJson.encodeToString(FileData.serializer(), original) val decoded = AimuxJson.decodeFromString(FileData.serializer(), json) assertThat(decoded).isEqualTo(original) - assertThat(json).contains("\"Data\"") + assertThat(json).contains("\"type\":\"data\"") } @Test @@ -428,7 +403,7 @@ class TypedModelRoundTripTest { val json = AimuxJson.encodeToString(FileData.serializer(), original) val decoded = AimuxJson.decodeFromString(FileData.serializer(), json) assertThat(decoded).isEqualTo(original) - assertThat(json).contains("\"Url\"") + assertThat(json).contains("\"type\":\"url\"") } // ── GenerateResult (integration: mixed content variants) ─────────── @@ -441,7 +416,7 @@ class TypedModelRoundTripTest { GenerateContent.ToolCall( toolCallId = "call_1", toolName = "get_weather", - input = JsonObject(mapOf("location" to JsonPrimitive("Tokyo"))), + input = """{"location":"Tokyo"}""", ), GenerateContent.ToolResult( toolCallId = "call_1", @@ -459,11 +434,9 @@ class TypedModelRoundTripTest { // The whole structure round-trips intact. assertThat(decoded).isEqualTo(original) - // Variant order and tags are preserved. - assertThat(decoded.contentVariantTags) + // Variant order is preserved. + assertThat(decoded.content.map { it::class.simpleName }) .containsExactly("Text", "ToolCall", "ToolResult") - assertThat(decoded.hasContentVariant("ToolCall")).isTrue() - assertThat(decoded.hasContentVariant("Reasoning")).isFalse() // Spot-check the decoded content variants by type and field. assertThat(decoded.content[0]).isInstanceOf(GenerateContent.Text::class.java) @@ -476,8 +449,9 @@ class TypedModelRoundTripTest { assertThat((decoded.content[2] as GenerateContent.ToolResult).result.toString()) .isEqualTo("""{"temp":"20"}""") - // The external tags are present on the wire, and there is no `output`. - assertThat(json).contains("\"Text\"").contains("\"ToolCall\"").contains("\"ToolResult\"") + // The tags are present on the wire, and there is no `output`. + assertThat(json).contains("\"type\":\"text\"").contains("\"type\":\"tool-call\"") + .contains("\"type\":\"tool-result\"") assertThat(json).contains("\"result\"").doesNotContain("\"output\"") } } diff --git a/bindings/node/__test__/cassette.test.ts b/bindings/node/__test__/cassette.test.ts index bdfb52160..81dd3f395 100644 --- a/bindings/node/__test__/cassette.test.ts +++ b/bindings/node/__test__/cassette.test.ts @@ -27,7 +27,7 @@ test('cassette: OpenAI generate', async (t) => { t.is(typeof result.text, 'string', 'should have text field') t.true(result.text.length > 0, 'text should be non-empty') t.truthy(result.usage, 'should have usage') - t.truthy(result.finish_reason, 'should have finish_reason') + t.truthy(result.finishReason, 'should have finishReason') } finally { await srv.stop() } @@ -46,9 +46,9 @@ test('cassette: OpenAI stream', async (t) => { } t.true(parts.length > 0, 'should receive stream parts') - const types = parts.map((p) => Object.keys(p)[0]) - t.true(types.includes('StreamStart'), 'should have StreamStart') - t.true(types.includes('Finish'), 'should have Finish') + const types = parts.map((p) => p.type) + t.true(types.includes('stream-start'), 'should have stream-start') + t.true(types.includes('finish'), 'should have finish') } finally { await srv.stop() } diff --git a/bindings/node/__test__/decision.test.ts b/bindings/node/__test__/decision.test.ts index c742519de..21da4f085 100644 --- a/bindings/node/__test__/decision.test.ts +++ b/bindings/node/__test__/decision.test.ts @@ -57,8 +57,8 @@ test('missing or null usage counts preserve valid decision answers', async t => usage = counts const result = await decide(model, options) t.is(Object.keys(result.answers).length, 3) - t.is(result.usage?.input_tokens.total, counts.input_tokens ?? null) - t.is(result.usage?.output_tokens.total, counts.output_tokens ?? null) + t.is(result.usage?.inputTokens.total ?? null, counts.input_tokens ?? null) + t.is(result.usage?.outputTokens.total ?? null, counts.output_tokens ?? null) t.deepEqual(result.usage?.raw, counts) } }) diff --git a/bindings/node/__test__/e2e.test.ts b/bindings/node/__test__/e2e.test.ts index cdebb8ae1..56ad216a9 100644 --- a/bindings/node/__test__/e2e.test.ts +++ b/bindings/node/__test__/e2e.test.ts @@ -82,7 +82,7 @@ test('e2e: OpenAI generateText via mock server', async (t) => { t.is(r.text, 'Rust is a systems programming language.') t.truthy(r.usage) - t.truthy(r.finish_reason) + t.truthy(r.finishReason) } finally { await closeServer(server) } @@ -105,8 +105,8 @@ test('e2e: OpenAI streamText via mock server', async (t) => { t.true(parts.length > 0) const text = parts - .filter((p) => p.TextDelta) - .map((p) => p.TextDelta.delta) + .filter((p) => p.type === 'text-delta') + .map((p) => p.delta) .join('') t.is(text, 'Hello world') @@ -149,8 +149,8 @@ test('e2e: Anthropic streamText via mock server', async (t) => { t.true(parts.length > 0) const text = parts - .filter((p) => p.TextDelta) - .map((p) => p.TextDelta.delta) + .filter((p) => p.type === 'text-delta') + .map((p) => p.delta) .join('') t.is(text, 'Hello from Claude') @@ -159,7 +159,7 @@ test('e2e: Anthropic streamText via mock server', async (t) => { } }) -test('e2e: OpenAI generateText with options (max_tokens, temperature)', async (t) => { +test('e2e: OpenAI generateText with options (maxOutputTokens, temperature)', async (t) => { let receivedBody: any = null const { server, url } = await startMockServer((req, res) => { let body = '' @@ -173,7 +173,7 @@ test('e2e: OpenAI generateText with options (max_tokens, temperature)', async (t try { const model = await deepseek('test-key', 'gpt-4o', url) - const opts = JSON.stringify({ max_output_tokens: 100, temperature: 0.5 }) + const opts = JSON.stringify({ maxOutputTokens: 100, temperature: 0.5 }) await model.generateText(JSON.stringify('Hello'), opts) // Verify the options were passed through to the HTTP request @@ -204,7 +204,7 @@ const OPENAI_TOOL_CALL_RESPONSE = JSON.stringify({ usage: { prompt_tokens: 20, completion_tokens: 10, total_tokens: 30 }, }) -test('e2e: OpenAI generateText parses tool_calls (structured content)', async (t) => { +test('e2e: OpenAI generateText parses toolCalls (structured content)', async (t) => { const { server, url } = await startMockServer((req, res) => { res.writeHead(200, { 'content-type': 'application/json' }) res.end(OPENAI_TOOL_CALL_RESPONSE) @@ -217,7 +217,7 @@ test('e2e: OpenAI generateText parses tool_calls (structured content)', async (t type: 'function', name: 'get_weather', description: 'Get weather for a location', - input_schema: { + inputSchema: { type: 'object', properties: { location: { type: 'string' } }, required: ['location'], @@ -226,22 +226,22 @@ test('e2e: OpenAI generateText parses tool_calls (structured content)', async (t }) const r = JSON.parse(await model.generateText(JSON.stringify("What's the weather in Tokyo?"), opts)) - // Convenience field: tool_calls extracted - t.is(r.tool_calls.length, 1) - t.is(r.tool_calls[0].tool_name, 'get_weather') - t.is(r.tool_calls[0].tool_call_id, 'call_abc') - t.deepEqual(r.tool_calls[0].input, { location: 'Tokyo' }) + // Convenience field: toolCalls extracted + t.is(r.toolCalls.length, 1) + t.is(r.toolCalls[0].toolName, 'get_weather') + t.is(r.toolCalls[0].toolCallId, 'call_abc') + t.deepEqual(r.toolCalls[0].input, { location: 'Tokyo' }) - // Structured content: raw.content contains the ToolCall variant + // Structured content: raw.content contains the tool-call variant t.truthy(r.raw) t.true(Array.isArray(r.raw.content)) - const tc = r.raw.content.find((c: any) => c.ToolCall) - t.truthy(tc, 'raw.content contains a ToolCall variant') - t.is(tc.ToolCall.tool_name, 'get_weather') - t.is(tc.ToolCall.tool_call_id, 'call_abc') + const tc = r.raw.content.find((c: any) => c.type === 'tool-call') + t.truthy(tc, 'raw.content contains a tool-call variant') + t.is(tc.toolName, 'get_weather') + t.is(tc.toolCallId, 'call_abc') // raw content keeps the provider's argument text; the parsed object // lives on the top-level toolCalls. - t.is(tc.ToolCall.input, '{"location":"Tokyo"}') + t.is(tc.input, '{"location":"Tokyo"}') } finally { await closeServer(server) } @@ -284,7 +284,7 @@ test('e2e: OpenAI generateText with multi-role messages (system + user)', async // ── ToolChoice ────────────────────────────────────────────────────────────── -test('e2e: OpenAI generateText with tool_choice: required', async (t) => { +test('e2e: OpenAI generateText with toolChoice: required', async (t) => { let receivedBody: any = null const { server, url } = await startMockServer((req, res) => { let body = '' @@ -302,13 +302,13 @@ test('e2e: OpenAI generateText with tool_choice: required', async (t) => { tools: [{ type: 'function', name: 'get_weather', - input_schema: { type: 'object', properties: { location: { type: 'string' } } }, + inputSchema: { type: 'object', properties: { location: { type: 'string' } } }, }], - tool_choice: 'required', + toolChoice: 'required', }) await model.generateText(JSON.stringify('Hello'), opts) - // tool_choice reaches the provider request body as "required" + // toolChoice reaches the provider request body as "required" t.is(receivedBody.tool_choice, 'required') } finally { await closeServer(server) @@ -336,7 +336,7 @@ test('e2e: OpenAI streamText parses tool-call stream parts', async (t) => { tools: [{ type: 'function', name: 'get_weather', - input_schema: { type: 'object', properties: { location: { type: 'string' } } }, + inputSchema: { type: 'object', properties: { location: { type: 'string' } } }, }], }) const parts: any[] = [] @@ -344,16 +344,16 @@ test('e2e: OpenAI streamText parses tool-call stream parts', async (t) => { parts.push(JSON.parse(json)) } - // The stream must contain a ToolCall or ToolInputDelta part (not just TextDelta/Finish) - const hasToolPart = parts.some( - (p) => p.ToolCall || p.ToolInputDelta || p.ToolInputStart, + // The stream must contain a tool-call or tool-input part (not just text-delta/finish) + const hasToolPart = parts.some((p) => + ['tool-call', 'tool-input-delta', 'tool-input-start'].includes(p.type), ) t.true(hasToolPart, 'stream contained a tool-related StreamPart') - // A complete ToolCall part should carry the parsed tool name + input - const toolCall = parts.find((p) => p.ToolCall) + // A complete tool-call part should carry the parsed tool name + input + const toolCall = parts.find((p) => p.type === 'tool-call') if (toolCall) { - t.is(toolCall.ToolCall.tool_name, 'get_weather') + t.is(toolCall.toolName, 'get_weather') } } finally { await closeServer(server) @@ -362,7 +362,7 @@ test('e2e: OpenAI streamText parses tool-call stream parts', async (t) => { // ── Tool-call full round-trip ─────────────────────────────────────────────── -test('e2e: OpenAI tool-call full round-trip (ToolCall → ToolResult → final text)', async (t) => { +test('e2e: OpenAI tool-call full round-trip (tool-call → tool-result → final text)', async (t) => { // Two calls: the mock returns tool_calls on the first, final text on the second. let callCount = 0 let secondRequestBody: any = null @@ -385,7 +385,7 @@ test('e2e: OpenAI tool-call full round-trip (ToolCall → ToolResult → final t type: 'function', name: 'get_weather', description: 'Get weather for a location', - input_schema: { + inputSchema: { type: 'object', properties: { location: { type: 'string' } }, required: ['location'], @@ -395,30 +395,31 @@ test('e2e: OpenAI tool-call full round-trip (ToolCall → ToolResult → final t // Step 1: first call — model requests a tool call. const r1 = JSON.parse(await model.generateText(JSON.stringify("What's the weather in Tokyo?"), opts)) - t.is(r1.tool_calls[0].tool_name, 'get_weather') - t.is(r1.tool_calls[0].tool_call_id, 'call_abc') + t.is(r1.toolCalls[0].toolName, 'get_weather') + t.is(r1.toolCalls[0].toolCallId, 'call_abc') // Step 2: user "executes" the tool, then builds the full conversation: // user → assistant(tool_call) → tool(result) - // Input uses the engine's ContentPart variants (tool_call / tool_result); + // Input uses the engine's ContentPart variants (tool-call / tool-result); // the engine converts these to the OpenAI wire format on the outbound request. const messages = [ { role: 'user', content: "What's the weather in Tokyo?" }, { role: 'assistant', content: [{ - type: 'tool_call', - tool_call_id: 'call_abc', - tool_name: 'get_weather', + type: 'tool-call', + toolCallId: 'call_abc', + toolName: 'get_weather', input: { location: 'Tokyo' }, }], }, { role: 'tool', content: [{ - type: 'tool_result', - tool_call_id: 'call_abc', - result: { temperature: 22, condition: 'sunny' }, + type: 'tool-result', + toolCallId: 'call_abc', + toolName: 'get_weather', + output: { type: 'json', value: { temperature: 22, condition: 'sunny' } }, }], }, ] diff --git a/bindings/node/__test__/error.test.ts b/bindings/node/__test__/error.test.ts index 12eab5d7a..a8ab022e6 100644 --- a/bindings/node/__test__/error.test.ts +++ b/bindings/node/__test__/error.test.ts @@ -66,7 +66,7 @@ test('stream error path yields a typed AimuxError', async (t) => { baseUrl: 'http://127.0.0.1:1', }) const err = (await t.throwsAsync(async () => { - for await (const _part of streamText(model, 'hello', { max_retries: 0 })) { + for await (const _part of streamText(model, 'hello', { maxRetries: 0 })) { // never yields } })) as AimuxError @@ -119,7 +119,7 @@ test('APICallError carries every field the response produced', async (t) => { try { const model = await native.openai('test-key', 'gpt-4o', { baseUrl: url }) const err = (await t.throwsAsync(() => - model.generateText(JSON.stringify('hi'), JSON.stringify({ max_retries: 0 })), + model.generateText(JSON.stringify('hi'), JSON.stringify({ maxRetries: 0 })), )) as APICallError t.true(err instanceof APICallError) t.true(err instanceof native.APICallError) @@ -156,7 +156,7 @@ test('exhausted retries throw RetryError with the per-attempt history', async (t try { const model = await native.openai('test-key', 'gpt-4o', { baseUrl: url }) const err = (await t.throwsAsync(() => - model.generateText(JSON.stringify('hi'), JSON.stringify({ max_retries: 1 })), + model.generateText(JSON.stringify('hi'), JSON.stringify({ maxRetries: 1 })), )) as RetryError t.true(err instanceof RetryError) t.true(err instanceof AimuxError) @@ -239,7 +239,7 @@ test.serial('a missing API key is a LoadAPIKeyError at call time carrying the en try { const model = await provider('groq', null, 'some-model') const err = (await t.throwsAsync(() => - model.generateText(JSON.stringify('hi'), JSON.stringify({ max_retries: 0 })), + model.generateText(JSON.stringify('hi'), JSON.stringify({ maxRetries: 0 })), )) as LoadAPIKeyError t.true(err instanceof LoadAPIKeyError) t.true(err instanceof AimuxError) @@ -269,7 +269,7 @@ test.serial('a missing provider setting is a LoadSettingError at call time', asy try { const model = await native.azure('test-key', '', 'some-deployment') const err = (await t.throwsAsync(() => - model.generateText(JSON.stringify('hi'), JSON.stringify({ max_retries: 0 })), + model.generateText(JSON.stringify('hi'), JSON.stringify({ maxRetries: 0 })), )) as LoadSettingError t.true(err instanceof LoadSettingError) t.true(err instanceof AimuxError) diff --git a/bindings/node/__test__/index.test.ts b/bindings/node/__test__/index.test.ts index 029db1701..fbb4a7d97 100644 --- a/bindings/node/__test__/index.test.ts +++ b/bindings/node/__test__/index.test.ts @@ -20,7 +20,7 @@ test('native module loads and exports functions', (t) => { }) test('video poll options are exported from the public type barrel', (t) => { - const poll: VideoPollOptions = { interval_ms: 1_000, timeout_ms: 120_000 } + const poll: VideoPollOptions = { intervalMs: 1_000, timeoutMs: 120_000 } const options: Pick = { poll } t.deepEqual(options.poll, poll) }) @@ -41,16 +41,20 @@ test('provider() creates a registry model instance (RFC-0017 phase 4)', async (t t.is(typeof model.streamText, 'function') }) -test('provider() with undefined apiKey reads the registry env var', async (t) => { - // ABACUS_API_KEY is unset in CI. As in the AI SDK, the key is loaded when a - // request is made, so construction succeeds and the call fails with a clear - // error (missing env key), proving the env-var path is wired. - delete process.env.ABACUS_API_KEY - const model = await provider('abacus', undefined, 'm') - await t.throwsAsync( - () => model.generateText(JSON.stringify('hi')), - { message: /api key/i }, - ) +test.serial('provider() with undefined apiKey reads the registry env var', async (t) => { + // The key is resolved at call time: with the env var unset the call must + // fail with a clear error (missing env key), proving the env-var path is wired. + const saved = process.env.GROQ_API_KEY + delete process.env.GROQ_API_KEY + try { + const model = await provider('groq', undefined, 'm') + await t.throwsAsync( + () => model.generateText(JSON.stringify('hi'), JSON.stringify({ maxRetries: 0 })), + { message: /api key/i }, + ) + } finally { + if (saved !== undefined) process.env.GROQ_API_KEY = saved + } }) test('provider() rejects unknown provider names with the available list', async (t) => { diff --git a/bindings/node/__test__/multimodal_e2e.test.ts b/bindings/node/__test__/multimodal_e2e.test.ts index ba780f4ed..85d51d58f 100644 --- a/bindings/node/__test__/multimodal_e2e.test.ts +++ b/bindings/node/__test__/multimodal_e2e.test.ts @@ -80,7 +80,7 @@ const FILES_RESPONSE = JSON.stringify({ // fetch result). A single-response mock can't cover the full flow, so the video // test only exercises construction + result-JSON parsing (see test below). const VIDEO_RESULT_JSON = JSON.stringify({ - videos: [{ Url: { url: 'https://example.com/v.mp4', media_type: 'video/mp4' } }], + videos: [{ type: 'url', url: 'https://example.com/v.mp4', mediaType: 'video/mp4' }], }) // ── Tests ─────────────────────────────────────────────────────────────────── @@ -114,11 +114,11 @@ test('e2e: OpenAI speech (TTS) via mock server returns binary audio', async (t) try { const speaker = await openaiSpeech('test-key', 'tts-1', url) const r = JSON.parse( - await speaker.generate(JSON.stringify({ text: 'Hi', voice: 'alloy', output_format: 'mp3' })), + await speaker.generate(JSON.stringify({ text: 'Hi', voice: 'alloy', outputFormat: 'mp3' })), ) - t.truthy(r.audio.Binary, 'audio comes back as AudioData::Binary') - t.true(r.audio.Binary.length > 0, 'binary audio is non-empty') + t.true(Array.isArray(r.audio), 'audio comes back as AudioData::Binary (a byte array)') + t.true(r.audio.length > 0, 'binary audio is non-empty') } finally { await closeServer(server) } @@ -132,14 +132,14 @@ test('e2e: OpenAI image generation via mock server', async (t) => { try { const imager = await openaiImage('test-key', 'dall-e-3', url) - // ImageCallOptions has non-Option required fields `n` and `provider_options`, + // ImageCallOptions has non-Option required fields `n` and `providerOptions`, // so both must be present in the opts JSON for serde deserialization. const r = JSON.parse( - await imager.generate(JSON.stringify({ prompt: 'otter', n: 1, provider_options: {} })), + await imager.generate(JSON.stringify({ prompt: 'otter', n: 1, providerOptions: {} })), ) - t.is(r.images.Base64.length, 1) - t.is(r.images.Base64[0], 'aW1hZ2Ux') + t.is(r.images.length, 1) + t.is(r.images[0], 'aW1hZ2Ux') } finally { await closeServer(server) } @@ -169,14 +169,14 @@ test('e2e: Cohere reranking via mock server', async (t) => { try { const reranker = await cohereReranking('test-key', 'rerank-v3.0', url) - // `docs_json` deserializes into the externally-tagged `RerankingDocuments` - // enum, so it must be `{"Text":{"values":[...]}}` (not a bare array). + // `docs_json` deserializes into the `type`-tagged `RerankingDocuments` + // enum, so it must be `{"type":"text","values":[...]}` (not a bare array). const r = JSON.parse( - await reranker.rerank('which?', JSON.stringify({ Text: { values: ['doc1', 'doc2'] } })), + await reranker.rerank('which?', JSON.stringify({ type: 'text', values: ['doc1', 'doc2'] })), ) t.is(r.ranking.length, 2) - t.is(r.ranking[0].relevance_score, 0.95) + t.is(r.ranking[0].relevanceScore, 0.95) } finally { await closeServer(server) } @@ -210,7 +210,7 @@ test('e2e: OpenAI files upload via mock server', async (t) => { const files = await openaiFiles('test-key', url) const r = JSON.parse(await files.uploadFile('dGVzdA==', 'application/pdf')) - t.is(r.provider_reference.openai, 'file-abc') + t.is(r.providerReference.openai, 'file-abc') } finally { await closeServer(server) } @@ -225,6 +225,6 @@ test('e2e: Google video construction + result parsing', async (t) => { t.truthy(video, 'googleVideo factory constructs without throwing') const r = JSON.parse(VIDEO_RESULT_JSON) - t.is(r.videos[0].Url.url, 'https://example.com/v.mp4') - t.is(r.videos[0].Url.media_type, 'video/mp4') + t.is(r.videos[0].url, 'https://example.com/v.mp4') + t.is(r.videos[0].mediaType, 'video/mp4') }) diff --git a/bindings/node/__test__/reasoning_replay.test.ts b/bindings/node/__test__/reasoning_replay.test.ts index b9b86dff3..2f64b8abb 100644 --- a/bindings/node/__test__/reasoning_replay.test.ts +++ b/bindings/node/__test__/reasoning_replay.test.ts @@ -2,15 +2,14 @@ // reported against aimux 0.1.1 when driving OpenAI-compatible thinking models // (e.g. DeepSeek `deepseek-v4-flash`) in multi-turn tool-call conversations. // -// Issue 1: a `tool`-role message carrying `ContentPart[]` with a `tool_result` -// part built from the legacy `output` field was rejected by `ModelPrompt` -// deserialization ("data did not match any variant of untagged enum -// ModelPrompt"). The Rust `ContentPart::ToolResult.result` field now accepts -// `output` as a serde alias, so both shapes round-trip. +// Issue 1: a `tool`-role message carrying `ContentPart[]` with a `tool-result` +// part (the AI SDK `ToolResultPart` shape, with a structured `output`) was +// rejected by `ModelPrompt` deserialization ("data did not match any variant +// of untagged enum ModelPrompt"). // // Issue 2: thinking models require prior assistant `reasoning_content` to be // replayed on later turns. The OpenAI message converter now lifts -// `ContentPart::Reasoning` parts to a top-level `reasoning_content` string. +// `reasoning` content parts to a top-level `reasoning_content` string. // // These tests use a mock HTTP server (no real API) and assert the outbound // request body shape that the Rust engine produces. @@ -52,9 +51,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,25 +64,25 @@ test('issue 1: tool_result with legacy `output` field is accepted and carries to try { const model = await deepseek('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' }, { role: 'assistant', content: [{ - type: 'tool_call', - tool_call_id: 'tc1', - tool_name: 'write_file', + type: 'tool-call', + toolCallId: 'tc1', + toolName: 'write_file', input: { path: '/tmp/test.txt' }, }], }, { role: 'tool', content: [{ - type: 'tool_result', - tool_call_id: 'tc1', - output: 'Successfully wrote to /tmp/test.txt', + type: 'tool-result', + toolCallId: 'tc1', + toolName: 'write_file', + output: { type: 'text', value: 'Successfully wrote to /tmp/test.txt' }, }], }, ] @@ -105,7 +104,7 @@ test('issue 1: tool_result with legacy `output` field is accepted and carries to // ── Issue 2: reasoning_content replay on the request side ─────────────────── -test('issue 2: assistant reasoning + tool_call lifts reasoning to reasoning_content', async (t) => { +test('issue 2: assistant reasoning + tool-call lifts reasoning to reasoning_content', async (t) => { let requestBody: any = null const { server, url } = await startMockServer(async (req, res) => { requestBody = await readBody(req) @@ -117,7 +116,7 @@ test('issue 2: assistant reasoning + tool_call lifts reasoning to reasoning_cont const model = await deepseek('test-key', 'deepseek-v4-flash', url) // Reproduces the user's table row: assistant - // [{reasoning},{tool_call}] + tool result. The first turn produced a + // [{reasoning},{tool-call}] + tool result. The first turn produced a // reasoning_content + tool_call; the second turn must replay both. // A tool message carries tool_result parts only (AI SDK `ToolModelMessage`). const messages = [ @@ -125,17 +124,17 @@ test('issue 2: assistant reasoning + tool_call lifts reasoning to reasoning_cont { role: 'assistant', content: [ - { type: 'reasoning', text: 'I need to inspect files before answering.', signature: null }, - { type: 'tool_call', tool_call_id: 'call_1', tool_name: 'read_file', input: { path: 'README.md' } }, + { type: 'reasoning', text: 'I need to inspect files before answering.' }, + { type: 'tool-call', toolCallId: 'call_1', toolName: 'read_file', input: { path: 'README.md' } }, ], }, { role: 'tool', content: [{ - type: 'tool_result', - tool_call_id: 'call_1', - tool_name: 'read_file', - output: 'contents of README.md', + type: 'tool-result', + toolCallId: 'call_1', + toolName: 'read_file', + output: { type: 'text', value: 'contents of README.md' }, }], }, ] @@ -173,7 +172,7 @@ test('issue 2: assistant reasoning + text (no tool calls) emits reasoning_conten { role: 'assistant', content: [ - { type: 'reasoning', text: '2 plus 2 equals 4.', signature: null }, + { type: 'reasoning', text: '2 plus 2 equals 4.' }, { type: 'text', text: '4' }, ], }, @@ -208,9 +207,9 @@ test('issue 2: assistant without reasoning omits reasoning_content', async (t) = { role: 'assistant', content: [{ - type: 'tool_call', - tool_call_id: 'call_1', - tool_name: 'write_file', + type: 'tool-call', + toolCallId: 'call_1', + toolName: 'write_file', input: { path: '/tmp/x' }, }], }, diff --git a/bindings/node/__test__/roundtrip-fields.test.ts b/bindings/node/__test__/roundtrip-fields.test.ts index 66b8924cd..d66f98cc6 100644 --- a/bindings/node/__test__/roundtrip-fields.test.ts +++ b/bindings/node/__test__/roundtrip-fields.test.ts @@ -6,7 +6,7 @@ // `signature` is echoed as a thinking block on the wire (input // round-trip), and a thinking-block response surfaces `signature` on // the result (response visibility, #131). -// - google: a tool_result's `toolName` reaches the request as +// - google: a tool-result's `toolName` reaches the request as // functionResponse.name (#127). import test from 'ava' @@ -69,14 +69,14 @@ test('roundtrip: anthropic echoes prompt reasoning signature and surfaces respon { role: 'assistant', content: [ - { type: 'reasoning', text: 'pondering', provider_options: { anthropic: { signature: 'sig-prompt-1' } } }, + { type: 'reasoning', text: 'pondering', providerOptions: { anthropic: { signature: 'sig-prompt-1' } } }, { type: 'text', text: 'answer' }, ], }, { role: 'user', content: [{ type: 'text', text: 'go on' }] }, ] const opts = JSON.stringify({ - provider_options: { + providerOptions: { anthropic: { thinking: { type: 'enabled', budgetTokens: 1024 } }, }, }) @@ -120,12 +120,12 @@ test('roundtrip: google toolName reaches functionResponse.name', async (t) => { { role: 'assistant', content: [ - { type: 'tool_call', tool_call_id: 'call-1', tool_name: 'weather', input: { location: 'SF' } }, + { type: 'tool-call', toolCallId: 'call-1', toolName: 'weather', input: { location: 'SF' } }, ], }, { role: 'tool', - content: [{ type: 'tool_result', tool_call_id: 'call-1', tool_name: 'weather', result: { temp: 70 } }], + content: [{ type: 'tool-result', toolCallId: 'call-1', toolName: 'weather', output: { type: 'json', value: { temp: 70 } } }], }, ] await model.generateText(JSON.stringify(prompt)) diff --git a/bindings/node/__test__/session.test.ts b/bindings/node/__test__/session.test.ts index 1cc0b8a45..b01304eab 100644 --- a/bindings/node/__test__/session.test.ts +++ b/bindings/node/__test__/session.test.ts @@ -57,29 +57,29 @@ test.serial('sessionId groups calls and query API returns typed results', async const model = await deepseek('sk-test-fake-key', 'gpt-4o-mini', { baseUrl: url }) // Two calls in the same session. - await generateText(model, 'first', { session_id: 'sess-1' }) - await generateText(model, 'second', { session_id: 'sess-1' }) + await generateText(model, 'first', { sessionId: 'sess-1' }) + await generateText(model, 'second', { sessionId: 'sess-1' }) const calls: SessionCall[] = getSessionCalls('sess-1') t.is(calls.length, 2) t.is(calls[0].step, 0) t.is(calls[1].step, 1) - t.truthy(calls[0].call_id) - t.truthy(calls[0].recorded_at) - t.not(calls[0].call_id, calls[1].call_id) + t.truthy(calls[0].callId) + t.truthy(calls[0].recordedAt) + t.not(calls[0].callId, calls[1].callId) // A call without a sessionId (inference off) is not grouped. await generateText(model, 'third') const sessions: SessionView[] = getSessions() t.is(sessions.length, 1) - t.is(sessions[0].session_id, 'sess-1') + t.is(sessions[0].sessionId, 'sess-1') t.is(sessions[0].source, 'Explicit') // Unknown session → empty. t.deepEqual(getSessionCalls('nope'), []) // Separate explicit session. - await generateText(model, 'other', { session_id: 'sess-2' }) + await generateText(model, 'other', { sessionId: 'sess-2' }) t.is(getSessions().length, 2) t.is(getSessionCalls('sess-2').length, 1) }) @@ -97,7 +97,7 @@ test.serial('opt-in inferer groups prefix continuations into auto sessions', asy await generateText(model, [{ role: 'user', content: 'u1' }, { role: 'assistant', content: 'a1' }, { role: 'user', content: 'u2' }]) const sessions = getSessions() - const autos = sessions.filter((s) => s.session_id.startsWith('auto-')) + const autos = sessions.filter((s) => s.sessionId.startsWith('auto-')) t.is(autos.length, 1, 'prefix continuation stays in one auto session') t.is(autos[0].source, 'Inferred') t.is(autos[0].calls.length, 2) diff --git a/bindings/node/__test__/trace.test.ts b/bindings/node/__test__/trace.test.ts index f6c2e7ac2..67b356f68 100644 --- a/bindings/node/__test__/trace.test.ts +++ b/bindings/node/__test__/trace.test.ts @@ -55,7 +55,7 @@ test('trace() records calls and query API returns typed results', async (t) => { // > 4 KiB user message so block-aligned prefixes actually match. const big = 'x'.repeat(5000) - await generateText(traced, [{ role: 'user', content: big }], { session_id: 'sess-1' }) + await generateText(traced, [{ role: 'user', content: big }], { sessionId: 'sess-1' }) await generateText( traced, [ @@ -63,7 +63,7 @@ test('trace() records calls and query API returns typed results', async (t) => { { role: 'assistant', content: 'a1' }, { role: 'user', content: 'u2' }, ], - { session_id: 'sess-1' }, + { sessionId: 'sess-1' }, ) // Untraced models reject the query API. @@ -74,28 +74,26 @@ test('trace() records calls and query API returns typed results', async (t) => { t.is(stats.length, 1) t.is(stats[0].provider, 'deepseek.chat') t.is(stats[0].requests, 2) - t.truthy(stats[0].reported_hit_rate) - t.truthy(stats[0].client_upper_bound_hit_rate) - t.truthy(stats[0].verdict_counts) + t.truthy(stats[0].verdictCounts) const chain = JSON.parse(traced.traceSessionChain('sess-1')) as any - t.is(chain.record_ids.length, 2) - t.truthy(chain.prefix_stability) + t.is(chain.recordIds.length, 2) + t.truthy(chain.prefixStability) const jsonl = traced.traceExportJsonl() const lines = jsonl.trim().split('\n') t.is(lines.length, 2, 'one TraceRecord per line') const first = JSON.parse(lines[0]) - t.truthy(first.fingerprint.body_hash) - t.is(first.session_id, 'sess-1') + t.truthy(first.fingerprint.bodyHash) + t.is(first.sessionId, 'sess-1') traced.traceClear() t.is(traced.traceExportJsonl().trim(), '') }) -test('default streams carry no Raw parts (RFC-0016 M2 contract)', async (t) => { +test('default streams carry no raw parts (RFC-0016 M2 contract)', async (t) => { // The RFC-0015 stream meta part was removed in favor of the M2 contract: - // Raw parts appear only when include_raw_chunks is set. Probing itself is + // raw parts appear only when includeRawChunks is set. Probing itself is // unaffected (TraceLayer records internally; request bodies remain // available on the non-streaming path — RFC-0023 runtime recording is the // planned (draft) mechanism for future streaming-side access). @@ -110,10 +108,10 @@ test('default streams carry no Raw parts (RFC-0016 M2 contract)', async (t) => { } t.truthy(parts.length >= 1) - t.truthy(parts[0].includes('StreamStart'), `part[0] must be StreamStart: ${parts[0]}`) + t.is(JSON.parse(parts[0]).type, 'stream-start', `part[0] must be stream-start: ${parts[0]}`) t.true( - parts.every((p) => !p.includes('"Raw"')), - 'no Raw parts by default', + parts.every((p) => JSON.parse(p).type !== 'raw'), + 'no raw parts by default', ) }) @@ -124,5 +122,5 @@ test('non-traced model still generates normally', async (t) => { const model = await deepseek('sk-test-fake-key', 'gpt-4o-mini', { baseUrl: url }) const result: GenerateTextResult = await generateText(model, 'hello') t.truthy(result.text) - t.is(result.usage.input_tokens.total, 200) + t.is(result.usage.inputTokens.total, 200) }) diff --git a/bindings/node/__test__/transcription-session.test.ts b/bindings/node/__test__/transcription-session.test.ts index ab3ea6630..9462b96fd 100644 --- a/bindings/node/__test__/transcription-session.test.ts +++ b/bindings/node/__test__/transcription-session.test.ts @@ -130,7 +130,7 @@ test.serial('nextPart timeout preserves the live session', async (t) => { const session = await startTranscriptionSession(model, null) try { const first = JSON.parse((await session.nextPart(3000)) as string) - t.truthy('StreamStart' in first) + t.is(first.type, 'stream-start') const err = (await t.throwsAsync(session.nextPart(250))) as AimuxError // Timeout is a spent budget, not a provider-call transport failure. @@ -140,7 +140,7 @@ test.serial('nextPart timeout preserves the live session', async (t) => { // The session stayed live across the timeout. const late = JSON.parse((await session.nextPart(5000)) as string) - t.truthy('TranscriptDelta' in late) + t.is(late.type, 'transcript-delta') } finally { session.close() } @@ -159,7 +159,7 @@ test.serial('server error event rejects with a typed APICallError', async (t) => const session = await startTranscriptionSession(model, null) try { const first = JSON.parse((await session.nextPart(5000)) as string) - t.truthy('StreamStart' in first) + t.is(first.type, 'stream-start') const err = (await t.throwsAsync(session.nextPart(5000))) as AimuxError t.true(err instanceof APICallError) diff --git a/bindings/node/__test__/wrapper.test.ts b/bindings/node/__test__/wrapper.test.ts index b80b30c10..1212f4d36 100644 --- a/bindings/node/__test__/wrapper.test.ts +++ b/bindings/node/__test__/wrapper.test.ts @@ -2,8 +2,8 @@ // // Verifies that `generateText` / `streamText` (from src/index.ts) erase the // JSON-string boundary: inputs are typed objects and outputs are parsed into -// typed objects (`.text`, `.tool_calls`, `.raw.content`, stream `TextDelta` / -// `ToolCall`). Uses the same mock-HTTP-server pattern as e2e.test.ts — no real +// typed objects (`.text`, `.toolCalls`, `.raw.content`, stream `text-delta` / +// `tool-call`). Uses the same mock-HTTP-server pattern as e2e.test.ts — no real // API calls. import test from 'ava' @@ -14,7 +14,7 @@ import { createServer, type Server } from 'node:http' import { deepseek, anthropic, generateText, streamText } from '../src/index.ts' import type { GenerateTextResult, - StreamPart, + TextStreamPart, ModelMessage, Tool, ToolChoice, @@ -123,12 +123,12 @@ const OPENAI_STREAM_TOOL_BODY = [ 'data: [DONE]\n\n', ].join('') -// A typed tool definition + tool_choice, reused across tests. +// A typed tool definition + toolChoice, reused across tests. const weatherTool: Tool = { type: 'function', name: 'get_weather', description: 'Get weather for a location', - input_schema: { + inputSchema: { type: 'object', properties: { location: { type: 'string' } }, required: ['location'], @@ -137,7 +137,7 @@ const weatherTool: Tool = { // ── Tests ─────────────────────────────────────────────────────────────────── -test('wrapper: generateText returns a typed object (.text / .usage / .finish_reason / .raw.content)', async (t) => { +test('wrapper: generateText returns a typed object (.text / .usage / .finishReason / .raw.content)', async (t) => { const { server, url } = await startMockServer((req, res) => { res.writeHead(200, { 'content-type': 'application/json' }) res.end(OPENAI_CHAT_RESPONSE) @@ -151,20 +151,20 @@ test('wrapper: generateText returns a typed object (.text / .usage / .finish_rea // Top-level convenience fields are typed and present. t.is(result.text, 'Rust is a systems programming language.') t.truthy(result.usage, 'usage is present') - t.truthy(result.finish_reason, 'finish_reason is present') + t.truthy(result.finishReason, 'finishReason is present') t.true(Array.isArray(result.warnings)) // raw is the provider-facing GenerateResult; .content is a typed array. t.truthy(result.raw, 'raw provider result is present') t.true(Array.isArray(result.raw.content), 'raw.content is a typed array') - const textPart = result.raw.content.find((c) => 'Text' in c) - t.truthy(textPart, 'raw.content contains a Text content part') + const textPart = result.raw.content.find((c) => c.type === 'text') + t.truthy(textPart, 'raw.content contains a text content part') } finally { await closeServer(server) } }) -test('wrapper: generateText parses tool_calls + raw.content ToolCall', async (t) => { +test('wrapper: generateText parses toolCalls + raw.content tool-call', async (t) => { const { server, url } = await startMockServer((req, res) => { res.writeHead(200, { 'content-type': 'application/json' }) res.end(OPENAI_TOOL_CALL_RESPONSE) @@ -177,27 +177,27 @@ test('wrapper: generateText parses tool_calls + raw.content ToolCall', async (t) tools: [weatherTool], }) - // Convenience field: tool_calls extracted and typed. - t.is(result.tool_calls.length, 1) - t.is(result.tool_calls[0].tool_name, 'get_weather') - t.is(result.tool_calls[0].tool_call_id, 'call_abc') - t.deepEqual(result.tool_calls[0].input, { location: 'Tokyo' }) - - // Structured content: raw.content carries the ToolCall variant. - const tc = result.raw.content.find((c) => 'ToolCall' in c) - t.truthy(tc, 'raw.content contains a ToolCall content part') - if (tc && 'ToolCall' in tc) { - t.is(tc.ToolCall.tool_name, 'get_weather') - t.is(tc.ToolCall.tool_call_id, 'call_abc') + // Convenience field: toolCalls extracted and typed. + t.is(result.toolCalls.length, 1) + t.is(result.toolCalls[0].toolName, 'get_weather') + t.is(result.toolCalls[0].toolCallId, 'call_abc') + t.deepEqual(result.toolCalls[0].input, { location: 'Tokyo' }) + + // Structured content: raw.content carries the tool-call variant. + const tc = result.raw.content.find((c) => c.type === 'tool-call') + t.truthy(tc, 'raw.content contains a tool-call content part') + if (tc && tc.type === 'tool-call') { + t.is(tc.toolName, 'get_weather') + t.is(tc.toolCallId, 'call_abc') // raw content keeps the provider's argument text (see e2e.test.ts). - t.is(tc.ToolCall.input, '{"location":"Tokyo"}') + t.is(tc.input, '{"location":"Tokyo"}') } } finally { await closeServer(server) } }) -test('wrapper: generateText passes tool_choice through to the provider', async (t) => { +test('wrapper: generateText passes toolChoice through to the provider', async (t) => { const cap = capturingHandler(OPENAI_TOOL_CALL_RESPONSE) const { server, url } = await startMockServer(cap.handler) @@ -206,7 +206,7 @@ test('wrapper: generateText passes tool_choice through to the provider', async ( const toolChoice: ToolChoice = 'required' await generateText(model, 'Hello', { tools: [weatherTool], - tool_choice: toolChoice, + toolChoice, }) const received = JSON.parse(cap.body) @@ -260,7 +260,7 @@ test('wrapper: generateText works across providers (Anthropic mock)', async (t) } }) -test('wrapper: streamText yields typed TextDelta parts', async (t) => { +test('wrapper: streamText yields typed text-delta parts', async (t) => { const { server, url } = await startMockServer((req, res) => { res.writeHead(200, { 'content-type': 'text/event-stream' }) res.end(OPENAI_STREAM_BODY) @@ -268,7 +268,7 @@ test('wrapper: streamText yields typed TextDelta parts', async (t) => { try { const model = await deepseek('test-key', 'gpt-4o', url) - const parts: StreamPart[] = [] + const parts: TextStreamPart[] = [] // streamText is an async generator of typed StreamParts — no JSON.parse. for await (const part of streamText(model, 'Say hello')) { parts.push(part) @@ -276,18 +276,18 @@ test('wrapper: streamText yields typed TextDelta parts', async (t) => { t.true(parts.length > 0) - // Narrow the tagged union by key presence (typed, not `any`). + // Narrow the tagged union by its `type` tag (typed, not `any`). const deltas = parts - .filter((p): p is Extract => 'TextDelta' in p) - .map((p) => p.TextDelta.delta) + .filter((p): p is Extract => p.type === 'text-delta') + .map((p) => p.delta) t.is(deltas.join(''), 'Hello world') } finally { await closeServer(server) } }) -test('wrapper: streamText emits Raw parts when include_raw_chunks set', async (t) => { - // RFC-0016 M2: include_raw_chunks surfaces one Raw part per JSON SSE event +test('wrapper: streamText emits raw parts when includeRawChunks set', async (t) => { + // RFC-0016 M2: includeRawChunks surfaces one raw part per JSON SSE event // (parsed payload, before the parsed parts; [DONE] excluded). const { server, url } = await startMockServer((req, res) => { res.writeHead(200, { 'content-type': 'text/event-stream' }) @@ -296,32 +296,32 @@ test('wrapper: streamText emits Raw parts when include_raw_chunks set', async (t try { const model = await deepseek('test-key', 'gpt-4o', url) - const parts: StreamPart[] = [] + const parts: TextStreamPart[] = [] for await (const part of streamText(model, 'Say hello', { - include_raw_chunks: true, + includeRawChunks: true, })) { parts.push(part) } const rawParts = parts.filter( - (p): p is Extract => 'Raw' in p, + (p): p is Extract => p.type === 'raw', ) // OPENAI_STREAM_BODY has 3 JSON events (2 content + 1 usage chunk); // the [DONE] sentinel emits no Raw. t.is(rawParts.length, 3) - const firstRaw = rawParts[0].Raw as { raw_value: any } - t.is(firstRaw.raw_value.choices[0].delta.content, 'Hello') + const firstRaw = rawParts[0].rawValue as any + t.is(firstRaw.choices[0].delta.content, 'Hello') - // Raw for the first event precedes its TextDelta. + // The raw part for the first event precedes its text-delta. const firstRawIdx = parts.indexOf(rawParts[0]) - const firstTextIdx = parts.findIndex((p) => 'TextDelta' in p) - t.true(firstRawIdx < firstTextIdx, 'Raw must precede the parsed parts') + const firstTextIdx = parts.findIndex((p) => p.type === 'text-delta') + t.true(firstRawIdx < firstTextIdx, 'raw must precede the parsed parts') } finally { await closeServer(server) } }) -test('wrapper: no Raw parts by default', async (t) => { +test('wrapper: no raw parts by default', async (t) => { const { server, url } = await startMockServer((req, res) => { res.writeHead(200, { 'content-type': 'text/event-stream' }) res.end(OPENAI_STREAM_BODY) @@ -329,17 +329,17 @@ test('wrapper: no Raw parts by default', async (t) => { try { const model = await deepseek('test-key', 'gpt-4o', url) - const parts: StreamPart[] = [] + const parts: TextStreamPart[] = [] for await (const part of streamText(model, 'Say hello')) { parts.push(part) } - t.true(parts.every((p) => !('Raw' in p)), 'no Raw parts expected by default') + t.true(parts.every((p) => p.type !== 'raw'), 'no raw parts expected by default') } finally { await closeServer(server) } }) -test('wrapper: streamText yields typed ToolCall parts', async (t) => { +test('wrapper: streamText yields typed tool-call parts', async (t) => { const { server, url } = await startMockServer((req, res) => { res.writeHead(200, { 'content-type': 'text/event-stream' }) res.end(OPENAI_STREAM_TOOL_BODY) @@ -347,7 +347,7 @@ test('wrapper: streamText yields typed ToolCall parts', async (t) => { try { const model = await deepseek('test-key', 'gpt-4o', url) - const parts: StreamPart[] = [] + const parts: TextStreamPart[] = [] for await (const part of streamText(model, "What's the weather?", { tools: [weatherTool], })) { @@ -356,15 +356,15 @@ test('wrapper: streamText yields typed ToolCall parts', async (t) => { // The stream must contain a tool-related part. t.true( - parts.some((p) => 'ToolCall' in p || 'ToolInputStart' in p || 'ToolInputDelta' in p), + parts.some((p) => ['tool-call', 'tool-input-start', 'tool-input-delta'].includes(p.type)), 'stream contained a tool-related StreamPart', ) - // A complete ToolCall part carries the parsed tool name + input (typed). - const toolCall = parts.find((p): p is Extract => 'ToolCall' in p) + // A complete tool-call part carries the parsed tool name + input (typed). + const toolCall = parts.find((p): p is Extract => p.type === 'tool-call') if (toolCall) { - t.is(toolCall.ToolCall.tool_name, 'get_weather') - t.deepEqual(toolCall.ToolCall.input, { location: 'Tokyo' }) + t.is(toolCall.toolName, 'get_weather') + t.deepEqual(toolCall.input, { location: 'Tokyo' }) } } finally { await closeServer(server) diff --git a/bindings/node/examples/generate-text.ts b/bindings/node/examples/generate-text.ts index 11588b93f..a35b95938 100644 --- a/bindings/node/examples/generate-text.ts +++ b/bindings/node/examples/generate-text.ts @@ -16,7 +16,7 @@ async function main() { const result = await generateText(model, 'Explain Rust ownership in one sentence.') console.log('Text:', result.text) console.log('Usage:', result.usage) - console.log('Finish reason:', result.finish_reason) + console.log('Finish reason:', result.finishReason) } main().catch(console.error) diff --git a/bindings/node/examples/stream-text.ts b/bindings/node/examples/stream-text.ts index a2c1b9e74..65b465cd5 100644 --- a/bindings/node/examples/stream-text.ts +++ b/bindings/node/examples/stream-text.ts @@ -3,7 +3,6 @@ // Run: OPENAI_API_KEY=sk-... node --experimental-strip-types examples/stream-text.ts import { openai, streamText } from '../src/index.ts' -import type { StreamPart } from '../src/index.ts' async function main() { const apiKey = process.env.OPENAI_API_KEY @@ -16,12 +15,10 @@ async function main() { console.log('Streaming:\n') for await (const part of streamText(model, 'Write a haiku about Rust.')) { - // StreamPart is a tagged union — check the type field - const obj = part as Record - const key = Object.keys(obj)[0] - if (key === 'TextDelta') { - process.stdout.write((obj[key] as { delta: string }).delta) - } else if (key === 'Finish') { + // TextStreamPart is a tagged union — check the type field + if (part.type === 'text-delta') { + process.stdout.write(part.delta) + } else if (part.type === 'finish') { console.log('\n\n[done]') } } diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index c311db1b0..35d4d8947 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -112,7 +112,7 @@ export declare class Model { * Generate a structured JSON object from the model (M12, RFC-0016). * * Same signature as [`Model::generate_text`]; returns a JSON-serialized - * `GenerateObjectResult`. Pass `response_format: { "Json": { ... } }` + * `GenerateObjectResult`. Pass `responseFormat: { "type": "json", ... }` * via `options` for schema control; the function applies JSON repair * before parsing. */ @@ -298,7 +298,7 @@ export declare function anthropicAws(apiKey: string, region: string, modelId: st * Resolve one invalid tool call against a host's repair reply. * * `optsJson` is the same string the call was generated with; the tool set - * comes from it. `replyJson` is `{"type":"repaired","tool_call":{…}}`, + * comes from it. `replyJson` is `{"type":"repaired","toolCall":{…}}`, * `{"type":"unchanged"}`, or `{"type":"failed","message":"…"}`. Returns the * resulting `ToolCall` JSON — valid, or invalid carrying a nested * `ToolCallRepairError`. @@ -311,8 +311,8 @@ export declare function applyToolCallRepair(toolCallJson: string, optsJson: stri /** * Apply a repair reply to a serialized `GenerateTextResult` or - * `GenerateObjectResult`, rewriting both `tool_calls` and the matching - * `response_messages` tool-call part. `optsJson` is the same string the call + * `GenerateObjectResult`, rewriting both `toolCalls` and the matching + * `responseMessages` tool-call part. `optsJson` is the same string the call * was generated with. * * The OpenAI-shaped result has no equivalent: it carries no `invalid` / @@ -564,14 +564,14 @@ export declare function tavilySearch(apiKey: string, baseUrl?: string | undefine /** * Build the repair argument for one invalid tool call. * - * `toolCallJson` is a `GenerateTextResult.tool_calls` entry with + * `toolCallJson` is a `GenerateTextResult.toolCalls` entry with * `invalid: true`. `prompt` and `optsJson` are **the same two strings the * call was generated with** (`generateText` / `streamText`); messages, * instructions, and the tool set are derived from them here, so no caller * repeats that derivation. * - * Returns `{tool_call, error, input_schema, tools, messages, instructions}` - * — the AI SDK `repairToolCall` argument, with `tool_call.input` the + * Returns `{toolCall, error, inputSchema, tools, messages, instructions}` + * — the AI SDK `repairToolCall` argument, with `toolCall.input` the * provider's raw argument text — or the JSON literal `"null"` when the * options carried no tools: as in the AI SDK, a call made without a tool set * is never repaired, and the caller skips it. `"null"` is a success. diff --git a/bindings/node/src/index.ts b/bindings/node/src/index.ts index db64f3a0a..a4bc898e3 100644 --- a/bindings/node/src/index.ts +++ b/bindings/node/src/index.ts @@ -246,11 +246,11 @@ export type { /** The argument passed to a {@link RepairToolCall} function. */ export type ToolCallRepairContext = { - tool_call: RawToolCall + toolCall: RawToolCall /** The lookup / parse / validation failure that made the call invalid. */ error: AiMuxError /** JSON Schema of the called tool; the empty-object schema when unknown. */ - input_schema: JsonValue + inputSchema: JsonValue tools: Tool[] messages: ModelMessage[] instructions: string | null @@ -316,7 +316,7 @@ async function repairReplyFor( let reply: ToolCallRepairReply try { const replacement = await repair(context) - reply = replacement ? { type: 'repaired', tool_call: replacement } : { type: 'unchanged' } + reply = replacement ? { type: 'repaired', toolCall: replacement } : { type: 'unchanged' } } catch (e) { reply = { type: 'failed', message: errorMessage(e) } } @@ -325,7 +325,7 @@ async function repairReplyFor( /** * Repair every invalid call in a serialized result, before it is decoded. - * `tool_calls` sits at the top level of a generate-text result and under `raw` + * `toolCalls` sits at the top level of a generate-text result and under `raw` * in a generate-object one. */ async function repairResultJson( @@ -335,17 +335,17 @@ async function repairResultJson( repair: RepairToolCall, ): Promise { const result = JSON.parse(resultJson) as { - tool_calls?: ToolCall[] - raw?: { tool_calls?: ToolCall[] } + toolCalls?: ToolCall[] + raw?: { toolCalls?: ToolCall[] } } - const calls = result.tool_calls ?? result.raw?.tool_calls ?? [] + const calls = result.toolCalls ?? result.raw?.toolCalls ?? [] let patched = resultJson for (const call of calls) { if (call.invalid !== true) continue const reply = await repairReplyFor(call, promptJson, optsJson, repair) if (reply === null) continue - patched = applyToolCallRepairToResult(patched, optsJson, call.tool_call_id, reply) + patched = applyToolCallRepairToResult(patched, optsJson, call.toolCallId, reply) } return patched } @@ -355,8 +355,8 @@ async function repairResultJson( * * @param model - A raw model instance from `openai()`, `anthropic()`, etc. * @param prompt - A plain string or an array of typed chat messages. - * @param options - Optional typed generation options (tools, tool_choice, - * temperature, response_format, …). Invalid tool calls arrive + * @param options - Optional typed generation options (tools, toolChoice, + * temperature, responseFormat, …). Invalid tool calls arrive * with `invalid`/`error` set on the tool call; pass * {@link RepairToolCall | `repairToolCall`} to fix them up * before the result is decoded. @@ -395,7 +395,7 @@ export async function generateText( * * @param model - A raw model instance from `openai()`, `anthropic()`, etc. * @param prompt - A plain string or an array of typed chat messages. - * @param options - Optional typed generation options (tools, tool_choice, …). + * @param options - Optional typed generation options (tools, toolChoice, …). * @param signal - Optional `AbortSignal`; aborting it cancels the stream. * * Internally drives the raw `model.streamText(JSON.stringify(prompt), …)` @@ -407,7 +407,7 @@ export async function generateText( * import { openai, streamText } from 'aimux' * const model = await openai(apiKey, 'gpt-4o') * for await (const part of streamText(model, 'Write a haiku about Rust.')) { - * if ('TextDelta' in part) process.stdout.write(part.TextDelta.delta) + * if (part.type === 'text-delta') process.stdout.write(part.delta) * } * ``` */ @@ -424,15 +424,14 @@ export async function* streamText( const gen = await model.streamText(promptJson, optsJson, bridge) for await (const json of gen) { const part = JSON.parse(json) as TextStreamPart - // Only the settled ToolCall part is repairable; ToolInputDelta parts are + // Only the settled tool-call part is repairable; tool-input-delta parts are // the provider's raw text and pass through immediately (AI SDK behaviour). - if (repair && 'ToolCall' in part && part.ToolCall.invalid === true) { - const reply = await repairReplyFor(part.ToolCall, promptJson, optsJson, repair) + if (repair && part.type === 'tool-call' && part.invalid === true) { + const reply = await repairReplyFor(part, promptJson, optsJson, repair) if (reply !== null) { yield { - ToolCall: JSON.parse( - applyToolCallRepair(JSON.stringify(part.ToolCall), optsJson, reply), - ) as Extract['ToolCall'], + type: 'tool-call', + ...(JSON.parse(applyToolCallRepair(JSON.stringify(part), optsJson, reply)) as ToolCall), } continue } diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 786af95cd..711e9bf31 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -250,7 +250,7 @@ impl Model { /// Generate a structured JSON object from the model (M12, RFC-0016). /// /// Same signature as [`Model::generate_text`]; returns a JSON-serialized - /// `GenerateObjectResult`. Pass `response_format: { "Json": { ... } }` + /// `GenerateObjectResult`. Pass `responseFormat: { "type": "json", ... }` /// via `options` for schema control; the function applies JSON repair /// before parsing. #[napi(ts_return_type = "Promise")] @@ -1621,14 +1621,14 @@ fn parse_opts(json: Option<&str>) -> MResult { /// Build the repair argument for one invalid tool call. /// -/// `toolCallJson` is a `GenerateTextResult.tool_calls` entry with +/// `toolCallJson` is a `GenerateTextResult.toolCalls` entry with /// `invalid: true`. `prompt` and `optsJson` are **the same two strings the /// call was generated with** (`generateText` / `streamText`); messages, /// instructions, and the tool set are derived from them here, so no caller /// repeats that derivation. /// -/// Returns `{tool_call, error, input_schema, tools, messages, instructions}` -/// — the AI SDK `repairToolCall` argument, with `tool_call.input` the +/// Returns `{toolCall, error, inputSchema, tools, messages, instructions}` +/// — the AI SDK `repairToolCall` argument, with `toolCall.input` the /// provider's raw argument text — or the JSON literal `"null"` when the /// options carried no tools: as in the AI SDK, a call made without a tool set /// is never repaired, and the caller skips it. `"null"` is a success. @@ -1660,7 +1660,7 @@ pub fn tool_call_repair_context( /// Resolve one invalid tool call against a host's repair reply. /// /// `optsJson` is the same string the call was generated with; the tool set -/// comes from it. `replyJson` is `{"type":"repaired","tool_call":{…}}`, +/// comes from it. `replyJson` is `{"type":"repaired","toolCall":{…}}`, /// `{"type":"unchanged"}`, or `{"type":"failed","message":"…"}`. Returns the /// resulting `ToolCall` JSON — valid, or invalid carrying a nested /// `ToolCallRepairError`. @@ -1689,8 +1689,8 @@ pub fn apply_tool_call_repair( } /// Apply a repair reply to a serialized `GenerateTextResult` or -/// `GenerateObjectResult`, rewriting both `tool_calls` and the matching -/// `response_messages` tool-call part. `optsJson` is the same string the call +/// `GenerateObjectResult`, rewriting both `toolCalls` and the matching +/// `responseMessages` tool-call part. `optsJson` is the same string the call /// was generated with. /// /// The OpenAI-shaped result has no equivalent: it carries no `invalid` / diff --git a/bindings/node/src/types/AiMuxError.ts b/bindings/node/src/types/AiMuxError.ts index 0a44a0a3e..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/AspectRatio.ts b/bindings/node/src/types/AspectRatio.ts index c466b9b65..fe8b3f70c 100644 --- a/bindings/node/src/types/AspectRatio.ts +++ b/bindings/node/src/types/AspectRatio.ts @@ -7,4 +7,4 @@ * `aspectRatio` options. Aligned with the TS template-literal type * `` `${number}:${number}` ``. */ -export type AspectRatio = { width: number, height: number, }; +export type AspectRatio = `${number}:${number}`; 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 fd965cc5e..3c517067a 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"; /** @@ -7,61 +8,34 @@ import type { JsonValue } from "./serde_json/JsonValue"; * * Shared between `ModelMessage` (user-facing) and `LanguageModelPrompt` (provider-facing). */ -export type ContentPart = { "type": "text", text: string, +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). */ -input: JsonValue, +input: JsonValue, /** * 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. */ -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, -/** - * 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, +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 702e85cf1..671bdb60e 100644 --- a/bindings/node/src/types/EvaluationCallOptions.ts +++ b/bindings/node/src/types/EvaluationCallOptions.ts @@ -15,4 +15,4 @@ state: EvaluationInput, /** * Questions by id; insertion order is the question order. */ -questions: { [key in string]: EvaluationQuestion }, headers: { [key in string]: string } | null, provider_options: { [key in string]: { [key in string]: JsonValue } } | null, }; +questions: { [key in string]: EvaluationQuestion }, 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 a0f49741b..1f6ae6136 100644 --- a/bindings/node/src/types/EvaluationQuestion.ts +++ b/bindings/node/src/types/EvaluationQuestion.ts @@ -14,4 +14,4 @@ criteria: { [key in string]: EvaluationInput | null }, } | { "type": "score", in /** * 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 63bb20c33..646491258 100644 --- a/bindings/node/src/types/EvaluationResult.ts +++ b/bindings/node/src/types/EvaluationResult.ts @@ -13,4 +13,4 @@ export type EvaluationResult = { /** * Exactly one answer per question, under the original question ids. */ -answers: { [key in string]: EvaluationAnswer }, rounding: EvaluationRounding | null, usage: EvaluationUsage | null, warnings: Array, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, response: ResponseInfo | null, }; +answers: { [key in string]: EvaluationAnswer }, 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 98b9ac920..4fde225a8 100644 --- a/bindings/node/src/types/GenerateTextResult.ts +++ b/bindings/node/src/types/GenerateTextResult.ts @@ -18,76 +18,76 @@ 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, +toolCalls: Array, /** * Why generation stopped. */ -finish_reason: FinishReason, +finishReason: 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, +reasoningText: 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, +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. */ -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. */ -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/InputRecord.ts b/bindings/node/src/types/InputRecord.ts index 61cb96552..51348b0b6 100644 --- a/bindings/node/src/types/InputRecord.ts +++ b/bindings/node/src/types/InputRecord.ts @@ -12,7 +12,7 @@ export type InputRecord = { * Decision contract used for validation and deterministic offline replay. * Connection settings remain outside the recording. */ -decision_capabilities?: DecisionCapabilities | null, operation?: RecordingOperation, +decisionCapabilities?: DecisionCapabilities | null, operation?: RecordingOperation, /** * 完整 prompt(消息数组,含 ContentPart::Image 等多模态)。 */ 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 38777b88a..4f5c94f36 100644 --- a/bindings/node/src/types/OutcomeRecord.ts +++ b/bindings/node/src/types/OutcomeRecord.ts @@ -9,13 +9,13 @@ export type OutcomeRecord = { /** * Normalized decision result, independent of a provider's wire format. */ -decision_result?: JsonValue | null, status: OutcomeStatus, finish_reason: string | null, error: string | null, +decisionResult?: JsonValue | null, 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/Size.ts b/bindings/node/src/types/Size.ts index 960627114..32711645a 100644 --- a/bindings/node/src/types/Size.ts +++ b/bindings/node/src/types/Size.ts @@ -7,4 +7,4 @@ * `resolution` options. Aligned with the TS template-literal type * `` `${number}x${number}` ``. */ -export type Size = { width: number, height: number, }; +export type Size = `${number}x${number}`; 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 03c3f862f..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 bf8b91600..7192e5db3 100644 --- a/bindings/node/src/types/StreamTextResultAggregated.ts +++ b/bindings/node/src/types/StreamTextResultAggregated.ts @@ -19,69 +19,69 @@ 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, +reasoningText: string, /** * Tool calls requested by the model. */ -tool_calls: Array, +toolCalls: Array, /** * Sources / citations. */ -sources: Array, +sources: Array, /** * Files generated by the model. */ -files: Array, +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. */ -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, +totalUsage: 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, +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, /** * 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. */ -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 7457a4b09..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/bindings/python/python/aimux/__init__.py b/bindings/python/python/aimux/__init__.py index 1709a57ad..0e0494967 100644 --- a/bindings/python/python/aimux/__init__.py +++ b/bindings/python/python/aimux/__init__.py @@ -270,12 +270,12 @@ def _prompt_to_json(prompt: Union[str, List[Dict[str, Any]]]) -> str: return json.dumps({"prompt": prompt}) -#: The ``options["repair_tool_call"]`` function (RFC-0035), the equivalent of +#: The ``options["repairToolCall"]`` function (RFC-0035), the equivalent of #: the AI SDK ``repairToolCall``. #: #: It is called once per tool call that came back ``invalid``, with the repair -#: context ``{tool_call, error, input_schema, tools, messages, instructions}`` -#: — ``tool_call["input"]`` being the provider's raw argument text. Return a +#: context ``{toolCall, error, inputSchema, tools, messages, instructions}`` +#: — ``toolCall["input"]`` being the provider's raw argument text. Return a #: replacement raw tool call (same shape, ``input`` a string) to re-validate, #: or ``None`` to leave the call invalid. Raising marks the repair failed: the #: call keeps its original error with the exception message as the cause. @@ -291,16 +291,16 @@ def _prompt_to_json(prompt: Union[str, List[Dict[str, Any]]]) -> str: def _opts_to_json(options: Optional[Dict[str, Any]]) -> Optional[str]: """Convert options dict to JSON string. - ``repair_tool_call`` is a Python callable and stays on this side of the + ``repairToolCall`` is a Python callable and stays on this side of the boundary; everything else is passed through to the native layer. """ if options is None: return None - return json.dumps({k: v for k, v in options.items() if k != "repair_tool_call"}) + return json.dumps({k: v for k, v in options.items() if k != "repairToolCall"}) def _repair_fn(options: Optional[Dict[str, Any]]) -> Optional[_repair.RepairAdapter]: - fn = options.get("repair_tool_call") if options else None + fn = options.get("repairToolCall") if options else None if fn is None: return None return lambda context: _repair.run_hook(fn, context) @@ -319,7 +319,7 @@ def generate_text( options: Optional generation options. Returns: - Dict with keys: text, tool_calls, finish_reason, usage, warnings, raw. + Dict with keys: text, toolCalls, finishReason, usage, warnings, raw. """ prompt_json = _prompt_to_json(prompt) opts_json = _opts_to_json(options) @@ -338,17 +338,17 @@ def generate_object( """Generate a structured JSON object from the model (M12, RFC-0016). Same signature as ``generate_text``. Pass - ``response_format: {"Json": {...}}`` via options for schema control; the + ``responseFormat: {"type": "json", ...}`` via options for schema control; the function applies JSON repair before parsing. Args: model: A model instance from openai(), anthropic(), etc. prompt: A string or a list of message dicts. - options: Optional generation options (incl. response_format). + options: Optional generation options (incl. responseFormat). Returns: - Dict with keys: object, finish_reason, raw_finish_reason, usage, - warnings, reasoning, provider_metadata, response, raw. + Dict with keys: object, finishReason, rawFinishReason, usage, + warnings, reasoning, providerMetadata, response, raw. """ prompt_json = _prompt_to_json(prompt) opts_json = _opts_to_json(options) @@ -392,12 +392,12 @@ def stream_text( prompt: Union[str, List[Dict[str, Any]]], options: Optional[Dict[str, Any]] = None, ): - """Stream text from a model. Yields StreamPart dicts. + """Stream text from a model. Yields StreamPart dicts (tagged by ``type``). Usage: for part in stream_text(model, "Write a haiku about Rust."): - if "TextDelta" in part: - print(part["TextDelta"]["delta"], end="") + if part["type"] == "text-delta": + print(part["delta"], end="") """ prompt_json = _prompt_to_json(prompt) opts_json = _opts_to_json(options) @@ -448,7 +448,7 @@ def stream_text_as_openai( ``chat.completion.chunk`` object. Works with any provider. Stream options (include_usage, include_reasoning) are read from - options["provider_options"]["openai"]["stream_options"] (both default True). + options["providerOptions"]["openai"]["stream_options"] (both default True). Usage: for chunk in stream_text_as_openai(model, "Write a haiku."): diff --git a/bindings/python/python/aimux/_repair.py b/bindings/python/python/aimux/_repair.py index b8ed800a4..7ec2b7bdd 100644 --- a/bindings/python/python/aimux/_repair.py +++ b/bindings/python/python/aimux/_repair.py @@ -50,7 +50,7 @@ def _reply(repair: RepairAdapter, context: Dict[str, Any]) -> str: return json.dumps({"type": "failed", "message": str(exc)}) if replacement is None: return json.dumps({"type": "unchanged"}) - return json.dumps({"type": "repaired", "tool_call": replacement}) + return json.dumps({"type": "repaired", "toolCall": replacement}) def _context(call: Dict[str, Any], prompt_json: str, opts_json: Optional[str]): @@ -66,14 +66,14 @@ def repair_result( ) -> str: """Repair every invalid tool call of a serialized result, in order. - Handles ``generate_text`` / ``generate_object`` (``raw.tool_calls``) and the + Handles ``generate_text`` / ``generate_object`` (``raw.toolCalls``) and the aggregated stream result alike; one repair attempt per call, as in the AI SDK. Returns the patched result JSON. """ result = json.loads(result_json) - calls = result.get("tool_calls") + calls = result.get("toolCalls") if calls is None: - calls = result.get("raw", {}).get("tool_calls") or [] + calls = result.get("raw", {}).get("toolCalls") or [] for call in calls: if not call.get("invalid"): continue @@ -81,7 +81,7 @@ def repair_result( if context is None: continue result_json = apply_tool_call_repair_to_result( - result_json, opts_json, call["tool_call_id"], _reply(repair, context) + result_json, opts_json, call["toolCallId"], _reply(repair, context) ) return result_json @@ -110,14 +110,13 @@ def repair_stream_part( opts_json: Optional[str], repair: RepairAdapter, ) -> str: - """Replace an invalid ``ToolCall`` stream part with its repaired form. + """Replace an invalid ``tool-call`` stream part with its repaired form. Every other part — tool-input deltas included — is forwarded untouched, so repair never delays the stream. """ - part = json.loads(part_json) - call = part.get("ToolCall") if isinstance(part, dict) else None - if call is None or not call.get("invalid"): + call = json.loads(part_json) + if call.get("type") != "tool-call" or not call.get("invalid"): return part_json context = _context(call, prompt_json, opts_json) if context is None: @@ -125,4 +124,4 @@ def repair_stream_part( repaired = apply_tool_call_repair( json.dumps(call), opts_json, _reply(repair, context) ) - return json.dumps({"ToolCall": json.loads(repaired)}) + return json.dumps({"type": "tool-call", **json.loads(repaired)}) diff --git a/bindings/python/python/aimux/wrapper.py b/bindings/python/python/aimux/wrapper.py index e2e42d3d9..d5ddcc289 100644 --- a/bindings/python/python/aimux/wrapper.py +++ b/bindings/python/python/aimux/wrapper.py @@ -12,9 +12,11 @@ The Pydantic models mirror the Rust core types in ``aimux-core/src/`` (see ``bindings/node/src/types/*.ts`` for the field structure generated by ts-rs). -Field names are ``snake_case`` to match the Rust serde wire format; the few -camelCase exceptions required by the wire format (``toolName``) are handled -internally by custom serializers. +The wire format is the AI SDK's: JSON keys are camelCase and unions are +discriminated by ``type`` (kebab-case values). Python attributes stay +``snake_case``; every model maps them to the camelCase JSON keys through an +alias generator, so both ``tool_call_id`` and ``toolCallId`` validate and +serialization uses the camelCase form. """ from __future__ import annotations @@ -22,7 +24,8 @@ import json from typing import Annotated, Any, Callable, Dict, Iterator, List, Literal, Optional, Union -from pydantic import BaseModel, ConfigDict, Field, RootModel, model_serializer, model_validator +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter +from pydantic.alias_generators import to_camel from . import _repair from .aimux import Model @@ -91,6 +94,16 @@ ] +class _Wire(BaseModel): + """Base of every aimux model: ``snake_case`` attributes, camelCase JSON keys.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class _Meta(_Wire): + provider_metadata: Optional[Dict[str, Any]] = None + + # ───────────────────────────────────────────────────────────────────────────── # String-literal type aliases (mirror serde rename rules) # ───────────────────────────────────────────────────────────────────────────── @@ -108,10 +121,10 @@ "provider-default", "none", "minimal", "low", "medium", "high", "xhigh" ] -#: Opaque provider error value (Rust ``AiMuxError``). A mixed newtype/struct -#: externally-tagged enum, so it is left as an arbitrary JSON value rather than -#: a discriminated union. Named ``AiMuxErrorValue`` to avoid confusion with the -#: raised ``AimuxError`` exception. +#: Opaque provider error value (Rust ``AiMuxError``): an object discriminated +#: by ``name`` (``"AI_APICallError"`` …), left as an arbitrary JSON value. +#: Named ``AiMuxErrorValue`` to avoid confusion with the raised ``AimuxError`` +#: exception. AiMuxErrorValue = Any @@ -119,20 +132,20 @@ # Simple struct models # ───────────────────────────────────────────────────────────────────────────── -class InputTokenUsage(BaseModel): +class InputTokenUsage(_Wire): total: Optional[int] = None no_cache: Optional[int] = None cache_read: Optional[int] = None cache_write: Optional[int] = None -class OutputTokenUsage(BaseModel): +class OutputTokenUsage(_Wire): total: Optional[int] = None text: Optional[int] = None reasoning: Optional[int] = None -class Usage(BaseModel): +class Usage(_Wire): """Token usage statistics (Rust ``Usage``).""" input_tokens: InputTokenUsage = Field(default_factory=InputTokenUsage) @@ -140,14 +153,14 @@ class Usage(BaseModel): raw: Optional[Dict[str, Any]] = None -class FinishReason(BaseModel): +class FinishReason(_Wire): """Why generation stopped (Rust ``FinishReason``).""" unified: FinishReasonUnified raw: Optional[str] = None -class ResponseMetadata(BaseModel): +class ResponseMetadata(_Wire): """Metadata about the API response (Rust ``ResponseMetadata``).""" id: Optional[str] = None @@ -156,11 +169,13 @@ class ResponseMetadata(BaseModel): class GenerateResponseMetadata(ResponseMetadata): + """Response metadata with headers and body (Rust ``ResponseInfo``).""" + headers: Optional[Dict[str, str]] = None body: Optional[Any] = None -class ToolCall(BaseModel): +class ToolCall(_Meta): """A tool call requested by the model (Rust ``tool::ToolCall``).""" tool_call_id: str @@ -168,12 +183,11 @@ class ToolCall(BaseModel): input: Any provider_executed: Optional[bool] = None dynamic: Optional[bool] = None - provider_metadata: Optional[Any] = None invalid: Optional[bool] = None error: Optional[AiMuxErrorValue] = None -class RawToolCall(BaseModel): +class RawToolCall(_Meta): """A tool call as the provider emitted it (Rust ``RawToolCall``). Unlike :class:`ToolCall`, ``input`` is the **raw argument text** — the @@ -186,256 +200,114 @@ class RawToolCall(BaseModel): input: str provider_executed: Optional[bool] = None dynamic: Optional[bool] = None - provider_metadata: Optional[Any] = None # ───────────────────────────────────────────────────────────────────────────── -# External-tag enums (Rust serde default for enums) -# -# Rust serializes these as ``{"Variant": {fields}}``. Pydantic discriminated -# unions use an *internal* tag, so each enum is modelled as a ``RootModel`` -# over a discriminated union with a ``type`` field, plus a before-validator -# that converts the external tag into the internal discriminator and a wrap -# serializer that converts it back. +# Unions: internally tagged by ``type`` (kebab-case), as in the AI SDK. # ───────────────────────────────────────────────────────────────────────────── -def _external_tag_before(data: Any) -> Any: - """Convert ``{"Variant": {payload}}`` → ``{"type": "Variant", **payload}``. - - Passes other shapes through unchanged (so already-internal-tagged data and - scalars still validate). - """ - if isinstance(data, dict) and len(data) == 1: - (tag, payload), = data.items() - if isinstance(payload, dict): - return {"type": tag, **payload} - return data - - -def _external_tag_serializer(out: Any) -> Any: - """Convert ``{"type": "Variant", **fields}`` → ``{"Variant": {fields}}``.""" - if isinstance(out, dict) and "type" in out: - tag = out.pop("type") - return {tag: out} - return out - - # ── Warning ────────────────────────────────────────────────────────────────── -class _WarningUnsupported(BaseModel): - type: Literal["Unsupported"] +class _WarningUnsupported(_Wire): + type: Literal["unsupported"] feature: str details: Optional[str] = None -class _WarningCompatibility(BaseModel): - type: Literal["Compatibility"] +class _WarningCompatibility(_Wire): + type: Literal["compatibility"] feature: str details: Optional[str] = None -class _WarningDeprecated(BaseModel): - type: Literal["Deprecated"] +class _WarningDeprecated(_Wire): + type: Literal["deprecated"] setting: str message: str -class _WarningOther(BaseModel): - type: Literal["Other"] +class _WarningOther(_Wire): + type: Literal["other"] message: str -_WarningUnion = Annotated[ - Union[ - _WarningUnsupported, - _WarningCompatibility, - _WarningDeprecated, - _WarningOther, - ], +#: A provider warning (Rust ``Warning``). +Warning = Annotated[ + Union[_WarningUnsupported, _WarningCompatibility, _WarningDeprecated, _WarningOther], Field(discriminator="type"), ] -class Warning(RootModel[_WarningUnion]): - """A provider warning (Rust ``Warning``).""" - - root: _WarningUnion - - @model_validator(mode="before") - @classmethod - def _ext_to_int(cls, data: Any) -> Any: - return _external_tag_before(data) - - @model_serializer(mode="wrap") - def _int_to_ext(self, handler): # type: ignore[no-untyped-def] - return _external_tag_serializer(handler(self)) - - # ── FileBytes / FileData ──────────────────────────────────────────────────── -# -# ``FileBytes`` and ``FileData`` are externally tagged Rust enums. -# ``FileData``'s variants are struct variants (dict payloads), so it reuses the -# shared ``_external_tag_before`` / ``_external_tag_serializer`` helpers. -# ``FileBytes`` are newtype variants whose payload is *not* a dict -# (``{"Binary": [1, 2, 3]}`` / ``{"Base64": "..."}``), so it has a dedicated -# before-validator / serializer. - -class _FileBytesBinary(BaseModel): - type: Literal["Binary"] - data: List[int] +#: Raw bytes (a JSON number array) or a base64 string (Rust ``FileBytes``). +FileBytes = Union[List[int], str] -class _FileBytesBase64(BaseModel): - type: Literal["Base64"] - data: str - -_FileBytesUnion = Annotated[ - Union[ - _FileBytesBinary, - _FileBytesBase64, - ], - Field(discriminator="type"), -] - - -class FileBytes(RootModel[_FileBytesUnion]): - """Raw bytes or a base64-encoded string (Rust ``FileBytes``). - - Wire form: ``{"Binary": [1, 2, 3]}`` / ``{"Base64": "..."}``. - """ - - root: _FileBytesUnion - - @model_validator(mode="before") - @classmethod - def _ext_to_int(cls, data: Any) -> Any: - # {"Binary": [1, 2, 3]} → {"type": "Binary", "data": [1, 2, 3]} - # {"Base64": "x"} → {"type": "Base64", "data": "x"} - if isinstance(data, dict) and len(data) == 1: - (tag, payload), = data.items() - return {"type": tag, "data": payload} - return data - - @model_serializer(mode="wrap") - def _int_to_ext(self, handler): # type: ignore[no-untyped-def] - out = handler(self) - if isinstance(out, dict) and "type" in out and "data" in out: - return {out["type"]: out["data"]} - return out - - -class _FileDataData(BaseModel): - type: Literal["Data"] +class _FileDataData(_Wire): + type: Literal["data"] data: FileBytes -class _FileDataUrl(BaseModel): - type: Literal["Url"] +class _FileDataUrl(_Wire): + type: Literal["url"] url: str original_url: Optional[str] = None -class _FileDataReference(BaseModel): - type: Literal["Reference"] +class _FileDataReference(_Wire): + type: Literal["reference"] reference: Dict[str, str] -class _FileDataText(BaseModel): - type: Literal["Text"] +class _FileDataText(_Wire): + type: Literal["text"] text: str -_FileDataUnion = Annotated[ - Union[ - _FileDataData, - _FileDataUrl, - _FileDataReference, - _FileDataText, - ], +#: File data (Rust ``FileData``). +FileData = Annotated[ + Union[_FileDataData, _FileDataUrl, _FileDataReference, _FileDataText], Field(discriminator="type"), ] - -class FileData(RootModel[_FileDataUnion]): - """File data as a tagged union (Rust ``FileData``). - - Wire form: ``{"Data": {"data": }}``, ``{"Url": {"url": "..."}}``, - ``{"Reference": {"reference": {...}}}``, ``{"Text": {"text": "..."}}``. - """ - - root: _FileDataUnion - - @model_validator(mode="before") - @classmethod - def _ext_to_int(cls, data: Any) -> Any: - return _external_tag_before(data) - - @model_serializer(mode="wrap") - def _int_to_ext(self, handler): # type: ignore[no-untyped-def] - return _external_tag_serializer(handler(self)) - - -_GeneratedFileDataUnion = Annotated[ +#: Data or a URL returned for a generated file (Rust ``GeneratedFileData``). +GeneratedFileData = Annotated[ Union[_FileDataData, _FileDataUrl], Field(discriminator="type") ] -class GeneratedFileData(RootModel[_GeneratedFileDataUnion]): - @model_validator(mode="before") - @classmethod - def _ext_to_int(cls, data: Any) -> Any: - return _external_tag_before(data) - - @model_serializer(mode="wrap") - def _int_to_ext(self, handler): - return _external_tag_serializer(handler(self)) - - -# ── GenerateContent ───────────────────────────────────────────────────────── - -class _ContentText(BaseModel): - type: Literal["Text"] - text: str - provider_metadata: Optional[Dict[str, Any]] = None - +class GeneratedFile(_Meta): + data: GeneratedFileData + media_type: str -class _ContentToolCall(BaseModel): - type: Literal["ToolCall"] - tool_call_id: str - tool_name: str - input: Any - provider_executed: Optional[bool] = None - dynamic: Optional[bool] = None - provider_metadata: Optional[Any] = None +# ── Content / stream parts ────────────────────────────────────────────────── -class _UrlSource(BaseModel): +class _UrlSource(_Meta): id: str source_type: Literal["url"] url: str title: Optional[str] = None - provider_metadata: Optional[Dict[str, Any]] = None -class _DocumentSource(BaseModel): +class _DocumentSource(_Meta): id: str source_type: Literal["document"] media_type: str title: str filename: Optional[str] = None - provider_metadata: Optional[Dict[str, Any]] = None Source = Annotated[Union[_UrlSource, _DocumentSource], Field(discriminator="source_type")] class _ContentUrlSource(_UrlSource): - type: Literal["Source"] + type: Literal["source"] class _ContentDocumentSource(_DocumentSource): - type: Literal["Source"] + type: Literal["source"] _ContentSource = Annotated[ @@ -443,334 +315,236 @@ class _ContentDocumentSource(_DocumentSource): ] -class _ContentReasoning(BaseModel): - type: Literal["Reasoning"] +class _Text(_Meta): + type: Literal["text"] text: str - provider_metadata: Optional[Any] = None -class _ContentFile(BaseModel): - type: Literal["File", "ReasoningFile"] - data: GeneratedFileData - media_type: str - provider_metadata: Optional[Dict[str, Any]] = None +class _Reasoning(_Meta): + type: Literal["reasoning"] + text: str + + +class _Custom(_Meta): + type: Literal["custom"] + kind: str + + +class _File(GeneratedFile): + type: Literal["file"] + + +class _ReasoningFile(GeneratedFile): + type: Literal["reasoning-file"] + + +class _ReasoningFileOutput(_Meta): + type: Literal["reasoning-file"] + file: GeneratedFile + + +class _RawToolCallPart(RawToolCall): + type: Literal["tool-call"] + + +class _ToolCallPart(ToolCall): + type: Literal["tool-call"] -class _ContentToolResult(BaseModel): - type: Literal["ToolResult"] +class _ToolResultPart(_Meta): + type: Literal["tool-result"] tool_call_id: str tool_name: str result: Any is_error: Optional[bool] = None preliminary: Optional[bool] = None dynamic: Optional[bool] = None - provider_metadata: Optional[Any] = None -class _ContentCustom(BaseModel): - type: Literal["Custom"] - kind: str - provider_metadata: Optional[Dict[str, Any]] = None +class _RawApprovalRequest(_Meta): + type: Literal["tool-approval-request"] + approval_id: str + tool_call_id: str -class _ContentToolApprovalRequest(BaseModel): - type: Literal["ToolApprovalRequest"] +class _ApprovalRequest(_Wire): + type: Literal["tool-approval-request"] approval_id: str - tool_call_id: str - provider_metadata: Optional[Dict[str, Any]] = None + tool_call: ToolCall + reason: Optional[str] = None + is_automatic: Optional[bool] = None + signature: Optional[str] = None -_GenerateContentUnion = Annotated[ +#: A content item in the raw generation result (Rust ``GenerateContent``). +GenerateContent = Annotated[ Union[ - _ContentText, - _ContentToolCall, + _Text, + _RawToolCallPart, _ContentSource, - _ContentReasoning, - _ContentFile, - _ContentToolResult, - _ContentCustom, - _ContentToolApprovalRequest, + _Reasoning, + _File, + _ReasoningFile, + _Custom, + _RawApprovalRequest, + _ToolResultPart, ], Field(discriminator="type"), ] - -class GenerateContent(RootModel[_GenerateContentUnion]): - """A content item in the generation result (Rust ``GenerateContent``).""" - - root: _GenerateContentUnion - - @model_validator(mode="before") - @classmethod - def _ext_to_int(cls, data: Any) -> Any: - return _external_tag_before(data) - - @model_serializer(mode="wrap") - def _int_to_ext(self, handler): # type: ignore[no-untyped-def] - return _external_tag_serializer(handler(self)) +#: A content item of the user-facing result (Rust ``TextContent``). +TextContent = Annotated[ + Union[ + _Text, + _ToolCallPart, + _ContentSource, + _Reasoning, + _File, + _ReasoningFileOutput, + _Custom, + _ApprovalRequest, + _ToolResultPart, + ], + Field(discriminator="type"), +] # ── StreamPart ─────────────────────────────────────────────────────────────── -class _SPTextStart(BaseModel): - type: Literal["TextStart"] +class _SPIdMeta(_Meta): id: str - provider_metadata: Optional[Dict[str, Any]] = None -class _SPTextDelta(BaseModel): - type: Literal["TextDelta"] - id: str +class _SPDelta(_SPIdMeta): delta: str - provider_metadata: Optional[Dict[str, Any]] = None -class _SPTextEnd(BaseModel): - type: Literal["TextEnd"] - id: str - provider_metadata: Optional[Dict[str, Any]] = None +class _SPTextStart(_SPIdMeta): + type: Literal["text-start"] -class _SPStreamStart(BaseModel): - type: Literal["StreamStart"] - warnings: List[Warning] +class _SPTextDelta(_SPDelta): + type: Literal["text-delta"] -class _SPFinish(BaseModel): - type: Literal["Finish"] - finish_reason: FinishReason - usage: Usage - provider_metadata: Optional[Any] = None +class _SPTextEnd(_SPIdMeta): + type: Literal["text-end"] -class _SPError(BaseModel): - type: Literal["Error"] - error: Any # AiMuxErrorValue +class _SPStreamStart(_Wire): + type: Literal["stream-start"] + warnings: List[Warning] -class _SPToolInputStart(BaseModel): - type: Literal["ToolInputStart"] - id: str - tool_name: str - provider_executed: Optional[bool] = None - dynamic: Optional[bool] = None - title: Optional[str] = None - provider_metadata: Optional[Dict[str, Any]] = None +class _SPFinish(_Meta): + type: Literal["finish"] + finish_reason: FinishReason + usage: Usage -class _SPToolInputDelta(BaseModel): - type: Literal["ToolInputDelta"] - id: str - delta: str - provider_metadata: Optional[Dict[str, Any]] = None +class _SPFinishStep(_SPFinish): + type: Literal["finish-step"] + response: GenerateResponseMetadata -class _SPToolInputEnd(BaseModel): - type: Literal["ToolInputEnd"] - id: str - provider_metadata: Optional[Dict[str, Any]] = None +class _SPError(_Wire): + type: Literal["error"] + error: AiMuxErrorValue -class _SPToolCall(BaseModel): - type: Literal["ToolCall"] - tool_call_id: str +class _SPToolInputStart(_SPIdMeta): + type: Literal["tool-input-start"] tool_name: str - input: Any provider_executed: Optional[bool] = None dynamic: Optional[bool] = None - provider_metadata: Optional[Dict[str, Any]] = None - invalid: Optional[bool] = None - error: Optional[AiMuxErrorValue] = None - - -class _SPToolResult(BaseModel): - type: Literal["ToolResult"] - tool_call_id: str - tool_name: str - result: Any - is_error: Optional[bool] = None - preliminary: Optional[bool] = None - dynamic: Optional[bool] = None - provider_metadata: Optional[Dict[str, Any]] = None + title: Optional[str] = None -class _SPReasoningStart(BaseModel): - type: Literal["ReasoningStart"] - id: str - provider_metadata: Optional[Any] = None +class _SPToolInputDelta(_SPDelta): + type: Literal["tool-input-delta"] -class _SPReasoningDelta(BaseModel): - type: Literal["ReasoningDelta"] - id: str - delta: str - provider_metadata: Optional[Any] = None +class _SPToolInputEnd(_SPIdMeta): + type: Literal["tool-input-end"] -class _SPReasoningEnd(BaseModel): - type: Literal["ReasoningEnd"] - id: str - provider_metadata: Optional[Any] = None +class _SPReasoningStart(_SPIdMeta): + type: Literal["reasoning-start"] -class _SPFinishStep(_SPFinish): - type: Literal["FinishStep"] - response: GenerateResponseMetadata +class _SPReasoningDelta(_SPDelta): + type: Literal["reasoning-delta"] +class _SPReasoningEnd(_SPIdMeta): + type: Literal["reasoning-end"] -class _SPRaw(BaseModel): - type: Literal["Raw"] +class _SPRaw(_Wire): + type: Literal["raw"] raw_value: Any -class _SPFile(BaseModel): - type: Literal["File"] - data: GeneratedFileData - media_type: str - provider_metadata: Optional[Dict[str, Any]] = None - - -class _SPToolApprovalRequest(BaseModel): - type: Literal["ToolApprovalRequest"] - approval_id: str - tool_call: ToolCall - reason: Optional[str] = None - is_automatic: Optional[bool] = None - signature: Optional[str] = None - - -class _GeneratedFile(BaseModel): - data: GeneratedFileData - media_type: str - provider_metadata: Optional[Dict[str, Any]] = None - - -class _SPReasoningFile(BaseModel): - type: Literal["ReasoningFile"] - file: _GeneratedFile - provider_metadata: Optional[Dict[str, Any]] = None - - -_TextContentUnion = Annotated[ - Union[ - _ContentText, _SPToolCall, _ContentSource, _ContentReasoning, - _SPFile, _SPReasoningFile, _ContentToolResult, _ContentCustom, _SPToolApprovalRequest, - ], - Field(discriminator="type"), -] - - -class TextContent(RootModel[_TextContentUnion]): - @model_validator(mode="before") - @classmethod - def _ext_to_int(cls, data: Any) -> Any: - return _external_tag_before(data) - - @model_serializer(mode="wrap") - def _int_to_ext(self, handler): - return _external_tag_serializer(handler(self)) - - -_StreamPartUnion = Annotated[ +#: A single chunk of the stream returned by ``stream_text`` (Rust +#: ``TextStreamPart``), e.g. ``{"type": "text-delta", "id": ..., "delta": ...}``. +StreamPart = Annotated[ Union[ _SPTextStart, _SPTextDelta, _SPTextEnd, _SPStreamStart, _SPFinish, + _SPFinishStep, _SPError, _SPToolInputStart, _SPToolInputDelta, _SPToolInputEnd, - _SPToolCall, - _SPToolResult, - _SPFile, - _SPReasoningFile, + _ToolCallPart, + _ToolResultPart, + _File, + _ReasoningFileOutput, + _Custom, + _ApprovalRequest, _SPReasoningStart, _SPReasoningDelta, _SPReasoningEnd, - _SPFinishStep, _ContentSource, _SPRaw, - _ContentCustom, - _SPToolApprovalRequest, ], Field(discriminator="type"), ] -class StreamPart(RootModel[_StreamPartUnion]): - """A single chunk in the stream returned by ``stream_text`` (Rust ``StreamPart``). - - Externally tagged on the wire: ``{"TextDelta": {"id": ..., "delta": ...}}``. - ``parse_stream_part`` / ``StreamPart.model_validate`` accept that shape and - :meth:`model_dump` re-emits it. - """ - - root: _StreamPartUnion - - @model_validator(mode="before") - @classmethod - def _ext_to_int(cls, data: Any) -> Any: - return _external_tag_before(data) - - @model_serializer(mode="wrap") - def _int_to_ext(self, handler): # type: ignore[no-untyped-def] - return _external_tag_serializer(handler(self)) - - # ───────────────────────────────────────────────────────────────────────────── -# ResponseFormat — external-tag enum with a unit variant (``"Text"``) +# ResponseFormat — ``{"type": "text"}`` | ``{"type": "json", schema?, ...}`` # ───────────────────────────────────────────────────────────────────────────── -class _ResponseFormatJsonBody(BaseModel): - model_config = ConfigDict(populate_by_name=True) +class _ResponseFormatText(_Wire): + type: Literal["text"] = "text" + +class _ResponseFormatJson(_Wire): + type: Literal["json"] = "json" schema_: Optional[Any] = Field(default=None, alias="schema") name: Optional[str] = None description: Optional[str] = None -class ResponseFormat(RootModel[Union[Literal["Text"], _ResponseFormatJsonBody]]): - """How the model should format its response (Rust ``ResponseFormat``). - - Wire format: ``"Text"`` or ``{"Json": {"schema": ..., "name": ..., "description": ...}}``. - """ - - root: Union[Literal["Text"], _ResponseFormatJsonBody] - - @model_validator(mode="before") - @classmethod - def _unwrap(cls, data: Any) -> Any: - if isinstance(data, dict) and len(data) == 1: - (tag, payload), = data.items() - if tag == "Text": - return "Text" - if tag == "Json": - return payload if isinstance(payload, dict) else {} - return data - - @model_serializer(mode="wrap") - def _ser(self, handler): # type: ignore[no-untyped-def] - out = handler(self) - if isinstance(out, dict): - return {"Json": out} - return out # bare "Text" +#: How the model should format its response (Rust ``ResponseFormat``). +ResponseFormat = Annotated[ + Union[_ResponseFormatText, _ResponseFormatJson], Field(discriminator="type") +] # ───────────────────────────────────────────────────────────────────────────── -# Internal-tag enums (Rust ``#[serde(tag = "type", rename_all = "snake_case")]``) -# These are already internal-tagged on the wire, so a standard discriminated -# union with a ``type`` discriminator works directly. +# Tools # ───────────────────────────────────────────────────────────────────────────── -class FunctionToolInputExample(BaseModel): +class FunctionToolInputExample(_Wire): input: Dict[str, Any] -class FunctionTool(BaseModel): +class FunctionTool(_Wire): """A user-defined function tool (Rust ``FunctionTool``).""" type: Literal["function"] = "function" @@ -782,7 +556,7 @@ class FunctionTool(BaseModel): input_examples: Optional[List[FunctionToolInputExample]] = None -class ProviderTool(BaseModel): +class ProviderTool(_Wire): """A provider-defined tool (Rust ``ProviderTool``).""" type: Literal["provider"] = "provider" @@ -797,96 +571,89 @@ class ProviderTool(BaseModel): ] -# ── ContentPart (internal tag ``type``, snake_case variants) ──────────────── +# ── ContentPart (prompt parts: internal tag ``type``, kebab-case) ─────────── + +class _PartOptions(_Wire): + provider_options: Optional[Any] = None + -class TextContentPart(BaseModel): +class TextContentPart(_PartOptions): """A text content part (Rust ``ContentPart::Text``).""" type: Literal["text"] = "text" text: str - provider_options: Optional[Any] = None -class _ImageContentPart(BaseModel): +class _ImageContentPart(_PartOptions): type: Literal["image"] = "image" image: List[int] media_type: str - provider_options: Optional[Any] = None -class _FileContentPart(BaseModel): +class _FileContentPart(_PartOptions): type: Literal["file"] = "file" data: List[int] media_type: str filename: Optional[str] = None - provider_options: Optional[Any] = None -class _FileBase64ContentPart(BaseModel): - type: Literal["file_base64"] = "file_base64" +class _FileBase64ContentPart(_PartOptions): + type: Literal["file-base64"] = "file-base64" data: str media_type: str filename: Optional[str] = None - provider_options: Optional[Any] = None -class _FileUrlContentPart(BaseModel): - type: Literal["file_url"] = "file_url" +class _FileUrlContentPart(_PartOptions): + type: Literal["file-url"] = "file-url" url: str media_type: str - provider_options: Optional[Any] = None -class _FileReferenceContentPart(BaseModel): - type: Literal["file_reference"] = "file_reference" +class _FileReferenceContentPart(_PartOptions): + type: Literal["file-reference"] = "file-reference" media_type: str reference: Any filename: Optional[str] = None - provider_options: Optional[Any] = None -class _ReasoningContentPart(BaseModel): +class _ReasoningContentPart(_PartOptions): type: Literal["reasoning"] = "reasoning" text: str signature: Optional[str] = None - provider_options: Optional[Any] = None -class _ToolCallContentPart(BaseModel): - type: Literal["tool_call"] = "tool_call" +class _ToolCallContentPart(_PartOptions): + type: Literal["tool-call"] = "tool-call" tool_call_id: str tool_name: str input: Any provider_executed: Optional[bool] = None - provider_options: Optional[Any] = None -class _ToolResultContentPart(BaseModel): - type: Literal["tool_result"] = "tool_result" +class _ToolResultContentPart(_PartOptions): + """``output`` is a ``{"type": "text" | "json" | "execution-denied" | + "error-text" | "error-json" | "content", ...}`` object.""" + + 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 - provider_options: Optional[Any] = None + tool_name: str + output: Any -class _CustomContentPart(BaseModel): +class _CustomContentPart(_PartOptions): type: Literal["custom"] = "custom" kind: str - provider_options: Optional[Any] = None -class _ReasoningFileContentPart(BaseModel): - type: Literal["reasoning_file"] = "reasoning_file" +class _ReasoningFileContentPart(_PartOptions): + type: Literal["reasoning-file"] = "reasoning-file" data: GeneratedFileData media_type: str - provider_options: Optional[Any] = None -class _ToolApprovalRequestContentPart(BaseModel): - type: Literal["tool_approval_request"] = "tool_approval_request" +class _ToolApprovalRequestContentPart(_Wire): + type: Literal["tool-approval-request"] = "tool-approval-request" approval_id: str tool_call_id: str reason: Optional[str] = None @@ -919,31 +686,15 @@ class _ToolApprovalRequestContentPart(BaseModel): # ───────────────────────────────────────────────────────────────────────────── -# ToolChoice — mixed tagged/untagged enum -# Wire: ``"auto" | "none" | "required" | {"type": "tool", "toolName": "..."}``. -# Note the camelCase ``toolName`` (hand-rolled serde on the Rust side). +# ToolChoice — ``"auto" | "none" | "required" | {"type": "tool", "toolName": ...}`` # ───────────────────────────────────────────────────────────────────────────── -class ToolChoiceTool(BaseModel): +class ToolChoiceTool(_Wire): """The ``tool`` tool-choice variant (forces a specific tool).""" type: Literal["tool"] = "tool" tool_name: str - @model_validator(mode="before") - @classmethod - def _accept_camel(cls, data: Any) -> Any: - if isinstance(data, dict) and "toolName" in data and "tool_name" not in data: - data = {**data, "tool_name": data.pop("toolName")} - return data - - @model_serializer(mode="wrap") - def _emit_camel(self, handler): # type: ignore[no-untyped-def] - out = handler(self) - if isinstance(out, dict): - out["toolName"] = out.pop("tool_name", None) - return out - ToolChoice = Union[Literal["auto", "none", "required"], ToolChoiceTool] @@ -952,7 +703,7 @@ def _emit_camel(self, handler): # type: ignore[no-untyped-def] # User-facing prompt / message models # ───────────────────────────────────────────────────────────────────────────── -class ModelMessage(BaseModel): +class ModelMessage(_Wire): """A single user-facing chat message (Rust ``ModelMessage``). ``content`` is untagged on the wire: a bare string or a list of content @@ -967,7 +718,7 @@ class ModelMessage(BaseModel): # Options + results # ───────────────────────────────────────────────────────────────────────────── -class TimeoutConfiguration(BaseModel): +class TimeoutConfiguration(_Wire): """Per-call timeout configuration (Rust ``TimeoutConfiguration``). All values are milliseconds; ``None`` disables the corresponding limit. @@ -979,7 +730,7 @@ class TimeoutConfiguration(BaseModel): chunk_ms: Optional[int] = None -class ToolCallRepairContext(BaseModel): +class ToolCallRepairContext(_Wire): """What a ``repair_tool_call`` function is given (RFC-0035). Mirrors the AI SDK ``repairToolCall`` argument: the offending call with its @@ -1002,7 +753,7 @@ class ToolCallRepairContext(BaseModel): RepairToolCall = Callable[[ToolCallRepairContext], Optional[RawToolCall]] -class GenerateTextOptions(BaseModel): +class GenerateTextOptions(_Wire): """User-facing options for ``generate_text`` / ``stream_text``. Mirrors Rust ``GenerateTextOptions``. All fields are optional; unset fields @@ -1059,11 +810,11 @@ class GenerateTextOptions(BaseModel): """ -class RequestInfo(BaseModel): +class RequestInfo(_Wire): body: Optional[Any] = None -class GenerateResult(BaseModel): +class GenerateResult(_Wire): """Raw provider result (Rust ``GenerateResult``), exposed via ``raw``.""" content: List[GenerateContent] @@ -1075,7 +826,7 @@ class GenerateResult(BaseModel): response: Optional[GenerateResponseMetadata] = None -class GenerateTextResult(BaseModel): +class GenerateTextResult(_Wire): """Result of ``generate_text`` (user-facing, Rust ``GenerateTextResult``).""" content: List[TextContent] = Field(default_factory=list) @@ -1105,7 +856,7 @@ class GenerateTextResult(BaseModel): total_usage: Usage = Field(default_factory=Usage) -class GenerateObjectResult(BaseModel): +class GenerateObjectResult(_Wire): """Result of ``generate_object`` (user-facing, M12, Rust ``GenerateObjectResult``). The parsed JSON object plus convenience fields from the underlying ``generate_text`` call. @@ -1126,7 +877,7 @@ class GenerateObjectResult(BaseModel): raw: GenerateTextResult -class StreamTextResultAggregated(BaseModel): +class StreamTextResultAggregated(_Wire): """Aggregated result of ``stream_text().consume()`` (M11, Rust ``StreamTextResultAggregated``). Mirrors ``GenerateTextResult``'s user-facing fields (without ``raw``, since streaming has no @@ -1302,7 +1053,9 @@ def _prompt_to_json(prompt: Prompt) -> str: f"prompt must be str or list of messages, got {type(prompt).__name__}" ) msgs = [ - m.model_dump(mode="json", exclude_none=True) if isinstance(m, BaseModel) else m + m.model_dump(mode="json", exclude_none=True, by_alias=True) + if isinstance(m, BaseModel) + else m for m in prompt ] return json.dumps({"prompt": msgs}) @@ -1320,7 +1073,7 @@ def _opts_to_json(options: Optional[GenerateTextOptions]) -> Optional[str]: # ``exclude_none`` mirrors Rust's ``#[serde(skip_serializing_if = "Option::is_none")]`` # on the inner struct fields, producing clean payloads (absent == None for the # Rust deserializer). ``by_alias`` emits the ``schema`` field of the JSON - # response-format body (and is a no-op for the snake_case fields elsewhere). + # response-format body and the camelCase keys of every model. return options.model_dump_json(exclude_none=True, by_alias=True) @@ -1335,7 +1088,7 @@ def adapter(context: dict) -> Optional[dict]: replacement = _repair.run_hook(fn, typed) if replacement is None: return None - return replacement.model_dump(mode="json", exclude_none=True) + return replacement.model_dump(mode="json", exclude_none=True, by_alias=True) return adapter @@ -1376,15 +1129,15 @@ def stream_text( ) -> Iterator[dict]: """Stream text from a model, yielding parsed ``StreamPart`` dicts. - Each yielded item is the external-tagged dict form produced by the Rust - core, e.g. ``{"TextDelta": {"id": ..., "delta": ...}}``. For typed access, - pass the dict through :func:`parse_stream_part`. + Each yielded item is the dict form produced by the Rust core, tagged by + ``type``, e.g. ``{"type": "text-delta", "id": ..., "delta": ...}``. For + typed access, pass the dict through :func:`parse_stream_part`. Usage:: for part in stream_text(model, "Write a haiku about Rust."): - if "TextDelta" in part: - print(part["TextDelta"]["delta"], end="") + if part["type"] == "text-delta": + print(part["delta"], end="") """ prompt_json = _prompt_to_json(prompt) opts_json = _opts_to_json(options) @@ -1397,9 +1150,12 @@ def stream_text( yield json.loads(part_json) -def parse_stream_part(part: dict) -> StreamPart: - """Validate a yielded stream-part dict into a typed :class:`StreamPart`.""" - return StreamPart.model_validate(part) +_STREAM_PART = TypeAdapter(StreamPart) + + +def parse_stream_part(part: dict) -> BaseModel: + """Validate a yielded stream-part dict into its typed model (see ``StreamPart``).""" + return _STREAM_PART.validate_python(part) def generate_text_as_openai( diff --git a/bindings/python/tests/test_aimux.py b/bindings/python/tests/test_aimux.py index 60b3397f9..ccb1bb8ce 100644 --- a/bindings/python/tests/test_aimux.py +++ b/bindings/python/tests/test_aimux.py @@ -101,7 +101,7 @@ def test_provider_invalid_config_raises(): with pytest.raises(Exception, match="invalid config"): provider( - "groq", + "abacus", "sk-test-fake-key", "llama-3.3-70b", config={"headers": "not-a-map"}, @@ -109,14 +109,13 @@ def test_provider_invalid_config_raises(): def test_provider_missing_env_key_raises(monkeypatch): - """provider() with api_key=None reads the env var when a request is made, and - fails clearly when it is unset (the AI SDK loads the key lazily).""" + """provider() with api_key=None reads the env var at call time and fails clearly when unset.""" from aimux import LoadAPIKeyError, generate_text, provider monkeypatch.delenv("ABACUS_API_KEY", raising=False) model = provider("abacus", None, "m") with pytest.raises(LoadAPIKeyError, match="(?i)api key"): - generate_text(model, "hi") + generate_text(model, "hi", {"maxRetries": 0}) def test_provider_params_fill_a_preset_template(monkeypatch): @@ -127,7 +126,7 @@ def test_provider_params_fill_a_preset_template(monkeypatch): # Without the parameter the preset cannot build its base URL (reported at call time). unfilled = provider("cloudflare_workers_ai", "sk-test-fake-key", "m") with pytest.raises(InvalidArgumentError, match="account_id"): - generate_text(unfilled, "hi", {"max_retries": 0}) + generate_text(unfilled, "hi", {"maxRetries": 0}) model = provider( "cloudflare_workers_ai", "sk-test-fake-key", diff --git a/bindings/python/tests/test_cassette.py b/bindings/python/tests/test_cassette.py index 8a4ab70f7..d168de9ec 100644 --- a/bindings/python/tests/test_cassette.py +++ b/bindings/python/tests/test_cassette.py @@ -22,16 +22,16 @@ def test_generate(self): assert isinstance(result["text"], str) assert len(result["text"]) > 0, "text should be non-empty" assert "usage" in result - assert "finish_reason" in result + assert "finishReason" in result def test_stream(self): with CassetteServer("openai") as srv: model = openai("test-key", "gpt-4o", f"{srv.url}/v1") parts = list(stream_text(model, "Hello")) assert len(parts) > 0, "should receive stream parts" - types = [list(p.keys())[0] for p in parts] - assert "StreamStart" in types, "should have StreamStart" - assert "Finish" in types, "should have Finish" + types = [p["type"] for p in parts] + assert "stream-start" in types, "should have stream-start" + assert "finish" in types, "should have finish" class TestAnthropicCassette: @@ -75,7 +75,7 @@ def test_generate_usage_raw_carries_vendor_fields(self): assert isinstance(raw.get("prompt_cache_hit_tokens"), int) assert isinstance(raw.get("prompt_cache_miss_tokens"), int) # Typed totals still work alongside the raw object. - assert result["usage"]["input_tokens"]["total"] is not None + assert result["usage"]["inputTokens"]["total"] is not None class TestGroqCassette: diff --git a/bindings/python/tests/test_decision.py b/bindings/python/tests/test_decision.py index afc16a439..c1cb851a1 100644 --- a/bindings/python/tests/test_decision.py +++ b/bindings/python/tests/test_decision.py @@ -62,7 +62,7 @@ def test_decision_uses_unified_runtime_recording(server, tmp_path): assert record['complete'] is True assert record['input']['operation'] == 'decision' assert record['input']['options']['questions'] == QUESTIONS - assert record['outcome']['decision_result']['answers'] == result['answers'] + assert record['outcome']['decisionResult']['answers'] == result['answers'] assert record['exchanges'][0]['attempt'] == 1 assert 'test-key' not in json.dumps(record) @@ -82,8 +82,8 @@ def test_optional_usage_counts_preserve_answers(server, monkeypatch, usage): result = decide(jev_decision('test-key', 'jev-latest', base + '/v1/systemone'), {'message': 'Billed twice'}, QUESTIONS, max_retries=0) assert len(result['answers']) == 3 - assert result['usage']['input_tokens']['total'] == usage.get('input_tokens') - assert result['usage']['output_tokens']['total'] == usage.get('output_tokens') + assert result['usage']['inputTokens'].get('total') == usage.get('input_tokens') + assert result['usage']['outputTokens'].get('total') == usage.get('output_tokens') assert result['usage']['raw'] == usage diff --git a/bindings/python/tests/test_e2e.py b/bindings/python/tests/test_e2e.py index edfac45dc..c2d0c0232 100644 --- a/bindings/python/tests/test_e2e.py +++ b/bindings/python/tests/test_e2e.py @@ -154,14 +154,14 @@ def test_stream_text(self): parts = list(stream_text(model, "Say hello")) assert len(parts) > 0 text = "".join( - p["TextDelta"]["delta"] for p in parts if "TextDelta" in p + p["delta"] for p in parts if p["type"] == "text-delta" ) assert text == "Hello world" def test_generate_text_with_options(self): with MockServer(OPENAI_CHAT) as mock: model = deepseek("test-key", "gpt-4o", mock.url) - generate_text(model, "Hello", {"max_output_tokens": 100, "temperature": 0.5}) + generate_text(model, "Hello", {"maxOutputTokens": 100, "temperature": 0.5}) # Options should reach the HTTP request (tested implicitly — if they # don't serialize, the Rust side would error) @@ -181,7 +181,7 @@ def test_stream_text(self): parts = list(stream_text(model, "Hello")) assert len(parts) > 0 text = "".join( - p["TextDelta"]["delta"] for p in parts if "TextDelta" in p + p["delta"] for p in parts if p["type"] == "text-delta" ) assert text == "Hello from Claude" @@ -350,7 +350,7 @@ def test_generate_text_parses_tool_calls(self): "type": "function", "name": "get_weather", "description": "Get weather for a location", - "input_schema": { + "inputSchema": { "type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"], @@ -359,22 +359,22 @@ def test_generate_text_parses_tool_calls(self): } result = generate_text(model, "What's the weather in Tokyo?", opts) - # Convenience field: tool_calls extracted - assert len(result["tool_calls"]) == 1 - assert result["tool_calls"][0]["tool_name"] == "get_weather" - assert result["tool_calls"][0]["tool_call_id"] == "call_abc" - assert result["tool_calls"][0]["input"] == {"location": "Tokyo"} + # Convenience field: toolCalls extracted + assert len(result["toolCalls"]) == 1 + assert result["toolCalls"][0]["toolName"] == "get_weather" + assert result["toolCalls"][0]["toolCallId"] == "call_abc" + assert result["toolCalls"][0]["input"] == {"location": "Tokyo"} # Structured content: raw.content contains the ToolCall variant assert "raw" in result assert isinstance(result["raw"]["content"], list) - tc = next((c for c in result["raw"]["content"] if "ToolCall" in c), None) - assert tc is not None, "raw.content must contain a ToolCall variant" - assert tc["ToolCall"]["tool_name"] == "get_weather" - assert tc["ToolCall"]["tool_call_id"] == "call_abc" + tc = next((c for c in result["raw"]["content"] if c["type"] == "tool-call"), None) + assert tc is not None, "raw.content must contain a tool-call variant" + assert tc["toolName"] == "get_weather" + assert tc["toolCallId"] == "call_abc" # raw content keeps the provider's argument text; parsing happens - # at the Core boundary (top-level tool_calls carry the object). - assert tc["ToolCall"]["input"] == '{"location":"Tokyo"}' + # at the Core boundary (top-level toolCalls carry the object). + assert tc["input"] == '{"location":"Tokyo"}' def test_multi_role_messages_reach_provider(self): with RecordingMockServer(OPENAI_CHAT) as mock: @@ -401,12 +401,12 @@ def test_tool_choice_reaches_provider(self): "tools": [{ "type": "function", "name": "get_weather", - "input_schema": { + "inputSchema": { "type": "object", "properties": {"location": {"type": "string"}}, }, }], - "tool_choice": "required", + "toolChoice": "required", } generate_text(model, "Hello", opts) @@ -414,7 +414,7 @@ def test_tool_choice_reaches_provider(self): assert body["tool_choice"] == "required" def test_stream_text_parses_tool_call_parts(self): - """Stream text should surface ToolCall/ToolInputDelta parts (not just TextDelta/Finish).""" + """Stream text should surface tool-call/tool-input-delta parts (not just text-delta/finish).""" sse_body = ( 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_xyz","type":"function","function":{"name":"get_weather","arguments":""}}]}}]}\n\n' 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"location\\":\\"Tokyo\\"}"}}]}}]}\n\n' @@ -427,7 +427,7 @@ def test_stream_text_parses_tool_call_parts(self): "tools": [{ "type": "function", "name": "get_weather", - "input_schema": { + "inputSchema": { "type": "object", "properties": {"location": {"type": "string"}}, }, @@ -436,18 +436,17 @@ def test_stream_text_parses_tool_call_parts(self): parts = list(stream_text(model, "What's the weather in Tokyo?", opts)) assert len(parts) > 0 - # Stream must contain a tool-related StreamPart (not just TextDelta/Finish) + # Stream must contain a tool-related StreamPart (not just text-delta/finish) has_tool_part = any( - "ToolCall" in p or "ToolInputDelta" in p or "ToolInputStart" in p + p["type"] in ("tool-call", "tool-input-delta", "tool-input-start") for p in parts ) assert has_tool_part, f"expected a tool stream part; got: {parts}" # If a complete ToolCall part is present, verify its fields - tool_call_parts = [p for p in parts if "ToolCall" in p] + tool_call_parts = [p for p in parts if p["type"] == "tool-call"] if tool_call_parts: - tc = tool_call_parts[0]["ToolCall"] - assert tc["tool_name"] == "get_weather" + assert tool_call_parts[0]["toolName"] == "get_weather" def test_tool_call_round_trip(self): """Full tool-call round trip: two generate_text calls with a ToolResult @@ -458,7 +457,7 @@ def test_tool_call_round_trip(self): Second call → model returns final text (OPENAI_CHAT). The messages use the framework's user-facing content-part shape - (``tool_call`` / ``tool_result`` parts). The OpenAI converter turns + (``tool-call`` / ``tool-result`` parts). The OpenAI converter turns these into the provider-facing body (assistant ``tool_calls`` array + ``tool`` role message with ``tool_call_id``), which is what we assert on for the second request. @@ -471,7 +470,7 @@ def test_tool_call_round_trip(self): "type": "function", "name": "get_weather", "description": "Get weather for a location", - "input_schema": { + "inputSchema": { "type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"], @@ -481,21 +480,23 @@ def test_tool_call_round_trip(self): # Step 1: first call — the model requests a tool call. result = generate_text(model, "What's the weather in Tokyo?", opts) - assert len(result["tool_calls"]) == 1 - assert result["tool_calls"][0]["tool_name"] == "get_weather" - assert result["tool_calls"][0]["tool_call_id"] == "call_abc" + assert len(result["toolCalls"]) == 1 + assert result["toolCalls"][0]["toolName"] == "get_weather" + assert result["toolCalls"][0]["toolCallId"] == "call_abc" # Step 2: back-fill the ToolResult and re-send the conversation. # Roles: user → assistant(tool_call) → tool(result). messages = [ {"role": "user", "content": "What's the weather in Tokyo?"}, {"role": "assistant", "content": [ - {"type": "tool_call", "tool_call_id": "call_abc", - "tool_name": "get_weather", "input": {"location": "Tokyo"}}, + {"type": "tool-call", "toolCallId": "call_abc", + "toolName": "get_weather", "input": {"location": "Tokyo"}}, ]}, {"role": "tool", "content": [ - {"type": "tool_result", "tool_call_id": "call_abc", - "result": {"temperature": 22, "condition": "sunny"}}, + {"type": "tool-result", "toolCallId": "call_abc", + "toolName": "get_weather", + "output": {"type": "json", + "value": {"temperature": 22, "condition": "sunny"}}}, ]}, ] diff --git a/bindings/python/tests/test_error.py b/bindings/python/tests/test_error.py index c76b09851..cb1ebd0ce 100644 --- a/bindings/python/tests/test_error.py +++ b/bindings/python/tests/test_error.py @@ -100,7 +100,7 @@ def test_api_call_error_carries_every_field_the_response_produced(): model = openai("test-key", "gpt-4o", f"http://127.0.0.1:{port}") # 429 is retryable; disable retries to observe the bare call failure. with pytest.raises(APICallError) as excinfo: - generate_text(model, "hi", {"max_retries": 0}) + generate_text(model, "hi", {"maxRetries": 0}) err = excinfo.value assert type(err) is APICallError @@ -135,7 +135,7 @@ def test_retry_error_carries_reason_and_typed_attempt_history(): model = openai("test-key", "gpt-4o", f"http://127.0.0.1:{port}") with pytest.raises(RetryError) as excinfo: - generate_text(model, "hi", {"max_retries": 1}) + generate_text(model, "hi", {"maxRetries": 1}) err = excinfo.value assert type(err) is RetryError @@ -172,7 +172,7 @@ def test_missing_api_key_is_load_api_key_error(monkeypatch): monkeypatch.delenv("GROQ_API_KEY", raising=False) model = provider("groq", None, "some-model") with pytest.raises(LoadAPIKeyError) as excinfo: - generate_text(model, "hi", {"max_retries": 0}) + generate_text(model, "hi", {"maxRetries": 0}) err = excinfo.value assert type(err) is LoadAPIKeyError @@ -200,7 +200,7 @@ def test_missing_setting_is_load_setting_error_at_call_time(monkeypatch): monkeypatch.delenv("AZURE_RESOURCE_NAME", raising=False) model = azure("test-key", "", "some-deployment") with pytest.raises(LoadSettingError) as excinfo: - generate_text(model, "hi", {"max_retries": 0}) + generate_text(model, "hi", {"maxRetries": 0}) err = excinfo.value assert type(err) is LoadSettingError diff --git a/bindings/python/tests/test_multimodal_e2e.py b/bindings/python/tests/test_multimodal_e2e.py index 2146cb4da..a9640c86c 100644 --- a/bindings/python/tests/test_multimodal_e2e.py +++ b/bindings/python/tests/test_multimodal_e2e.py @@ -141,7 +141,7 @@ def __exit__(self, *args): # Google Video uses a multi-step async API; we only parse a canned result here. VIDEO_RESULT_JSON = json.dumps({ "videos": [ - {"Url": {"url": "https://example.com/v.mp4", "media_type": "video/mp4"}}, + {"type": "url", "url": "https://example.com/v.mp4", "mediaType": "video/mp4"}, ], }) @@ -167,23 +167,23 @@ def test_speech(self): # Mock returns raw binary with content-type audio/mpeg. with MockServer(SPEECH_RESP, content_type='audio/mpeg') as mock: speaker = openai_speech("test-key", "tts-1", base_url=mock.url) - opts = json.dumps({"text": "Hi", "voice": "alloy", "output_format": "mp3"}) + opts = json.dumps({"text": "Hi", "voice": "alloy", "outputFormat": "mp3"}) result = json.loads(speaker.generate(opts)) - # Binary audio comes back as AudioData::Binary -> {"Binary": [bytes...]} - assert "Binary" in result["audio"] - assert len(result["audio"]["Binary"]) > 0 + # Binary audio comes back as AudioData::Binary -> a JSON array of bytes. + assert isinstance(result["audio"], list) + assert len(result["audio"]) > 0 def test_image(self): with MockServer(IMAGE_RESP) as mock: imager = openai_image("test-key", "dall-e-3", base_url=mock.url) - # ImageCallOptions.provider_options is a required (non-Option) field, + # ImageCallOptions.providerOptions is a required (non-Option) field, # so it must be present in the serialized options. opts = json.dumps( - {"prompt": "otter", "n": 1, "provider_options": {}} + {"prompt": "otter", "n": 1, "providerOptions": {}} ) result = json.loads(imager.generate(opts)) - assert len(result["images"]["Base64"]) == 1 - assert result["images"]["Base64"][0] == "aW1hZ2Ux" + assert len(result["images"]) == 1 + assert result["images"][0] == "aW1hZ2Ux" def test_transcription(self): with MockServer(TRANSCRIPTION_RESP) as mock: @@ -198,12 +198,12 @@ def test_reranking(self): reranker = cohere_reranking( "test-key", "rerank-v3.0", base_url=mock.url ) - # RerankingDocuments is an externally-tagged enum; the text-documents - # variant is {"Text": {"values": [...]}} (a bare array is rejected). - docs = json.dumps({"Text": {"values": ["doc1", "doc2"]}}) + # RerankingDocuments is tagged by ``type``; the text-documents + # variant is {"type": "text", "values": [...]} (a bare array is rejected). + docs = json.dumps({"type": "text", "values": ["doc1", "doc2"]}) result = json.loads(reranker.rerank("which?", docs)) assert len(result["ranking"]) == 2 - assert result["ranking"][0]["relevance_score"] == 0.95 + assert result["ranking"][0]["relevanceScore"] == 0.95 def test_search(self): with MockServer(SEARCH_RESP) as mock: @@ -217,7 +217,7 @@ def test_files(self): with MockServer(FILES_RESP) as mock: files = openai_files("test-key", base_url=mock.url) result = json.loads(files.upload_file("dGVzdA==", "application/pdf")) - assert result["provider_reference"]["openai"] == "file-abc" + assert result["providerReference"]["openai"] == "file-abc" def test_video_construction_and_result_parsing(self): # Google Video uses a multi-step async API (POST predict → poll operation @@ -230,5 +230,5 @@ def test_video_construction_and_result_parsing(self): result = json.loads(VIDEO_RESULT_JSON) assert len(result["videos"]) == 1 - assert result["videos"][0]["Url"]["url"] == "https://example.com/v.mp4" - assert result["videos"][0]["Url"]["media_type"] == "video/mp4" + assert result["videos"][0]["url"] == "https://example.com/v.mp4" + assert result["videos"][0]["mediaType"] == "video/mp4" diff --git a/bindings/python/tests/test_session.py b/bindings/python/tests/test_session.py index e04ee87c9..61fc1e630 100644 --- a/bindings/python/tests/test_session.py +++ b/bindings/python/tests/test_session.py @@ -35,14 +35,14 @@ def test_explicit_session_groups_calls(self): calls = session_calls("sess-1") assert len(calls) == 2 assert [c["step"] for c in calls] == [0, 1] - assert calls[0]["call_id"] != calls[1]["call_id"] - assert calls[0]["recorded_at"].endswith("Z") + assert calls[0]["callId"] != calls[1]["callId"] + assert calls[0]["recordedAt"].endswith("Z") # A call without session_id (inference off) is not grouped. generate_text(model, "third") sessions = list_sessions() assert len(sessions) == 1 - assert sessions[0]["session_id"] == "sess-1" + assert sessions[0]["sessionId"] == "sess-1" assert sessions[0]["source"] == "Explicit" # Unknown session → empty. @@ -71,7 +71,7 @@ def test_opt_in_inferer_groups_prefix_continuations(self): ) sessions = list_sessions() - autos = [s for s in sessions if s["session_id"].startswith("auto-")] + autos = [s for s in sessions if s["sessionId"].startswith("auto-")] assert len(autos) == 1, "prefix continuation stays in one auto session" assert autos[0]["source"] == "Inferred" assert len(autos[0]["calls"]) == 2 diff --git a/bindings/python/tests/test_trace.py b/bindings/python/tests/test_trace.py index 513b97f2b..c55759de9 100644 --- a/bindings/python/tests/test_trace.py +++ b/bindings/python/tests/test_trace.py @@ -40,18 +40,18 @@ def test_trace_records_calls_and_query_api(self): assert len(stats) == 1 assert stats[0]["requests"] == 2 assert stats[0]["provider"] == "deepseek.chat" - assert "verdict_counts" in stats[0] + assert "verdictCounts" in stats[0] chain = json.loads(traced.trace_session_chain("sess-1")) - assert len(chain["record_ids"]) == 2 - assert chain["prefix_stability"] > 0.5 + assert len(chain["recordIds"]) == 2 + assert chain["prefixStability"] > 0.5 jsonl = traced.trace_export_jsonl() lines = [l for l in jsonl.strip().split("\n") if l] assert len(lines) == 2, "one TraceRecord per line" first = json.loads(lines[0]) - assert first["fingerprint"]["body_hash"] - assert first["session_id"] == "sess-1" + assert first["fingerprint"]["bodyHash"] + assert first["sessionId"] == "sess-1" traced.trace_clear() assert traced.trace_export_jsonl().strip() == "" diff --git a/bindings/python/tests/test_transcription_session.py b/bindings/python/tests/test_transcription_session.py index ac98385ba..e1ae4f807 100644 --- a/bindings/python/tests/test_transcription_session.py +++ b/bindings/python/tests/test_transcription_session.py @@ -185,7 +185,7 @@ def test_next_part_timeout_preserves_the_live_session(self): try: first = session.next_part(timeout_ms=3000) assert first is not None - assert "StreamStart" in json.loads(first) + assert json.loads(first)["type"] == "stream-start" with pytest.raises(APITimeoutError) as ei: session.next_part(timeout_ms=250) @@ -198,7 +198,7 @@ def test_next_part_timeout_preserves_the_live_session(self): # The session stayed live across the timeout. late = session.next_part(timeout_ms=5000) assert late is not None - assert "TranscriptDelta" in json.loads(late) + assert json.loads(late)["type"] == "transcript-delta" finally: session.close() @@ -212,7 +212,7 @@ def test_server_error_event_raises(self): try: first = session.next_part(timeout_ms=5000) assert first is not None - assert "StreamStart" in json.loads(first) + assert json.loads(first)["type"] == "stream-start" with pytest.raises(APICallError) as ei: session.next_part(timeout_ms=5000) diff --git a/bindings/python/tests/test_wrapper.py b/bindings/python/tests/test_wrapper.py index 39177f4de..86083ef43 100644 --- a/bindings/python/tests/test_wrapper.py +++ b/bindings/python/tests/test_wrapper.py @@ -19,7 +19,6 @@ TextContentPart, FunctionTool, ToolChoiceTool, - StreamPart, generate_text, stream_text, parse_stream_part, @@ -81,12 +80,12 @@ def test_parses_tool_calls(self): # raw.content carries the ToolCall variant as a typed GenerateContent. assert isinstance(result.raw.content, list) - tool_call_parts = [c for c in result.raw.content if c.root.type == "ToolCall"] - assert tool_call_parts, "raw.content must contain a ToolCall variant" - assert tool_call_parts[0].root.tool_name == "get_weather" - assert tool_call_parts[0].root.tool_call_id == "call_abc" + tool_call_parts = [c for c in result.raw.content if c.type == "tool-call"] + assert tool_call_parts, "raw.content must contain a tool-call variant" + assert tool_call_parts[0].tool_name == "get_weather" + assert tool_call_parts[0].tool_call_id == "call_abc" # raw content keeps the provider's argument text (see test_e2e). - assert tool_call_parts[0].root.input == '{"location":"Tokyo"}' + assert tool_call_parts[0].input == '{"location":"Tokyo"}' def test_raw_content_is_text_variant(self): with MockServer(OPENAI_CHAT) as mock: @@ -95,8 +94,8 @@ def test_raw_content_is_text_variant(self): # raw.content[0] is a typed Text content part. first = result.raw.content[0] - assert first.root.type == "Text" - assert first.root.text == "Rust is a systems programming language." + assert first.type == "text" + assert first.text == "Rust is a systems programming language." # ── stream_text: typed-ish dicts ───────────────────────────────────────────── @@ -109,7 +108,7 @@ def test_yields_text_delta_dicts(self): parts = list(stream_text(model, "Say hello")) assert len(parts) > 0 - text = "".join(p["TextDelta"]["delta"] for p in parts if "TextDelta" in p) + text = "".join(p["delta"] for p in parts if p["type"] == "text-delta") assert text == "Hello world" def test_stream_part_can_be_typed(self): @@ -117,15 +116,14 @@ def test_stream_part_can_be_typed(self): with MockServer(OPENAI_STREAM, content_type="text/event-stream") as mock: model = deepseek("test-key", "gpt-4o", mock.url) first_delta = next( - p for p in stream_text(model, "Say hello") if "TextDelta" in p + p for p in stream_text(model, "Say hello") if p["type"] == "text-delta" ) sp = parse_stream_part(first_delta) - assert isinstance(sp, StreamPart) - assert sp.root.type == "TextDelta" - assert sp.root.delta == "Hello" - # Round-trips back to the external-tag dict form. - assert sp.model_dump(exclude_none=True) == first_delta + assert sp.type == "text-delta" + assert sp.delta == "Hello" + # Round-trips back to the wire dict form. + assert sp.model_dump(exclude_none=True, by_alias=True) == first_delta # ── RFC-0016 M2: include_raw_chunks ───────────────────────────────────── @@ -139,19 +137,19 @@ def test_include_raw_chunks_emits_raw_parts(self): GenerateTextOptions(include_raw_chunks=True)) ) - raw_parts = [p for p in parts if "Raw" in p] + raw_parts = [p for p in parts if p["type"] == "raw"] # OPENAI_STREAM has 3 JSON events (2 content chunks + 1 usage chunk); # the [DONE] sentinel emits no Raw. assert len(raw_parts) == 3 # Each Raw carries the parsed JSON payload of the SSE event. assert ( - raw_parts[0]["Raw"]["raw_value"]["choices"][0]["delta"]["content"] + raw_parts[0]["rawValue"]["choices"][0]["delta"]["content"] == "Hello" ) # Raw for the first event precedes its TextDelta. first_raw_idx = parts.index(raw_parts[0]) first_text_idx = next( - i for i, p in enumerate(parts) if "TextDelta" in p + i for i, p in enumerate(parts) if p["type"] == "text-delta" ) assert first_raw_idx < first_text_idx @@ -161,13 +159,13 @@ def test_raw_chunks_off_by_default(self): model = deepseek("test-key", "gpt-4o", mock.url) parts = list(stream_text(model, "Say hello")) - assert all("Raw" not in p for p in parts) + assert all(p["type"] != "raw" for p in parts) def test_include_raw_chunks_option_roundtrip(self): """Pure model-layer round-trip of the include_raw_chunks option.""" opts = GenerateTextOptions(include_raw_chunks=True) - dumped = opts.model_dump_json(exclude_none=True) - assert '"include_raw_chunks":true' in dumped + dumped = _opts_to_json(opts) + assert '"includeRawChunks":true' in dumped back = GenerateTextOptions.model_validate_json(dumped) assert back.include_raw_chunks is True @@ -230,18 +228,18 @@ def test_tool_choice_tool_variant_forces_tool_at_provider(self): def test_tool_choice_serializes_camel_case_tool_name(self): """At the wrapper→Rust JSON boundary, the ``tool`` tool-choice variant must serialize as ``{"type":"tool","toolName":"..."}`` (camelCase), the - hand-rolled serde wire format Rust expects. This is a deterministic + AI SDK shape Rust expects. This is a deterministic unit test of the boundary, independent of provider conversion.""" opts = GenerateTextOptions( tools=[FunctionTool(name="get_weather", input_schema={"type": "object"})], tool_choice=ToolChoiceTool(tool_name="get_weather"), ) wire = json.loads(_opts_to_json(opts)) - assert wire["tool_choice"] == {"type": "tool", "toolName": "get_weather"} + assert wire["toolChoice"] == {"type": "tool", "toolName": "get_weather"} # And it round-trips back into the typed model. from aimux.wrapper import ToolChoiceTool as _TCT - back = _TCT.model_validate(wire["tool_choice"]) + back = _TCT.model_validate(wire["toolChoice"]) assert back.tool_name == "get_weather" @@ -331,9 +329,9 @@ def test_round_trip_with_typed_content_parts(self): role="assistant", content=[ { - "type": "tool_call", - "tool_call_id": "call_abc", - "tool_name": "get_weather", + "type": "tool-call", + "toolCallId": "call_abc", + "toolName": "get_weather", "input": {"location": "Tokyo"}, } ], @@ -342,9 +340,13 @@ def test_round_trip_with_typed_content_parts(self): role="tool", content=[ { - "type": "tool_result", - "tool_call_id": "call_abc", - "result": {"temperature": 22, "condition": "sunny"}, + "type": "tool-result", + "toolCallId": "call_abc", + "toolName": "get_weather", + "output": { + "type": "json", + "value": {"temperature": 22, "condition": "sunny"}, + }, } ], ), diff --git a/bindings/swift/Sources/Aimux/Aimux.swift b/bindings/swift/Sources/Aimux/Aimux.swift index 28962b8aa..a34384baf 100644 --- a/bindings/swift/Sources/Aimux/Aimux.swift +++ b/bindings/swift/Sources/Aimux/Aimux.swift @@ -176,7 +176,7 @@ public enum AimuxError: Error, LocalizedError, CustomStringConvertible, Equatabl /// The model produced tool arguments that fail to parse or validate. case invalidToolInput(message: String, status: Int, retryMs: Int64, retryable: Bool, toolName: String, toolInput: String) /// A `repairToolCall` hook itself failed; `originalError` is the error it - /// was repairing, as externally-tagged wire JSON (the same encoding as + /// was repairing, as `AiMuxError` JSON tagged by `name` (the same encoding as /// `ToolCall.error`). case toolCallRepair(message: String, status: Int, retryMs: Int64, retryable: Bool, originalError: String) /// No API key was passed and the fallback environment variable is unset @@ -367,7 +367,7 @@ public enum AimuxError: Error, LocalizedError, CustomStringConvertible, Equatabl } /// `.toolCallRepair` only: the original lookup/parse/validation error as - /// externally-tagged wire JSON (the same encoding as `ToolCall.error`). + /// `AiMuxError` JSON tagged by `name` (the same encoding as `ToolCall.error`). public var originalError: String? { if case .toolCallRepair(_, _, _, _, let v) = self { return v } return nil @@ -752,10 +752,10 @@ public final class Model: @unchecked Sendable { /// registry entry. /// - modelId: Model id. /// - configJson: Optional JSON object of `ProviderOptions` - /// (`{"base_url": "...", "headers": {...}, "organization": "...", - /// "project": "...", "params": {...}}`); `nil` for defaults. - /// `max_retries` (a per-call option) and `body_overrides` (removed) are - /// rejected as `.invalidArgument`. + /// (`{"baseUrl": "...", "headers": {...}, "organization": "...", + /// "project": "...", "params": {...}}`); `nil` for defaults. Any + /// other key (`maxRetries` is a per-call option) is rejected as + /// `.invalidArgument`. public static func provider( name: String, apiKey: String? = nil, modelId: String, configJson: String? = nil ) throws -> Model { @@ -850,8 +850,8 @@ public final class Model: @unchecked Sendable { /// it does not trap: the array is as likely to come from a `.filter` /// as from a literal, so C's zero-children failure surfaces instead). /// - configJson: optional config: `{"router": "rule"|"weighted", - /// "weights": [...], "fallback": "on_error"|"none", "provider_name", - /// "model_id"}`. + /// "weights": [...], "fallback": "on_error"|"none", "providerName", + /// "modelId"}`. /// - Returns: a new RouterModel wrapping the children. public static func router( _ models: [Model], @@ -900,21 +900,6 @@ public final class Model: @unchecked Sendable { return Model(handle: handle) } - /// Register external OpenAI-compatible providers from a JSON config string - /// (RFC-0020). - /// - /// `configJSON` is `{ "providers": [ { "name", "base_url", ... } ] }`. - /// Entries override same-named built-ins or add new ones. Like - /// `initRecording`, this mutates process-global registry state. - /// - /// - Parameter configJSON: Provider registry config JSON. - /// - Throws: `AimuxError` (`.invalidArgument`) when the registry rejects - /// the document. - public static func registerProviders(_ configJSON: String) throws { - try validateJson(configJSON, parameter: "configJSON") - if let e = aimux_register_providers(configJSON) { throw expectAimuxError(e, context: "registerProviders") } - } - /// Set the global proxy configuration (M6, RFC-0016). Must be called before /// the first `generateText` / `streamText` call; a no-op (returns without /// error) if the shared HTTP client is already initialised. @@ -963,7 +948,7 @@ public final class Model: @unchecked Sendable { /// Generate a structured JSON object (M12, RFC-0016). /// /// Same signature as `generateText`; returns a JSON-serialized - /// `GenerateObjectResult`. Pass `response_format: { "Json": { ... } }` + /// `GenerateObjectResult`. Pass `responseFormat: { "type": "json", ... }` /// via `options` for schema control; aimux-core applies JSON repair /// before parsing. /// @@ -1028,7 +1013,7 @@ public final class Model: @unchecked Sendable { /// - Parameters: /// - prompt: A prompt string (serialized as JSON). /// - options: Optional GenerateTextOptions (serialized as JSON). - /// - onPart: Called for each StreamPart (JSON string). + /// - onPart: Called for each TextStreamPart (JSON string). /// - onDone: Called when the stream completes normally. /// - onError: Called on stream failure with the decoded error unchanged /// (`AimuxError`, or `DecodingError.dataCorrupted` for unparseable @@ -1099,7 +1084,7 @@ public final class Model: @unchecked Sendable { } } - /// Stream text as an AsyncSequence of StreamPart JSON strings. + /// Stream text as an AsyncSequence of TextStreamPart JSON strings. /// /// Usage: /// ```swift diff --git a/bindings/swift/Sources/Aimux/Multimodal.swift b/bindings/swift/Sources/Aimux/Multimodal.swift index 7278d9e8f..1f5535023 100644 --- a/bindings/swift/Sources/Aimux/Multimodal.swift +++ b/bindings/swift/Sources/Aimux/Multimodal.swift @@ -201,8 +201,8 @@ public final class TranscriptionModel: @unchecked Sendable { /// /// - Parameters: /// - options: Optional session options JSON - /// (`{"input_audio_format": {...}, "provider_options", "headers", - /// "include_raw_chunks"}`). + /// (`{"inputAudioFormat": {...}, "providerOptions", "headers", + /// "includeRawChunks"}`). /// - abortHandle: Optional abort handle (`aimux_abort_signal_new`); /// firing it aborts the session. public func startStream(options: String? = nil, abortHandle: UInt64 = 0) throws -> TranscriptionSession { @@ -490,108 +490,41 @@ public final class Files: @unchecked Sendable { // ───────────────────────────────────────────────────────────────────────────── // Multimodal result types (Codable) // -// These mirror the Go structs in bindings/go/multimodal_types.go. Field names -// are snake_case on the wire; Swift camelCase is restored via CodingKeys (the -// same approach used in Types.swift). `provider_metadata` uses the existing -// `JSONValue` type; `warnings` is decoded leniently as `[JSONValue]` (matching -// Go's `json.RawMessage`), so unknown warning shapes don't break decoding. +// These decode the JSON of the generated `bindings/node/src/types/*Result.ts` +// types: camelCase fields (so the structs synthesize their `Codable`), +// optional fields absent rather than null, `warnings` typed as `[Warning]`. // ───────────────────────────────────────────────────────────────────────────── -/// A `CodingKey` accepting any string, used to decode/encode the externally -/// tagged multimodal enums (`AudioData`, `ImageOutputs`, `VideoData`). Mirrors -/// `AnyCodingKey` in Types.swift, which is file-private and unavailable here. -fileprivate struct MMCodingKey: CodingKey { - let stringValue: String - init?(stringValue: String) { self.stringValue = stringValue } - var intValue: Int? { nil } - init?(intValue: Int) { return nil } - init(_ s: String) { self.stringValue = s } -} - -/// Build a `DecodingError.dataCorrupted` for a coding path + message. -fileprivate func mmDecodingError(_ codingPath: [any CodingKey], _ message: String) -> DecodingError { - DecodingError.dataCorrupted(.init(codingPath: codingPath, debugDescription: message, underlyingError: nil)) -} - // MARK: - Embedding -/// Token usage for an embedding call (input tokens only). +/// Token usage for an embedding call. public struct EmbeddingUsage: Codable, Equatable { - public var tokens: UInt32? - - public init(tokens: UInt32? = nil) { - self.tokens = tokens - } + public var tokens: UInt32 } /// Provider response metadata for embeddings. public struct EmbeddingResponse: Codable, Equatable { public var headers: [String: String]? public var body: JSONValue? - - enum CodingKeys: String, CodingKey { - case headers - case body - } - - public init(headers: [String: String]? = nil, body: JSONValue? = nil) { - self.headers = headers; self.body = body - } } /// The result of an embedding call. public struct EmbeddingResult: Codable, Equatable { public var embeddings: [[Float]] public var usage: EmbeddingUsage? + public var providerMetadata: ProviderMetadata? public var response: EmbeddingResponse? - public var providerMetadata: JSONValue? - public var warnings: [JSONValue]? - - enum CodingKeys: String, CodingKey { - case embeddings, usage, response - case providerMetadata = "provider_metadata" - case warnings - } - - public init(embeddings: [[Float]], usage: EmbeddingUsage? = nil, response: EmbeddingResponse? = nil, - providerMetadata: JSONValue? = nil, warnings: [JSONValue]? = nil) { - self.embeddings = embeddings; self.usage = usage; self.response = response - self.providerMetadata = providerMetadata; self.warnings = warnings - } + public var warnings: [Warning] } // MARK: - Speech (TTS) -/// Generated audio: a base64-encoded string or raw binary bytes. -/// Wire: `{"Base64":"…"}` | `{"Binary":[…]}`. -public enum AudioData: Codable, Equatable { - case base64(String) - case binary([UInt8]) +/// Generated audio: a base64-encoded string or raw bytes. Wire: untagged +/// (`"…"` | `[…]`), the same shape as ``FileBytes``. +public typealias AudioData = FileBytes - public init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: MMCodingKey.self) - guard let key = c.allKeys.first?.stringValue else { - throw mmDecodingError(c.codingPath, "missing audio-data tag") - } - switch key { - case "Base64": - self = .base64(try c.decode(String.self, forKey: MMCodingKey(key))) - case "Binary": - self = .binary(try c.decode([UInt8].self, forKey: MMCodingKey(key))) - default: - throw mmDecodingError(c.codingPath, "unknown audio-data tag \(key)") - } - } - - public func encode(to encoder: Encoder) throws { - var c = encoder.container(keyedBy: MMCodingKey.self) - switch self { - case .base64(let s): - try c.encode(s, forKey: MMCodingKey("Base64")) - case .binary(let bytes): - try c.encode(bytes, forKey: MMCodingKey("Binary")) - } - } +public struct SpeechRequest: Codable, Equatable { + public var body: JSONValue? } /// Response information for a speech call. @@ -600,71 +533,37 @@ public struct SpeechResponse: Codable, Equatable { public var modelId: String? public var headers: [String: String]? public var body: JSONValue? - - enum CodingKeys: String, CodingKey { - case timestamp - case modelId = "model_id" - case headers - case body - } - - public init(timestamp: String? = nil, modelId: String? = nil, - headers: [String: String]? = nil, body: JSONValue? = nil) { - self.timestamp = timestamp; self.modelId = modelId - self.headers = headers; self.body = body - } } /// The result of a speech generation call. public struct SpeechResult: Codable, Equatable { public var audio: AudioData + public var warnings: [Warning] + public var request: SpeechRequest? public var response: SpeechResponse - public var providerMetadata: JSONValue? - public var warnings: [JSONValue]? - - enum CodingKeys: String, CodingKey { - case audio, response - case providerMetadata = "provider_metadata" - case warnings - } - - public init(audio: AudioData, response: SpeechResponse, - providerMetadata: JSONValue? = nil, warnings: [JSONValue]? = nil) { - self.audio = audio; self.response = response - self.providerMetadata = providerMetadata; self.warnings = warnings - } + public var providerMetadata: ProviderMetadata? } // MARK: - Image -/// Generated images: all base64 strings or all binary byte arrays. -/// Wire: `{"Base64":["…"]}` | `{"Binary":[[…]]}`. +/// Generated images: all base64 strings or all binary byte arrays. Wire: +/// untagged (`["…"]` | `[[…]]`). public enum ImageOutputs: Codable, Equatable { case base64([String]) case binary([[UInt8]]) public init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: MMCodingKey.self) - guard let key = c.allKeys.first?.stringValue else { - throw mmDecodingError(c.codingPath, "missing image-outputs tag") - } - switch key { - case "Base64": - self = .base64(try c.decode([String].self, forKey: MMCodingKey(key))) - case "Binary": - self = .binary(try c.decode([[UInt8]].self, forKey: MMCodingKey(key))) - default: - throw mmDecodingError(c.codingPath, "unknown image-outputs tag \(key)") - } + let c = try decoder.singleValueContainer() + if let strings = try? c.decode([String].self) { self = .base64(strings); return } + if let batches = try? c.decode([[UInt8]].self) { self = .binary(batches); return } + throw aimuxDecodingError(c.codingPath, "expected base64 strings or byte arrays") } public func encode(to encoder: Encoder) throws { - var c = encoder.container(keyedBy: MMCodingKey.self) + var c = encoder.singleValueContainer() switch self { - case .base64(let strings): - try c.encode(strings, forKey: MMCodingKey("Base64")) - case .binary(let batches): - try c.encode(batches, forKey: MMCodingKey("Binary")) + case .base64(let strings): try c.encode(strings) + case .binary(let batches): try c.encode(batches) } } } @@ -674,36 +573,21 @@ public struct ImageResponse: Codable, Equatable { public var timestamp: String? public var modelId: String? public var headers: [String: String]? +} - enum CodingKeys: String, CodingKey { - case timestamp - case modelId = "model_id" - case headers - } - - public init(timestamp: String? = nil, modelId: String? = nil, headers: [String: String]? = nil) { - self.timestamp = timestamp; self.modelId = modelId; self.headers = headers - } +public struct ImageUsage: Codable, Equatable { + public var inputTokens: UInt32? + public var outputTokens: UInt32? + public var totalTokens: UInt32? } /// The result of an image generation call. public struct ImageResult: Codable, Equatable { public var images: ImageOutputs + public var warnings: [Warning] + public var providerMetadata: ProviderMetadata? public var response: ImageResponse - public var providerMetadata: JSONValue? - public var warnings: [JSONValue]? - - enum CodingKeys: String, CodingKey { - case images, response - case providerMetadata = "provider_metadata" - case warnings - } - - public init(images: ImageOutputs, response: ImageResponse, - providerMetadata: JSONValue? = nil, warnings: [JSONValue]? = nil) { - self.images = images; self.response = response - self.providerMetadata = providerMetadata; self.warnings = warnings - } + public var usage: ImageUsage? } // MARK: - Transcription (STT) @@ -711,28 +595,12 @@ public struct ImageResult: Codable, Equatable { /// A transcript segment with timing. public struct TranscriptionSegment: Codable, Equatable { public var text: String - /// Required `f64` in the core, not an `Option` — see `TranscriptionSegment.ts` — - /// so these stay non-optional and are always encoded; a provider that omits - /// them decodes to `0` rather than failing. public var startSecond: Double public var endSecond: Double +} - enum CodingKeys: String, CodingKey { - case text - case startSecond = "start_second" - case endSecond = "end_second" - } - - public init(text: String, startSecond: Double, endSecond: Double) { - self.text = text; self.startSecond = startSecond; self.endSecond = endSecond - } - - public init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - self.text = try c.decodeIfPresent(String.self, forKey: .text) ?? "" - self.startSecond = try c.decodeIfPresent(Double.self, forKey: .startSecond) ?? 0 - self.endSecond = try c.decodeIfPresent(Double.self, forKey: .endSecond) ?? 0 - } +public struct TranscriptionRequest: Codable, Equatable { + public var body: String? } /// Response information for a transcription call. @@ -741,125 +609,55 @@ public struct TranscriptionResponse: Codable, Equatable { public var modelId: String? public var headers: [String: String]? public var body: JSONValue? - - enum CodingKeys: String, CodingKey { - case timestamp - case modelId = "model_id" - case headers - case body - } - - public init(timestamp: String? = nil, modelId: String? = nil, - headers: [String: String]? = nil, body: JSONValue? = nil) { - self.timestamp = timestamp; self.modelId = modelId - self.headers = headers; self.body = body - } } /// The result of a transcription call. public struct TranscriptionResult: Codable, Equatable { public var text: String - public var segments: [TranscriptionSegment]? + public var segments: [TranscriptionSegment] public var language: String? + public var durationInSeconds: Double? + public var warnings: [Warning] + public var request: TranscriptionRequest? public var response: TranscriptionResponse - public var providerMetadata: JSONValue? - public var warnings: [JSONValue]? - - enum CodingKeys: String, CodingKey { - case text, segments, language, response - case providerMetadata = "provider_metadata" - case warnings - } - - public init(text: String, response: TranscriptionResponse, - segments: [TranscriptionSegment]? = nil, language: String? = nil, - providerMetadata: JSONValue? = nil, warnings: [JSONValue]? = nil) { - self.text = text; self.response = response - self.segments = segments; self.language = language - self.providerMetadata = providerMetadata; self.warnings = warnings - } + public var providerMetadata: ProviderMetadata? } // MARK: - Video -/// The URL variant of `VideoData`. -public struct VideoUrlData: Codable, Equatable { - public var url: String - public var mediaType: String? - - enum CodingKeys: String, CodingKey { - case url - case mediaType = "media_type" - } - - public init(url: String, mediaType: String? = nil) { - self.url = url; self.mediaType = mediaType - } -} - -/// The base64 variant of `VideoData`. -public struct VideoBase64Data: Codable, Equatable { - public var data: String - public var mediaType: String? - - enum CodingKeys: String, CodingKey { - case data - case mediaType = "media_type" - } - - public init(data: String, mediaType: String? = nil) { - self.data = data; self.mediaType = mediaType - } -} - -/// The binary variant of `VideoData`. -public struct VideoBinaryData: Codable, Equatable { - public var data: [UInt8] - public var mediaType: String? - - enum CodingKeys: String, CodingKey { - case data - case mediaType = "media_type" - } - - public init(data: [UInt8], mediaType: String? = nil) { - self.data = data; self.mediaType = mediaType - } -} - -/// Generated video: a download URL, a base64 string, or raw binary bytes. -/// Wire: `{"Url":{…}}` | `{"Base64":{…}}` | `{"Binary":{…}}`. +/// Generated video. Wire: `{"type":"url","url","mediaType"}`, +/// `{"type":"base64","data":"…","mediaType"}` or +/// `{"type":"binary","data":[…],"mediaType"}`. public enum VideoData: Codable, Equatable { - case url(VideoUrlData) - case base64(VideoBase64Data) - case binary(VideoBinaryData) + case url(url: String, mediaType: String) + case base64(data: String, mediaType: String) + case binary(data: [UInt8], mediaType: String) + + private enum Field: String, CodingKey { case type, url, data, mediaType } public init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: MMCodingKey.self) - guard let key = c.allKeys.first?.stringValue else { - throw mmDecodingError(c.codingPath, "missing video-data tag") - } - switch key { - case "Url": - self = .url(try c.decode(VideoUrlData.self, forKey: MMCodingKey(key))) - case "Base64": - self = .base64(try c.decode(VideoBase64Data.self, forKey: MMCodingKey(key))) - case "Binary": - self = .binary(try c.decode(VideoBinaryData.self, forKey: MMCodingKey(key))) - default: - throw mmDecodingError(c.codingPath, "unknown video-data tag \(key)") + let c = try decoder.container(keyedBy: Field.self) + let mediaType = try c.decode(String.self, forKey: .mediaType) + switch try c.decode(String.self, forKey: .type) { + case "url": self = .url(url: try c.decode(String.self, forKey: .url), mediaType: mediaType) + case "base64": self = .base64(data: try c.decode(String.self, forKey: .data), mediaType: mediaType) + case "binary": self = .binary(data: try c.decode([UInt8].self, forKey: .data), mediaType: mediaType) + case let t: throw decoder.unknownTag(t, in: "video data") } } public func encode(to encoder: Encoder) throws { - var c = encoder.container(keyedBy: MMCodingKey.self) + var c = encoder.container(keyedBy: Field.self) switch self { - case .url(let d): - try c.encode(d, forKey: MMCodingKey("Url")) - case .base64(let d): - try c.encode(d, forKey: MMCodingKey("Base64")) - case .binary(let d): - try c.encode(d, forKey: MMCodingKey("Binary")) + case .url(let url, let mediaType): + try c.encode("url", forKey: .type); try c.encode(url, forKey: .url) + try c.encode(mediaType, forKey: .mediaType) + case .base64(let data, let mediaType): + try c.encode("base64", forKey: .type); try c.encode(data, forKey: .data) + try c.encode(mediaType, forKey: .mediaType) + case .binary(let data, let mediaType): + try c.encode("binary", forKey: .type); try c.encode(data, forKey: .data) + try c.encode(mediaType, forKey: .mediaType) } } } @@ -869,36 +667,14 @@ public struct VideoResponse: Codable, Equatable { public var timestamp: String? public var modelId: String? public var headers: [String: String]? - - enum CodingKeys: String, CodingKey { - case timestamp - case modelId = "model_id" - case headers - } - - public init(timestamp: String? = nil, modelId: String? = nil, headers: [String: String]? = nil) { - self.timestamp = timestamp; self.modelId = modelId; self.headers = headers - } } /// The result of a video generation call. public struct VideoResult: Codable, Equatable { public var videos: [VideoData] + public var warnings: [Warning] + public var providerMetadata: ProviderMetadata? public var response: VideoResponse - public var providerMetadata: JSONValue? - public var warnings: [JSONValue]? - - enum CodingKeys: String, CodingKey { - case videos, response - case providerMetadata = "provider_metadata" - case warnings - } - - public init(videos: [VideoData], response: VideoResponse, - providerMetadata: JSONValue? = nil, warnings: [JSONValue]? = nil) { - self.videos = videos; self.response = response - self.providerMetadata = providerMetadata; self.warnings = warnings - } } // MARK: - Reranking @@ -907,15 +683,6 @@ public struct VideoResult: Codable, Equatable { public struct RerankingRank: Codable, Equatable { public var index: Int public var relevanceScore: Double - - enum CodingKeys: String, CodingKey { - case index - case relevanceScore = "relevance_score" - } - - public init(index: Int, relevanceScore: Double) { - self.index = index; self.relevanceScore = relevanceScore - } } /// Optional response information for a reranking call. @@ -925,40 +692,14 @@ public struct RerankingResponse: Codable, Equatable { public var modelId: String? public var headers: [String: String]? public var body: JSONValue? - - enum CodingKeys: String, CodingKey { - case id - case timestamp - case modelId = "model_id" - case headers - case body - } - - public init(id: String? = nil, timestamp: String? = nil, modelId: String? = nil, - headers: [String: String]? = nil, body: JSONValue? = nil) { - self.id = id; self.timestamp = timestamp; self.modelId = modelId - self.headers = headers; self.body = body - } } /// The result of a reranking call. public struct RerankingResult: Codable, Equatable { public var ranking: [RerankingRank] + public var providerMetadata: ProviderMetadata? + public var warnings: [Warning]? public var response: RerankingResponse? - public var providerMetadata: JSONValue? - public var warnings: [JSONValue]? - - enum CodingKeys: String, CodingKey { - case ranking, response - case providerMetadata = "provider_metadata" - case warnings - } - - public init(ranking: [RerankingRank], response: RerankingResponse? = nil, - providerMetadata: JSONValue? = nil, warnings: [JSONValue]? = nil) { - self.ranking = ranking; self.response = response - self.providerMetadata = providerMetadata; self.warnings = warnings - } } // MARK: - Search @@ -970,56 +711,22 @@ public struct SearchResultItem: Codable, Equatable { public var content: String? public var rawContent: String? public var score: Double? - public var providerMetadata: [String: JSONValue]? - - enum CodingKeys: String, CodingKey { - case title, url, content - case rawContent = "raw_content" - case score - case providerMetadata = "provider_metadata" - } - - public init(title: String? = nil, url: String? = nil, content: String? = nil, - rawContent: String? = nil, score: Double? = nil, providerMetadata: [String: JSONValue]? = nil) { - self.title = title; self.url = url; self.content = content - self.rawContent = rawContent; self.score = score; self.providerMetadata = providerMetadata - } + public var providerMetadata: ProviderMetadata? } /// Optional response information for a search call. public struct SearchResponse: Codable, Equatable { public var headers: [String: String]? public var body: JSONValue? - - enum CodingKeys: String, CodingKey { - case headers - case body - } - - public init(headers: [String: String]? = nil, body: JSONValue? = nil) { - self.headers = headers; self.body = body - } } /// The result of a search call. public struct SearchResult: Codable, Equatable { public var results: [SearchResultItem] public var answer: String? + public var providerMetadata: ProviderMetadata? + public var warnings: [Warning] public var response: SearchResponse? - public var providerMetadata: JSONValue? - public var warnings: [JSONValue]? - - enum CodingKeys: String, CodingKey { - case results, answer, response - case providerMetadata = "provider_metadata" - case warnings - } - - public init(results: [SearchResultItem], answer: String? = nil, response: SearchResponse? = nil, - providerMetadata: JSONValue? = nil, warnings: [JSONValue]? = nil) { - self.results = results; self.answer = answer; self.response = response - self.providerMetadata = providerMetadata; self.warnings = warnings - } } // MARK: - Files @@ -1029,31 +736,17 @@ public struct UploadFileResult: Codable, Equatable { public var providerReference: [String: String] public var mediaType: String? public var filename: String? - public var providerMetadata: JSONValue? - public var warnings: [JSONValue]? - - enum CodingKeys: String, CodingKey { - case providerReference = "provider_reference" - case mediaType = "media_type" - case filename - case providerMetadata = "provider_metadata" - case warnings - } - - public init(providerReference: [String: String], mediaType: String? = nil, filename: String? = nil, - providerMetadata: JSONValue? = nil, warnings: [JSONValue]? = nil) { - self.providerReference = providerReference - self.mediaType = mediaType; self.filename = filename - self.providerMetadata = providerMetadata; self.warnings = warnings - } + public var providerMetadata: ProviderMetadata? + public var warnings: [Warning] } + // ───────────────────────────────────────────────────────────────────────────── // TranscriptionSession (RFC-0028 streaming) // ───────────────────────────────────────────────────────────────────────────── /// Thrown by `TranscriptionSession.nextPart` when the stream ended normally -/// (a `Finish` part was delivered earlier). +/// (a `finish` part was delivered earlier). /// /// A session the caller `close()`d does NOT throw this — it throws the Swift /// boundary projection (`DecodingError.dataCorrupted`, "transcription session diff --git a/bindings/swift/Sources/Aimux/ToolCallRepair.swift b/bindings/swift/Sources/Aimux/ToolCallRepair.swift index af5bd09a7..5d227a7c9 100644 --- a/bindings/swift/Sources/Aimux/ToolCallRepair.swift +++ b/bindings/swift/Sources/Aimux/ToolCallRepair.swift @@ -33,20 +33,11 @@ public struct RawToolCall: Codable, Equatable { public var input: String public var providerExecuted: Bool? public var dynamic: Bool? - public var providerMetadata: JSONValue? - - enum CodingKeys: String, CodingKey { - case toolCallId = "tool_call_id" - case toolName = "tool_name" - case input - case providerExecuted = "provider_executed" - case dynamic - case providerMetadata = "provider_metadata" - } + public var providerMetadata: ProviderMetadata? public init(toolCallId: String, toolName: String, input: String, providerExecuted: Bool? = nil, dynamic: Bool? = nil, - providerMetadata: JSONValue? = nil) { + providerMetadata: ProviderMetadata? = nil) { self.toolCallId = toolCallId; self.toolName = toolName; self.input = input self.providerExecuted = providerExecuted; self.dynamic = dynamic self.providerMetadata = providerMetadata @@ -58,9 +49,9 @@ public struct RawToolCall: Codable, Equatable { public struct ToolCallRepairContext: Codable, Equatable { /// The failing call, with the provider's raw argument text. public var toolCall: RawToolCall - /// The failure the call already carries, as externally-tagged wire JSON - /// (the same encoding as ``ToolCall/error``), e.g. - /// `{"InvalidToolInput":{"tool_name":…,"tool_input":…,"cause":…}}`. + /// The failure the call already carries: an `AiMuxError` object tagged by + /// `name` (the same encoding as ``ToolCall/error``), e.g. + /// `{"name":"AI_InvalidToolInputError","toolName":…,"toolInput":…,"cause":…}`. public var error: JSONValue /// JSON Schema of the named tool. A name that does not resolve to a /// function tool yields the AI SDK's default empty-object schema. @@ -72,13 +63,6 @@ public struct ToolCallRepairContext: Codable, Equatable { /// The instructions the call was generated with, if any. public var instructions: String? - enum CodingKeys: String, CodingKey { - case toolCall = "tool_call" - case error - case inputSchema = "input_schema" - case tools, messages, instructions - } - public init(toolCall: RawToolCall, error: JSONValue, inputSchema: JSONValue, tools: [Tool], messages: [ModelMessage], instructions: String? = nil) { self.toolCall = toolCall; self.error = error; self.inputSchema = inputSchema @@ -106,7 +90,7 @@ struct RepairToolCallBox: Equatable { /// Build the repair argument for one invalid tool call. /// /// - Parameters: -/// - toolCall: One `GenerateTextResult.tool_calls` entry with `"invalid": true`. +/// - toolCall: One `GenerateTextResult.toolCalls` entry with `"invalid": true`. /// - prompt: The prompt JSON the call was generated with. /// - options: The options JSON the call was generated with. /// - Returns: The serialized ``ToolCallRepairContext``, or the JSON literal @@ -138,8 +122,8 @@ public func applyToolCallRepair( } /// Apply a repair reply to a serialized `GenerateTextResult` or -/// `GenerateObjectResult`. Both `tool_calls` and the matching -/// `response_messages` tool-call part are rewritten — the transcript must keep +/// `GenerateObjectResult`. Both `toolCalls` and the matching +/// `responseMessages` tool-call part are rewritten — the transcript must keep /// pointing at the same entry, or the next turn replays the unrepaired /// arguments. public func applyToolCallRepairToResult( @@ -161,7 +145,7 @@ public func applyToolCallRepairToResult( /// the patched JSON (the input unchanged when no repair function is set). /// /// Handles `GenerateTextResult` and `StreamTextResultAggregated` (top-level -/// `tool_calls`) as well as `GenerateObjectResult` (`raw.tool_calls`). Calls +/// `toolCalls`) as well as `GenerateObjectResult` (`raw.toolCalls`). Calls /// are visited in document order and each gets at most one repair attempt, as /// in the AI SDK. func repairedResultJson( @@ -172,12 +156,12 @@ func repairedResultJson( guard let document = (try? JSONSerialization.jsonObject(with: Data(resultJson.utf8))) as? [String: Any] else { return resultJson } // A GenerateObjectResult keeps the whole text result under `raw`. - let target = document["tool_calls"] != nil ? document : document["raw"] as? [String: Any] - guard let calls = target?["tool_calls"] as? [[String: Any]] else { return resultJson } + let target = document["toolCalls"] != nil ? document : document["raw"] as? [String: Any] + guard let calls = target?["toolCalls"] as? [[String: Any]] else { return resultJson } var patched = resultJson for call in calls where call["invalid"] as? Bool == true { - guard let id = call["tool_call_id"] as? String, let callJson = jsonString(call) else { continue } + guard let id = call["toolCallId"] as? String, let callJson = jsonString(call) else { continue } let contextJson = try toolCallRepairContext( toolCall: callJson, prompt: promptJson, options: optsJson ) @@ -190,9 +174,10 @@ func repairedResultJson( return patched } -/// Repair a `{"ToolCall": …}` stream part, returning the replacement part JSON. +/// Repair a `{"type":"tool-call", …}` stream part, returning the replacement +/// part JSON. /// -/// Any other part — `ToolInputDelta` above all — is returned untouched and +/// Any other part — `tool-input-delta` above all — is returned untouched and /// immediately: the AI SDK forwards argument deltas verbatim even when a repair /// function is configured. func repairedStreamPartJson( @@ -201,9 +186,9 @@ func repairedStreamPartJson( ) throws -> String { guard let repair = options?.repairToolCall, let part = (try? JSONSerialization.jsonObject(with: Data(partJson.utf8))) as? [String: Any], - let call = part["ToolCall"] as? [String: Any], - call["invalid"] as? Bool == true, - let callJson = jsonString(call) + part["type"] as? String == "tool-call", + part["invalid"] as? Bool == true, + let callJson = jsonString(part) else { return partJson } // The two library calls are pure — no runtime, no `ffi_block_on` — so they @@ -217,8 +202,10 @@ func repairedStreamPartJson( let repairedJson = try applyToolCallRepair( toolCall: callJson, options: optsJson, reply: reply ) - let repaired = try JSONSerialization.jsonObject(with: Data(repairedJson.utf8)) - return jsonString(["ToolCall": repaired]) ?? partJson + guard var repaired = try JSONSerialization.jsonObject(with: Data(repairedJson.utf8)) as? [String: Any] + else { return partJson } + repaired["type"] = "tool-call" + return jsonString(repaired) ?? partJson } /// Run `body` on another thread and block until it answers. @@ -263,7 +250,7 @@ private func repairReply(_ context: ToolCallRepairContext, _ repair: RepairToolC do { guard let replacement = try repair(context) else { return #"{"type":"unchanged"}"# } let encoded = try JSONEncoder().encode(replacement) - return #"{"type":"repaired","tool_call":"# + String(decoding: encoded, as: UTF8.self) + "}" + return #"{"type":"repaired","toolCall":"# + String(decoding: encoded, as: UTF8.self) + "}" } catch { let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error) return jsonString(["type": "failed", "message": message]) diff --git a/bindings/swift/Sources/Aimux/Types.swift b/bindings/swift/Sources/Aimux/Types.swift index 2b6b8b59b..355b470a6 100644 --- a/bindings/swift/Sources/Aimux/Types.swift +++ b/bindings/swift/Sources/Aimux/Types.swift @@ -2,20 +2,19 @@ // // The raw API in `Aimux.swift` exchanges JSON strings across the Swift↔C // boundary (`generateText(prompt:options:) -> String`, `streamText` yields -// JSON-string `StreamPart`s). This file adds a thin, *typed* layer on top: -// inputs and outputs are `Codable` Swift structs/enums mirroring -// `bindings/node/src/types/*.ts` (the ts-rs types generated from the Rust serde -// definitions). The raw API is left untouched; the typed methods live in a -// `Model` extension and delegate to the raw ones. +// JSON-string parts). This file adds a thin, *typed* layer on top: inputs and +// outputs are `Codable` Swift types decoding and encoding exactly the JSON of +// `bindings/node/src/types/*.ts` (the ts-rs types generated from the Rust +// serde definitions, which equal the AI SDK JSON). The raw API is left +// untouched; the typed methods live in a `Model` extension and delegate to it. // -// Wire conventions mirrored here (all derived from the Rust serde attributes): -// • struct fields are snake_case on the wire → Swift camelCase via CodingKeys -// • `Tool` / `ContentPart` → internally tagged by `type` (variant = snake_case) -// • `ToolChoice` → mixed: "auto"|"none"|"required" or {"type":"tool","toolName":...} -// (note: `toolName` is camelCase on the wire) -// • `ResponseFormat` → external tag with a unit variant: "Text" or {"Json":{...}} -// • `ModelPrompt` / `MessageContent` → untagged (string | array) -// • `GenerateContent` / `StreamPart` / `Warning` → external tag {"Variant":{...}} +// Wire conventions mirrored here: +// • field names are camelCase, so structs synthesize their `Codable` +// • optional fields are absent, never `null` +// • unions are internally tagged: `type` (kebab-case values); `Source` uses +// `sourceType`, `Role`-keyed messages use `role` +// • `ToolChoice` is mixed: "auto"|"none"|"required" or {"type":"tool","toolName":…} +// • `ModelPrompt` / `MessageContent` / `FileBytes` are untagged (string | array) import CAimuxFFI import Foundation @@ -75,11 +74,16 @@ public enum JSONValue: Codable, Equatable, Sendable { public var objectValue: [String: JSONValue]? { if case .object(let o) = self { return o } else { return nil } } } -// MARK: - AnyCodingKey (a CodingKey that can hold any string tag/field) +/// Provider-keyed options: `{ "": { "