Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Breaking

- Provider options and provider metadata have one typed shape, the AI SDK's
`Record<string, JSONObject>`: `SharedProviderOptions` and
`SharedProviderMetadata` (`ProviderMetadata` is an alias) are
`HashMap<String, JsonObject>`, namespace to JSON object. They replace
`serde_json::Value`, `HashMap<String, Value>` and the per-part
`Option<Value>`. Every `provider_options` / `provider_metadata` field of
content parts, messages, call options, tools, results and stream parts uses
them. `{"namespace": {"key": value}}` serializes as before; a value that is
not namespace -> object (a number, string or array under a namespace, or a
non-object at the top) is rejected when deserialized. Build one with
`provider_namespace(ns, json!({..}))`.

- Tool-call and tool-result types are unified, one per protocol layer
(`aimux-core`). A provider emits `tool::RawToolCall` (`input` is the raw
argument text) in `GenerateContent::ToolCall(..)` and
Expand Down
196 changes: 196 additions & 0 deletions MIGRATION-NS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# Namespace types migration recipe (provider options / provider metadata)

Do not commit this file.

## What changed

Both are now `HashMap<String, JsonObject>` (namespace -> JSON object), the AI SDK `Record<string, JSONObject>`:

```rust
// aimux_core::shared (also re-exported from aimux_core::prelude)
pub type JsonObject = serde_json::Map<String, serde_json::Value>;
pub type SharedProviderOptions = HashMap<String, JsonObject>;
pub type SharedProviderMetadata = HashMap<String, JsonObject>;
pub fn provider_namespace(namespace: &str, object: serde_json::Value) -> SharedProviderMetadata;
// aimux_core::types
pub type ProviderMetadata = SharedProviderMetadata; // was serde_json::Value
```

Every `provider_options` field (content parts, messages, CallOptions, GenerateTextOptions, FunctionTool, every
`*CallOptions`) is `Option<SharedProviderOptions>` (the media/embedding call options that were already non-Option stay
non-Option). Every `provider_metadata` field (GenerateContent, StreamPart, ToolCall/ToolResult/RawToolCall, Source,
ReasoningOutput, GenerateResult, ...) is `Option<ProviderMetadata>`. Not changed: `recording::ProviderRecord.provider_options`
(provider construction options such as org/project, flat, not namespaced).

Imports:

```rust
use aimux_core::shared::{provider_namespace, JsonObject, SharedProviderMetadata, SharedProviderOptions};
use aimux_core::types::ProviderMetadata;
```

Wire JSON is unchanged: `{"openai": {"k": v}}`. A non-object namespace value (`{"openai": 1}`, `[]`, `"x"`, `null`) or a
non-object top level is now a deserialization error.

## Patterns

### 1. Build metadata / options, one namespace (the common case)

```rust
// before
provider_metadata: Some(json!({ "xai": { "itemId": item_id } })),
// after
provider_metadata: Some(provider_namespace("xai", json!({ "itemId": item_id }))),
```

Same for `provider_options: Some(...)` (identical type). The helper is `#[must_use]`; a non-object second argument yields
an empty namespace, so pass an object literal, or build a `JsonObject` yourself (pattern 3).

Functions that returned `Value` metadata now return `ProviderMetadata`:

```rust
// before
fn server_tool_metadata(...) -> Value { ...; json!({ "google": payload }) }
// after
fn server_tool_metadata(...) -> ProviderMetadata { ...; provider_namespace("google", payload) }
```

### 2. Build metadata with several namespaces

```rust
// before
Some(json!({
"googleVertex": { "thoughtSignature": "a" },
"vertex": { "thoughtSignature": "b" },
}))
// after (tests / fixtures): deserialize the literal, shape is checked by the type
Some(serde_json::from_value(json!({
"googleVertex": { "thoughtSignature": "a" },
"vertex": { "thoughtSignature": "b" },
})).unwrap())
// after (non-test code): extend
let mut m = provider_namespace("bedrock", json!({ "signature": sig }));
m.extend(provider_namespace("amazonBedrock", json!({ "signature": sig })));
Some(m)
```

### 3. Build from a `Map` you filled imperatively

The `Value::Object(..)` wrapper goes away.

```rust
// before
let mut metadata = HashMap::new();
let mut prodia_meta = Map::new();
prodia_meta.insert("images".into(), json!([job_result]));
metadata.insert("prodia".into(), Value::Object(prodia_meta));
// after
metadata.insert("prodia".into(), prodia_meta); // prodia_meta: JsonObject (= Map<String, Value>)

// before
std::iter::once(("google".to_string(), Value::Object(metadata))).collect()
// after
HashMap::from([("google".to_string(), metadata)]) // or provider_namespace("google", Value::Object(metadata))
```

`Map::new()` is the same type as `JsonObject::new()`; `use aimux_core::shared::JsonObject` if you want the alias.

### 4. Merge into existing metadata

```rust
// before (Value)
meta["xai"]["reasoningEncryptedContent"] = json!(ec);
if let Some(o) = meta.as_object_mut() { o.insert(...) }
// after
meta.entry("xai".into()).or_default().insert("reasoningEncryptedContent".into(), json!(ec));
// merge a whole map one level deep
for (ns, obj) in other { meta.entry(ns).or_default().extend(obj); }
```

Building the inner object first and wrapping once is also fine:

```rust
let mut inner = json!({ "itemId": part_id }); // before: json! then index-assign
inner["reasoningEncryptedContent"] = json!(ec); // still works on a Value
provider_namespace("xai", inner) // wrap at the end
```

### 5. Read an option from a namespace

`options.provider_options.get("x")` now returns `Option<&JsonObject>`, not `Option<&Value>`. Method chains that call
`.get("k")`, `.and_then(|o| o.get("k"))`, `.as_str()` on the *inner values* are unchanged.

```rust
// before and after (compiles unchanged)
let v = opts.provider_options.get("openai").and_then(|o| o.get("user")).and_then(|v| v.as_str());
// per-part options (Option<Value> before, Option<SharedProviderOptions> now)
let cc = provider_options.as_ref().and_then(|o| o.get("anthropic")).and_then(|a| a.get("cacheControl"));
```

What breaks:

```rust
// before: object-ness check on the namespace value
let o = provider_opts.as_object()?; // E0599: no method as_object on &Map
// after: it already is the object
let o = provider_opts;
// before: .and_then(|v| v.as_object()) after: delete the and_then (the value is already &JsonObject)
// before: opts["k"] (Value index, Null on miss) after: Map index PANICS on a missing key; use opts.get("k")
// before: Value::is_object / is_null on the namespace after: gone (always an object)
// need a Value again (e.g. to feed serde_json::from_value or json!): Value::Object(obj.clone())
```

Looping a string-literal slice: `for key in &["bedrock", "amazonBedrock"] { po.get(key) }` fails with
`String: Borrow<&str>` (E0277); iterate by value (`for key in ["bedrock", "amazonBedrock"]`) or write `po.get(*key)`.

### 6. Pass through

Fields and function parameters that merely carry a value need no change except the declared type:

```rust
// before
fn f(meta: Option<Value>) -> StreamPart { StreamPart::TextEnd { id, provider_metadata: meta } }
// after
fn f(meta: Option<ProviderMetadata>) -> StreamPart { ... }
```

Local variables typed `Option<Value>` / `HashMap<String, Value>` that end up in these fields: retype them.
Intermediate scratch values that are not themselves stored (e.g. `StreamingToolCallTracker::with_extract_metadata`
closures, which still return `Option<Value>`) stay `Value`; `with_build_provider_metadata` still takes `Option<&Value>`
and returns `Option<ProviderMetadata>`, so build with `provider_namespace("google", json!({...}))`.

### 7. Tests comparing against `json!`

```rust
// before
assert_eq!(part.provider_metadata, Some(json!({ "anthropic": { "signature": "s" } })));
// after, option 1: compare typed
assert_eq!(part.provider_metadata, Some(provider_namespace("anthropic", json!({ "signature": "s" }))));
// after, option 2: compare the serialized shape (keeps the json! literal as-is)
assert_eq!(serde_json::to_value(&part.provider_metadata).unwrap(), json!({ "anthropic": { "signature": "s" } }));
// indexing keeps working: HashMap<String, Map> -> m["anthropic"]["signature"] == json!("s")
// but a whole namespace is a Map, not a Value: Value::Object(m["anthropic"].clone()) == json!({ ... })
```

### 8. `Option<Value>` per-part / per-message options

`ContentPart::*.provider_options`, `LanguageModelPromptMessage.provider_options` were `Option<Value>`; now
`Option<SharedProviderOptions>`. Constructing sites become `Some(provider_namespace(..))` (pattern 1); `None` unchanged.
A flat (non-namespaced) object such as `json!({ "organization": "o" })` is no longer representable there; if a test
or code passed one, it was relying on the loose shape: wrap it in a namespace or drop it.

### 9. `Value` results holding provider metadata

`GenerateTextResult.provider_metadata`, `GenerateObjectResult.provider_metadata`, `ReasoningOutput.provider_metadata` and
the stream `Finish` part are `Option<ProviderMetadata>`. `ResponseMessageBuilder` is `pub(crate)` (core only).

## Contract-test fixtures / tests that fail because they pin a non-object namespace value

Scanned every `*.json` / `*.jsonl` under contract-tests, aimux-core, aimux-provider-utils, aimux-providers/tests
(fixtures and cassettes), bindings and tools for `provider_options|provider_metadata|providerOptions|providerMetadata`
keys whose value is not `null` or an object of objects: none found. In particular
`contract-tests/fixtures/wire-format.json` (15 occurrences) already uses the namespace -> object shape, and
`aimux-core/tests/contract_test.rs` passes. Tests inside `aimux-providers/tests/*.rs` that build these values with
`json!` do not compile yet and are handled per vendor directory with the recipe above; any that pin a non-object
namespace value (for example `json!({ "openai": 1 })` or a flat `{ "organization": ... }`) must be reported with
file:line rather than kept loose.
24 changes: 13 additions & 11 deletions aimux-core/src/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use ts_rs::TS;

use crate::shared::SharedProviderOptions;

/// A part of a multi-part message.
///
/// Shared between `ModelMessage` (user-facing) and `LanguageModelPrompt` (provider-facing).
Expand All @@ -16,22 +18,22 @@ pub enum ContentPart {
text: String,
/// Provider-specific options for this part (e.g. `openai.promptCacheBreakpoint`).
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_options: Option<Value>,
provider_options: Option<SharedProviderOptions>,
},

/// Provider-specific custom content.
Custom {
kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_options: Option<Value>,
provider_options: Option<SharedProviderOptions>,
},

/// A file generated during reasoning.
ReasoningFile {
data: crate::shared::GeneratedFileData,
media_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_options: Option<Value>,
provider_options: Option<SharedProviderOptions>,
},

/// A request to approve a tool call on a later turn.
Expand All @@ -53,7 +55,7 @@ pub enum ContentPart {
image: Vec<u8>,
media_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_options: Option<Value>,
provider_options: Option<SharedProviderOptions>,
},

/// A file (raw bytes + MIME type).
Expand All @@ -63,7 +65,7 @@ pub enum ContentPart {
#[serde(default, skip_serializing_if = "Option::is_none")]
filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_options: Option<Value>,
provider_options: Option<SharedProviderOptions>,
},

/// A file whose inline data is an already-base64-encoded string.
Expand All @@ -78,7 +80,7 @@ pub enum ContentPart {
#[serde(default, skip_serializing_if = "Option::is_none")]
filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_options: Option<Value>,
provider_options: Option<SharedProviderOptions>,
},

/// A file referenced by URL.
Expand All @@ -88,7 +90,7 @@ pub enum ContentPart {
url: String,
media_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_options: Option<Value>,
provider_options: Option<SharedProviderOptions>,
},

/// A file referenced by a provider-specific reference (e.g. an OpenAI file ID).
Expand All @@ -102,7 +104,7 @@ pub enum ContentPart {
#[serde(default, skip_serializing_if = "Option::is_none")]
filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_options: Option<Value>,
provider_options: Option<SharedProviderOptions>,
},

/// A reasoning / thinking segment produced by the model.
Expand All @@ -119,7 +121,7 @@ pub enum ContentPart {
/// `anthropic.signature`, `anthropic.redactedData`,
/// `anthropic.cacheControl`).
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_options: Option<Value>,
provider_options: Option<SharedProviderOptions>,
},

/// A tool call requested by the model.
Expand All @@ -136,7 +138,7 @@ pub enum ContentPart {
/// Provider-specific options for this part (e.g.
/// `anthropic.cacheControl`).
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_options: Option<Value>,
provider_options: Option<SharedProviderOptions>,
},

/// The result of executing a tool call.
Expand Down Expand Up @@ -167,7 +169,7 @@ pub enum ContentPart {
/// Provider-specific options for this part (e.g.
/// `anthropic.cacheControl`).
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_options: Option<Value>,
provider_options: Option<SharedProviderOptions>,
},
}

Expand Down
11 changes: 6 additions & 5 deletions aimux-core/src/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ use crate::result::{
GenerateContent, GenerateResult, GeneratedFile, ReasoningFileOutput, ReasoningPart, Source,
StreamResult, StreamTextResultAggregated, TextContent, ToolApprovalRequestOutput,
};
use crate::shared::SharedProviderOptions;
use crate::stream_part::{StreamPart, TextStreamPart};
use crate::tool::Tool;
use crate::types::{FinishReason, ReasoningEffort, Usage, Warning};
use crate::types::{FinishReason, ProviderMetadata, ReasoningEffort, Usage, Warning};
use crate::{AbortSignal, retry, timeout};

/// Matches the AI SDK's `isOutputChunk`: only chunks containing model output
Expand Down Expand Up @@ -77,7 +78,7 @@ pub struct GenerateTextOptions {
pub tools: Option<Vec<Tool>>,
pub tool_choice: Option<ToolChoice>,
pub headers: Option<HashMap<String, String>>,
pub provider_options: Option<HashMap<String, Value>>,
pub provider_options: Option<SharedProviderOptions>,
/// Top-level reasoning effort.
pub reasoning: Option<ReasoningEffort>,
/// System instructions prepended to the prompt.
Expand Down Expand Up @@ -198,7 +199,7 @@ pub struct GenerateTextResult {
/// Provider-specific metadata (e.g. Anthropic cache info). Mirrored from
/// `raw.provider_metadata` for top-level convenience.
#[serde(default)]
pub provider_metadata: Option<Value>,
pub provider_metadata: Option<ProviderMetadata>,
/// Response metadata (id, timestamp, model_id). Mirrored from
/// `raw.response` for top-level convenience.
#[serde(default)]
Expand Down Expand Up @@ -230,7 +231,7 @@ pub struct GenerateObjectResult {
pub reasoning: Option<String>,
/// Provider-specific metadata (e.g. Anthropic cache info).
#[serde(default)]
pub provider_metadata: Option<Value>,
pub provider_metadata: Option<ProviderMetadata>,
/// Response metadata (id, timestamp, model_id).
#[serde(default)]
pub response: crate::types::ResponseMetadata,
Expand Down Expand Up @@ -340,7 +341,7 @@ impl StreamTextResult {
};
let mut raw_finish_reason: Option<String> = None;
let mut usage = Usage::default();
let mut finish_provider_metadata: Option<Value> = None;
let mut finish_provider_metadata: Option<ProviderMetadata> = None;
let mut response: Option<crate::types::ResponseMetadata> = None;
let mut rm = crate::response_messages::ResponseMessageBuilder::new();

Expand Down
4 changes: 2 additions & 2 deletions aimux-core/src/language_model_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

use crate::content::ContentPart;
use crate::message::Role;
use crate::shared::SharedProviderOptions;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use ts_rs::TS;

/// A single provider-facing message.
Expand All @@ -23,7 +23,7 @@ pub struct LanguageModelPromptMessage {
/// `anthropic.cacheControl`), applied to the last part of the message when
/// converting to a provider prompt. Mirrors the Vercel AI SDK
/// `LanguageModelV4Message.providerOptions`.
pub provider_options: Option<Value>,
pub provider_options: Option<SharedProviderOptions>,
}

/// The standardized prompt passed to `LanguageModel::do_generate` / `do_stream`.
Expand Down
Loading