Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
e88530e
refactor(core): one tool call type per layer, one tool result type
Oct 4, 2026
c7ba0bf
fix(core): an invalid call's unparsable argument text is its raw input
Oct 4, 2026
6befbbe
refactor: providers, FFI, bindings and tools follow the unified tool …
Oct 4, 2026
f09b81f
refactor(core): pass the tool structs instead of their field lists
Oct 4, 2026
633bcfd
docs: record the tool-type unification in the changelog and the wire-…
Oct 4, 2026
0f9e425
refactor(core): one source, file and reasoning type per layer
Oct 4, 2026
404603b
refactor: providers, tests, bindings and docs follow the unified cont…
Oct 4, 2026
fca91dc
refactor(core): one typed shape for provider options and provider met…
Oct 4, 2026
4579dd7
refactor(providers): vendor packages build and read typed provider op…
Oct 4, 2026
dc3f6d8
refactor: tests, FFI and bindings follow the typed provider options a…
Oct 4, 2026
37523b8
test(providers): remove hand-written mock-server tests that build pro…
Oct 5, 2026
1629f1b
refactor(core)!: model the provider prompt by role, with one file part
Oct 5, 2026
9804476
refactor: follow the role-modelled provider prompt in vendors, tools …
Oct 5, 2026
b295b08
refactor!: provider results carry request and response information as…
Oct 5, 2026
7f8aa41
Merge branch 'rfc-0036/prompt-types' into rfc-0036/result-envelope
Oct 5, 2026
020a599
docs(changelog): record the role-modelled provider prompt as breaking
Oct 5, 2026
d7a09bc
docs(changelog): record the provider result request and response fiel…
Oct 5, 2026
9d2dca5
fix(providers): file parts keep upstream media-type and text-data rules
Oct 5, 2026
d357bc8
Merge provider-prompt-types: file-part fixes from review
Oct 5, 2026
6ad20be
refactor(core)!: add the missing upstream content variants and usage …
Oct 5, 2026
55a8cfe
refactor(core)!: a provider option that is not an object is an error
Oct 5, 2026
abd0a4c
refactor(core)!: optional tool choice and typed tool examples and arg…
Oct 5, 2026
6da9143
Merge tool-types: optional tool choice and typed tool fields
Oct 5, 2026
1df5604
Merge output-content-types: new content variants and tool type changes
Oct 5, 2026
a5ad8ca
Merge provider-options-types and close the provider prompt gaps
Oct 5, 2026
1e326e6
test: delete two hand-written test files that pin Rust-only behaviour
Oct 5, 2026
682f8cb
Merge provider-prompt-types: type gaps closed below
Oct 5, 2026
752c4bb
test: drop hand-written tests the merge brought back
Oct 5, 2026
8359bb0
fix(core): provider prompt part tags use upstream's hyphenated names
Oct 5, 2026
53a1dae
Merge provider-prompt-types: hyphenated provider prompt tags
Oct 5, 2026
d16e8df
test: delete the self-generated contract fixtures and the tests that …
Oct 5, 2026
c8a6022
refactor(core)!: tool calls carry no separate thought signature
Oct 5, 2026
29df4db
refactor(core)!: a source is a url source or a document source
Oct 5, 2026
5627d5c
refactor(core)!: raw is the provider result itself
Oct 5, 2026
e9b46c9
Merge tool-types: no separate thought signature, contract fixtures de…
Oct 5, 2026
d1494a9
Merge output-content-types: source union, no thought signature, no co…
Oct 5, 2026
983efe0
Merge provider-options-types: source union, no thought signature, no …
Oct 5, 2026
c16b0b4
Merge provider-prompt-types: source union, no thought signature, no c…
Oct 5, 2026
8a5acf9
fix(core): default the call-layer tool choice to auto; restore ported…
Oct 6, 2026
bc0d8b1
fix(providers): map file citations and grounding documents to documen…
Oct 6, 2026
c24e364
fix(providers): read amazonBedrock before bedrock; validate vendor op…
Oct 6, 2026
3cec6f6
fix(providers): align prompt conversion with upstream for references,…
Oct 6, 2026
c791197
fix(core): absorb response metadata into the finish step; carry reque…
Oct 6, 2026
1568a7c
Merge the branch below: review fixes
Oct 6, 2026
835ae86
Merge the branch below: review fixes
Oct 6, 2026
5352e1c
Merge the branch below: review fixes
Oct 6, 2026
aa5db03
Merge the branch below: review fixes
Oct 6, 2026
3ccf7dd
fix(providers): surface option parse errors for Cohere, Google and Mi…
Oct 6, 2026
0d686cd
Merge the branch below: review fixes
Oct 6, 2026
7113ce7
Merge the branch below: review fixes
Oct 6, 2026
1acaa33
test(providers): Groq reference test expects upstream's error
Oct 6, 2026
507978a
Merge the branch below: review fixes
Oct 6, 2026
0986da5
style: format the Groq reference test
Oct 6, 2026
6a1da33
Merge the branch below: formatting
Oct 6, 2026
dbcc5be
chore: update tool types on refreshed RFC-0036 integration
Oct 7, 2026
59d9c51
chore: update output content types with refreshed foundation
Oct 7, 2026
271c994
fix(jev): adapt decision usage to split input and output token types
Oct 7, 2026
064057e
Merge branch 'codex/restack-216' into codex/restack-217
Oct 7, 2026
fb2ba7c
fix(jev): retain response metadata as a typed provider object
Oct 7, 2026
e0f6169
chore: update provider prompt types with refreshed foundation
Oct 7, 2026
afe7fab
Merge branch 'codex/restack-218' into codex/restack-219
Oct 7, 2026
c22dcfa
chore(node): regenerate decision provider options and metadata types
Oct 7, 2026
df272b1
Merge branch 'codex/restack-217' into codex/restack-218
Oct 7, 2026
c16749d
Merge branch 'codex/restack-218' into codex/restack-219
Oct 7, 2026
8e8b41f
Merge branch 'rfc-0036/integration' into rfc-0036/provider-result-types
Oct 9, 2026
5342962
docs(migration): describe tool-call metadata on StreamingToolCallDelta
Oct 9, 2026
40b926f
fix(core): update content when a tool-call repair is applied to a result
claude Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
28 changes: 6 additions & 22 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -390,47 +390,31 @@ jobs:
working-directory: bindings/flutter/example
run: flutter test

# ── Contract tests: shared JSON fixtures (catches cross-language type drift) ──
# Generated files must match their generators.
contract-tests:
name: Contract tests
name: Generator drift checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Providers doc generator drift (docs/api/providers.md must match generator output)
run: python3 scripts/gen_providers_doc.py --check
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Run Rust contract tests
run: cargo test --test contract_test -p aimux-core
- name: ts-rs export drift (checked-in types must match generator output)
run: python3 scripts/gen_ts_types.py --check
- name: Build Node binding
working-directory: bindings/node
run: |
npm ci
npx napi build --platform
- name: Run Node contract tests
working-directory: contract-tests
run: |
node --experimental-strip-types run-node.ts

# ── Swift contract tests: the eighth binding joins the shared fixtures ─────
# Needs macOS (XCTest) and the FFI cdylib, which the Swift package links via
# the CAimuxFFI system module. The Dart side needs no job here: flutter-binding
# already runs `flutter test`, which picks up bindings/flutter/test/contract_test.dart.
# Swift tests need macOS (XCTest) and the FFI cdylib, which the Swift package
# links via the CAimuxFFI system module.
swift-contract-tests:
name: Swift contract tests
name: Swift tests
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Build the FFI library the Swift package links against
run: cargo build -p aimux-ffi --release
- name: Run Swift contract tests
- name: Run Swift tests
working-directory: bindings/swift
env:
DYLD_LIBRARY_PATH: ${{ github.workspace }}/target/release
Expand Down
62 changes: 62 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,68 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Breaking

- Provider results carry request and response information as the AI SDK does
(`aimux-core`). `GenerateResult` has `request: Option<RequestInfo>` and
`response: Option<ResponseInfo>`; `ResponseInfo` gains `id` and holds the
timestamp, model id, headers and body. `StreamResult` has `request` and
`response: Option<StreamResponseInfo>`. The flat `request_body` and
`response_headers` fields are removed and `response` is optional; the call
layer fills a missing id, timestamp and model id. The user-facing results and
their JSON are unchanged.

- 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
`StreamPart::ToolCall(..)`, and `tool::ToolResult` in the `ToolResult`
variants; these variants are newtype variants now. `StreamPart` is generic
over the tool-call type: a provider's `do_stream` yields `StreamPart`
(= `StreamPart<RawToolCall>`), `stream_text` and everything after it yield
`TextStreamPart` (= `StreamPart<ToolCall>`, parsed input with `invalid` /
`error`). `RawToolCall` moved from `parse_tool_call` to `tool`. Wire format:
enum variants keep their JSON shape; a provider-layer `StreamPart::ToolCall`
has a string `input` and no `invalid` / `error`; a
`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
`Literal`) and `scripts/gen_provider_names.py`. Provider names are plain
Expand Down
7 changes: 1 addition & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ aimux/
├── aimux-provider-utils/ # HTTP utilities: retry, backoff, error parsing, API-key loading, streamed tool-call tracking
├── aimux-ffi/ # C ABI (opaque handle + JSON + push callback) for non-native bindings
├── bindings/ # Node, Python, Swift, Kotlin, Flutter, Go, C — share one Rust core
├── contract-tests/ # Shared JSON fixtures exercised across languages
├── rfc/ # Design docs (RFCs)
├── docs/ # Public product docs (see docs/README.md)
└── scripts/ # Code-generation and maintenance scripts
Expand Down Expand Up @@ -50,9 +49,6 @@ cargo test --workspace

# A single crate
cargo test -p aimux-providers --tests

# Contract tests (cross-language shared fixtures)
cargo test --test contract_test -p aimux-core
```

If you add or change a provider's request/response shapes, record a new
Expand Down Expand Up @@ -108,8 +104,7 @@ Keep the subject line imperative and ≤ 72 characters.
2. Make your change with focused commits.
3. Ensure `cargo fmt`, `cargo clippy -D warnings`, and `cargo test --workspace`
all pass locally. CI runs the same.
4. If you add a provider or binding, include cassette tests or contract
fixtures as appropriate.
4. If you add a provider or binding, include cassette tests.
5. Update the relevant doc (README, `docs/`, or `rfc/`) if your change affects
the public surface.
6. Open a PR against `master` and fill in the template.
Expand Down
195 changes: 195 additions & 0 deletions MIGRATION-NS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
# 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.
This includes `StreamingToolCallDelta::provider_metadata`, which is an `Option<ProviderMetadata>`: build it
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
Loading