diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d31c5288..dd3ac75e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -304,6 +304,12 @@ jobs: if ($dependencies -match '(?im)^\s*(?:VCRUNTIME|MSVCP|UCRTBASE|api-ms-win-crt)[^\s]*\.dll\s*$') { throw "Dynamic MSVC runtime dependency found:`n$dependencies" } + # The binding crate's own Rust unit tests (JSON parsing, no Node + # runtime). The crate is not a member of the main workspace, so + # rust-test does not run them; one leg suffices. + - name: Rust unit tests + if: matrix.settings.test && runner.os == 'Linux' + run: cargo test --lib --manifest-path bindings/node/Cargo.toml - name: Test if: matrix.settings.test working-directory: bindings/node @@ -349,6 +355,10 @@ jobs: cache-dependency-path: bindings/python/pyproject.toml - name: Install maturin run: pip install "maturin[patchelf]" + # The binding crate's own Rust unit tests (JSON parsing); the crate is + # not a member of the main workspace, so rust-test does not run them. + - name: Rust unit tests + run: cargo test --lib --manifest-path bindings/python/Cargo.toml - name: Build working-directory: bindings/python shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 228b993a5..7a855e84e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,163 @@ 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); bytes are base64 strings; + 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; + recordings stay schema 2 and still read the snake_case envelope keys + aimux 0.5 writes (`call_id`, `recorded_at`, `model_id`, ...). + +- MoA `failMode` values (`"best-effort"`, `"fail-fast"`) and the recording + operation value `"language-model"` use kebab-case. + +- Bytes are base64 strings in the public JSON, as the AI SDK writes a + `Uint8Array`: image part `image`, file part `data`, `FileBytes`, speech + `audio`, image outputs, video `binary` data and audio/image/video inputs. An + array of numbers is no longer read. Bindings keep their byte-valued APIs + (`Binary` variants, Python `bytes`) and encode them at the JSON boundary + (#232 C33). Decision images (`images[].data` in a provider's options) + follow the same rule; Python `decide` encodes `bytes` image data there + (C33a). + +- JSON `null` is kept apart from a missing value where the AI SDK's type is + `unknown`: response and request `body` (language, speech, transcription, + embedding, reranking and search results), `APICallError.data` and the tool + approval request's `inputSchemaInput` read `"body": null` as a present null + and write it back, and omit the field only when it was missing. Python + serializes without `exclude_none` and keeps a set `None` of an `Any` field; + Go reads these bodies as `json.RawMessage`, and `Error.Data` changes from + `any` to `json.RawMessage` (`nil` when missing, `null` when null); Dart + gains `hasBody` / `hasInputSchemaInput` (#232 C34). + +- File content parts are the AI SDK's `FilePart`: one `file` tag whose `data` + is a tagged `FileData` (`{type:"data"|"url"|"reference"|"text"}`), a string + (base64 or a URL) or a bare `{provider: id}` reference. The `file-base64`, + `file-url` and `file-reference` parts are removed; write + `{type:"file", data:{type:"url", url}}` and so on. A `reasoning-file` + part's `data` also takes a bare string. A `data:` URL's media type wins over + the part's `mediaType`, and response messages carry files as bare base64 + strings. Bindings replace the three part classes with a typed `FilePartData` + (#232 C35). + +- Generated files are the AI SDK's `GeneratedFile`: `files[]` holds + `{base64, mediaType, providerMetadata?}` instead of + `{type:"data"|"url", ...}`, and the text results' and stream's `file` / + `reasoning-file` parts are `{type, file: GeneratedFile, providerMetadata?}`. + `ReasoningFileOutput` is replaced by `GeneratedFileOutput`; the + provider-level `raw.content` keeps the V4 file + (`LanguageModelFile`: `{mediaType, data}`). Bindings gain a `GeneratedFile` + type with a bytes accessor (#232 C46). + +- Response timestamps are written as JavaScript's `Date.toISOString()` + (`"2025-03-06T13:50:19.000Z"`: UTC, milliseconds) instead of each + provider's own form (`+00:00` offsets, nanoseconds, Bedrock's HTTP date, + Open Responses' Unix seconds). Reading one requires an RFC 3339 date-time; + other text is an error, so stored results or recordings with another form + no longer parse. In Rust the fields are `Option` + (`aimux_core::Timestamp`) instead of `Option`. Binding types stay + strings (#232 C48). + +- The text stream's `text-delta` and `reasoning-delta` parts carry their + text under `text`, as the AI SDK's `TextStreamPart` does, instead of + `delta`. `tool-input-delta` keeps `delta`, and so does the provider-level + `StreamPart`. Rust matches `TextStreamPart::TextDelta { text, .. }`; the + bindings' typed parts rename `delta` to `text` (Go `TextDeltaPayload.Text`, + Java `getText()`, Kotlin/Swift/Dart `text`) (#229 B9). + +- Provider options, provider metadata and provider references keep their + keys in insertion order end to end, as the AI SDK's JS objects do: Rust + holds them as `IndexMap` (see the provider options entry below) and the + bindings no longer sort them. Go: `Tool.ProviderOptions` and + `TranscriptionSessionOpts.ProviderOptions` are `json.RawMessage` instead of + `map[string]json.RawMessage`, and `UploadFileResult.ProviderReference` is a + `ProviderReference` (raw JSON, read with `Get(provider)`) instead of + `map[string]string`. Java's `UploadFileResult` defaults to a + `LinkedHashMap`. Swift's typed dictionaries remain unordered (#229 NP2-1). + +- A tool call or source outside `content` carries its AI SDK `type` tag: the + text results' `toolCalls[]` and an approval request's `toolCall` are + `{"type": "tool-call", ...}` (the AI SDK's `TypedToolCall`), and their + `sources[]` are `{"type": "source", "sourceType", ...}`. So are the repair + context's `toolCall` and the call `apply_tool_call_repair` returns (a + `LanguageModelV4ToolCall`). Reading accepts JSON without the tag and rejects + a different one. Rust keeps `ToolCall` and `Source` untagged and writes the + tag through `wire::typed` / `wire::typed_vec` / `wire::Tagged`; TypeScript + types the fields as the new `TypedToolCall` and `TypedSource`. Every + binding's standalone `ToolCall`, `RawToolCall` and `Source` writes the tag + (Go through `MarshalJSON`; Java, Kotlin, Swift, Dart and Python as a fixed + `type` field) (#229 NP2-7). + +- Tool-call input parsed from the model's argument text writes an integral + number as JavaScript does: `1e2` and `100.0` become `100`, `-0.0` becomes + `0`, as `JSON.stringify(JSON.parse(text))` writes them, instead of + serde_json's `100.0`. Other numbers are unchanged. Bindings that tell + integers from floats (Python `int`, Go raw JSON, Jackson `IntNode`) now see + integers (#229). + +- The text results' usage, steps and typed tool outputs follow the same JSON + rules. `LanguageModelUsage` (`usage`, `totalUsage`, each step's and the + `finish` / `finish-step` parts' usage) is camelCase: `inputTokens`, + `inputTokenDetails {noCacheTokens, cacheReadTokens, cacheWriteTokens}`, + `outputTokens`, `outputTokenDetails {textTokens, reasoningTokens}`, + `totalTokens`, `raw?`; an unknown count is omitted instead of `null`, the + two detail objects are always written. `steps[]` are camelCase (`callId`, + `stepNumber`, `model {provider, modelId}`, `rawFinishReason?`, + `performance {stepTimeMs, responseTimeMs, timeToFirstOutputMs?, ...}`, + `response {id, timestamp, modelId, headers?, body?, messages}`). Tool + results, tool errors, `tool-output-denied` and `tool-approval-response` + parts write `toolCallId`, `toolName`, `providerExecuted`, `approvalId`; an + approval response's `toolCall` carries `type: "tool-call"`, and the + results' `toolResults[]` are `{"type": "tool-result", ...}` (the AI SDK's + `TypedToolResult`; TypeScript `TaggedToolResult`). Recordings write the + outcome's usage the same way. Go, Java, Kotlin, Swift and Dart read and + write the camelCase keys with unchanged field names; Python dicts and Node + objects carry the new keys. Kotlin, Swift and Dart keep a parsed JSON + `null` output apart from no output (Kotlin `JsonNull`, Swift `.null`, Dart + `hasOutput`). + +- `Output` (the `output` option of `generateText` / `streamText` and + `generate_object`) is tagged by `type`, as the AI SDK names its output + modes: `{"type": "text"}`, `{"type": "object", "schema", "name?", + "description?"}`, `{"type": "array", "element", "minItems?", "maxItems?", + ...}`, `{"type": "choice", "options"}`, `{"type": "json"}`. The externally + tagged `{"Object": {...}}` form is no longer read. + +- Decisions follow the same JSON rules. `DecideOptions` and + `DecisionCallOptions` write `maxRetries` and `providerOptions`; + `DecisionResult` and `DecideResult` write `rounding {probabilityDecimals, + scoreDecimals}`, `providerMetadata` and `usage {inputTokens, outputTokens, + totalTokens}`, an unknown count omitted instead of `null` + (`decision-result.ts:27-31`). The model description of + `aimux_decision_capabilities` and every binding's `describe` is + `{specificationVersion, provider, modelId, supportedQuestionTypes}`. The + options and metadata aimux defines in a provider's namespace are camelCase + too: `images[].mediaType`, OpenAI `choiceValues` and `safetyIdentifier`, + candidate scoring's `modelRevision`, `tokenizerRevision`, + `promptFormatVersion` and `encoded..{promptTokenIds, labelTokenIds}`, + and the metadata's `probabilitySource` and `latencyMs`; options a runtime + receives as given keep the runtime's names (`keep_alive`, + `chat_template_kwargs`, ...). Python's `decide` takes the JSON keys as + keyword arguments (`maxRetries=0`). Decision recordings write the options + and result this way, and replay refuses recorded options with snake_case + keys instead of ignoring them. + +- Request options reject keys they do not have (RFC-0042 §7.2): + `GenerateTextOptions` and its `timeout`, the options of the other + operations (embedding, speech, image, transcription, reranking, video and + its `poll`, search, file upload, `decide`), the MoA config, and the router + and transcription-session configs of the C ABI, Node and Python. aimux + 0.5's snake_case keys (`max_retries`, `timeout.total_ms`, `fail_mode`, + `provider_name`) and typos fail naming the key instead of falling back to + the default. The Node and Python router and transcription-session configs + are camelCase, as the C ABI's are. + - 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. @@ -200,10 +357,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `tool_approval_response { approval_id, approved, reason, provider_executed }`, which reaches the provider only for a provider-executed tool, as upstream; a tool message left empty is dropped. Wire format: the role and part tags - are unchanged; a tool result still reads the earlier `result` (or untyped - `output`) value with `is_error` and writes `text` / `json` / error outputs - that way, while `output` holding an AI SDK output object is read as typed; - a part its role does not allow (an `image` in an assistant message, a + are unchanged; a tool result carries only `output`, an AI SDK output + object, and the earlier `result` / `is_error` fields are no longer read or + written; a part its role does not allow (an `image` in an assistant message, a non-text system message, a string or text part in a tool message) is now rejected when the prompt is parsed instead of when it is converted. @@ -662,7 +818,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 replay time with an error that names it: its empty `provider_id` (`ProviderRecord::registry_id`) for a rebuild from a registry, a prompt in the older format (`InputRecord::unreadable_prompt`, loaded as empty), - older call options, or the pre-V4 decision options and results. + older call options (snake_case keys the camelCase options would ignore are + refused), or the pre-V4 decision options and results. `rebuild_provider(record, registry)` resolves `provider_id` through the caller-supplied registry, retaining its settings. If the default model method differs from the recorded one, pass the model @@ -796,7 +953,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 levels). Answers are `{choice, probabilities?}`, `{score, probabilities?}` (keyed `"0"`, `"1"`, …) and `{probability}`. `decide` takes `DecideOptions` (the call options plus `max_retries`, default 2, and `timeout`) and returns - `DecideResult` (answers, usage with `total_tokens`, warnings, rounding, + `DecideResult` (answers, usage with `totalTokens`, warnings, rounding, provider metadata, response). It validates the input, rejects unsupported question types before any I/O and validates the answers once after the retries, with the upstream messages. The model call runs inside @@ -806,9 +963,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `supported_question_types` and `do_decide(&DecisionCallOptions)`; `capabilities()` and `DecisionCapabilities` are removed, and provider limits are checked in `do_decide` before HTTP. Images and OpenAI typed choice - values move to `provider_options.`; the probability source, - confidence, OpenAI typed values, Laya abstentions and `latency_ms` move to - `provider_metadata.`. An OpenAI refusal or a vLLM skipped question + values move to `providerOptions.`; the probability source, + confidence, OpenAI typed values, Laya abstentions and `latencyMs` move to + `providerMetadata.`. An OpenAI refusal or a vLLM skipped question fails the decision instead of returning a partial answer. Decision recordings hold the `DecideOptions` and the `DecideResult`; recordings made before this change load but do not replay. @@ -831,7 +988,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 failures used to surface as `AIMUX_E_INVALID_ARGUMENT`. - `aimux_decide` takes `DecideOptions` JSON and returns `DecideResult` JSON. `aimux_decision_capabilities` keeps its name and signature but returns - `{specification_version, provider, model_id, supported_question_types}`. + `{specificationVersion, provider, modelId, supportedQuestionTypes}`. `aimux_provider_decision_model` on a provider without decision models returns `AIMUX_E_NO_SUCH_MODEL` (was `AIMUX_E_UNSUPPORTED_FUNCTIONALITY`). - `aimux_provider_new`, `aimux_provider_handle_new` and `config_json` resolve @@ -883,7 +1040,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 **All bindings** -- Typed provider tools in Java, Kotlin, Swift, Python and Flutter carry +- Typed provider tools in Java, Kotlin, Swift, Python, Flutter and Go carry optional `supportsDeferredResults`, omitted from JSON when absent (provider-utils@5.0.55 src/types/tool.ts:316-326). @@ -1038,6 +1195,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 timeout's start, `additionalRetryableError` and per-batch idempotency keys. +- RFC-0035 shows the repair context, reply and result patch in the final + JSON (`toolCall` tagged `tool-call`, errors tagged by `name`, camelCase + keys). It says that a repaired call failing re-validation is returned as an + invalid call, not wrapped in `AI_ToolCallRepairError`, as upstream does, and + registers the pre-evaluated `inputSchema` and Swift's delayed abort as + differences from upstream. + ## [0.5.0] - 2026-09-27 **Breaking release.** 13 PRs since 0.3.0: the cross-language error model diff --git a/aimux-core/Cargo.toml b/aimux-core/Cargo.toml index 9e0043176..141c32917 100644 --- a/aimux-core/Cargo.toml +++ b/aimux-core/Cargo.toml @@ -31,6 +31,8 @@ tokio-util = "0.7" httpdate = { workspace = true } rand = { workspace = true } base64 = "0.22" +# `Timestamp`: RFC 3339 parsing and `toISOString` formatting. +chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } jsonschema = { workspace = true } regex = "1" diff --git a/aimux-core/src/content.rs b/aimux-core/src/content.rs index 15f087654..d0e57ec29 100644 --- a/aimux-core/src/content.rs +++ b/aimux-core/src/content.rs @@ -8,29 +8,34 @@ //! `tool-model-message.ts:23`). `convert_to_language_model_prompt` turns them //! into the provider-facing parts of `language_model_message`. //! -//! The JSON is this layer's wire: `type` tags and field names are snake_case, -//! and a file is one of the five file variants (`image`, `file`, -//! `file_base64`, `file_url`, `file_reference`) rather than upstream's single -//! `file` part with a `FileData` payload. +//! The JSON is the AI SDK's: kebab-case `type` tags, camelCase fields, and +//! one `file` part whose `data` is upstream's `FilePart.data` ([`FilePartData`]). //! //! [`ModelMessage`]: crate::message::ModelMessage -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Serialize}; use serde_json::Value; use ts_rs::TS; -use crate::shared::{GeneratedFileData, SharedProviderOptions, SharedProviderReference}; +use crate::shared::{ + FileBytes, FileData, GeneratedFileData, SharedProviderOptions, SharedProviderReference, +}; use crate::types::NamespacedKind; /// A part of a user message: upstream's `TextPart | ImagePart | FilePart`. #[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" +)] #[cfg_attr(feature = "ts-export", ts(export))] pub enum UserContentPart { /// 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, }, @@ -39,55 +44,29 @@ pub enum UserContentPart { /// /// Deprecated upstream (`provider-utils` `types/content-part.ts:26-29`): /// use a `file` part with an `image` media type. The prompt conversion - /// turns it into one and logs a deprecation warning. + /// turns it into one and logs a deprecation warning. The JSON holds the + /// bytes as a base64 string. Image { + #[ts(type = "string")] + #[serde(with = "crate::wire::base64_bytes")] image: Vec, media_type: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, - /// A file (raw bytes + MIME type). + /// A file (upstream `FilePart`, `provider-utils` `types/content-part.ts:62-102`): + /// `data` is tagged `FileData` or a bare base64 string, URL string or + /// provider reference, and a `data:` URL's media type wins over + /// `media_type` when the prompt is converted. File { - data: Vec, + data: FilePartData, media_type: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] filename: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, - }, - - /// A file whose inline data is an already-base64-encoded string. - /// - /// The AI SDK `file` part with `data: { type: 'data', data: '' }`: - /// `data` holds the base64 string verbatim (it is not decoded), so it - /// round-trips through providers that emit a `base64` source unchanged. - FileBase64 { - data: String, - media_type: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - filename: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, - }, - - /// A file referenced by URL (the AI SDK `file` part with - /// `data: { type: 'url', url }`). - FileUrl { - url: String, - media_type: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, - }, - - /// A file referenced by a provider-specific id (the AI SDK `file` part - /// with `data: { type: 'reference', reference: { openai: 'file-xxx' } }`). - /// `reference` is a JSON object keyed by provider name. - FileReference { - media_type: String, - reference: Value, - #[serde(default, skip_serializing_if = "Option::is_none")] - filename: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -97,13 +76,18 @@ pub enum UserContentPart { /// FilePart | ReasoningPart | ReasoningFilePart | ToolCallPart | /// ToolResultPart | ToolApprovalRequest`. #[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" +)] #[cfg_attr(feature = "ts-export", ts(export))] pub enum AssistantContentPart { /// A text segment. Text { text: String, /// Provider-specific options for this part. + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -111,44 +95,22 @@ pub enum AssistantContentPart { /// Provider-specific custom content. Custom { kind: NamespacedKind, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, - /// A file (raw bytes + MIME type). + /// A file (upstream `FilePart`, `provider-utils` `types/content-part.ts:62-102`): + /// `data` is tagged `FileData` or a bare base64 string, URL string or + /// provider reference, and a `data:` URL's media type wins over + /// `media_type` when the prompt is converted. File { - data: Vec, - media_type: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - filename: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, - }, - - /// A file whose inline data is an already-base64-encoded string. - FileBase64 { - data: String, + data: FilePartData, media_type: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] filename: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, - }, - - /// A file referenced by URL. - FileUrl { - url: String, - media_type: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, - }, - - /// A file referenced by a provider-specific id. - FileReference { - media_type: String, - reference: Value, - #[serde(default, skip_serializing_if = "Option::is_none")] - filename: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -164,14 +126,17 @@ pub enum AssistantContentPart { /// 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, }, - /// A file generated during reasoning. + /// A file generated during reasoning (upstream `ReasoningFilePart`, + /// `types/content-part.ts:146-180`). ReasoningFile { - data: GeneratedFileData, + data: ReasoningFilePartData, media_type: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -185,10 +150,12 @@ pub enum AssistantContentPart { /// Whether the tool call is executed by the provider rather than by /// the client. 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, }, @@ -202,13 +169,21 @@ pub enum AssistantContentPart { 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, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + #[serde( + default, + deserialize_with = "crate::wire::present", + skip_serializing_if = "Option::is_none" + )] input_schema_input: Option, }, } @@ -218,7 +193,11 @@ pub enum AssistantContentPart { // the size gap between the two parts is not worth a box. #[allow(clippy::large_enum_variant)] #[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" +)] #[cfg_attr(feature = "ts-export", ts(export))] pub enum ToolContentPart { /// The result of a tool call. @@ -231,8 +210,10 @@ pub enum ToolContentPart { ToolApprovalResponse { approval_id: String, approved: bool, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] reason: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_executed: Option, }, @@ -246,6 +227,69 @@ impl UserContentPart { provider_options: None, } } + + /// An image part (no provider options). + pub fn image(image: Vec, media_type: impl Into) -> Self { + Self::Image { + image, + media_type: media_type.into(), + provider_options: None, + } + } + + /// A file part with inline bytes, written as + /// `data: { type: 'data', data: '' }` (no provider options, no + /// filename). + pub fn file(data: Vec, media_type: impl Into) -> Self { + Self::file_data( + FileData::Data { + data: FileBytes::Binary(data), + }, + media_type, + ) + } + + /// A file part whose inline data is an already-base64-encoded string, + /// passed on verbatim (no provider options, no filename). + pub fn file_base64(data: impl Into, media_type: impl Into) -> Self { + Self::file_data( + FileData::Data { + data: FileBytes::Base64(data.into()), + }, + media_type, + ) + } + + /// A file part referenced by URL, `data: { type: 'url', url }` (no + /// provider options). + pub fn file_url(url: impl Into, media_type: impl Into) -> Self { + Self::file_data( + FileData::Url { + url: url.into(), + original_url: None, + }, + media_type, + ) + } + + /// A file part referenced by a provider reference, + /// `data: { type: 'reference', reference: { [provider]: id } }` (no + /// provider options, no filename). + pub fn file_reference( + media_type: impl Into, + reference: SharedProviderReference, + ) -> Self { + Self::file_data(FileData::Reference { reference }, media_type) + } + + fn file_data(data: FileData, media_type: impl Into) -> Self { + Self::File { + data: FilePartData::Tagged(data), + media_type: media_type.into(), + filename: None, + provider_options: None, + } + } } impl AssistantContentPart { @@ -289,193 +333,68 @@ impl ToolContentPart { } } -/// The result of a tool call (upstream `ToolResultPart`). -/// -/// JSON: besides the upstream `output` object, the part keeps accepting the -/// earlier `result` form, an arbitrary JSON value read by -/// [`ToolResultOutput::from_result`] (with `is_error: true` the value is an -/// error). `output` holding a value that is not a [`ToolResultOutput`] object -/// is read the same way. A `text`, `json`, `error-text` or `error-json` -/// output without its own `provider_options` is written in the `result` -/// form; any other output is written as `output`. +/// The result of a tool call (upstream `ToolResultPart`, +/// `provider-utils` `types/content-part.ts:218-241`). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[serde(try_from = "ToolResultPartJson", into = "ToolResultPartJson")] -#[ts(as = "ToolResultPartJson")] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "ts-export", ts(export))] pub struct ToolResultPart { pub tool_call_id: String, /// The tool name. Required upstream; when absent here, the conversion /// takes it from the preceding tool call with the same id. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_name: Option, pub output: ToolResultOutput, /// Provider-specific options for this part (e.g. `anthropic.cacheControl`). - pub provider_options: Option, -} - -/// The JSON of a [`ToolResultPart`]: the upstream `output` or the earlier -/// `result` form. -#[derive(Serialize, Deserialize, TS)] -struct ToolResultPartJson { - tool_call_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - tool_name: Option, - /// A `ToolResultOutput`, or any other JSON value, read as `result` is. - #[serde( - default, - skip_serializing_if = "Option::is_none", - deserialize_with = "present" - )] - #[ts(optional, as = "Option", inline)] - output: Option, - /// Any JSON value: a string is a `text` output, anything else `json`. - #[serde( - default, - skip_serializing_if = "Option::is_none", - deserialize_with = "present" - )] - #[ts(optional, type = "JsonValue")] - result: Option, - /// With `result`: the value is an error, an `error-text` output. #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - is_error: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - provider_options: Option, -} - -/// The TypeScript type of [`ToolResultPartJson::output`]. -#[derive(TS)] -#[allow(dead_code)] -enum OutputJson { - #[ts(untagged)] - Output(ToolResultOutput), - #[ts(untagged)] - Value(Value), -} - -/// Keep an explicit `null` apart from a missing field. -fn present<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { - Value::deserialize(deserializer).map(Some) -} - -/// The `type` tags of [`ToolResultOutput`]. -const OUTPUT_TYPES: [&str; 6] = [ - "text", - "json", - "execution-denied", - "error-text", - "error-json", - "content", -]; - -impl TryFrom for ToolResultPart { - type Error = String; - - fn try_from(json: ToolResultPartJson) -> Result { - let is_typed = |value: &Value| { - value - .get("type") - .and_then(Value::as_str) - .is_some_and(|tag| OUTPUT_TYPES.contains(&tag)) - }; - let output = match (json.result, json.output) { - (Some(_), Some(_)) => { - return Err("a tool result has `output` or `result`, not both".into()); - } - (None, Some(output)) if is_typed(&output) => { - if json.is_error.is_some() { - return Err( - "`is_error` applies to a `result` value, not to a typed `output`".into(), - ); - } - serde_json::from_value(output) - .map_err(|error| format!("invalid tool result output: {error}"))? - } - (Some(value), None) | (None, Some(value)) => { - ToolResultOutput::from_result(value, json.is_error == Some(true)) - } - (None, None) => return Err("missing field `output`".into()), - }; - Ok(Self { - tool_call_id: json.tool_call_id, - tool_name: json.tool_name, - output, - provider_options: json.provider_options, - }) - } -} - -impl From for ToolResultPartJson { - fn from(part: ToolResultPart) -> Self { - let (result, is_error, output) = match part.output { - ToolResultOutput::Text { - value, - provider_options: None, - } => (Some(Value::String(value)), None, None), - ToolResultOutput::Json { - value, - provider_options: None, - } => (Some(value), None, None), - ToolResultOutput::ErrorText { - value, - provider_options: None, - } => (Some(Value::String(value)), Some(true), None), - ToolResultOutput::ErrorJson { - value, - provider_options: None, - } => (Some(value), Some(true), None), - output => ( - None, - None, - Some(serde_json::to_value(output).expect("a tool result output serializes")), - ), - }; - Self { - tool_call_id: part.tool_call_id, - tool_name: part.tool_name, - output, - result, - is_error, - provider_options: part.provider_options, - } - } + pub provider_options: Option, } /// The output of a tool call (upstream `ToolResultOutput`). #[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" +)] #[cfg_attr(feature = "ts-export", ts(export))] pub enum ToolResultOutput { /// Text for the model. Text { value: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, /// JSON for the model. Json { value: Value, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, /// The user denied the tool call. 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, }, /// An error message. ErrorText { value: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, /// An error as JSON. ErrorJson { value: Value, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -525,12 +444,17 @@ impl ToolResultOutput { /// conversion turns each into a `file` part with a deprecation warning, as /// upstream's `mapToolResultOutput` does. #[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" +)] #[cfg_attr(feature = "ts-export", ts(export))] pub enum ToolResultContentPart { /// Text. Text { text: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -538,8 +462,10 @@ pub enum ToolResultContentPart { File { data: crate::shared::FileData, 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, }, @@ -547,16 +473,20 @@ pub enum ToolResultContentPart { FileData { 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, }, /// A file URL. Deprecated: use `file` with `{ type: 'url' }` data. FileUrl { url: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] media_type: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -564,6 +494,7 @@ pub enum ToolResultContentPart { /// data. FileId { file_id: ToolResultFileId, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -571,6 +502,7 @@ pub enum ToolResultContentPart { /// `{ type: 'reference' }` data. FileReference { provider_reference: SharedProviderReference, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -578,12 +510,14 @@ pub enum ToolResultContentPart { ImageData { data: String, media_type: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, /// An image URL. Deprecated: use `file` with an `image` media type. ImageUrl { url: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -591,6 +525,7 @@ pub enum ToolResultContentPart { /// media type. ImageFileId { file_id: ToolResultFileId, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -598,11 +533,13 @@ pub enum ToolResultContentPart { /// media type. ImageFileReference { provider_reference: SharedProviderReference, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, /// Provider-specific content. Custom { + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -618,3 +555,127 @@ pub enum ToolResultFileId { Id(String), ByProvider(SharedProviderReference), } + +/// `FilePart.data` (`provider-utils` `types/content-part.ts:75`): +/// `FileData | DataContent | URL | ProviderReference`, in JSON a tagged +/// `FileData`, a string (base64 or a URL) or a bare `{ [provider]: id }`. +/// +/// A string is read as the AI SDK reads it when it converts the prompt +/// (`ai` `prompt/file-part-data.ts:125-131`): a URL when it parses as one +/// (a `data:` URL becomes inline data), base64 otherwise. +#[derive(Debug, Clone, PartialEq, Serialize, TS)] +#[serde(untagged)] +#[cfg_attr(feature = "ts-export", ts(export))] +pub enum FilePartData { + /// `{ type: 'data' | 'url' | 'reference' | 'text', ... }`. + Tagged(FileData), + /// A base64 string or a URL string. + String(String), + /// A bare provider reference. + Reference(SharedProviderReference), +} + +/// `ReasoningFilePart.data` (`types/content-part.ts:164`): +/// `FileDataData | FileDataUrl | DataContent | URL`, in JSON tagged data or a +/// URL, or a string (base64 or a URL). +#[derive(Debug, Clone, PartialEq, Serialize, TS)] +#[serde(untagged)] +#[cfg_attr(feature = "ts-export", ts(export))] +pub enum ReasoningFilePartData { + /// `{ type: 'data' | 'url', ... }`. + Tagged(GeneratedFileData), + /// A base64 string or a URL string. + String(String), +} + +// ── Deserialize by JSON kind ──────────────────────────────────────────────── +// +// The two file data types serialize untagged, but are read by JSON kind: an +// untagged `Deserialize` reports only "data did not match any variant", which +// hides the tagged data's own error (RFC-0042 §7.2, +// `rfc/0042-v4-type-boundaries.md:211`), and it read an object with an +// unknown `type` as a provider reference whose provider is "type". + +/// An object with a `type` key is tagged file data and never a provider +/// reference, as the AI SDK decides (`ai@7.0.127` +/// `src/prompt/file-part-data.ts:14-21` `isTaggedFileData`; +/// `@ai-sdk/provider-utils@5.0.55` `src/is-provider-reference.ts:9-21`, which +/// requires `!('type' in data)`), so an unknown `type` is a decode error +/// naming it. A string is the string form; any other object is a reference. +impl<'de> Deserialize<'de> for FilePartData { + fn deserialize>(deserializer: D) -> Result { + struct KindVisitor; + + impl<'de> serde::de::Visitor<'de> for KindVisitor { + type Value = FilePartData; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str( + "file data: a tagged object, a base64 or URL string, or a provider reference", + ) + } + + fn visit_str(self, value: &str) -> Result { + Ok(FilePartData::String(value.to_owned())) + } + + fn visit_string(self, value: String) -> Result { + Ok(FilePartData::String(value)) + } + + fn visit_map>( + self, + map: A, + ) -> Result { + let object = serde_json::Map::deserialize( + serde::de::value::MapAccessDeserializer::new(map), + )?; + let tagged = object.contains_key("type"); + let object = Value::Object(object); + if tagged { + FileData::deserialize(object).map(FilePartData::Tagged) + } else { + SharedProviderReference::deserialize(object).map(FilePartData::Reference) + } + .map_err(serde::de::Error::custom) + } + } + + deserializer.deserialize_any(KindVisitor) + } +} + +/// A string is the string form, an object is tagged generated-file data with +/// its own error. +impl<'de> Deserialize<'de> for ReasoningFilePartData { + fn deserialize>(deserializer: D) -> Result { + struct KindVisitor; + + impl<'de> serde::de::Visitor<'de> for KindVisitor { + type Value = ReasoningFilePartData; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .write_str("reasoning file data: a tagged object or a base64 or URL string") + } + + fn visit_str(self, value: &str) -> Result { + Ok(ReasoningFilePartData::String(value.to_owned())) + } + + fn visit_string(self, value: String) -> Result { + Ok(ReasoningFilePartData::String(value)) + } + + fn visit_map>( + self, + map: A, + ) -> Result { + GeneratedFileData::deserialize(serde::de::value::MapAccessDeserializer::new(map)) + .map(ReasoningFilePartData::Tagged) + } + } + + deserializer.deserialize_any(KindVisitor) + } +} diff --git a/aimux-core/src/decision_model.rs b/aimux-core/src/decision_model.rs index 03aee3cf6..3c50c9418 100644 --- a/aimux-core/src/decision_model.rs +++ b/aimux-core/src/decision_model.rs @@ -165,6 +165,7 @@ pub type DecisionQuestions = IndexMap; /// (`DecisionModelV4CallOptions`, /// `@ai-sdk/provider@4.0.22 src/decision-model/v4/decision-model-v4-call-options.ts:7-14`). #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "ts-export", ts(export))] pub struct DecisionCallOptions { /// One shared state, even when the value is an array. @@ -229,8 +230,9 @@ pub enum DecisionAnswer { /// Decimal places the provider rounded its output to; omit for full /// precision. Validation allows half a unit in the last place per rounded -/// value. +/// value (`decision-model-v4-result.ts:39-42`). #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "ts-export", ts(export))] pub struct DecisionRounding { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -241,8 +243,10 @@ pub struct DecisionRounding { pub score_decimals: Option, } -/// Token usage reported by a decision model. +/// Token usage reported by a decision model +/// (`decision-model-v4-result.ts:43-46`). #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "ts-export", ts(export))] pub struct DecisionUsage { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -256,6 +260,7 @@ pub struct DecisionUsage { /// The result of [`DecisionModel::do_decide`] (`DecisionModelV4Result`, /// `@ai-sdk/provider@4.0.22 src/decision-model/v4/decision-model-v4-result.ts:31-56`). #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "ts-export", ts(export))] pub struct DecisionResult { /// Exactly one answer per question, under the original question ids. @@ -304,6 +309,19 @@ pub trait DecisionModel: Send + Sync { async fn do_decide(&self, options: &DecisionCallOptions) -> Result; } +/// A model's `DecisionModelV4` properties as JSON, as the AI SDK names them +/// (`decision-model-v4.ts:7-11`): `{specificationVersion, provider, modelId, +/// supportedQuestionTypes}`. The bindings' `describe` returns this. +#[must_use] +pub fn decision_model_properties(model: &dyn DecisionModel) -> Value { + serde_json::json!({ + "specificationVersion": model.specification_version(), + "provider": model.provider(), + "modelId": model.model_id(), + "supportedQuestionTypes": model.supported_question_types(), + }) +} + /// The AI SDK's `DecisionUnsupportedQuestionTypeError` /// (`@ai-sdk/provider@4.0.22 src/errors/decision-unsupported-question-type-error.ts:10-37`), /// with its message. The error taxonomy has no variant for it (one would @@ -321,8 +339,10 @@ pub fn unsupported_question_type_error( )) } -/// Options of [`decide`]: the call options plus the operation settings. +/// Options of [`decide`]: the call options plus the operation settings +/// (`ai@7.0.128 src/decide/decide.ts:53-61`). #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "ts-export", ts(export))] pub struct DecideOptions { pub state: DecisionInput, @@ -363,18 +383,28 @@ impl DecideOptions { } } -/// Token usage of a decision, with the total when both counts are known. +/// Token usage of a decision, with the total when both counts are known +/// (`ai@7.0.128 src/decide/decision-result.ts:27-31`): an unknown count is +/// `undefined` there, so it is omitted. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "ts-export", ts(export))] pub struct DecideUsage { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub input_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub output_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub total_tokens: Option, } /// The result of [`decide`] (`DecisionResult` of /// `ai@7.0.128 src/decide/decision-result.ts:22-39`). #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "ts-export", ts(export))] pub struct DecideResult { #[ts(as = "std::collections::HashMap")] @@ -674,7 +704,7 @@ pub async fn decide( let mut response = result.response.unwrap_or_default(); response .timestamp - .get_or_insert_with(crate::util::rfc3339_now); + .get_or_insert_with(crate::timestamp::Timestamp::now); response .model_id .get_or_insert_with(|| model.model_id().to_owned()); diff --git a/aimux-core/src/decision_replay.rs b/aimux-core/src/decision_replay.rs index 01e45b551..7f1a64dcb 100644 --- a/aimux-core/src/decision_replay.rs +++ b/aimux-core/src/decision_replay.rs @@ -22,7 +22,7 @@ fn key(options: &Value) -> Value { "state": options["state"], "questions": options["questions"], "headers": options.get("headers").cloned().unwrap_or(Value::Null), - "provider_options": options.get("provider_options").cloned().unwrap_or(Value::Null), + "providerOptions": options.get("providerOptions").cloned().unwrap_or(Value::Null), }) } @@ -192,15 +192,28 @@ pub async fn replay_decision_with_model( "decision replay requires a decision recording".into(), )); } - let options: DecideOptions = - serde_json::from_value(recording.input.options.clone()).map_err(|e| { - if recording.provider.provider_id.is_empty() { - AiMuxError::JsonParse(format!( - "decision replay: the recording was {OLDER_RECORDING}: {e}" - )) - } else { - AiMuxError::JsonParse(e.to_string()) - } - })?; + let unread = crate::replay::snake_case_option_keys(&recording.input.options); + if !unread.is_empty() { + let older = if recording.provider.provider_id.is_empty() { + format!("the recording was {OLDER_RECORDING}: ") + } else { + String::new() + }; + return Err(AiMuxError::JsonParse(format!( + "decision replay: {older}this version reads the options camelCase and would \ + ignore {}", + unread.join(", ") + ))); + } + let options = crate::replay::readable_options(&recording.input.options); + let options: DecideOptions = serde_json::from_value(options).map_err(|e| { + if recording.provider.provider_id.is_empty() { + AiMuxError::JsonParse(format!( + "decision replay: the recording was {OLDER_RECORDING}: {e}" + )) + } else { + AiMuxError::JsonParse(e.to_string()) + } + })?; decide(model, options).await } diff --git a/aimux-core/src/embedding_model.rs b/aimux-core/src/embedding_model.rs index 1bb94a110..165bf90ed 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", deny_unknown_fields)] #[cfg_attr(feature = "ts-export", ts(export))] pub struct EmbeddingCallOptions { /// List of text values to generate embeddings for. @@ -37,15 +38,23 @@ pub struct EmbeddingCallOptions { /// Maximum number of retries, the AI SDK's `maxRetries`; `Some(0)` disables /// retrying. `None` uses 2, the AI SDK 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, } @@ -65,6 +74,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")] #[cfg_attr(feature = "ts-export", ts(export))] pub struct EmbeddingUsage { /// Number of input tokens consumed. @@ -75,18 +85,25 @@ pub struct EmbeddingUsage { /// /// Aligned with V4 `EmbeddingModelV4Result`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "ts-export", 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. @@ -95,11 +112,20 @@ pub struct EmbeddingResult { /// Debugging response info specific to embedding calls. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "ts-export", 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, + deserialize_with = "crate::wire::present", + skip_serializing_if = "Option::is_none" + )] pub body: Option, } diff --git a/aimux-core/src/error.rs b/aimux-core/src/error.rs index 3f39b671f..7b1f551f7 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")] #[cfg_attr(feature = "ts-export", ts(export))] pub struct ApiCallError { /// Sanitized request URL. Required for every API-derived failure. @@ -42,12 +43,14 @@ pub struct ApiCallError { /// stream errors do (OpenAI infers one from the frame's `code`/`type`, /// Anthropic from its error type); 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. @@ -56,13 +59,20 @@ 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, + deserialize_with = "crate::wire::present", + 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 @@ -106,6 +116,7 @@ pub enum RetryErrorReason { /// Complete error history for a retried model operation. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "ts-export", ts(export))] pub struct RetryError { pub reason: RetryErrorReason, @@ -186,6 +197,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)] +#[serde(from = "AiMuxErrorWire", into = "AiMuxErrorWire")] +#[ts(as = "AiMuxErrorWire")] #[cfg_attr(feature = "ts-export", ts(export))] pub enum AiMuxError { #[error("API call error: {0}")] @@ -217,10 +230,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, }, @@ -313,7 +328,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, @@ -331,6 +350,202 @@ 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 = "AI_MissingToolResultsError")] + MissingToolResults { tool_call_ids: Vec }, + #[serde(rename = "AI_ToolChoiceViolationError")] + ToolChoiceViolation(Box), + #[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, ToolChoiceViolation; + 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 }, + MissingToolResults { tool_call_ids } +} + +/// `". 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 missing_tool_results_message(tool_call_ids: &[String]) -> String { let many = tool_call_ids.len() > 1; format!( @@ -344,6 +559,7 @@ fn missing_tool_results_message(tool_call_ids: &[String]) -> String { /// What [`AiMuxError::ToolChoiceViolation`] carries: the field set of the AI /// SDK's `ToolChoiceViolationError` (`ai` `error/tool-choice-violation-error.ts`). #[derive(Debug, Clone, Serialize, Deserialize, TS, Error)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "ts-export", ts(export))] #[error("{}", tool_choice_violation_message(.tool_choice))] pub struct ToolChoiceViolationError { @@ -517,6 +733,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/files_model.rs b/aimux-core/src/files_model.rs index 10c041ae6..74d4ffcd1 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" +)] #[cfg_attr(feature = "ts-export", 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", deny_unknown_fields)] #[cfg_attr(feature = "ts-export", 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")] #[cfg_attr(feature = "ts-export", 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 043fd8341..1534fc649 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -34,9 +34,9 @@ use crate::options::{CallOptions, ToolChoice, prepare_tool_choice}; use crate::output::Output; use crate::parse_tool_call::{ToolCallRepair, parse_tool_call}; use crate::result::{ - ContentPart, GenerateContent, GenerateResult, GeneratedFile, ReasoningFileOutput, - ReasoningPart, Source, StreamResult, ToolApprovalRequestOutput, TypedToolError, - TypedToolResult, + ContentPart, GenerateContent, GenerateResult, GeneratedFile, GeneratedFileOutput, + LanguageModelFile, ReasoningPart, Source, StreamResult, ToolApprovalRequestOutput, + TypedToolError, TypedToolResult, }; use crate::shared::{RequestInfo, SharedHeaders, SharedProviderOptions}; use crate::step_result::{ @@ -64,7 +64,7 @@ fn response_metadata_defaults( }); metadata .timestamp - .get_or_insert_with(crate::util::rfc3339_now); + .get_or_insert_with(crate::timestamp::Timestamp::now); metadata.model_id.get_or_insert_with(|| model_id.to_owned()); metadata } @@ -87,14 +87,22 @@ fn is_output_chunk(part: &StreamPart) -> bool { } } +/// The text-generation file part for a provider file: its URL is downloaded, +/// the file is `{ base64, mediaType }`, and the part keeps the provider +/// metadata (`convert-language-model-content.ts:55-70`, +/// `stream-language-model-call.ts:569-590`). async fn resolve_file( - mut file: GeneratedFile, + file: LanguageModelFile, abort_signal: Option<&AbortSignal>, -) -> Result { - file.data = crate::shared::GeneratedFileData::Data { - data: crate::download::resolve_generated_file_data(&file.data, abort_signal).await?, - }; - Ok(file) +) -> Result { + Ok(GeneratedFileOutput { + file: GeneratedFile { + data: crate::download::resolve_generated_file_data(&file.data, abort_signal).await?, + media_type: file.media_type, + provider_metadata: None, + }, + provider_metadata: file.provider_metadata, + }) } // ───────────────────────────────────────────────────────────────────────────── @@ -105,42 +113,81 @@ async fn resolve_file( /// /// Unlike `CallOptions` (provider-facing), this does not include `prompt` /// (passed separately) and defaults are more ergonomic. +/// +/// Unknown keys are rejected (RFC-0042 §7.2): a host still writing aimux +/// 0.5's snake_case keys (`max_retries`, `timeout.total_ms`) gets an error +/// naming the key instead of the default it would silently fall back to. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "ts-export", 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, /// The structured output to generate, the AI SDK's `output` /// (`generate-text/output.ts`). It sets the model call's /// `response_format` and parses the final text into /// [`GenerateTextResult::output`]. `None` sends no response format and /// takes the text as the output. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub output: 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, /// Maximum number of retries, the AI SDK's `maxRetries`; `Some(0)` disables /// retrying. `None` uses 2, the AI SDK default. + #[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. /// @@ -150,6 +197,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. @@ -513,6 +562,7 @@ impl GenerateTextResult { /// `finalStep` and the static/dynamic subsets are left out: they are the /// last entry of `steps` and a filter on `dynamic`. #[derive(Serialize, TS)] +#[serde(rename_all = "camelCase")] #[ts(rename = "GenerateTextResult")] #[cfg_attr(feature = "ts-export", ts(export))] struct GenerateTextResultSnapshot<'a> { @@ -521,12 +571,25 @@ struct GenerateTextResultSnapshot<'a> { /// Deprecated upstream: the last step's reasoning. reasoning: Vec, /// Deprecated upstream: the last step's reasoning text. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] reasoning_text: Option, files: Vec<&'a GeneratedFile>, + /// Each with its `type` tag (`generate-text-result.ts:73`). + #[serde(with = "crate::wire::typed_vec")] + #[ts(as = "Vec")] sources: Vec<&'a Source>, + /// Each with its `type` tag (`generate-text-result.ts:78`). + #[serde(with = "crate::wire::typed_vec")] + #[ts(as = "Vec")] tool_calls: Vec<&'a ToolCall>, + /// Each with its `type` tag (`generate-text-result.ts:93`). + #[serde(with = "crate::wire::typed_vec")] + #[ts(as = "Vec")] tool_results: Vec<&'a TypedToolResult>, finish_reason: FinishReasonUnified, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] raw_finish_reason: Option<&'a str>, usage: &'a LanguageModelUsage, /// Deprecated upstream: equal to `usage`. @@ -538,6 +601,8 @@ struct GenerateTextResultSnapshot<'a> { response: &'a StepResponse, response_messages: Vec<&'a ModelMessage>, /// Deprecated upstream: the last step's provider metadata. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] provider_metadata: Option<&'a ProviderMetadata>, steps: &'a [StepResult], /// Absent when the output was not parsed. @@ -554,6 +619,7 @@ impl Serialize for GenerateTextResult { /// The values a serialized [`GenerateTextResult`] is read back from. #[derive(Deserialize)] +#[serde(rename_all = "camelCase")] struct GenerateTextResultFacts { steps: Vec, total_usage: LanguageModelUsage, @@ -615,12 +681,14 @@ fn stream_output(step: &StepResult, output: Option<&Output>) -> OutputState { /// Result of the deprecated [`generate_object`]: the AI SDK's /// `GenerateObjectResult` (`generate-object/generate-object-result.ts`). #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "ts-export", ts(export))] pub struct GenerateObjectResult { /// The generated object, as parsed by the output. pub object: Value, /// The reasoning text, `None` when there is none. - #[serde(default)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning: Option, /// Why the model call finished. pub finish_reason: FinishReasonUnified, @@ -635,21 +703,25 @@ pub struct GenerateObjectResult { /// The response of the model call, without messages. pub response: GenerateObjectResponse, /// Provider metadata of the model call. - #[serde(default)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_metadata: Option, } /// `GenerateObjectResult.response`: the AI SDK's /// `Omit`. -#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "ts-export", ts(export))] pub struct GenerateObjectResponse { pub id: String, - pub timestamp: String, + pub timestamp: crate::timestamp::Timestamp, pub model_id: String, - #[serde(default)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option, - #[serde(default)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub body: Option, } @@ -775,8 +847,8 @@ impl StreamTextResult { TextStreamPart::ToolError(error) => rm.tool_error(error), TextStreamPart::ToolApprovalResponse(response) => rm.approval_response(response), TextStreamPart::Source(source) => rm.source(source), - TextStreamPart::File(file) => rm.file(&file, false), - TextStreamPart::ReasoningFile(output) => rm.file(&output.file, true), + TextStreamPart::File(output) => rm.file(output, false), + TextStreamPart::ReasoningFile(output) => rm.file(output, true), TextStreamPart::Custom { kind, provider_metadata, @@ -1252,22 +1324,22 @@ async fn convert_generated_content( GenerateContent::Reasoning(reasoning) => rm.reasoning(reasoning), GenerateContent::Source(source) => rm.source(source.clone()), GenerateContent::File(file) => { - let file = timeout::run( + let output = timeout::run( resolve_file(file.clone(), abort_signal), abort_signal, operation_timeout, ) .await?; - rm.file(&file, false); + rm.file(output, false); } GenerateContent::ReasoningFile(file) => { - let file = timeout::run( + let output = timeout::run( resolve_file(file.clone(), abort_signal), abort_signal, operation_timeout, ) .await?; - rm.file(&file, true); + rm.file(output, true); } GenerateContent::Custom { kind, @@ -1366,7 +1438,9 @@ fn build_generated_step( request: request.unwrap_or_default(), response: StepResponse { id: metadata.id.unwrap_or_default(), - timestamp: metadata.timestamp.unwrap_or_default(), + timestamp: metadata + .timestamp + .unwrap_or_else(crate::timestamp::Timestamp::now), model_id: metadata.model_id.unwrap_or_default(), headers: response.headers, body: response.body, @@ -1442,7 +1516,7 @@ pub async fn generate_object( request: step.request.clone(), response: GenerateObjectResponse { id: step.response.id.clone(), - timestamp: step.response.timestamp.clone(), + timestamp: step.response.timestamp, model_id: step.response.model_id.clone(), headers: step.response.headers.clone(), body: step.response.body.clone(), @@ -1784,6 +1858,12 @@ async fn stream_in_operation( yield Ok(TextStreamPart::ToolError(error)); } } + StreamPartPayload::File(file) => { + match timeout::run(resolve_file(file, file_abort_signal.as_ref()), file_abort_signal.as_ref(), operation_timeout).await { + Ok(output) => yield Ok(TextStreamPart::File(output)), + Err(error) => yield Ok(TextStreamPart::Error { error }), + } + } StreamPartPayload::ToolApprovalRequest(approval) => { if let Some(call) = tool_calls.get(&approval.tool_call_id) { yield Ok(TextStreamPart::ToolApprovalRequest(ToolApprovalRequestOutput { @@ -1798,9 +1878,7 @@ async fn stream_in_operation( } StreamPartPayload::ReasoningFile(file) => { match timeout::run(resolve_file(file, file_abort_signal.as_ref()), file_abort_signal.as_ref(), operation_timeout).await { - Ok(file) => yield Ok(TextStreamPart::ReasoningFile(ReasoningFileOutput { - provider_metadata: file.provider_metadata.clone(), file, - })), + Ok(output) => yield Ok(TextStreamPart::ReasoningFile(output)), Err(error) => yield Ok(TextStreamPart::Error { error }), } } @@ -1837,19 +1915,13 @@ async fn stream_in_operation( let input = tool_calls.get(&result.tool_call_id).map(|call| call.input.clone()); yield Ok(provider_tool_result_part(result, input)); } - StreamPart::File(file) => { - match timeout::run(resolve_file(file, file_abort_signal.as_ref()), file_abort_signal.as_ref(), operation_timeout).await { - Ok(file) => yield Ok(TextStreamPart::File(file)), - Err(error) => yield Ok(TextStreamPart::Error { error }), - } - } // An empty text delta without provider metadata is not // forwarded (stream-text.ts:2881-2891); providers emit // one, e.g. OpenAI chat's opening `"content": ""`. StreamPart::TextDelta { delta, provider_metadata: None, .. } if delta.is_empty() => {} part => { if let Some(part) = part - .widen::() + .widen::() .into_text_stream_part() { yield Ok(part); @@ -1997,7 +2069,10 @@ impl StreamStep { TextStreamPart::FinishStep { response: StepResponseMetadata { id: self.response.id.unwrap_or_default(), - timestamp: self.response.timestamp.unwrap_or_default(), + timestamp: self + .response + .timestamp + .unwrap_or_else(crate::timestamp::Timestamp::now), model_id: self.response.model_id.unwrap_or_default(), headers, }, diff --git a/aimux-core/src/generate/pipeline_tests.rs b/aimux-core/src/generate/pipeline_tests.rs index 7d265d87d..7141591f6 100644 --- a/aimux-core/src/generate/pipeline_tests.rs +++ b/aimux-core/src/generate/pipeline_tests.rs @@ -338,7 +338,7 @@ async fn the_finish_part_ends_the_stream() { .collect(); assert_eq!( kinds, - ["Start", "StartStep", "TextDelta", "FinishStep", "Finish"] + ["start", "start-step", "text-delta", "finish-step", "finish"] ); } @@ -356,7 +356,7 @@ async fn a_provider_error_part_does_not_hide_the_finish() { .collect(); assert_eq!( kinds, - ["Start", "StartStep", "Error", "FinishStep", "Finish"] + ["start", "start-step", "error", "finish-step", "finish"] ); let Some(Ok(TextStreamPart::Finish { finish_reason, .. })) = parts.last() else { unreachable!() diff --git a/aimux-core/src/generate/tests.rs b/aimux-core/src/generate/tests.rs index 8fb143292..0f545f248 100644 --- a/aimux-core/src/generate/tests.rs +++ b/aimux-core/src/generate/tests.rs @@ -122,7 +122,7 @@ pub(super) fn text_stream( Ok(StreamPart::ResponseMetadata( crate::types::ResponseMetadata { id: Some("resp-1".into()), - timestamp: Some("2026-01-01T00:00:00.000Z".into()), + timestamp: Some("2026-01-01T00:00:00.000Z".parse().unwrap()), model_id: Some("mock-model-2026".into()), }, )), @@ -159,11 +159,10 @@ pub(super) async fn collect(result: StreamTextResult) -> Vec { } pub(super) fn kind(part: &TextStreamPart) -> String { - let value = serde_json::to_value(part).unwrap(); - value - .as_object() - .and_then(|object| object.keys().next().cloned()) - .unwrap_or_else(|| value.as_str().unwrap_or_default().to_string()) + serde_json::to_value(part).unwrap()["type"] + .as_str() + .expect("a stream part is tagged by `type`") + .to_owned() } fn generated(content: Vec, reason: FinishReasonUnified) -> GenerateResult { @@ -217,14 +216,14 @@ async fn the_stream_is_framed_as_the_ai_sdk_full_stream() { assert_eq!( kinds, [ - "Start", - "StartStep", - "TextStart", - "TextDelta", - "TextDelta", - "TextEnd", - "FinishStep", - "Finish" + "start", + "start-step", + "text-start", + "text-delta", + "text-delta", + "text-end", + "finish-step", + "finish" ] ); @@ -234,12 +233,12 @@ async fn the_stream_is_framed_as_the_ai_sdk_full_stream() { assert_eq!(request.body, Some(json!({ "model": "mock-model" }))); assert_eq!( serde_json::to_value(warnings).unwrap(), - json!([{ "Other": { "message": "careful" } }]) + json!([{ "type": "other", "message": "careful" }]) ); assert_eq!( serde_json::to_value(&parts[3]).unwrap(), - json!({ "TextDelta": { "id": "t1", "text": "Hel" } }) + json!({ "type": "text-delta", "id": "t1", "text": "Hel" }) ); let TextStreamPart::FinishStep { @@ -255,7 +254,7 @@ async fn the_stream_is_framed_as_the_ai_sdk_full_stream() { }; assert_eq!(response.id, "resp-1"); assert_eq!(response.model_id, "mock-model-2026"); - assert_eq!(response.timestamp, "2026-01-01T00:00:00.000Z"); + assert_eq!(response.timestamp.to_string(), "2026-01-01T00:00:00.000Z"); assert_eq!( response.headers.as_ref().unwrap()["x-request-id"], "req-1".to_string() @@ -298,7 +297,7 @@ async fn the_stream_is_framed_as_the_ai_sdk_full_stream() { assert_eq!(total_usage.total_tokens, Some(16)); assert!(total_usage.raw.is_none()); assert_eq!( - serde_json::to_value(&parts[7]).unwrap()["Finish"]["finish_reason"], + serde_json::to_value(&parts[7]).unwrap()["finishReason"], json!("stop") ); } @@ -372,7 +371,8 @@ async fn generate_text_derives_its_result_from_the_step() { let step = result.final_step(); assert_eq!(step.response.id, "resp-1"); assert_eq!(step.response.model_id, "mock-model"); - assert!(!step.response.timestamp.is_empty()); + // The call layer stamps a response without a timestamp. + assert!(step.response.timestamp.unix_millis() > 0); assert_eq!(step.response.body, Some(json!({ "id": "resp-1" }))); assert_eq!(step.performance.time_to_first_output_ms, None); assert_eq!(step.performance.output_tokens_per_second, None); @@ -406,8 +406,9 @@ async fn generate_text_derives_its_result_from_the_step() { /// The serialized result writes the derived values next to the stored ones /// and reads back only `steps`, `total_usage` and `output`. The finish reason -/// is a string and the usage is the flat `LanguageModelUsage` -/// (usage.ts:10-68). +/// is a string and the usage is the flat `LanguageModelUsage` (usage.ts:10-67), +/// camelCase with an unknown count absent; a step is its own fields +/// (step-result.ts:138-278), camelCase. #[tokio::test] async fn the_result_snapshot_round_trips() { let model = ScriptedModel::generating(generated( @@ -419,16 +420,25 @@ async fn the_result_snapshot_round_trips() { .unwrap(); let wire = serde_json::to_value(&result).unwrap(); assert_eq!(wire["text"], json!("Hello")); - assert_eq!(wire["finish_reason"], json!("stop")); - assert_eq!(wire["raw_finish_reason"], json!("stop")); - assert_eq!(wire["usage"]["input_tokens"], json!(10)); + assert_eq!(wire["finishReason"], json!("stop")); + assert_eq!(wire["rawFinishReason"], json!("stop")); + assert_eq!(wire["usage"]["inputTokens"], json!(10)); assert_eq!( - wire["usage"]["input_token_details"], - json!({ "no_cache_tokens": 6, "cache_read_tokens": 4, "cache_write_tokens": null }) + wire["usage"]["inputTokenDetails"], + json!({ "noCacheTokens": 6, "cacheReadTokens": 4 }) ); - assert_eq!(wire["usage"]["total_tokens"], json!(16)); + assert_eq!(wire["usage"]["totalTokens"], json!(16)); assert_eq!(wire["output"], json!("Hello")); assert_eq!(wire["steps"].as_array().unwrap().len(), 1); + let step = &wire["steps"][0]; + assert_eq!(step["stepNumber"], json!(0)); + assert_eq!(step["finishReason"], json!("stop")); + assert_eq!(step["rawFinishReason"], json!("stop")); + assert!(step["callId"].is_string()); + assert!(step["model"]["modelId"].is_string()); + assert!(step["response"]["modelId"].is_string()); + assert!(step["performance"]["responseTimeMs"].is_number()); + assert!(step["performance"].get("timeToFirstOutputMs").is_none()); assert!(wire.get("raw").is_none()); let back: GenerateTextResult = serde_json::from_value(wire.clone()).unwrap(); @@ -555,7 +565,7 @@ async fn an_aborted_stream_ends_with_an_abort_part() { assert_eq!(reason.as_deref(), Some("request aborted")); } } - assert_eq!(kinds, ["Start", "StartStep", "TextDelta", "Abort"]); + assert_eq!(kinds, ["start", "start-step", "text-delta", "abort"]); let mut model = ScriptedModel::streaming(vec![Ok(StreamPart::StreamStart { warnings: Vec::new(), @@ -594,7 +604,7 @@ async fn an_incomplete_stream_without_output_is_an_error() { ) .await; let kinds: Vec = parts.iter().map(kind).collect(); - assert_eq!(kinds, ["Start", "Error"]); + assert_eq!(kinds, ["start", "error"]); assert!(matches!( &parts[1], TextStreamPart::Error { error: AiMuxError::NoOutputGenerated(message) } @@ -615,7 +625,7 @@ async fn an_incomplete_stream_without_output_is_an_error() { let kinds: Vec = parts.iter().map(kind).collect(); assert_eq!( kinds, - ["Start", "StartStep", "TextDelta", "FinishStep", "Finish"] + ["start", "start-step", "text-delta", "finish-step", "finish"] ); let TextStreamPart::FinishStep { finish_reason, @@ -825,51 +835,54 @@ async fn stream_tool_results_and_errors_follow_the_ai_sdk() { assert_eq!( kinds, [ - "Start", - "StartStep", - "TextStart", - "TextDelta", - "TextEnd", - "ToolCall", - "ToolResult", - "ToolCall", - "ToolError", - "ToolResult", - "FinishStep", - "Finish" + "start", + "start-step", + "text-start", + "text-delta", + "text-end", + "tool-call", + "tool-result", + "tool-call", + "tool-error", + "tool-result", + "finish-step", + "finish" ] ); assert_eq!( serde_json::to_value(&parts[6]).unwrap(), - json!({ "ToolResult": { - "tool_call_id": "srv-1", - "tool_name": "web_search", + json!({ + "type": "tool-result", + "toolCallId": "srv-1", + "toolName": "web_search", "input": { "q": "rust" }, "output": [{ "url": "https://example.com" }], - "provider_executed": true, + "providerExecuted": true, "dynamic": true, - } }) + }) ); assert_eq!( serde_json::to_value(&parts[8]).unwrap(), - json!({ "ToolError": { - "tool_call_id": "srv-2", - "tool_name": "code_execution", + json!({ + "type": "tool-error", + "toolCallId": "srv-2", + "toolName": "code_execution", "input": { "code": "1/0" }, "error": { "error_code": "unavailable" }, - "provider_executed": true, + "providerExecuted": true, "dynamic": true, - } }) + }) ); assert_eq!( serde_json::to_value(&parts[9]).unwrap(), - json!({ "ToolResult": { - "tool_call_id": "srv-0", - "tool_name": "code_execution", + json!({ + "type": "tool-result", + "toolCallId": "srv-0", + "toolName": "code_execution", "output": { "stdout": "2" }, - "provider_executed": true, + "providerExecuted": true, "dynamic": true, - } }) + }) ); } @@ -939,16 +952,16 @@ async fn an_invalid_tool_call_is_followed_by_a_tool_error() { assert_eq!( kinds, [ - "Start", - "StartStep", - "TextStart", - "TextDelta", - "TextEnd", - "ToolCall", - "ToolError", - "ToolCall", - "FinishStep", - "Finish" + "start", + "start-step", + "text-start", + "text-delta", + "text-end", + "tool-call", + "tool-error", + "tool-call", + "finish-step", + "finish" ] ); let TextStreamPart::ToolCall(invalid) = &parts[5] else { @@ -1067,22 +1080,6 @@ const UPSTREAM_STREAM_PART_TYPES: [&str; 26] = [ "raw", ]; -/// `TextStream` → `text-stream`. -fn kebab(name: &str) -> String { - let mut out = String::new(); - for (index, c) in name.chars().enumerate() { - if c.is_ascii_uppercase() { - if index > 0 { - out.push('-'); - } - out.push(c.to_ascii_lowercase()); - } else { - out.push(c); - } - } - out -} - /// NP2-5: `TextStreamPart` has exactly the AI SDK's 26 part types, in the /// union's order (stream-text-result.ts:581-607). The variant tags are read /// from the exported TypeScript type. @@ -1090,12 +1087,10 @@ fn kebab(name: &str) -> String { fn the_stream_part_set_is_the_ai_sdks() { let ts = ::inline(&ts_rs::Config::default()); let tags: Vec = ts - .match_indices("{ \"") - .filter_map(|(index, _)| { - let rest = &ts[index + 3..]; - let tag = &rest[..rest.find('"')?]; - tag.starts_with(|c: char| c.is_ascii_uppercase()) - .then(|| kebab(tag)) + .match_indices("{ \"type\": \"") + .filter_map(|(index, tag)| { + let rest = &ts[index + tag.len()..]; + Some(rest[..rest.find('"')?].to_owned()) }) .collect(); assert_eq!(tags, UPSTREAM_STREAM_PART_TYPES); @@ -1356,8 +1351,8 @@ impl LanguageModel for UrlModel { fn url_prompt() -> Vec { serde_json::from_value(json!([ { "role": "user", "content": [ - { "type": "file_url", "url": "https://model.test/a.png", "media_type": "image/png" }, - { "type": "file_url", "url": "https://cdn.test/b.png", "media_type": "image/png" } + { "type": "file", "data": "https://model.test/a.png", "mediaType": "image/png" }, + { "type": "file", "data": "https://cdn.test/b.png", "mediaType": "image/png" } ] } ])) .unwrap() @@ -1478,7 +1473,7 @@ async fn the_default_prompt_download_is_guarded() { }; let prompt: Vec = serde_json::from_value(json!([ { "role": "user", "content": [ - { "type": "file_url", "url": "http://127.0.0.1:9/a.png", "media_type": "image/png" } + { "type": "file", "data": "http://127.0.0.1:9/a.png", "mediaType": "image/png" } ] } ])) .unwrap(); @@ -1569,7 +1564,7 @@ async fn a_repeated_tool_call_id_resolves_to_the_last_call_in_the_stream() { .find(|part| matches!(part, TextStreamPart::ToolResult(_))) .expect("the provider result"); assert_eq!( - serde_json::to_value(result).unwrap()["ToolResult"]["input"], + serde_json::to_value(result).unwrap()["input"], json!({ "q": "second" }) ); } diff --git a/aimux-core/src/image_model.rs b/aimux-core/src/image_model.rs index b2af51a1b..5202e880b 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" +)] #[cfg_attr(feature = "ts-export", ts(export))] pub enum ImageFile { /// Inline file data (base64 or binary) with an explicit media type. @@ -34,7 +39,10 @@ pub enum ImageFile { } /// File payload for an [`ImageFile::File`]: base64 string or raw bytes. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] +/// +/// The JSON is a base64 string either way, as in the AI SDK's JSON. +#[derive(Debug, Clone, TS)] +#[ts(type = "string")] #[cfg_attr(feature = "ts-export", ts(export))] pub enum ImageFileData { /// Base64-encoded string. @@ -43,6 +51,8 @@ pub enum ImageFileData { Binary(Vec), } +crate::wire::base64_wire_enum!(ImageFileData); + /// The generated images, returned as a homogeneous collection. /// /// The TS result type is `Array | Array` — i.e. either all @@ -50,7 +60,11 @@ pub enum ImageFileData { /// union. Providers should return data without unnecessary conversion: if the /// upstream API returns base64, return [`ImageOutputs::Base64`]; if binary, /// return [`ImageOutputs::Binary`]. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] +/// +/// The JSON is an array of base64 strings either way, as in the AI SDK's +/// JSON; parsing yields `Base64`. +#[derive(Debug, Clone, TS)] +#[ts(type = "Array")] #[cfg_attr(feature = "ts-export", ts(export))] pub enum ImageOutputs { /// Images as base64-encoded strings. @@ -59,17 +73,41 @@ pub enum ImageOutputs { Binary(Vec>), } +impl Serialize for ImageOutputs { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Base64(images) => images.serialize(serializer), + Self::Binary(images) => { + serializer.collect_seq(images.iter().map(|image| crate::wire::encode_base64(image))) + } + } + } +} + +impl<'de> Deserialize<'de> for ImageOutputs { + fn deserialize>(deserializer: D) -> Result { + Vec::::deserialize(deserializer).map(Self::Base64) + } +} + /// Token usage for an image generation call (if the provider reports it). /// /// Aligned with V4 `ImageModelV4Usage`. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "ts-export", 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 +115,13 @@ pub struct ImageUsage { /// /// Aligned with V4 `ImageModelV4CallOptions`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "ts-export", 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 +129,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. @@ -114,12 +164,18 @@ pub struct ImageCallOptions { /// Maximum number of retries, the AI SDK's `maxRetries`; `Some(0)` disables /// retrying. `None` uses 2, the AI SDK 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, } @@ -147,6 +203,7 @@ impl ImageCallOptions { /// /// Aligned with V4 `ImageModelV4Result`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "ts-export", ts(export))] pub struct ImageResult { /// Generated images (base64 strings or binary data). @@ -156,24 +213,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")] #[cfg_attr(feature = "ts-export", ts(export))] pub struct ImageResponse { - /// Timestamp for the start of the generated response (ISO 8601 string). - pub timestamp: Option, + /// Timestamp for the start of the generated response (`toISOString()` in the JSON). + #[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 c732a8a99..b632b21e1 100644 --- a/aimux-core/src/language_model_message.rs +++ b/aimux-core/src/language_model_message.rs @@ -6,7 +6,10 @@ //! file part. `convert_to_language_model_prompt` is the single conversion from //! the user-facing `ModelMessage`s. -use crate::content::{self as user, AssistantContentPart, ToolContentPart, UserContentPart}; +use crate::content::{ + self as user, AssistantContentPart, FilePartData, ReasoningFilePartData, ToolContentPart, + UserContentPart, +}; use crate::download::{DownloadFunction, DownloadedFile, PlannedDownload}; use crate::error::AiMuxError; use crate::language_model::SupportedUrls; @@ -30,112 +33,150 @@ 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" +)] #[cfg_attr(feature = "ts-export", ts(export))] pub enum LanguageModelMessage { System { content: String, + #[ts(optional)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, User { content: Vec, + #[ts(optional)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, Assistant { content: Vec, + #[ts(optional)] + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, Tool { content: Vec, + #[ts(optional)] + #[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")] #[cfg_attr(feature = "ts-export", 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")] #[cfg_attr(feature = "ts-export", 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")] #[cfg_attr(feature = "ts-export", 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")] #[cfg_attr(feature = "ts-export", 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")] #[cfg_attr(feature = "ts-export", ts(export))] #[ts(rename = "LanguageModelToolResultPart")] 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" +)] #[cfg_attr(feature = "ts-export", ts(export))] #[ts(rename = "LanguageModelToolResultOutput")] 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, }, @@ -146,12 +187,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" +)] #[cfg_attr(feature = "ts-export", ts(export))] pub enum ToolResultContent { Text(TextPart), File(FilePart), Custom { + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, @@ -159,38 +205,49 @@ pub enum ToolResultContent { /// A file generated as part of reasoning. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "ts-export", 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")] #[cfg_attr(feature = "ts-export", ts(export))] pub struct CustomPart { pub kind: NamespacedKind, + #[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")] #[cfg_attr(feature = "ts-export", 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, } /// Parts allowed in a user message. #[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" +)] #[cfg_attr(feature = "ts-export", ts(export))] pub enum UserPart { Text(TextPart), @@ -199,7 +256,11 @@ pub enum UserPart { /// Parts allowed in an assistant message. #[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" +)] #[cfg_attr(feature = "ts-export", ts(export))] pub enum AssistantPart { Text(TextPart), @@ -213,7 +274,11 @@ pub enum AssistantPart { /// Parts allowed in a tool message. #[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" +)] #[cfg_attr(feature = "ts-export", ts(export))] pub enum ToolPart { ToolResult(ToolResultPart), @@ -248,13 +313,6 @@ fn file_part( } } -fn reference(reference: &Value) -> Result { - Ok(FileData::Reference { - reference: serde_json::from_value(reference.clone()) - .map_err(|e| AiMuxError::InvalidPrompt(format!("invalid file reference: {e}")))?, - }) -} - fn bytes(data: &[u8]) -> FileData { FileData::Data { data: FileBytes::Binary(data.to_vec()), @@ -267,13 +325,6 @@ fn base64(data: &str) -> FileData { } } -fn url(url: &str) -> FileData { - FileData::Url { - url: url.to_owned(), - original_url: None, - } -} - fn text_part(text: &str, provider_options: Option<&SharedProviderOptions>) -> TextPart { TextPart { text: text.to_owned(), @@ -281,6 +332,97 @@ fn text_part(text: &str, provider_options: Option<&SharedProviderOptions>) -> Te } } +/// `convertToLanguageModelV4FilePart` (`ai` `prompt/file-part-data.ts:89-141`): +/// the tagged provider `FileData` for a file part's `data`, and the media type +/// of a `data:` URL, which takes precedence over the part's `mediaType`. +/// +/// A string is a URL when it parses as one and base64 otherwise; a `data:` +/// URL becomes inline base64 data; a URL string that parsing changes keeps +/// the given text as `originalUrl`. +/// +/// # Errors +/// +/// `InvalidPrompt` (upstream `InvalidDataContentError`) for a `data:` URL +/// passed as inline data or a `data:` URL without a comma. +pub fn convert_file_part_data( + data: &FilePartData, +) -> Result<(FileData, Option), AiMuxError> { + match data { + FilePartData::Tagged(FileData::Data { + data: FileBytes::Base64(text), + }) if text.starts_with("data:") => Err(AiMuxError::InvalidPrompt( + "Data URLs are not valid inline data. Pass them as { type: \"url\", url } instead." + .into(), + )), + FilePartData::Tagged(FileData::Url { url, original_url }) => { + convert_url_to_file_part_data(url, original_url.clone()) + } + FilePartData::Tagged(data) => Ok((data.clone(), None)), + FilePartData::String(text) => match url::Url::parse(text) { + Ok(parsed) => { + let (mut data, media_type) = convert_url_to_file_part_data(parsed.as_str(), None)?; + if let FileData::Url { url, original_url } = &mut data + && url != text + { + *original_url = Some(text.clone()); + } + Ok((data, media_type)) + } + Err(_) => Ok(( + FileData::Data { + data: FileBytes::Base64(text.clone()), + }, + None, + )), + }, + FilePartData::Reference(reference) => Ok(( + FileData::Reference { + reference: reference.clone(), + }, + None, + )), + } +} + +/// `convertUrlToFilePartData` (`file-part-data.ts:27-48`) with `splitDataUrl` +/// (`split-data-url.ts`): a `data:` URL is split into its media type and +/// base64 content; any other URL stays a URL. +fn convert_url_to_file_part_data( + url: &str, + original_url: Option, +) -> Result<(FileData, Option), AiMuxError> { + let data_url = match url::Url::parse(url) { + Ok(parsed) if parsed.scheme() == "data" => parsed, + _ => { + return Ok(( + FileData::Url { + url: url.to_owned(), + original_url, + }, + None, + )); + } + }; + let url = data_url.as_str(); + let mut pieces = url.split(','); + let header = pieces.next().unwrap_or_default(); + let media_type = header + .split(';') + .next() + .and_then(|scheme_and_type| scheme_and_type.split(':').nth(1)); + match (media_type, pieces.next()) { + (Some(media_type), Some(base64_content)) => Ok(( + FileData::Data { + data: FileBytes::Base64(base64_content.to_owned()), + }, + Some(media_type.to_owned()), + )), + _ => Err(AiMuxError::InvalidPrompt(format!( + "Invalid data URL format in content {url}" + ))), + } +} + /// The files downloaded for a prompt's URLs, by URL. pub(crate) type DownloadedAssets = HashMap; @@ -368,11 +510,13 @@ pub(crate) async fn download_assets( .. } => { for part in parts { - if let UserContentPart::FileUrl { - url, media_type, .. + if let UserContentPart::File { + data, media_type, .. } = part + && let Ok((FileData::Url { url, .. }, data_url_media_type)) = + convert_file_part_data(data) { - plan(url, media_type); + plan(&url, data_url_media_type.as_deref().unwrap_or(media_type)); } } } @@ -440,42 +584,24 @@ fn user_part( media_type, filename, provider_options, - } => UserPart::File(file_part( - bytes(data), - media_type, - filename.as_ref(), - provider_options.as_ref(), - )), - UserContentPart::FileBase64 { - data, - media_type, - filename, - provider_options, - } => UserPart::File(file_part( - base64(data), - media_type, - filename.as_ref(), - provider_options.as_ref(), - )), - UserContentPart::FileUrl { - url: file_url, - media_type, - provider_options, - } => UserPart::File(match assets.get(file_url) { - Some(file) => downloaded_file_part(file, media_type, None, provider_options.clone()), - None => file_part(url(file_url), media_type, None, provider_options.as_ref()), - }), - UserContentPart::FileReference { - media_type, - reference: file_reference, - filename, - provider_options, - } => UserPart::File(file_part( - reference(file_reference)?, - media_type, - filename.as_ref(), - provider_options.as_ref(), - )), + } => { + let (data, data_url_media_type) = convert_file_part_data(data)?; + let media_type = data_url_media_type.as_deref().unwrap_or(media_type); + UserPart::File(match &data { + FileData::Url { url, .. } if assets.contains_key(url) => downloaded_file_part( + &assets[url], + media_type, + filename.clone(), + provider_options.clone(), + ), + _ => file_part( + data, + media_type, + filename.as_ref(), + provider_options.as_ref(), + ), + }) + } }) } @@ -502,44 +628,15 @@ fn assistant_part( media_type, filename, provider_options, - } => AssistantPart::File(file_part( - bytes(data), - media_type, - filename.as_ref(), - provider_options.as_ref(), - )), - AssistantContentPart::FileBase64 { - data, - media_type, - filename, - provider_options, - } => AssistantPart::File(file_part( - base64(data), - media_type, - filename.as_ref(), - provider_options.as_ref(), - )), - AssistantContentPart::FileUrl { - url: file_url, - media_type, - provider_options, - } => AssistantPart::File(file_part( - url(file_url), - media_type, - None, - provider_options.as_ref(), - )), - AssistantContentPart::FileReference { - media_type, - reference: file_reference, - filename, - provider_options, - } => AssistantPart::File(file_part( - reference(file_reference)?, - media_type, - filename.as_ref(), - provider_options.as_ref(), - )), + } => { + let (data, data_url_media_type) = convert_file_part_data(data)?; + AssistantPart::File(file_part( + data, + data_url_media_type.as_deref().unwrap_or(media_type), + filename.as_ref(), + provider_options.as_ref(), + )) + } AssistantContentPart::Reasoning { text, provider_options, @@ -551,11 +648,36 @@ fn assistant_part( data, media_type, provider_options, - } => AssistantPart::ReasoningFile(ReasoningFilePart { - data: data.clone(), - media_type: media_type.clone(), - provider_options: provider_options.clone(), - }), + } => { + let data = match data { + ReasoningFilePartData::Tagged(GeneratedFileData::Data { data }) => { + FilePartData::Tagged(FileData::Data { data: data.clone() }) + } + ReasoningFilePartData::Tagged(GeneratedFileData::Url { url, original_url }) => { + FilePartData::Tagged(FileData::Url { + url: url.clone(), + original_url: original_url.clone(), + }) + } + ReasoningFilePartData::String(text) => FilePartData::String(text.clone()), + }; + // `convert-to-language-model-prompt.ts:313-327`. + let (data, data_url_media_type) = convert_file_part_data(&data)?; + let data = match data { + FileData::Data { data } => GeneratedFileData::Data { data }, + FileData::Url { url, original_url } => GeneratedFileData::Url { url, original_url }, + FileData::Reference { .. } | FileData::Text { .. } => { + return Err(AiMuxError::InvalidPrompt( + "Unsupported reasoning-file data type".into(), + )); + } + }; + AssistantPart::ReasoningFile(ReasoningFilePart { + data, + media_type: data_url_media_type.unwrap_or_else(|| media_type.clone()), + provider_options: provider_options.clone(), + }) + } AssistantContentPart::ToolCall { tool_call_id, tool_name, @@ -1243,7 +1365,7 @@ mod tests { fn tool_output(items: Value) -> Vec { serde_json::from_value(json!([ { "role": "tool", "content": [ - { "type": "tool_result", "tool_call_id": "c1", "tool_name": "t", + { "type": "tool-result", "toolCallId": "c1", "toolName": "t", "output": { "type": "content", "value": items } } ] } ])) @@ -1300,17 +1422,17 @@ mod tests { fn deprecated_tool_output_items_become_file_parts() { let (parts, warnings) = convert_output( json!([ - { "type": "file-data", "data": "AAEC", "media_type": "application/pdf", + { "type": "file-data", "data": "AAEC", "mediaType": "application/pdf", "filename": "a.pdf" }, { "type": "file-url", "url": "https://example.com/a.pdf", - "media_type": "application/pdf" }, - { "type": "file-id", "file_id": "file-1" }, - { "type": "file-id", "file_id": { "anthropic": "file-2" } }, - { "type": "file-reference", "provider_reference": { "openai": "file-3" } }, - { "type": "image-data", "data": "iVBO", "media_type": "image/png" }, + "mediaType": "application/pdf" }, + { "type": "file-id", "fileId": "file-1" }, + { "type": "file-id", "fileId": { "anthropic": "file-2" } }, + { "type": "file-reference", "providerReference": { "openai": "file-3" } }, + { "type": "image-data", "data": "iVBO", "mediaType": "image/png" }, { "type": "image-url", "url": "https://example.com/cat.png" }, - { "type": "image-file-id", "file_id": "file-4" }, - { "type": "image-file-reference", "provider_reference": { "openai": "file-5" } }, + { "type": "image-file-id", "fileId": "file-4" }, + { "type": "image-file-reference", "providerReference": { "openai": "file-5" } }, ]), Some("openai"), ) @@ -1438,7 +1560,7 @@ mod tests { #[test] fn string_file_id_needs_a_provider() { let error = - convert_output(json!([{ "type": "file-id", "file_id": "file-1" }]), None).unwrap_err(); + convert_output(json!([{ "type": "file-id", "fileId": "file-1" }]), None).unwrap_err(); assert_eq!( error.to_string(), AiMuxError::InvalidPrompt( @@ -1469,7 +1591,7 @@ mod tests { fn user_image_part_logs_a_deprecation_warning() { let messages: Vec = serde_json::from_value(json!([ { "role": "user", "content": [ - { "type": "image", "image": [1, 2], "media_type": "image/png" } + { "type": "image", "image": "AQI=", "mediaType": "image/png" } ] } ])) .unwrap(); @@ -1503,23 +1625,30 @@ mod download_tests { use regex::Regex; use serde_json::json; + fn url(url: &str) -> FileData { + FileData::Url { + url: url.to_owned(), + original_url: None, + } + } + fn messages() -> Vec { serde_json::from_value(json!([ { "role": "user", "content": [ - { "type": "file_url", "url": "https://model.test/a.png", "media_type": "image/png" }, - { "type": "file_url", "url": "https://cdn.test/b", "media_type": "image" }, - { "type": "file_url", "url": "data:image/png;base64,AAAA", "media_type": "image/png" } + { "type": "file", "data": "https://model.test/a.png", "mediaType": "image/png" }, + { "type": "file", "data": { "type": "url", "url": "https://cdn.test/b" }, "mediaType": "image" }, + { "type": "file", "data": "data:image/png;base64,AAAA", "mediaType": "image/png" } ] }, { "role": "assistant", "content": [ - { "type": "file_url", "url": "https://cdn.test/assistant.png", "media_type": "image/png" }, - { "type": "tool_call", "tool_call_id": "c1", "tool_name": "t", "input": {} } + { "type": "file", "data": "https://cdn.test/assistant.png", "mediaType": "image/png" }, + { "type": "tool-call", "toolCallId": "c1", "toolName": "t", "input": {} } ] }, { "role": "tool", "content": [ - { "type": "tool_result", "tool_call_id": "c1", "tool_name": "t", + { "type": "tool-result", "toolCallId": "c1", "toolName": "t", "output": { "type": "content", "value": [ - { "type": "file", "data": { "Url": { "url": "https://cdn.test/c.pdf" } }, - "media_type": "application/pdf" }, - { "type": "file-url", "url": "https://cdn.test/c.pdf", "media_type": "application/pdf" } + { "type": "file", "data": { "type": "url", "url": "https://cdn.test/c.pdf" }, + "mediaType": "application/pdf" }, + { "type": "file-url", "url": "https://cdn.test/c.pdf", "mediaType": "application/pdf" } ] } } ] } ])) @@ -1607,7 +1736,8 @@ mod download_tests { (url("https://model.test/a.png"), "image/png"), // Downloaded: a top-level media type takes the downloaded one. (bytes(b"https://cdn.test/b"), "image/jpeg"), - (url("data:image/png;base64,AAAA"), "image/png"), + // A `data:` URL is inline data (`file-part-data.ts:31-41`). + (base64("AAAA"), "image/png"), ] ); diff --git a/aimux-core/src/lib.rs b/aimux-core/src/lib.rs index 0be00df8c..50738b44b 100644 --- a/aimux-core/src/lib.rs +++ b/aimux-core/src/lib.rs @@ -62,12 +62,14 @@ pub mod speech_model; pub mod step_result; pub mod stream_part; mod timeout; +pub mod timestamp; pub mod tool; pub mod trace; pub mod transcription_model; pub mod types; pub mod util; pub mod video_model; +pub mod wire; pub mod wrap_provider; /// Convenience re-exports. @@ -127,9 +129,10 @@ pub mod prelude { RerankingCallOptions, RerankingModel, RerankingResult, rerank, }; pub use crate::result::{ - ContentPart, GenerateResult, GeneratedFile, RawToolApprovalRequest, ReasoningOutput, - ReasoningPart, Source, StreamResult, ToolApprovalRequestOutput, ToolApprovalResponseOutput, - ToolOutputDenied, TypedToolError, TypedToolResult, + ContentPart, GenerateResult, GeneratedFile, GeneratedFileOutput, LanguageModelFile, + RawToolApprovalRequest, ReasoningOutput, ReasoningPart, Source, StreamResult, + ToolApprovalRequestOutput, ToolApprovalResponseOutput, ToolOutputDenied, TypedToolError, + TypedToolResult, }; pub use crate::router::{ FallbackPolicy, Router, RouterConfig, RouterModel, RuleRouter, WeightedRouter, @@ -153,6 +156,7 @@ pub mod prelude { StepResultPerformance, }; pub use crate::stream_part::{StreamPart, TextStreamPart}; + pub use crate::timestamp::Timestamp; pub use crate::tool::{ FunctionTool, FunctionToolInputExample, ProviderTool, RawToolCall, Tool, ToolCall, ToolResult, @@ -188,5 +192,6 @@ pub use reranking_model::RerankingModel; pub use search_model::SearchModel; pub use skills_model::Skills; pub use speech_model::SpeechModel; +pub use timestamp::{Timestamp, TimestampError}; pub use transcription_model::TranscriptionModel; pub use video_model::VideoModel; diff --git a/aimux-core/src/message.rs b/aimux-core/src/message.rs index ffe522284..7dd6d330e 100644 --- a/aimux-core/src/message.rs +++ b/aimux-core/src/message.rs @@ -7,6 +7,9 @@ //! `LanguageModelPrompt` (provider-facing) before calling //! `LanguageModel::do_generate` / `do_stream`. +use std::fmt; + +use serde::de::{self, Deserializer, SeqAccess, Visitor}; use serde::{Deserialize, Serialize}; use ts_rs::TS; @@ -15,37 +18,45 @@ use crate::shared::SharedProviderOptions; /// A single user-facing chat message, a union by role. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[serde(tag = "role", rename_all = "lowercase")] +#[serde( + tag = "role", + rename_all = "lowercase", + rename_all_fields = "camelCase" +)] #[cfg_attr(feature = "ts-export", ts(export))] pub enum ModelMessage { /// Upstream `SystemModelMessage`. System { content: String, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, /// Upstream `UserModelMessage`. User { content: UserContent, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, /// Upstream `AssistantModelMessage`. Assistant { content: AssistantContent, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, /// Upstream `ToolModelMessage`. Tool { content: ToolContent, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] provider_options: Option, }, } /// The content of a user message: a string or parts (upstream `UserContent`). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[derive(Debug, Clone, PartialEq, Serialize, TS)] #[serde(untagged)] #[cfg_attr(feature = "ts-export", ts(export))] pub enum UserContent { @@ -55,7 +66,7 @@ pub enum UserContent { /// The content of an assistant message: a string or parts (upstream /// `AssistantContent`). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[derive(Debug, Clone, PartialEq, Serialize, TS)] #[serde(untagged)] #[cfg_attr(feature = "ts-export", ts(export))] pub enum AssistantContent { @@ -152,7 +163,7 @@ impl ModelMessage { /// What the user passes as `prompt` to `generate_text` / `stream_text`. /// /// Can be a simple string (converted to a single user message) or a list of messages. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[derive(Debug, Clone, Serialize, TS)] #[serde(untagged)] #[cfg_attr(feature = "ts-export", ts(export))] pub enum ModelPrompt { @@ -180,3 +191,92 @@ impl From> for ModelPrompt { ModelPrompt::Messages(msgs) } } + +// ── Deserialize by JSON kind ──────────────────────────────────────────────── +// +// The three types above serialize untagged, but an untagged `Deserialize` +// tries each variant in turn and, when none fits, reports only "data did not +// match any variant of untagged enum ...": a prompt part with an unknown +// `type` would lose the "unknown variant `x`, expected one of ..." error its +// own union gives (RFC-0042 §7.2, `rfc/0042-v4-type-boundaries.md:211`). They +// are read by JSON kind instead: a string is the text, an array is read as +// the parts or messages, with each element's own error. + +/// Read a string or an array of `T` by JSON kind: `text` builds the value +/// from a string, `array` from the array's elements, each decoded with its +/// own error. +fn text_or_array<'de, D, T, U>( + deserializer: D, + expecting: &'static str, + text: fn(String) -> U, + array: fn(Vec) -> U, +) -> Result +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + struct KindVisitor { + expecting: &'static str, + text: fn(String) -> U, + array: fn(Vec) -> U, + } + + impl<'de, T: Deserialize<'de>, U> Visitor<'de> for KindVisitor { + type Value = U; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.expecting) + } + + fn visit_str(self, value: &str) -> Result { + Ok((self.text)(value.to_owned())) + } + + fn visit_string(self, value: String) -> Result { + Ok((self.text)(value)) + } + + fn visit_seq>(self, seq: A) -> Result { + Vec::deserialize(de::value::SeqAccessDeserializer::new(seq)).map(self.array) + } + } + + deserializer.deserialize_any(KindVisitor { + expecting, + text, + array, + }) +} + +impl<'de> Deserialize<'de> for UserContent { + fn deserialize>(deserializer: D) -> Result { + text_or_array( + deserializer, + "a string or an array of user content parts", + Self::Text, + Self::Parts, + ) + } +} + +impl<'de> Deserialize<'de> for AssistantContent { + fn deserialize>(deserializer: D) -> Result { + text_or_array( + deserializer, + "a string or an array of assistant content parts", + Self::Text, + Self::Parts, + ) + } +} + +impl<'de> Deserialize<'de> for ModelPrompt { + fn deserialize>(deserializer: D) -> Result { + text_or_array( + deserializer, + "a prompt string or an array of messages", + Self::Text, + Self::Messages, + ) + } +} diff --git a/aimux-core/src/moa.rs b/aimux-core/src/moa.rs index 71953a024..d1f5c795e 100644 --- a/aimux-core/src/moa.rs +++ b/aimux-core/src/moa.rs @@ -27,7 +27,7 @@ use crate::types::{Usage, Warning}; /// Reference-failure policy. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] -#[serde(rename_all = "snake_case")] +#[serde(rename_all = "kebab-case")] pub enum MoaFailMode { /// Drop a failed reference + emit a `Warning::Other`; keep going (default). #[default] @@ -36,7 +36,11 @@ pub enum MoaFailMode { FailFast, } +/// MoA configuration, camelCase JSON with every field optional. Unknown keys +/// are rejected, so aimux 0.5's `fail_mode` is an error naming it rather than +/// the default policy. #[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] 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 efd9f7613..d09b74fca 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")] #[cfg_attr(feature = "ts-export", 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")] #[cfg_attr(feature = "ts-export", 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")] #[cfg_attr(feature = "ts-export", 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")] #[cfg_attr(feature = "ts-export", 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")] #[cfg_attr(feature = "ts-export", 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")] #[cfg_attr(feature = "ts-export", 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")] #[cfg_attr(feature = "ts-export", 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/openai_output.rs b/aimux-core/src/openai_output.rs index 9dc4a8d67..f8c53b66d 100644 --- a/aimux-core/src/openai_output.rs +++ b/aimux-core/src/openai_output.rs @@ -37,7 +37,7 @@ use crate::error::AiMuxError; use crate::generate::GenerateTextResult; use crate::parse_tool_call::original_tool_call_text; use crate::result::{ - ContentPart, GenerateContent, GenerateResult, GeneratedFile, ReasoningOutput, Source, + ContentPart, GenerateContent, GenerateResult, GeneratedFileOutput, ReasoningOutput, Source, TypedToolError, TypedToolResult, }; use crate::shared::GeneratedFileData; @@ -252,7 +252,9 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio }, }, GenerateContent::Source(source) => CompletionContent::Source(source), - GenerateContent::File(file) => CompletionContent::File(file), + GenerateContent::File(file) => { + CompletionContent::File(file_data_to_text(&file.data, &file.media_type)) + } GenerateContent::ToolResult(result) => CompletionContent::ToolResult { tool_name: &result.tool_name, output: &result.result, @@ -296,7 +298,9 @@ pub(crate) fn text_result_to_chat_completion( arguments: parsed_tool_call_arguments(&call.input, call.invalid, call.error.as_ref()), }, ContentPart::Source(source) => CompletionContent::Source(source), - ContentPart::File { file, .. } => CompletionContent::File(file), + ContentPart::File { file, .. } => { + CompletionContent::File(format!("data:{};base64,{}", file.media_type, file.base64())) + } ContentPart::ToolResult(result) => CompletionContent::ToolResult { tool_name: &result.tool_name, output: &result.output, @@ -347,7 +351,8 @@ enum CompletionContent<'a> { arguments: String, }, Source(&'a Source), - File(&'a GeneratedFile), + /// A generated file as text: its URL or a `data:` URL. + File(String), /// A provider-executed tool result or error. ToolResult { tool_name: &'a str, @@ -411,13 +416,11 @@ fn build_chat_completion<'a>( } content_text.push_str(url); } - CompletionContent::File(GeneratedFile { - data, media_type, .. - }) => { + CompletionContent::File(text) => { if !content_text.is_empty() { content_text.push('\n'); } - content_text.push_str(&file_data_to_text(data, media_type)); + content_text.push_str(&text); } CompletionContent::Source(Source::Document { .. }) | CompletionContent::None => {} CompletionContent::ToolResult { @@ -954,13 +957,11 @@ impl StreamState { chunks.push(chunk); } - TextStreamPart::File(GeneratedFile { - data, media_type, .. - }) => { + TextStreamPart::File(GeneratedFileOutput { file, .. }) => { if let Some(c) = self.ensure_started() { chunks.push(c); } - let text = file_data_to_text(data, media_type); + let text = format!("data:{};base64,{}", file.media_type, file.base64()); let mut chunk = self.base_chunk(); chunk.choices = vec![ChatCompletionChunkChoice { index: 0, diff --git a/aimux-core/src/operation.rs b/aimux-core/src/operation.rs index 060ecd8f8..224a95a1f 100644 --- a/aimux-core/src/operation.rs +++ b/aimux-core/src/operation.rs @@ -27,13 +27,14 @@ tokio::task_local! { /// Operation settings that are not part of the model call. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct OperationSettings { /// Maximum number of retries, the AI SDK's `maxRetries`; `Some(0)` /// disables retrying. `None` uses the default. - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_retries: Option, /// Timeout configuration (total / step / first-chunk / chunk idle). - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout: Option, /// Explicit session id (RFC-0024), for grouping consecutive calls. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -82,17 +83,14 @@ mod tests { #[test] fn the_settings_serialize_as_the_call_options_fields_did() { // The recorded input keeps the keys it had when the three settings - // were `CallOptions` fields: `max_retries` and `timeout` always, - // `session_id` only when set. + // were `CallOptions` fields: `maxRetries`, `timeout` and `sessionId`, + // each only when set. let json = serde_json::to_value(OperationSettings::default()).unwrap(); - assert_eq!( - json, - serde_json::json!({ "max_retries": null, "timeout": null }) - ); + assert_eq!(json, serde_json::json!({})); let back: OperationSettings = serde_json::from_value(serde_json::json!({ - "max_retries": 1, - "timeout": { "total_ms": 5, "step_ms": null, "first_chunk_ms": null, "chunk_ms": null }, - "session_id": "s", + "maxRetries": 1, + "timeout": { "totalMs": 5 }, + "sessionId": "s", "temperature": 0.5 })) .unwrap(); diff --git a/aimux-core/src/options.rs b/aimux-core/src/options.rs index 902576d8f..1ebc67e2a 100644 --- a/aimux-core/src/options.rs +++ b/aimux-core/src/options.rs @@ -19,14 +19,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" +)] #[cfg_attr(feature = "ts-export", 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, }, } @@ -42,7 +53,12 @@ pub enum ResponseFormat { /// /// All values are milliseconds; `None` disables the corresponding limit. A /// `total` timeout also covers retry backoff and the whole streamed response. +/// +/// Unknown keys, upstream's `toolMs` / `tools` among them, are rejected +/// (RFC-0042 §7.2): aimux 0.5's `total_ms` is an error naming it, not a call +/// without a timeout. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "ts-export", ts(export))] pub struct TimeoutConfiguration { /// Overall timeout for the entire call (including retries and, for @@ -50,21 +66,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, } @@ -78,54 +98,82 @@ pub struct TimeoutConfiguration { /// operation (`OperationSettings`), not call options, as in the AI SDK /// (`language-model-v4-call-options.ts:8-138` has none of them). #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "ts-export", 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. `generate_text` / `stream_text` /// always set it through [`prepare_tool_choice`], as the AI SDK does. + #[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, /// Abort signal for cancelling the call. @@ -146,6 +194,8 @@ pub struct CallOptions { /// presets and Azure included), Groq, DeepSeek, Anthropic Messages, and /// the OpenAI and ElevenLabs transcription streams; the other model /// families do not emit `Raw` yet. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub include_raw_chunks: Option, } diff --git a/aimux-core/src/output.rs b/aimux-core/src/output.rs index 5ee691af7..ec7f2dea3 100644 --- a/aimux-core/src/output.rs +++ b/aimux-core/src/output.rs @@ -20,7 +20,19 @@ use crate::error::AiMuxError; use crate::options::ResponseFormat; /// What the final text of a generation is parsed into. +/// +/// Upstream's `Output` is a JavaScript object with functions and has no JSON +/// form; aimux's JSON follows the public JSON rules: tagged by `type` with the +/// output kind (upstream's mode `name`, `output.ts:68`, `:114`, `:241`, `:520`, `:657`; +/// the field `name` stays the schema name), and the arguments of +/// `Output.object()` / `array()` / `choice()` / `json()` camelCase +/// (`{"type":"array","element":{...},"minItems":1}`). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] #[cfg_attr(feature = "ts-export", ts(export))] pub enum Output { /// The text itself (`Output.text()`). @@ -28,8 +40,10 @@ pub enum Output { /// A JSON object matching `schema` (`Output.object()`). Object { schema: Value, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] name: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] description: Option, }, @@ -37,12 +51,16 @@ pub enum Output { /// is asked for `{"elements": [...]}`. Array { element: Value, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] min_items: Option, + #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] max_items: 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, }, @@ -50,15 +68,19 @@ pub enum Output { /// `{"result": "