From 85f4619ee2f3eef6e33eb96874ee114c2cf22e8a Mon Sep 17 00:00:00 2001 From: Trey Kim Date: Sun, 11 Oct 2026 03:43:29 +0800 Subject: [PATCH 1/7] ci: run on exp/** branches --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eaf7a4d97..67c660ced 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [master] + branches: [master, "exp/**"] pull_request: - branches: [master] + branches: [master, "exp/**"] env: CARGO_TERM_COLOR: always From a37299b7fc3429e6e44f86a5e42067554dae024f Mon Sep 17 00:00:00 2001 From: Trey Kim Date: Sun, 11 Oct 2026 03:43:29 +0800 Subject: [PATCH 2/7] ci: run on exp/** branches --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eaf7a4d97..67c660ced 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [master] + branches: [master, "exp/**"] pull_request: - branches: [master] + branches: [master, "exp/**"] env: CARGO_TERM_COLOR: always From 915478aadb70c8053ddeb77fbc636d2d05017760 Mon Sep 17 00:00:00 2001 From: Trey Kim Date: Sun, 11 Oct 2026 04:13:19 +0800 Subject: [PATCH 3/7] docs(api): regenerate providers.md (gen_providers_doc.py) --- docs/api/providers.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/api/providers.md b/docs/api/providers.md index 2f9a2f273..addef4710 100644 --- a/docs/api/providers.md +++ b/docs/api/providers.md @@ -9,7 +9,7 @@ | category | count | |----------|-------| -| Registry-backed OpenAI-compatible (`provider(name, ...)`) | 281 | +| Registry-backed OpenAI-compatible (`provider(name, ...)`) | 282 | | Non-registry: Native protocol providers | 14 | | Non-registry: Speech-only providers (TTS) | 4 | | Non-registry: Transcription-only providers (STT) | 5 | @@ -22,11 +22,11 @@ | Non-registry: Stability image provider (image modality only) | 1 | | Non-registry: Video-only provider (runwayml) | 1 | | Non-registry: Search-only providers (web search modality) | 11 | -| **Total providers** | **326** | +| **Total providers** | **327** | -**281 registry-backed OpenAI-compatible providers** (create presets by name via `provider(name, ...)`) + **45 non-registry providers** (construct via the typed factories listed below). +**282 registry-backed OpenAI-compatible providers** (create presets by name via `provider(name, ...)`) + **45 non-registry providers** (construct via the typed factories listed below). -## Registry-backed (OpenAI-compatible) — 281 +## Registry-backed (OpenAI-compatible) — 282 | name | display | auth | env var | base_url | |------|---------|------|---------|----------| @@ -153,6 +153,7 @@ | `krutrim` | Krutrim | api_key | `KRUTRIM_API_KEY` | `https://api.krutrim.ai/v1` | | `kuae_cloud_coding_plan` | KUAE Cloud Coding Plan | api_key | `KUAE_API_KEY` | `https://coding-plan-endpoint.kuaecloud.net/v1` | | `lambda_ai` | Lambda AI | api_key | `LAMBDA_API_KEY` | `https://api.lambda.ai/v1` | +| `laya` | Laya | none | — | `http://127.0.0.1:8000/v1` (URL from `LAYA_BASE_URL`) | | `lemonade` | Lemonade | api_key | `LEMONADE_API_KEY` | `http://localhost:13305/v1` | | `lemonfox_ai` | Lemonfox AI | api_key | `LEMONFOX_API_KEY` | `https://api.lemonfox.ai/v1` | | `libertai` | Libertai | api_key | `LIBERTAI_API_KEY` | `https://api.libertai.io/v1` | From aa4b293c83b121f6c67ac648794d1795ca4f83ca Mon Sep 17 00:00:00 2001 From: Trey Kim Date: Sun, 11 Oct 2026 06:39:16 +0800 Subject: [PATCH 4/7] feat(provider): aimux-provider crate with AI SDK V4 language-model types (1.6 design draft, not wired) --- Cargo.toml | 1 + aimux-provider/Cargo.toml | 15 ++ aimux-provider/src/call_options.rs | 129 ++++++++++ aimux-provider/src/content.rs | 231 ++++++++++++++++++ aimux-provider/src/lib.rs | 38 +++ aimux-provider/src/prompt.rs | 210 ++++++++++++++++ aimux-provider/src/shared.rs | 96 ++++++++ aimux-provider/src/stream.rs | 115 +++++++++ .../tests/no_aimux_dependencies_test.rs | 19 ++ aimux-provider/tests/wire_round_trip_test.rs | 170 +++++++++++++ 10 files changed, 1024 insertions(+) create mode 100644 aimux-provider/Cargo.toml create mode 100644 aimux-provider/src/call_options.rs create mode 100644 aimux-provider/src/content.rs create mode 100644 aimux-provider/src/lib.rs create mode 100644 aimux-provider/src/prompt.rs create mode 100644 aimux-provider/src/shared.rs create mode 100644 aimux-provider/src/stream.rs create mode 100644 aimux-provider/tests/no_aimux_dependencies_test.rs create mode 100644 aimux-provider/tests/wire_round_trip_test.rs diff --git a/Cargo.toml b/Cargo.toml index 72caa4314..6ea3dbdc8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "aimux-core", "aimux-ffi", + "aimux-provider", "aimux-provider-utils", "aimux-providers", "aimux-stream", diff --git a/aimux-provider/Cargo.toml b/aimux-provider/Cargo.toml new file mode 100644 index 000000000..2d36e8132 --- /dev/null +++ b/aimux-provider/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "aimux-provider" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "AI SDK LanguageModelV4 specification types for aimux (design draft, not wired)" +publish = false + +# #200 §2.1 constraint 1: no dependency on any other aimux crate. +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/aimux-provider/src/call_options.rs b/aimux-provider/src/call_options.rs new file mode 100644 index 000000000..072ea8053 --- /dev/null +++ b/aimux-provider/src/call_options.rs @@ -0,0 +1,129 @@ +//! `language-model-v4-call-options.ts`, tool definitions and tool choice. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::{JsonObject, JsonValue, LanguageModelV4Prompt, SharedV4ProviderOptions}; + +/// `LanguageModelV4CallOptions` without `abortSignal` (behaviour, not data). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4CallOptions { + pub prompt: LanguageModelV4Prompt, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stop_sequences: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub top_p: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub top_k: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub presence_penalty: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub frequency_penalty: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response_format: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub include_raw_chunks: Option, + /// A `None` value removes the header. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headers: Option>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_options: Option, +} + +/// `responseFormat`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum LanguageModelV4ResponseFormat { + Text, + Json { + /// JSON Schema 7. + #[serde(default, skip_serializing_if = "Option::is_none")] + schema: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + }, +} + +/// `reasoning`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum LanguageModelV4Reasoning { + ProviderDefault, + None, + Minimal, + Low, + Medium, + High, + Xhigh, +} + +/// `LanguageModelV4FunctionTool | LanguageModelV4ProviderTool`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum LanguageModelV4Tool { + Function(LanguageModelV4FunctionTool), + Provider(LanguageModelV4ProviderTool), +} + +/// `LanguageModelV4FunctionTool`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4FunctionTool { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// JSON Schema 7. + pub input_schema: JsonValue, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_examples: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub strict: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_options: Option, +} + +/// An `inputExamples` entry. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LanguageModelV4InputExample { + pub input: JsonObject, +} + +/// `LanguageModelV4ProviderTool`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4ProviderTool { + /// `{provider}.{tool}`. + pub id: String, + pub name: String, + pub args: JsonObject, +} + +/// `LanguageModelV4ToolChoice`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +pub enum LanguageModelV4ToolChoice { + Auto, + None, + Required, + Tool { tool_name: String }, +} diff --git a/aimux-provider/src/content.rs b/aimux-provider/src/content.rs new file mode 100644 index 000000000..b78b3bd22 --- /dev/null +++ b/aimux-provider/src/content.rs @@ -0,0 +1,231 @@ +//! `language-model-v4-content.ts` and the generate result: the V4 output +//! layer. + +use serde::{Deserialize, Serialize}; + +use crate::{ + JsonObject, JsonValue, SharedV4Headers, SharedV4InlineFileData, SharedV4ProviderMetadata, + SharedV4Warning, +}; + +/// `LanguageModelV4Text`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4Text { + pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_metadata: Option, +} + +/// `LanguageModelV4Reasoning`. +pub type LanguageModelV4ReasoningContent = LanguageModelV4Text; + +/// `LanguageModelV4CustomContent`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4CustomContent { + /// `{provider}.{kind}`. + pub kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_metadata: Option, +} + +/// `LanguageModelV4File` / `LanguageModelV4ReasoningFile`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4File { + pub media_type: String, + pub data: SharedV4InlineFileData, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_metadata: Option, +} + +/// `LanguageModelV4ToolApprovalRequest`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4ToolApprovalRequest { + pub approval_id: String, + pub tool_call_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_metadata: Option, +} + +/// `LanguageModelV4Source`, tagged by `sourceType`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde( + tag = "sourceType", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +pub enum LanguageModelV4Source { + Url { + id: String, + url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, + Document { + id: String, + media_type: String, + title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + filename: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, +} + +/// `LanguageModelV4ToolCall`. `input` is the provider's argument text, +/// unparsed (RFC-0042 §3.1/§3.2). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4ToolCall { + pub tool_call_id: String, + pub tool_name: String, + pub input: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_executed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dynamic: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_metadata: Option, +} + +/// `LanguageModelV4ToolResult`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4ToolResult { + pub tool_call_id: String, + pub tool_name: String, + /// `NonNullable`. + pub result: JsonValue, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preliminary: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dynamic: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_metadata: Option, +} + +/// `LanguageModelV4Content`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum LanguageModelV4Content { + Text(LanguageModelV4Text), + Reasoning(LanguageModelV4ReasoningContent), + Custom(LanguageModelV4CustomContent), + ReasoningFile(LanguageModelV4File), + File(LanguageModelV4File), + ToolApprovalRequest(LanguageModelV4ToolApprovalRequest), + Source(LanguageModelV4Source), + ToolCall(LanguageModelV4ToolCall), + ToolResult(LanguageModelV4ToolResult), +} + +/// `LanguageModelV4FinishReason`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LanguageModelV4FinishReason { + pub unified: LanguageModelV4UnifiedFinishReason, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw: Option, +} + +/// `LanguageModelV4FinishReason['unified']`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum LanguageModelV4UnifiedFinishReason { + Stop, + Length, + ContentFilter, + ToolCalls, + Error, + Other, +} + +/// `LanguageModelV4Usage`. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4Usage { + pub input_tokens: LanguageModelV4InputTokens, + pub output_tokens: LanguageModelV4OutputTokens, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw: Option, +} + +/// `LanguageModelV4Usage['inputTokens']`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4InputTokens { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub no_cache: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_read: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_write: Option, +} + +/// `LanguageModelV4Usage['outputTokens']`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4OutputTokens { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, +} + +/// `LanguageModelV4ResponseMetadata`. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4ResponseMetadata { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// ISO 8601 (`Date` on the TS side). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timestamp: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_id: Option, +} + +/// `LanguageModelV4GenerateResult['request']`. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct LanguageModelV4RequestInfo { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body: Option, +} + +/// `LanguageModelV4GenerateResult['response']`. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4ResponseInfo { + #[serde(flatten)] + pub metadata: LanguageModelV4ResponseMetadata, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headers: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body: Option, +} + +/// `LanguageModelV4GenerateResult`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4GenerateResult { + pub content: Vec, + pub finish_reason: LanguageModelV4FinishReason, + pub usage: LanguageModelV4Usage, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response: Option, + pub warnings: Vec, +} diff --git a/aimux-provider/src/lib.rs b/aimux-provider/src/lib.rs new file mode 100644 index 000000000..0e3bdbee2 --- /dev/null +++ b/aimux-provider/src/lib.rs @@ -0,0 +1,38 @@ +//! AI SDK `@ai-sdk/provider` V4 language-model specification types +//! (`language-model/v4`, `shared/v4`), pinned to `@ai-sdk/provider@4.0.21`. +//! +//! Design draft for the "V4 input" and "V4 output" layers of RFC-0042 §3.1 +//! (#200:58). Nothing in the workspace depends on this crate yet; it is not +//! wired into any provider or core path. +//! +//! Wire shape is AI SDK JSON: camelCase fields, kebab-case `type` tags, +//! optional fields omitted when absent, bytes as base64 strings. +//! +//! Layer rules (RFC-0042 §3.1): +//! - V4 input: [`LanguageModelV4Prompt`] / [`LanguageModelV4CallOptions`]; a +//! prompt tool call's `input` is parsed JSON. No core retry, session or +//! recording settings. +//! - V4 output: [`LanguageModelV4Content`] / [`LanguageModelV4StreamPart`]; a +//! tool call's `input` is the provider's argument *text* (`String`). No core +//! `invalid` / `error` / tool metadata. +//! +//! Not modelled: the `LanguageModelV4` interface itself (`doGenerate`, +//! `doStream`, `supportedUrls`) and `abortSignal`, which are behaviour, not +//! data. + +mod call_options; +mod content; +mod prompt; +mod shared; +mod stream; + +pub use call_options::*; +pub use content::*; +pub use prompt::*; +pub use shared::*; +pub use stream::*; + +/// `JSONValue`. +pub type JsonValue = serde_json::Value; +/// `JSONObject`. +pub type JsonObject = serde_json::Map; diff --git a/aimux-provider/src/prompt.rs b/aimux-provider/src/prompt.rs new file mode 100644 index 000000000..00a4fc6f1 --- /dev/null +++ b/aimux-provider/src/prompt.rs @@ -0,0 +1,210 @@ +//! `language-model-v4-prompt.ts`: the V4 input layer. + +use serde::{Deserialize, Serialize}; + +use crate::{JsonValue, SharedV4FileData, SharedV4InlineFileData, SharedV4ProviderOptions}; + +/// `LanguageModelV4Prompt`. +pub type LanguageModelV4Prompt = Vec; + +/// `LanguageModelV4Message`, tagged by `role`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde( + tag = "role", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +pub enum LanguageModelV4Message { + System { + content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_options: Option, + }, + User { + content: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_options: Option, + }, + Assistant { + content: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_options: Option, + }, + Tool { + content: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_options: Option, + }, +} + +/// `LanguageModelV4TextPart`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4TextPart { + pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_options: Option, +} + +/// `LanguageModelV4FilePart`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4FilePart { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filename: Option, + pub data: SharedV4FileData, + pub media_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_options: Option, +} + +/// `LanguageModelV4ReasoningFilePart`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4ReasoningFilePart { + pub data: SharedV4InlineFileData, + pub media_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_options: Option, +} + +/// `LanguageModelV4CustomPart`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4CustomPart { + /// `{provider}.{kind}`. + pub kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_options: Option, +} + +/// `LanguageModelV4ToolCallPart`. `input` is parsed JSON (input layer). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4ToolCallPart { + pub tool_call_id: String, + pub tool_name: String, + pub input: JsonValue, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_executed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_options: Option, +} + +/// `LanguageModelV4ToolResultPart`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4ToolResultPart { + pub tool_call_id: String, + pub tool_name: String, + pub output: LanguageModelV4ToolResultOutput, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_options: Option, +} + +/// `LanguageModelV4ToolApprovalResponsePart`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageModelV4ToolApprovalResponsePart { + pub approval_id: String, + pub approved: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_options: Option, +} + +/// User message content. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum LanguageModelV4UserPart { + Text(LanguageModelV4TextPart), + File(LanguageModelV4FilePart), +} + +/// Assistant message content. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum LanguageModelV4AssistantPart { + Text(LanguageModelV4TextPart), + File(LanguageModelV4FilePart), + Custom(LanguageModelV4CustomPart), + Reasoning(LanguageModelV4TextPart), + ReasoningFile(LanguageModelV4ReasoningFilePart), + ToolCall(LanguageModelV4ToolCallPart), + ToolResult(LanguageModelV4ToolResultPart), +} + +/// Tool message content. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum LanguageModelV4ToolPart { + ToolResult(LanguageModelV4ToolResultPart), + ToolApprovalResponse(LanguageModelV4ToolApprovalResponsePart), +} + +/// `LanguageModelV4ToolResultOutput`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +pub enum LanguageModelV4ToolResultOutput { + Text { + value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_options: Option, + }, + Json { + value: JsonValue, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_options: Option, + }, + ExecutionDenied { + #[serde(default, skip_serializing_if = "Option::is_none")] + reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_options: Option, + }, + ErrorText { + value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_options: Option, + }, + ErrorJson { + value: JsonValue, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_options: Option, + }, + Content { + value: Vec, + }, +} + +/// An element of the `content` tool-result output. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +pub enum LanguageModelV4ToolResultContentPart { + Text { + text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_options: Option, + }, + File { + data: SharedV4FileData, + media_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + filename: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_options: Option, + }, + Custom { + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_options: Option, + }, +} diff --git a/aimux-provider/src/shared.rs b/aimux-provider/src/shared.rs new file mode 100644 index 000000000..e8e0be1bf --- /dev/null +++ b/aimux-provider/src/shared.rs @@ -0,0 +1,96 @@ +//! `shared/v4`. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::JsonObject; + +/// `SharedV4ProviderOptions`: provider name to options object. +pub type SharedV4ProviderOptions = BTreeMap; +/// `SharedV4ProviderMetadata`: provider name to metadata object. +pub type SharedV4ProviderMetadata = BTreeMap; +/// `SharedV4Headers`. +pub type SharedV4Headers = BTreeMap; +/// `SharedV4ProviderReference`: provider name to provider file id. +pub type SharedV4ProviderReference = BTreeMap; + +/// `SharedV4AudioFormat`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedV4AudioFormat { + #[serde(rename = "type")] + pub format_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate: Option, +} + +/// `SharedV4FileDataData | SharedV4FileDataUrl`: the inline or linked file +/// data allowed in model output and reasoning files. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +pub enum SharedV4InlineFileData { + /// Base64 text (`Uint8Array` crosses the wire as base64). + Data { data: String }, + Url { + url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + original_url: Option, + }, +} + +/// `SharedV4FileData`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +pub enum SharedV4FileData { + /// Base64 text (`Uint8Array` crosses the wire as base64). + Data { + data: String, + }, + Url { + url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + original_url: Option, + }, + Reference { + reference: SharedV4ProviderReference, + }, + Text { + text: String, + }, +} + +/// `SharedV4Warning`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +pub enum SharedV4Warning { + Unsupported { + feature: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + details: Option, + }, + Compatibility { + feature: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + details: Option, + }, + Deprecated { + setting: String, + message: String, + }, + Other { + message: String, + }, +} diff --git a/aimux-provider/src/stream.rs b/aimux-provider/src/stream.rs new file mode 100644 index 000000000..9522b41fa --- /dev/null +++ b/aimux-provider/src/stream.rs @@ -0,0 +1,115 @@ +//! `language-model-v4-stream-part.ts` and the stream result envelope. + +use serde::{Deserialize, Serialize}; + +use crate::{ + JsonValue, LanguageModelV4CustomContent, LanguageModelV4File, LanguageModelV4FinishReason, + LanguageModelV4ResponseMetadata, LanguageModelV4Source, LanguageModelV4ToolApprovalRequest, + LanguageModelV4ToolCall, LanguageModelV4ToolResult, LanguageModelV4Usage, SharedV4Headers, + SharedV4ProviderMetadata, SharedV4Warning, +}; + +/// `LanguageModelV4StreamPart`. Text and reasoning deltas use `delta` (the +/// core layer calls it `text`; RFC-0042 §3.1). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +pub enum LanguageModelV4StreamPart { + TextStart { + id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, + TextDelta { + id: String, + delta: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, + TextEnd { + id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, + ReasoningStart { + id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, + ReasoningDelta { + id: String, + delta: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, + ReasoningEnd { + id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, + ToolInputStart { + id: String, + tool_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_executed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + dynamic: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + title: Option, + }, + ToolInputDelta { + id: String, + delta: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, + ToolInputEnd { + id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, + ToolApprovalRequest(LanguageModelV4ToolApprovalRequest), + ToolCall(LanguageModelV4ToolCall), + ToolResult(LanguageModelV4ToolResult), + Custom(LanguageModelV4CustomContent), + File(LanguageModelV4File), + ReasoningFile(LanguageModelV4File), + Source(LanguageModelV4Source), + StreamStart { + warnings: Vec, + }, + ResponseMetadata(LanguageModelV4ResponseMetadata), + Finish { + usage: LanguageModelV4Usage, + finish_reason: LanguageModelV4FinishReason, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, + Raw { + raw_value: JsonValue, + }, + Error { + error: JsonValue, + }, +} + +/// `LanguageModelV4StreamResult` without the stream itself. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct LanguageModelV4StreamResultInfo { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response: Option, +} + +/// `LanguageModelV4StreamResult['response']`. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct LanguageModelV4StreamResponseInfo { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headers: Option, +} diff --git a/aimux-provider/tests/no_aimux_dependencies_test.rs b/aimux-provider/tests/no_aimux_dependencies_test.rs new file mode 100644 index 000000000..968d68052 --- /dev/null +++ b/aimux-provider/tests/no_aimux_dependencies_test.rs @@ -0,0 +1,19 @@ +//! #200 §2.1 constraint 1: `aimux-provider` depends on no other aimux crate. + +#[test] +fn manifest_has_no_aimux_dependencies() { + let manifest = include_str!("../Cargo.toml"); + let mut section = ""; + for line in manifest.lines().map(str::trim) { + if line.starts_with('[') { + section = line; + continue; + } + if section.contains("dependencies") { + assert!( + !line.starts_with("aimux"), + "{section} has an aimux dependency: {line}" + ); + } + } +} diff --git a/aimux-provider/tests/wire_round_trip_test.rs b/aimux-provider/tests/wire_round_trip_test.rs new file mode 100644 index 000000000..e6c025fba --- /dev/null +++ b/aimux-provider/tests/wire_round_trip_test.rs @@ -0,0 +1,170 @@ +//! Every V4 type round-trips its AI SDK JSON exactly (camelCase fields, +//! kebab-case tags, absent optionals omitted). + +use aimux_provider::*; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; + +fn round_trip(wire: Value) -> T { + let parsed: T = serde_json::from_value(wire.clone()).unwrap_or_else(|e| panic!("{e}: {wire}")); + assert_eq!(serde_json::to_value(&parsed).unwrap(), wire); + parsed +} + +#[test] +fn prompt_round_trips() { + let prompt: LanguageModelV4Prompt = round_trip(json!([ + {"role": "system", "content": "be brief"}, + {"role": "user", "content": [ + {"type": "text", "text": "hi", "providerOptions": {"openai": {"x": 1}}}, + {"type": "file", "filename": "a.png", "mediaType": "image/png", "data": {"type": "data", "data": "AQID"}}, + {"type": "file", "mediaType": "application/pdf", "data": {"type": "url", "url": "https://x/a.pdf"}}, + {"type": "file", "mediaType": "application/pdf", "data": {"type": "reference", "reference": {"openai": "file-1"}}}, + {"type": "file", "mediaType": "text/plain", "data": {"type": "text", "text": "notes"}} + ], "providerOptions": {"anthropic": {"cache": true}}}, + {"role": "assistant", "content": [ + {"type": "reasoning", "text": "think"}, + {"type": "reasoning-file", "mediaType": "image/png", "data": {"type": "data", "data": "AA=="}}, + {"type": "custom", "kind": "openai.compaction"}, + {"type": "tool-call", "toolCallId": "c1", "toolName": "weather", "input": {"city": "Tokyo"}, "providerExecuted": true}, + {"type": "tool-result", "toolCallId": "c0", "toolName": "search", "output": {"type": "json", "value": [1, 2]}} + ]}, + {"role": "tool", "content": [ + {"type": "tool-result", "toolCallId": "c1", "toolName": "weather", "output": {"type": "content", "value": [ + {"type": "text", "text": "sunny"}, + {"type": "file", "mediaType": "image/png", "data": {"type": "data", "data": "AQ=="}, "filename": "w.png"}, + {"type": "custom"} + ]}}, + {"type": "tool-result", "toolCallId": "c2", "toolName": "rm", "output": {"type": "execution-denied", "reason": "no"}}, + {"type": "tool-result", "toolCallId": "c3", "toolName": "t", "output": {"type": "error-text", "value": "boom"}}, + {"type": "tool-result", "toolCallId": "c4", "toolName": "t", "output": {"type": "error-json", "value": {"e": 1}}}, + {"type": "tool-result", "toolCallId": "c5", "toolName": "t", "output": {"type": "text", "value": "ok"}}, + {"type": "tool-approval-response", "approvalId": "a1", "approved": false, "reason": "unsafe"} + ]} + ])); + // Input layer: a prompt tool call's input is parsed JSON. + let LanguageModelV4Message::Assistant { content, .. } = &prompt[2] else { + panic!() + }; + let LanguageModelV4AssistantPart::ToolCall(call) = &content[3] else { + panic!() + }; + assert_eq!(call.input, json!({"city": "Tokyo"})); +} + +#[test] +fn call_options_round_trip() { + round_trip::(json!({ + "prompt": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}], + "maxOutputTokens": 100, "temperature": 0.5, "stopSequences": ["\n"], + "topP": 0.9, "topK": 40.0, "presencePenalty": 0.1, "frequencyPenalty": 0.2, + "responseFormat": {"type": "json", "schema": {"type": "object"}, "name": "out"}, + "seed": 7, + "tools": [ + {"type": "function", "name": "weather", "description": "d", "inputSchema": {"type": "object"}, + "inputExamples": [{"input": {"city": "Paris"}}], "strict": true}, + {"type": "provider", "id": "openai.web_search", "name": "web_search", "args": {"size": "low"}} + ], + "toolChoice": {"type": "tool", "toolName": "weather"}, + "includeRawChunks": true, + "headers": {"x-a": "1", "x-b": null}, + "reasoning": "provider-default", + "providerOptions": {"openai": {"store": false}} + })); + for choice in ["auto", "none", "required"] { + round_trip::(json!({"type": choice})); + } + round_trip::(json!({"type": "text"})); + for level in [ + "provider-default", + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + ] { + round_trip::(json!(level)); + } +} + +#[test] +fn generate_result_round_trips() { + let result: LanguageModelV4GenerateResult = round_trip(json!({ + "content": [ + {"type": "text", "text": "hi", "providerMetadata": {"openai": {"id": "m"}}}, + {"type": "reasoning", "text": "r"}, + {"type": "custom", "kind": "openai.x"}, + {"type": "reasoning-file", "mediaType": "image/png", "data": {"type": "url", "url": "https://x", "originalUrl": "https://y"}}, + {"type": "file", "mediaType": "image/png", "data": {"type": "data", "data": "AQID"}}, + {"type": "tool-approval-request", "approvalId": "a1", "toolCallId": "c1"}, + {"type": "source", "sourceType": "url", "id": "s1", "url": "https://x", "title": "X"}, + {"type": "source", "sourceType": "document", "id": "s2", "mediaType": "application/pdf", "title": "D", "filename": "d.pdf"}, + {"type": "tool-call", "toolCallId": "c1", "toolName": "weather", "input": "{\"city\":\"Tokyo\"}", "dynamic": true}, + {"type": "tool-result", "toolCallId": "c1", "toolName": "weather", "result": {"t": 20}, "isError": false, "preliminary": true} + ], + "finishReason": {"unified": "tool-calls", "raw": "tool_use"}, + "usage": {"inputTokens": {"total": 10, "noCache": 6, "cacheRead": 4}, "outputTokens": {"total": 5, "text": 3, "reasoning": 2}, "raw": {"x": 1}}, + "providerMetadata": {"openai": {"a": 1}}, + "request": {"body": {"model": "m"}}, + "response": {"id": "r1", "timestamp": "2026-10-10T00:00:00Z", "modelId": "m", "headers": {"x": "y"}, "body": {}}, + "warnings": [ + {"type": "unsupported", "feature": "topK", "details": "d"}, + {"type": "compatibility", "feature": "seed"}, + {"type": "deprecated", "setting": "s", "message": "m"}, + {"type": "other", "message": "m"} + ] + })); + // Output layer: a tool call's input is the provider's text, unparsed. + let LanguageModelV4Content::ToolCall(call) = &result.content[8] else { + panic!() + }; + assert_eq!(call.input, "{\"city\":\"Tokyo\"}"); +} + +#[test] +fn stream_parts_round_trip() { + for part in [ + json!({"type": "stream-start", "warnings": []}), + json!({"type": "response-metadata", "id": "r", "modelId": "m"}), + json!({"type": "text-start", "id": "t"}), + json!({"type": "text-delta", "id": "t", "delta": "Hel"}), + json!({"type": "text-end", "id": "t", "providerMetadata": {"p": {}}}), + json!({"type": "reasoning-start", "id": "r"}), + json!({"type": "reasoning-delta", "id": "r", "delta": "x"}), + json!({"type": "reasoning-end", "id": "r"}), + json!({"type": "tool-input-start", "id": "c", "toolName": "w", "providerExecuted": true, "dynamic": true, "title": "W"}), + json!({"type": "tool-input-delta", "id": "c", "delta": "{\"ci"}), + json!({"type": "tool-input-end", "id": "c"}), + json!({"type": "tool-approval-request", "approvalId": "a", "toolCallId": "c"}), + json!({"type": "tool-call", "toolCallId": "c", "toolName": "w", "input": "\"Tokyo\""}), + json!({"type": "tool-result", "toolCallId": "c", "toolName": "w", "result": "ok"}), + json!({"type": "custom", "kind": "a.b"}), + json!({"type": "file", "mediaType": "image/png", "data": {"type": "data", "data": "AA=="}}), + json!({"type": "reasoning-file", "mediaType": "image/png", "data": {"type": "data", "data": "AA=="}}), + json!({"type": "source", "sourceType": "url", "id": "s", "url": "https://x"}), + json!({"type": "finish", "usage": {"inputTokens": {}, "outputTokens": {}}, "finishReason": {"unified": "stop"}}), + json!({"type": "raw", "rawValue": {"any": [1]}}), + json!({"type": "error", "error": "boom"}), + ] { + round_trip::(part); + } + round_trip::( + json!({"request": {"body": "x"}, "response": {"headers": {"a": "b"}}}), + ); +} + +#[test] +fn unknown_tags_are_rejected() { + assert!( + serde_json::from_value::(json!({"type": "text", "text": "x"})) + .is_err() + ); + assert!( + serde_json::from_value::( + json!({"role": "developer", "content": "x"}) + ) + .is_err() + ); +} From 59975fb5d6b5c351add897f7411db90d7cf17485 Mon Sep 17 00:00:00 2001 From: Trey Kim Date: Sun, 11 Oct 2026 06:50:37 +0800 Subject: [PATCH 5/7] feat(provider): ProviderError mirroring @ai-sdk/provider src/errors (std Error, no wire; D21) --- aimux-provider/src/errors.rs | 425 ++++++++++++++++++++++++++++ aimux-provider/src/lib.rs | 5 + aimux-provider/tests/errors_test.rs | 240 ++++++++++++++++ 3 files changed, 670 insertions(+) create mode 100644 aimux-provider/src/errors.rs create mode 100644 aimux-provider/tests/errors_test.rs diff --git a/aimux-provider/src/errors.rs b/aimux-provider/src/errors.rs new file mode 100644 index 000000000..f3e23025c --- /dev/null +++ b/aimux-provider/src/errors.rs @@ -0,0 +1,425 @@ +//! `@ai-sdk/provider` `src/errors`: the provider error classes as one Rust +//! error type. +//! +//! Each variant mirrors one upstream class. `message` is the upstream +//! `message`; `cause` is reachable through [`std::error::Error::source`]. +//! The constructors fill in upstream's default messages. Mapping to aimux +//! error codes and golden fixtures is deferred to 3.3 (D21). + +use std::collections::BTreeMap; +use std::error::Error as StdError; +use std::fmt; + +use crate::{JsonValue, SharedV4ProviderReference}; + +/// A boxed underlying error (`cause`). +pub type BoxError = Box; + +/// `NoSuchModelError.modelType`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelType { + LanguageModel, + EmbeddingModel, + ImageModel, + TranscriptionModel, + SpeechModel, + RerankingModel, + VideoModel, + EvaluationModel, +} + +impl ModelType { + /// Upstream spelling (`languageModel`, ...). + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::LanguageModel => "languageModel", + Self::EmbeddingModel => "embeddingModel", + Self::ImageModel => "imageModel", + Self::TranscriptionModel => "transcriptionModel", + Self::SpeechModel => "speechModel", + Self::RerankingModel => "rerankingModel", + Self::VideoModel => "videoModel", + Self::EvaluationModel => "evaluationModel", + } + } +} + +impl fmt::Display for ModelType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// `TypeValidationContext`. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TypeValidationContext { + pub field: Option, + pub entity_name: Option, + pub entity_id: Option, +} + +/// The `@ai-sdk/provider@4.0.21` error classes. +#[derive(Debug)] +#[non_exhaustive] +pub enum ProviderError { + /// `AISDKError` used directly (`ai-sdk-error.ts`). + AiSdk { + name: String, + message: String, + cause: Option, + }, + /// `APICallError` (`api-call-error.ts`). + ApiCall { + message: String, + url: String, + request_body_values: JsonValue, + status_code: Option, + response_headers: Option>, + response_body: Option, + is_retryable: bool, + data: Option, + cause: Option, + }, + /// `EmptyResponseBodyError` (`empty-response-body-error.ts`). + EmptyResponseBody { message: String }, + /// `EvaluationUnsupportedQuestionTypeError`. + EvaluationUnsupportedQuestionType { + message: String, + question_id: String, + question_type: String, + provider: String, + model_id: String, + }, + /// `InvalidArgumentError` (`invalid-argument-error.ts`). + InvalidArgument { + message: String, + argument: String, + cause: Option, + }, + /// `InvalidPromptError` (`invalid-prompt-error.ts`). + InvalidPrompt { + message: String, + prompt: JsonValue, + cause: Option, + }, + /// `InvalidResponseDataError` (`invalid-response-data-error.ts`). + InvalidResponseData { message: String, data: JsonValue }, + /// `JSONParseError` (`json-parse-error.ts`). + JsonParse { + message: String, + text: String, + cause: Option, + }, + /// `LoadAPIKeyError` (`load-api-key-error.ts`). + LoadApiKey { message: String }, + /// `LoadSettingError` (`load-setting-error.ts`). + LoadSetting { message: String }, + /// `NoContentGeneratedError` (`no-content-generated-error.ts`). + NoContentGenerated { message: String }, + /// `NoSuchModelError` (`no-such-model-error.ts`). `error_name` defaults to + /// `AI_NoSuchModelError`; subclasses pass their own. + NoSuchModel { + message: String, + error_name: String, + model_id: String, + model_type: ModelType, + }, + /// `NoSuchProviderReferenceError`. + NoSuchProviderReference { + message: String, + provider: String, + reference: SharedV4ProviderReference, + }, + /// `TooManyEmbeddingValuesForCallError`. + TooManyEmbeddingValuesForCall { + message: String, + provider: String, + model_id: String, + max_embeddings_per_call: usize, + values: Vec, + }, + /// `TypeValidationError` (`type-validation-error.ts`). + TypeValidation { + message: String, + value: JsonValue, + context: Option, + cause: Option, + }, + /// `UnsupportedFunctionalityError`. + UnsupportedFunctionality { + message: String, + functionality: String, + }, +} + +/// `getErrorMessage(cause)`. +fn error_message(cause: Option<&BoxError>) -> String { + cause.map_or_else(|| "unknown error".to_owned(), ToString::to_string) +} + +impl ProviderError { + /// `APICallError` with upstream's default `isRetryable`: status 408, 409, + /// 429 or >= 500. + #[must_use] + pub fn api_call( + message: impl Into, + url: impl Into, + request_body_values: JsonValue, + status_code: Option, + ) -> Self { + Self::ApiCall { + message: message.into(), + url: url.into(), + request_body_values, + status_code, + response_headers: None, + response_body: None, + is_retryable: status_code.is_some_and(|s| matches!(s, 408 | 409 | 429) || s >= 500), + data: None, + cause: None, + } + } + + /// `EmptyResponseBodyError` with the default message. + #[must_use] + pub fn empty_response_body() -> Self { + Self::EmptyResponseBody { + message: "Empty response body".into(), + } + } + + /// `EvaluationUnsupportedQuestionTypeError` with the default message. + #[must_use] + pub fn evaluation_unsupported_question_type( + question_id: impl Into, + question_type: impl Into, + provider: impl Into, + model_id: impl Into, + ) -> Self { + let (question_id, question_type, provider, model_id) = ( + question_id.into(), + question_type.into(), + provider.into(), + model_id.into(), + ); + Self::EvaluationUnsupportedQuestionType { + message: format!( + "Question \"{question_id}\" has type \"{question_type}\", which is not supported by provider \"{provider}\" and model \"{model_id}\"." + ), + question_id, + question_type, + provider, + model_id, + } + } + + /// `InvalidResponseDataError` with the default message. + #[must_use] + pub fn invalid_response_data(data: JsonValue) -> Self { + Self::InvalidResponseData { + message: format!("Invalid response data: {data}."), + data, + } + } + + /// `JSONParseError`; the message embeds the text and the cause. + #[must_use] + pub fn json_parse(text: impl Into, cause: Option) -> Self { + let text = text.into(); + Self::JsonParse { + message: format!( + "JSON parsing failed: Text: {text}.\nError message: {}", + error_message(cause.as_ref()) + ), + text, + cause, + } + } + + /// `NoContentGeneratedError` with the default message. + #[must_use] + pub fn no_content_generated() -> Self { + Self::NoContentGenerated { + message: "No content generated.".into(), + } + } + + /// `NoSuchModelError` with the default name and message. + #[must_use] + pub fn no_such_model(model_id: impl Into, model_type: ModelType) -> Self { + let model_id = model_id.into(); + Self::NoSuchModel { + message: format!("No such {model_type}: {model_id}"), + error_name: "AI_NoSuchModelError".into(), + model_id, + model_type, + } + } + + /// `NoSuchProviderReferenceError` with the default message. + #[must_use] + pub fn no_such_provider_reference( + provider: impl Into, + reference: SharedV4ProviderReference, + ) -> Self { + let provider = provider.into(); + let available: Vec<&str> = reference.keys().map(String::as_str).collect(); + Self::NoSuchProviderReference { + message: format!( + "No provider reference found for provider '{provider}'. Available providers: {}", + available.join(", ") + ), + provider, + reference, + } + } + + /// `TooManyEmbeddingValuesForCallError`; the message counts `values`. + #[must_use] + pub fn too_many_embedding_values_for_call( + provider: impl Into, + model_id: impl Into, + max_embeddings_per_call: usize, + values: Vec, + ) -> Self { + let (provider, model_id) = (provider.into(), model_id.into()); + Self::TooManyEmbeddingValuesForCall { + message: format!( + "Too many values for a single embedding call. The {provider} model \"{model_id}\" can only embed up to {max_embeddings_per_call} values per call, but {} values were provided.", + values.len() + ), + provider, + model_id, + max_embeddings_per_call, + values, + } + } + + /// `TypeValidationError`; the message embeds the context, value and cause. + #[must_use] + pub fn type_validation( + value: JsonValue, + context: Option, + cause: Option, + ) -> Self { + let mut prefix = String::from("Type validation failed"); + if let Some(ctx) = &context { + if let Some(field) = &ctx.field { + prefix.push_str(&format!(" for {field}")); + } + if ctx.entity_name.is_some() || ctx.entity_id.is_some() { + let mut parts = Vec::new(); + if let Some(name) = &ctx.entity_name { + parts.push(name.clone()); + } + if let Some(id) = &ctx.entity_id { + parts.push(format!("id: \"{id}\"")); + } + prefix.push_str(&format!(" ({})", parts.join(", "))); + } + } + Self::TypeValidation { + message: format!( + "{prefix}: Value: {value}.\nError message: {}", + error_message(cause.as_ref()) + ), + value, + context, + cause, + } + } + + /// `UnsupportedFunctionalityError` with the default message. + #[must_use] + pub fn unsupported_functionality(functionality: impl Into) -> Self { + let functionality = functionality.into(); + Self::UnsupportedFunctionality { + message: format!("'{functionality}' functionality not supported."), + functionality, + } + } + + /// The upstream `name` (`AI_APICallError`, ...). + #[must_use] + pub fn name(&self) -> &str { + match self { + Self::AiSdk { name, .. } => name, + Self::ApiCall { .. } => "AI_APICallError", + Self::EmptyResponseBody { .. } => "AI_EmptyResponseBodyError", + Self::EvaluationUnsupportedQuestionType { .. } => { + "AI_EvaluationUnsupportedQuestionTypeError" + } + Self::InvalidArgument { .. } => "AI_InvalidArgumentError", + Self::InvalidPrompt { .. } => "AI_InvalidPromptError", + Self::InvalidResponseData { .. } => "AI_InvalidResponseDataError", + Self::JsonParse { .. } => "AI_JSONParseError", + Self::LoadApiKey { .. } => "AI_LoadAPIKeyError", + Self::LoadSetting { .. } => "AI_LoadSettingError", + Self::NoContentGenerated { .. } => "AI_NoContentGeneratedError", + Self::NoSuchModel { error_name, .. } => error_name, + Self::NoSuchProviderReference { .. } => "AI_NoSuchProviderReferenceError", + Self::TooManyEmbeddingValuesForCall { .. } => "AI_TooManyEmbeddingValuesForCallError", + Self::TypeValidation { .. } => "AI_TypeValidationError", + Self::UnsupportedFunctionality { .. } => "AI_UnsupportedFunctionalityError", + } + } + + /// The upstream `message`. + #[must_use] + pub fn message(&self) -> &str { + match self { + Self::AiSdk { message, .. } + | Self::ApiCall { message, .. } + | Self::EmptyResponseBody { message } + | Self::EvaluationUnsupportedQuestionType { message, .. } + | Self::InvalidArgument { message, .. } + | Self::InvalidPrompt { message, .. } + | Self::InvalidResponseData { message, .. } + | Self::JsonParse { message, .. } + | Self::LoadApiKey { message } + | Self::LoadSetting { message } + | Self::NoContentGenerated { message } + | Self::NoSuchModel { message, .. } + | Self::NoSuchProviderReference { message, .. } + | Self::TooManyEmbeddingValuesForCall { message, .. } + | Self::TypeValidation { message, .. } + | Self::UnsupportedFunctionality { message, .. } => message, + } + } + + /// Upstream `APICallError.isRetryable`; no other class is retryable. + #[must_use] + pub fn is_retryable(&self) -> bool { + matches!( + self, + Self::ApiCall { + is_retryable: true, + .. + } + ) + } +} + +impl fmt::Display for ProviderError { + /// `message`, as upstream `Error.message`. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.message()) + } +} + +impl StdError for ProviderError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + match self { + Self::AiSdk { cause, .. } + | Self::ApiCall { cause, .. } + | Self::InvalidArgument { cause, .. } + | Self::InvalidPrompt { cause, .. } + | Self::JsonParse { cause, .. } + | Self::TypeValidation { cause, .. } => { + cause.as_deref().map(|c| c as &(dyn StdError + 'static)) + } + _ => None, + } + } +} diff --git a/aimux-provider/src/lib.rs b/aimux-provider/src/lib.rs index 0e3bdbee2..6a8fadd33 100644 --- a/aimux-provider/src/lib.rs +++ b/aimux-provider/src/lib.rs @@ -16,18 +16,23 @@ //! tool call's `input` is the provider's argument *text* (`String`). No core //! `invalid` / `error` / tool metadata. //! +//! Errors: [`ProviderError`] mirrors `src/errors` (std `Error`, no wire +//! format; D21). +//! //! Not modelled: the `LanguageModelV4` interface itself (`doGenerate`, //! `doStream`, `supportedUrls`) and `abortSignal`, which are behaviour, not //! data. mod call_options; mod content; +mod errors; mod prompt; mod shared; mod stream; pub use call_options::*; pub use content::*; +pub use errors::*; pub use prompt::*; pub use shared::*; pub use stream::*; diff --git a/aimux-provider/tests/errors_test.rs b/aimux-provider/tests/errors_test.rs new file mode 100644 index 000000000..47ae97ee1 --- /dev/null +++ b/aimux-provider/tests/errors_test.rs @@ -0,0 +1,240 @@ +//! `ProviderError`: construction, Display text (upstream messages), +//! `is_retryable`, and the `source()` chain. + +use std::error::Error; +use std::io; + +use aimux_provider::{ModelType, ProviderError, TypeValidationContext}; +use serde_json::json; + +fn io_cause(text: &str) -> aimux_provider::BoxError { + Box::new(io::Error::other(text.to_owned())) +} + +#[test] +fn api_call_retryable_follows_upstream_status_rule() { + for (status, retryable) in [ + (None, false), + (Some(400), false), + (Some(401), false), + (Some(404), false), + (Some(408), true), + (Some(409), true), + (Some(429), true), + (Some(499), false), + (Some(500), true), + (Some(503), true), + ] { + let error = ProviderError::api_call("Bad", "https://x/v1", json!({"a": 1}), status); + assert_eq!(error.is_retryable(), retryable, "status {status:?}"); + assert_eq!(error.name(), "AI_APICallError"); + assert_eq!(error.to_string(), "Bad"); + } + // An explicit isRetryable overrides the default, as upstream. + let ProviderError::ApiCall { .. } = ProviderError::api_call("m", "u", json!(null), Some(500)) + else { + panic!() + }; + let explicit = ProviderError::ApiCall { + message: "m".into(), + url: "u".into(), + request_body_values: json!(null), + status_code: Some(500), + response_headers: None, + response_body: Some("oops".into()), + is_retryable: false, + data: None, + cause: Some(io_cause("reset")), + }; + assert!(!explicit.is_retryable()); + assert_eq!(explicit.source().unwrap().to_string(), "reset"); +} + +#[test] +fn only_api_call_is_retryable() { + for error in [ + ProviderError::empty_response_body(), + ProviderError::invalid_response_data(json!({"x": 1})), + ProviderError::no_content_generated(), + ProviderError::LoadApiKey { + message: "k".into(), + }, + ProviderError::unsupported_functionality("seed"), + ] { + assert!(!error.is_retryable(), "{}", error.name()); + } +} + +#[test] +fn default_messages_match_upstream() { + let cases: Vec<(ProviderError, &str, &str)> = vec![ + ( + ProviderError::empty_response_body(), + "AI_EmptyResponseBodyError", + "Empty response body", + ), + ( + ProviderError::evaluation_unsupported_question_type("q1", "rating", "openai", "gpt"), + "AI_EvaluationUnsupportedQuestionTypeError", + "Question \"q1\" has type \"rating\", which is not supported by provider \"openai\" and model \"gpt\".", + ), + ( + ProviderError::invalid_response_data(json!({"a": [1]})), + "AI_InvalidResponseDataError", + "Invalid response data: {\"a\":[1]}.", + ), + ( + ProviderError::no_content_generated(), + "AI_NoContentGeneratedError", + "No content generated.", + ), + ( + ProviderError::no_such_model("m1", ModelType::EmbeddingModel), + "AI_NoSuchModelError", + "No such embeddingModel: m1", + ), + ( + ProviderError::no_such_provider_reference( + "google", + [ + ("openai".to_string(), "f1".to_string()), + ("anthropic".to_string(), "f2".to_string()), + ] + .into(), + ), + "AI_NoSuchProviderReferenceError", + "No provider reference found for provider 'google'. Available providers: anthropic, openai", + ), + ( + ProviderError::too_many_embedding_values_for_call( + "openai", + "e3", + 2, + vec![json!("a"), json!("b"), json!("c")], + ), + "AI_TooManyEmbeddingValuesForCallError", + "Too many values for a single embedding call. The openai model \"e3\" can only embed up to 2 values per call, but 3 values were provided.", + ), + ( + ProviderError::unsupported_functionality("topK"), + "AI_UnsupportedFunctionalityError", + "'topK' functionality not supported.", + ), + ( + ProviderError::LoadApiKey { + message: "OpenAI API key is missing.".into(), + }, + "AI_LoadAPIKeyError", + "OpenAI API key is missing.", + ), + ( + ProviderError::LoadSetting { + message: "region missing".into(), + }, + "AI_LoadSettingError", + "region missing", + ), + ( + ProviderError::InvalidArgument { + message: "bad n".into(), + argument: "n".into(), + cause: None, + }, + "AI_InvalidArgumentError", + "bad n", + ), + ( + ProviderError::InvalidPrompt { + message: "empty".into(), + prompt: json!([]), + cause: None, + }, + "AI_InvalidPromptError", + "empty", + ), + ( + ProviderError::AiSdk { + name: "AI_Custom".into(), + message: "custom".into(), + cause: None, + }, + "AI_Custom", + "custom", + ), + ]; + for (error, name, text) in cases { + assert_eq!(error.name(), name); + assert_eq!(error.to_string(), text); + assert!(error.source().is_none(), "{name}"); + } +} + +#[test] +fn json_parse_message_and_source() { + let error = ProviderError::json_parse("{\"a\":", Some(io_cause("EOF while parsing"))); + assert_eq!(error.name(), "AI_JSONParseError"); + assert_eq!( + error.to_string(), + "JSON parsing failed: Text: {\"a\":.\nError message: EOF while parsing" + ); + assert_eq!(error.source().unwrap().to_string(), "EOF while parsing"); + let no_cause = ProviderError::json_parse("x", None); + assert!( + no_cause + .to_string() + .ends_with("Error message: unknown error") + ); +} + +#[test] +fn type_validation_message_context_and_source_chain() { + let inner = ProviderError::unsupported_functionality("x"); + let error = ProviderError::type_validation( + json!({"id": 7}), + Some(TypeValidationContext { + field: Some("message.parts[3].data".into()), + entity_name: Some("Message".into()), + entity_id: Some("m-1".into()), + }), + Some(Box::new(inner)), + ); + assert_eq!( + error.to_string(), + "Type validation failed for message.parts[3].data (Message, id: \"m-1\"): Value: {\"id\":7}.\nError message: 'x' functionality not supported." + ); + // Two-level chain: TypeValidation -> UnsupportedFunctionality -> none. + let first = error.source().unwrap(); + assert_eq!(first.to_string(), "'x' functionality not supported."); + assert!(first.source().is_none()); + + let bare = ProviderError::type_validation(json!(1), None, None); + assert_eq!( + bare.to_string(), + "Type validation failed: Value: 1.\nError message: unknown error" + ); +} + +#[test] +fn no_such_model_subclass_name_is_kept() { + let ProviderError::NoSuchModel { model_type, .. } = + ProviderError::no_such_model("x", ModelType::LanguageModel) + else { + panic!() + }; + assert_eq!(model_type.as_str(), "languageModel"); + let sub = ProviderError::NoSuchModel { + message: "No such provider: p".into(), + error_name: "AI_NoSuchProviderError".into(), + model_id: "x".into(), + model_type: ModelType::LanguageModel, + }; + assert_eq!(sub.name(), "AI_NoSuchProviderError"); +} + +#[test] +fn errors_are_send_sync_and_boxable() { + fn assert_send_sync() {} + assert_send_sync::(); + let boxed: Box = Box::new(ProviderError::no_content_generated()); + assert_eq!(boxed.to_string(), "No content generated."); +} From 874f261fb3aba28333c1cac068116abf3cf5d180 Mon Sep 17 00:00:00 2001 From: Trey Kim Date: Sun, 11 Oct 2026 12:25:06 +0800 Subject: [PATCH 6/7] ci: run push builds only on master; exp/** via pull_request --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67c660ced..7a3ed2a18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [master, "exp/**"] + branches: [master] pull_request: branches: [master, "exp/**"] From 8455db9abe5315ebbc2d542cc419490e228cb6f6 Mon Sep 17 00:00:00 2001 From: Trey Kim Date: Sun, 11 Oct 2026 12:25:06 +0800 Subject: [PATCH 7/7] ci: cancel superseded PR runs via concurrency group Only pull_request runs cancel in-progress runs in the same group; master push runs are never cancelled. Note: with push limited to master (previous commit), direct pushes to exp/restart no longer run CI separately; exp/** branches are covered only via pull_request. --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a3ed2a18..5c1c7c748 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [master, "exp/**"] +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: CARGO_TERM_COLOR: always