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
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Breaking

- The prompt a provider receives is modelled by role (`aimux-core`), as the AI
SDK's `LanguageModelV4Message`. `LanguageModelPrompt` is
`Vec<LanguageModelMessage>`: `System { content: String }`,
`User { content: Vec<UserPart> }`, `Assistant { content: Vec<AssistantPart> }`,
`Tool { content: Vec<ToolPart> }`. The parts are `TextPart`, `FilePart`,
`ReasoningPart`, `ToolCallPart`, `ToolResultPart`. A provider sees one file
part, `FilePart { data: FileData, media_type, filename }`; the five
user-facing file variants are folded into it by
`convert_to_language_model_prompt`, which now returns `Result` and rejects a
part its role does not allow. `LanguageModelPromptMessage` is removed. The
user-facing `ModelMessage` and `ContentPart` are unchanged. Recordings store
the new prompt JSON: a system message's `content` is a string and every file
input is a `file` part.

- 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 All @@ -23,6 +49,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`GenerateContent::ToolCall` with a non-string `input` no longer
deserializes; the standalone `tool::ToolResult` gains a required
`tool_name` and optional `dynamic` / `provider_metadata`.
- Source, generated-file and reasoning types are unified, one per concept
(`aimux-core`). `result::Source`, `result::GeneratedFile` and
`result::ReasoningOutput` are the payloads of `GenerateContent::{Source,
File, Reasoning}` and `StreamPart::{Source, File}` (newtype variants now) and
the elements of `sources`, `files` and `reasoning` on `GenerateTextResult`
and `StreamTextResultAggregated`; `StreamPart::ResponseMetadata` wraps
`types::ResponseMetadata`. `SourcePart`, `FilePart` and `ReasoningPart` are
removed (TypeScript: `Source`, `GeneratedFile`, `ReasoningOutput`). Wire
format: enum variants keep their JSON shape; the top-level `sources` /
`files` / `reasoning` arrays gain `provider_metadata` (so the reasoning
signature is no longer dropped), and a source's `url` / `title` serialize as
`null` when absent instead of being omitted.

- Removed the generated `ProviderName` type in every binding (Rust enum, TS
const object, Go/Java/Kotlin consts, Swift enum, Dart consts, Python
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.
10 changes: 4 additions & 6 deletions aimux-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,10 @@ futures = { workspace = true }
async-stream = { workspace = true }
tracing = { workspace = true }
ts-rs = { workspace = true }
# Deliberately narrower than the workspace-wide `full`: core's production
# code uses only timers (`time`) and `select!` (`macros`). Keeping the
# declaration at the real API surface documents what Core actually asks
# of the runtime (RFC-0016 had removed core's tokio dependency; RFC-0031
# reintroduces it for operation deadlines and retry backoff).
tokio = { version = "1", features = ["time", "macros", "rt", "sync"] }
# Core also resolves generated file URLs before exposing file outputs.
tokio = { version = "1", features = ["time", "macros", "rt", "sync", "net"] }
reqwest = { workspace = true }
url = { workspace = true }
tokio-util = "0.7"
httpdate = { workspace = true }
rand = { workspace = true }
Expand Down
36 changes: 15 additions & 21 deletions aimux-core/src/composite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
use std::sync::Arc;

use crate::language_model::LanguageModel;
use crate::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage};
use crate::language_model_message::{LanguageModelMessage, LanguageModelPrompt};
use crate::result::GenerateContent;
use crate::types::{TokenUsage, Usage};
use crate::types::{InputTokenUsage, OutputTokenUsage, Usage};

/// Composite-model child handle. `Arc` (not `Box`) so children are `Clone`,
/// match the FFI/Node registry shape, and stay alive after a child handle is
Expand All @@ -31,28 +31,26 @@ pub type ChildModel = Arc<dyn LanguageModel>;
/// Add two `Usage` values field-by-field. `Usage::raw` (provider-opaque) is
/// dropped — summing raw maps across providers is meaningless.
///
/// Each `TokenUsage` field is `Option<u32>`; `None` is treated as zero so a
/// Each usage detail field is `Option<u32>`; `None` is treated as zero so a
/// child that doesn't report a breakdown doesn't erase the other child's data.
#[must_use]
pub fn add_usage(a: Usage, b: &Usage) -> Usage {
Usage {
input_tokens: add_token_usage(a.input_tokens, &b.input_tokens),
output_tokens: add_token_usage(a.output_tokens, &b.output_tokens),
input_tokens: InputTokenUsage {
total: opt_add(a.input_tokens.total, b.input_tokens.total),
no_cache: opt_add(a.input_tokens.no_cache, b.input_tokens.no_cache),
cache_read: opt_add(a.input_tokens.cache_read, b.input_tokens.cache_read),
cache_write: opt_add(a.input_tokens.cache_write, b.input_tokens.cache_write),
},
output_tokens: OutputTokenUsage {
total: opt_add(a.output_tokens.total, b.output_tokens.total),
text: opt_add(a.output_tokens.text, b.output_tokens.text),
reasoning: opt_add(a.output_tokens.reasoning, b.output_tokens.reasoning),
},
raw: None,
}
}

fn add_token_usage(a: TokenUsage, b: &TokenUsage) -> TokenUsage {
TokenUsage {
total: opt_add(a.total, b.total),
no_cache: opt_add(a.no_cache, b.no_cache),
cache_read: opt_add(a.cache_read, b.cache_read),
cache_write: opt_add(a.cache_write, b.cache_write),
text: opt_add(a.text, b.text),
reasoning: opt_add(a.reasoning, b.reasoning),
}
}

fn opt_add(a: Option<u32>, b: Option<u32>) -> Option<u32> {
match (a, b) {
(None, None) => None,
Expand Down Expand Up @@ -119,10 +117,6 @@ pub fn build_aggregator_prompt(
body.push('\n');
}

out.push(LanguageModelPromptMessage {
role: crate::message::Role::User,
content: vec![crate::content::ContentPart::text(body)],
provider_options: None,
});
out.push(LanguageModelMessage::user_text(body));
out
}
Loading
Loading