From d500d27d7f93147e7ed2ef13f2aa97feaf6a6111 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:33:02 +0000 Subject: [PATCH] refactor(core)!: messages, content, provider options and prompts follow the AI SDK V4 types - Content: one source, file and reasoning type per layer. A source is a url source or a document source; file citations and grounding documents map to document sources. The missing upstream content variants and usage fields are added. - Provider options and provider metadata have one typed shape. Vendor packages build and read them; a provider option that is not an object is an error, unknown keys no longer leak, and option parse errors surface for Cohere, Google and Mistral. Bedrock reads amazonBedrock before bedrock. - The provider prompt is modelled by role with one file part. Part tags use upstream's hyphenated names; references, files and tool output convert as upstream does, and file parts keep upstream's media-type and text-data rules. - The #226 decision adapters and Jev follow the typed options, metadata and split usage types. - Hand-written tests that built provider prompts by hand or pinned Rust-only behaviour are deleted; ported upstream tests are restored. FFI, bindings and their generated types follow. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A3WxDBWBA4ituJjXsHaEAa --- CHANGELOG.md | 38 + MIGRATION-NS.md | 196 +++ aimux-core/Cargo.toml | 10 +- aimux-core/src/composite.rs | 36 +- aimux-core/src/content.rs | 49 +- aimux-core/src/download.rs | 130 ++ .../src/download_guard.rs | 13 +- aimux-core/src/error.rs | 6 + aimux-core/src/generate.rs | 743 +++-------- aimux-core/src/language_model_message.rs | 508 ++++++- aimux-core/src/lib.rs | 19 +- aimux-core/src/moa.rs | 8 +- aimux-core/src/openai_output.rs | 63 +- aimux-core/src/options.rs | 3 +- aimux-core/src/recording.rs | 10 +- aimux-core/src/replay.rs | 338 ++--- aimux-core/src/response_messages.rs | 334 +++-- aimux-core/src/result.rs | 298 ++++- aimux-core/src/router.rs | 528 -------- aimux-core/src/session.rs | 118 -- aimux-core/src/shared.rs | 50 +- aimux-core/src/stream_part.rs | 95 +- aimux-core/src/tool.rs | 5 +- aimux-core/src/trace/layer.rs | 33 +- aimux-core/src/types.rs | 22 +- aimux-core/src/video_model.rs | 31 +- aimux-core/tests/cache_probe_test.rs | 608 +-------- aimux-core/tests/error_value_golden_test.rs | 110 -- aimux-core/tests/tool_input_test.rs | 418 +----- aimux-ffi/src/lib.rs | 379 +----- aimux-ffi/src/transcription_session.rs | 2 +- aimux-provider-utils/src/lib.rs | 3 +- .../tests/http_recording_test.rs | 10 +- .../tests/streaming_tool_call_tracker_test.rs | 18 +- .../src/anthropic/cache_control.rs | 16 +- aimux-providers/src/anthropic/convert.rs | 1112 ++++++++++------ aimux-providers/src/anthropic/files.rs | 6 +- aimux-providers/src/anthropic/mod.rs | 60 +- aimux-providers/src/anthropic/model.rs | 13 +- aimux-providers/src/anthropic/stream.rs | 194 +-- aimux-providers/src/anthropic/usage.rs | 10 +- aimux-providers/src/anthropic_aws/model.rs | 2 + aimux-providers/src/assemblyai.rs | 5 +- aimux-providers/src/aws_polly.rs | 3 +- aimux-providers/src/azure/responses.rs | 10 +- aimux-providers/src/bedrock/convert.rs | 523 +++++--- aimux-providers/src/bedrock/model.rs | 92 +- aimux-providers/src/bedrock/reranking.rs | 3 +- aimux-providers/src/black_forest_labs.rs | 4 +- aimux-providers/src/cartesia.rs | 3 +- aimux-providers/src/codex.rs | 8 +- aimux-providers/src/cohere/convert.rs | 238 ++-- aimux-providers/src/cohere/model.rs | 63 +- aimux-providers/src/cohere/reranking.rs | 3 +- aimux-providers/src/decision_support.rs | 18 +- aimux-providers/src/deepgram.rs | 4 +- aimux-providers/src/elevenlabs.rs | 3 +- aimux-providers/src/fal.rs | 4 +- aimux-providers/src/gladia.rs | 15 +- aimux-providers/src/google/convert.rs | 738 +++++++---- aimux-providers/src/google/files.rs | 8 +- aimux-providers/src/google/image.rs | 31 +- aimux-providers/src/google/model.rs | 274 ++-- aimux-providers/src/google_pse.rs | 3 +- aimux-providers/src/huggingface/responses.rs | 183 ++- aimux-providers/src/hume.rs | 3 +- aimux-providers/src/jina_ai.rs | 3 +- aimux-providers/src/linkup.rs | 3 +- aimux-providers/src/lmnt.rs | 3 +- aimux-providers/src/luma.rs | 2 +- aimux-providers/src/mistral/convert.rs | 264 ++-- aimux-providers/src/mistral/model.rs | 56 +- aimux-providers/src/open_responses.rs | 523 ++++---- aimux-providers/src/openai/convert.rs | 1171 ++++++++++------- aimux-providers/src/openai/decision.rs | 19 +- aimux-providers/src/openai/files.rs | 10 +- aimux-providers/src/openai/image.rs | 8 +- aimux-providers/src/openai/model.rs | 69 +- .../src/openai/responses/convert.rs | 377 ++++-- aimux-providers/src/openai/responses/mod.rs | 10 +- .../src/openai/responses/responses_convert.rs | 280 +++- aimux-providers/src/openai/transcription.rs | 4 +- aimux-providers/src/prodia.rs | 2 +- aimux-providers/src/recraft.rs | 4 +- aimux-providers/src/replay.rs | 9 +- aimux-providers/src/replicate.rs | 2 +- aimux-providers/src/runtime_decision.rs | 17 +- aimux-providers/src/stability.rs | 2 +- aimux-providers/src/systemone.rs | 10 +- aimux-providers/src/vertex/anthropic_model.rs | 53 +- aimux-providers/src/vertex/image.rs | 46 +- aimux-providers/src/vertex/model.rs | 361 ++++- aimux-providers/src/voyage/reranking.rs | 3 +- aimux-providers/src/xai/convert.rs | 176 +-- aimux-providers/src/xai/model.rs | 61 +- aimux-providers/src/xai/responses/convert.rs | 322 +++-- aimux-providers/src/xai/responses/mod.rs | 94 +- aimux-providers/tests/alibaba_test.rs | 117 +- .../tests/amazon_bedrock_image_test.rs | 18 - aimux-providers/tests/anthropic_files_test.rs | 2 +- aimux-providers/tests/aws_polly_test.rs | 36 +- aimux-providers/tests/baseten_test.rs | 10 +- aimux-providers/tests/bedrock_convert_test.rs | 417 +++--- .../tests/bedrock_embedding_test.rs | 77 -- .../tests/bedrock_stream_replay_test.rs | 12 +- aimux-providers/tests/cartesia_speech_test.rs | 10 +- .../tests/cartesia_transcription_test.rs | 53 - aimux-providers/tests/cerebras_test.rs | 10 +- aimux-providers/tests/codex_test.rs | 10 +- .../tests/cohere_embedding_test.rs | 68 - aimux-providers/tests/conformance_test.rs | 12 +- .../tests/data_loss_regression_test.rs | 226 +--- .../tests/deepgram_transcription_test.rs | 70 - aimux-providers/tests/deepinfra_test.rs | 10 +- aimux-providers/tests/deepseek_chat_test.rs | 634 +-------- .../tests/elevenlabs_realtime_stream_test.rs | 69 - .../tests/elevenlabs_speech_test.rs | 5 +- aimux-providers/tests/fal_image_test.rs | 5 +- aimux-providers/tests/fireworks_test.rs | 10 +- .../tests/google_embedding_test.rs | 67 - aimux-providers/tests/google_files_test.rs | 73 +- aimux-providers/tests/google_image_test.rs | 195 --- aimux-providers/tests/google_pse_test.rs | 29 - aimux-providers/tests/groq_test.rs | 297 ++--- .../tests/huggingface_responses_test.rs | 727 +--------- aimux-providers/tests/jev_test.rs | 15 +- aimux-providers/tests/jina_ai_test.rs | 28 - aimux-providers/tests/linkup_test.rs | 29 - aimux-providers/tests/lmnt_speech_test.rs | 5 +- .../tests/mistral_embedding_test.rs | 12 + aimux-providers/tests/moonshotai_test.rs | 10 +- aimux-providers/tests/open_responses_test.rs | 831 +++--------- .../tests/openai_compatible_test.rs | 78 +- aimux-providers/tests/openai_decision_test.rs | 16 +- .../tests/openai_embedding_test.rs | 40 - aimux-providers/tests/openai_image_test.rs | 263 ---- .../tests/openai_transcription_test.rs | 188 +-- aimux-providers/tests/openrouter_test.rs | 41 +- aimux-providers/tests/perplexity_test.rs | 38 +- aimux-providers/tests/provider_error_test.rs | 10 +- aimux-providers/tests/reasoning_map_test.rs | 10 +- aimux-providers/tests/recraft_test.rs | 28 - aimux-providers/tests/replicate_image_test.rs | 18 - .../tests/runtime_decision_test.rs | 36 +- .../tests/thin_wrapper_config_test.rs | 10 +- .../tests/vertex_transcription_test.rs | 36 - .../tests/voyage_embedding_test.rs | 64 - bindings/flutter/lib/types.dart | 333 +++-- bindings/flutter/lib/types.g.dart | 41 +- bindings/flutter/test/typed_model_test.dart | 51 - .../flutter/test/typed_round_trip_test.dart | 109 +- bindings/go/batch1_e2e_test.go | 42 - bindings/go/roundtrip_test.go | 56 - bindings/go/types.go | 137 +- .../main/java/ai/arcships/aimux/Types.java | 901 +++++++++---- .../main/kotlin/ai/arcships/aimux/Types.kt | 231 +++- .../ai/arcships/aimux/TypedModelTest.kt | 28 +- .../node/__test__/reasoning_replay.test.ts | 10 +- .../node/__test__/roundtrip-fields.test.ts | 2 +- bindings/node/src/error.rs | 4 +- bindings/node/src/index.ts | 18 + bindings/node/src/multimodal.rs | 2 +- bindings/node/src/types.ts | 28 +- bindings/node/src/types/AiMuxError.ts | 2 +- bindings/node/src/types/AssistantPart.ts | 13 + bindings/node/src/types/CallOptions.ts | 6 +- bindings/node/src/types/ContentPart.ts | 9 +- bindings/node/src/types/CustomPart.ts | 7 + .../node/src/types/DecisionCallOptions.ts | 2 +- bindings/node/src/types/DecisionResult.ts | 2 +- .../node/src/types/EmbeddingCallOptions.ts | 2 +- bindings/node/src/types/EmbeddingResult.ts | 2 +- bindings/node/src/types/FileData.ts | 2 +- bindings/node/src/types/FilePart.ts | 5 +- bindings/node/src/types/FunctionTool.ts | 2 +- bindings/node/src/types/GenerateContent.ts | 7 +- .../node/src/types/GenerateObjectResult.ts | 2 +- bindings/node/src/types/GenerateResponse.ts | 7 + bindings/node/src/types/GenerateResult.ts | 38 +- .../node/src/types/GenerateTextOptions.ts | 2 +- bindings/node/src/types/GenerateTextResult.ts | 17 +- bindings/node/src/types/GeneratedFile.ts | 11 + bindings/node/src/types/GeneratedFileData.ts | 7 + bindings/node/src/types/ImageCallOptions.ts | 2 +- bindings/node/src/types/ImageResult.ts | 2 +- bindings/node/src/types/InputRecord.ts | 6 +- .../{TokenUsage.ts => InputTokenUsage.ts} | 14 +- .../node/src/types/LanguageModelMessage.ts | 13 + .../src/types/LanguageModelPromptMessage.ts | 19 - .../src/types/LanguageModelReasoningPart.ts | 7 + bindings/node/src/types/OutputTokenUsage.ts | 14 + .../node/src/types/RawToolApprovalRequest.ts | 7 + bindings/node/src/types/RawToolCall.ts | 2 +- .../node/src/types/ReasoningFileOutput.ts | 5 + bindings/node/src/types/ReasoningFilePart.ts | 8 + bindings/node/src/types/ReasoningOutput.ts | 11 + bindings/node/src/types/ReasoningPart.ts | 10 +- .../node/src/types/RerankingCallOptions.ts | 2 +- bindings/node/src/types/RerankingResult.ts | 2 +- bindings/node/src/types/SearchCallOptions.ts | 2 +- bindings/node/src/types/SearchResult.ts | 2 +- bindings/node/src/types/SearchResultItem.ts | 2 +- bindings/node/src/types/Source.ts | 9 + bindings/node/src/types/SourcePart.ts | 6 - bindings/node/src/types/SpeechCallOptions.ts | 2 +- bindings/node/src/types/SpeechResult.ts | 2 +- bindings/node/src/types/StreamPart.ts | 16 +- bindings/node/src/types/StreamResponse.ts | 6 + .../src/types/StreamTextResultAggregated.ts | 17 +- bindings/node/src/types/TextContent.ts | 13 + bindings/node/src/types/TextPart.ts | 7 + .../src/types/ToolApprovalRequestOutput.ts | 7 + .../src/types/ToolApprovalResponsePart.ts | 7 + bindings/node/src/types/ToolCall.ts | 2 +- bindings/node/src/types/ToolCallPart.ts | 7 + bindings/node/src/types/ToolPart.ts | 8 + bindings/node/src/types/ToolResult.ts | 2 +- bindings/node/src/types/ToolResultContent.ts | 9 + bindings/node/src/types/ToolResultOutput.ts | 8 + bindings/node/src/types/ToolResultPart.ts | 8 + .../src/types/TranscriptionCallOptions.ts | 2 +- .../node/src/types/TranscriptionResult.ts | 2 +- .../node/src/types/TranscriptionStreamPart.ts | 2 +- .../node/src/types/UploadFileCallOptions.ts | 2 +- bindings/node/src/types/UploadFileResult.ts | 2 +- bindings/node/src/types/Usage.ts | 8 +- bindings/node/src/types/UserPart.ts | 8 + bindings/node/src/types/VideoCallOptions.ts | 2 +- bindings/node/src/types/VideoResult.ts | 2 +- bindings/python/python/aimux/wrapper.py | 186 ++- bindings/python/src/error.rs | 4 +- bindings/python/src/multimodal.rs | 2 +- bindings/swift/Sources/Aimux/Types.swift | 310 ++++- docs/api/node.md | 6 +- tools/aimux-replay/src/main.rs | 27 +- tools/aimux-web/examples/gen_demo_fixtures.rs | 51 +- tools/aimux-web/examples/gen_fixture.rs | 17 +- tools/aimux-web/src/api/replay.rs | 2 +- tools/aimux-web/src/wire.rs | 16 +- 239 files changed, 9424 insertions(+), 11554 deletions(-) create mode 100644 MIGRATION-NS.md create mode 100644 aimux-core/src/download.rs rename {aimux-provider-utils => aimux-core}/src/download_guard.rs (97%) create mode 100644 bindings/node/src/types/AssistantPart.ts create mode 100644 bindings/node/src/types/CustomPart.ts create mode 100644 bindings/node/src/types/GenerateResponse.ts create mode 100644 bindings/node/src/types/GeneratedFile.ts create mode 100644 bindings/node/src/types/GeneratedFileData.ts rename bindings/node/src/types/{TokenUsage.ts => InputTokenUsage.ts} (58%) create mode 100644 bindings/node/src/types/LanguageModelMessage.ts delete mode 100644 bindings/node/src/types/LanguageModelPromptMessage.ts create mode 100644 bindings/node/src/types/LanguageModelReasoningPart.ts create mode 100644 bindings/node/src/types/OutputTokenUsage.ts create mode 100644 bindings/node/src/types/RawToolApprovalRequest.ts create mode 100644 bindings/node/src/types/ReasoningFileOutput.ts create mode 100644 bindings/node/src/types/ReasoningFilePart.ts create mode 100644 bindings/node/src/types/ReasoningOutput.ts create mode 100644 bindings/node/src/types/Source.ts delete mode 100644 bindings/node/src/types/SourcePart.ts create mode 100644 bindings/node/src/types/StreamResponse.ts create mode 100644 bindings/node/src/types/TextContent.ts create mode 100644 bindings/node/src/types/TextPart.ts create mode 100644 bindings/node/src/types/ToolApprovalRequestOutput.ts create mode 100644 bindings/node/src/types/ToolApprovalResponsePart.ts create mode 100644 bindings/node/src/types/ToolCallPart.ts create mode 100644 bindings/node/src/types/ToolPart.ts create mode 100644 bindings/node/src/types/ToolResultContent.ts create mode 100644 bindings/node/src/types/ToolResultOutput.ts create mode 100644 bindings/node/src/types/ToolResultPart.ts create mode 100644 bindings/node/src/types/UserPart.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d57c7edd..bdac7ea98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`: `System { content: String }`, + `User { content: Vec }`, `Assistant { content: Vec }`, + `Tool { content: Vec }`. 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`: `SharedProviderOptions` and + `SharedProviderMetadata` (`ProviderMetadata` is an alias) are + `HashMap`, namespace to JSON object. They replace + `serde_json::Value`, `HashMap` and the per-part + `Option`. 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 @@ -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 diff --git a/MIGRATION-NS.md b/MIGRATION-NS.md new file mode 100644 index 000000000..631c4c433 --- /dev/null +++ b/MIGRATION-NS.md @@ -0,0 +1,196 @@ +# Namespace types migration recipe (provider options / provider metadata) + +Do not commit this file. + +## What changed + +Both are now `HashMap` (namespace -> JSON object), the AI SDK `Record`: + +```rust +// aimux_core::shared (also re-exported from aimux_core::prelude) +pub type JsonObject = serde_json::Map; +pub type SharedProviderOptions = HashMap; +pub type SharedProviderMetadata = HashMap; +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` (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`. 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) + +// 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 before, Option 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) -> StreamPart { StreamPart::TextEnd { id, provider_metadata: meta } } +// after +fn f(meta: Option) -> StreamPart { ... } +``` + +Local variables typed `Option` / `HashMap` 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`) stay `Value`; `with_build_provider_metadata` still takes `Option<&Value>` +and returns `Option`, 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 -> m["anthropic"]["signature"] == json!("s") +// but a whole namespace is a Map, not a Value: Value::Object(m["anthropic"].clone()) == json!({ ... }) +``` + +### 8. `Option` per-part / per-message options + +`ContentPart::*.provider_options`, `LanguageModelPromptMessage.provider_options` were `Option`; now +`Option`. 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`. `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. diff --git a/aimux-core/Cargo.toml b/aimux-core/Cargo.toml index e41db186f..c48aaee7c 100644 --- a/aimux-core/Cargo.toml +++ b/aimux-core/Cargo.toml @@ -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 } diff --git a/aimux-core/src/composite.rs b/aimux-core/src/composite.rs index 215fce58d..d4f3b5505 100644 --- a/aimux-core/src/composite.rs +++ b/aimux-core/src/composite.rs @@ -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 @@ -31,28 +31,26 @@ pub type ChildModel = Arc; /// 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`; `None` is treated as zero so a +/// Each usage detail field is `Option`; `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, b: Option) -> Option { match (a, b) { (None, None) => None, @@ -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 } diff --git a/aimux-core/src/content.rs b/aimux-core/src/content.rs index 8e97e5948..25681a97c 100644 --- a/aimux-core/src/content.rs +++ b/aimux-core/src/content.rs @@ -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). @@ -16,7 +18,36 @@ 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, + provider_options: Option, + }, + + /// Provider-specific custom content. + Custom { + kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_options: Option, + }, + + /// A file generated during reasoning. + ReasoningFile { + data: crate::shared::GeneratedFileData, + media_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_options: Option, + }, + + /// A request to approve a tool call on a later turn. + ToolApprovalRequest { + approval_id: String, + tool_call_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + is_automatic: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + signature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + input_schema_input: Option, }, /// An image (raw bytes + MIME type). @@ -24,7 +55,7 @@ pub enum ContentPart { image: Vec, media_type: String, #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, + provider_options: Option, }, /// A file (raw bytes + MIME type). @@ -34,7 +65,7 @@ pub enum ContentPart { #[serde(default, skip_serializing_if = "Option::is_none")] filename: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, + provider_options: Option, }, /// A file whose inline data is an already-base64-encoded string. @@ -49,7 +80,7 @@ pub enum ContentPart { #[serde(default, skip_serializing_if = "Option::is_none")] filename: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, + provider_options: Option, }, /// A file referenced by URL. @@ -59,7 +90,7 @@ pub enum ContentPart { url: String, media_type: String, #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, + provider_options: Option, }, /// A file referenced by a provider-specific reference (e.g. an OpenAI file ID). @@ -73,7 +104,7 @@ pub enum ContentPart { #[serde(default, skip_serializing_if = "Option::is_none")] filename: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, + provider_options: Option, }, /// A reasoning / thinking segment produced by the model. @@ -90,7 +121,7 @@ pub enum ContentPart { /// `anthropic.signature`, `anthropic.redactedData`, /// `anthropic.cacheControl`). #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, + provider_options: Option, }, /// A tool call requested by the model. @@ -107,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, + provider_options: Option, }, /// The result of executing a tool call. @@ -138,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, + provider_options: Option, }, } diff --git a/aimux-core/src/download.rs b/aimux-core/src/download.rs new file mode 100644 index 000000000..cfa7d0f45 --- /dev/null +++ b/aimux-core/src/download.rs @@ -0,0 +1,130 @@ +//! Resolve generated file URLs before exposing their file data. + +use std::net::SocketAddr; + +use futures::StreamExt; + +use crate::abort_signal::AbortSignal; +use crate::download_guard::{validate_download_target, validate_download_url}; +use crate::error::AiMuxError; +use crate::shared::{FileBytes, GeneratedFileData}; + +const DEFAULT_MAX_DOWNLOAD_SIZE: usize = 2 * 1024 * 1024 * 1024; +const MAX_DOWNLOAD_REDIRECTS: usize = 10; + +pub(crate) async fn resolve_generated_file_data( + data: &GeneratedFileData, + abort_signal: Option<&AbortSignal>, +) -> Result { + match data { + GeneratedFileData::Data { data } => Ok(data.clone()), + GeneratedFileData::Url { url, .. } => { + let (data, _) = download(url, None, abort_signal).await?; + Ok(FileBytes::Binary(data)) + } + } +} + +async fn download( + url: &str, + max_bytes: Option, + abort_signal: Option<&AbortSignal>, +) -> Result<(Vec, Option), AiMuxError> { + let operation = download_inner(url, max_bytes.unwrap_or(DEFAULT_MAX_DOWNLOAD_SIZE)); + if let Some(signal) = abort_signal { + tokio::select! { + biased; + () = signal.cancelled() => Err(AiMuxError::from_abort_signal(signal)), + result = operation => result, + } + } else { + operation.await + } +} + +async fn download_inner( + url: &str, + max_bytes: usize, +) -> Result<(Vec, Option), AiMuxError> { + let mut current = url.to_owned(); + for redirects in 0..=MAX_DOWNLOAD_REDIRECTS { + let parsed = validate_download_url(¤t)?; + let addresses = validate_download_target(¤t, None).await?; + let host = parsed + .host_str() + .ok_or_else(|| AiMuxError::InvalidArgument("download URL has no host".into()))?; + let port = parsed + .port_or_known_default() + .ok_or_else(|| AiMuxError::InvalidArgument("download URL has no known port".into()))?; + let addresses: Vec<_> = addresses + .into_iter() + .map(|address| SocketAddr::new(address, port)) + .collect(); + // Direct connections use only the addresses validated above. An + // automatic proxy or redirect must not bypass the address policy. + let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .resolve_to_addrs(host, &addresses) + .build() + .map_err(download_error)?; + let response = client + .get(parsed.clone()) + .header( + reqwest::header::USER_AGENT, + concat!("aimux/", env!("CARGO_PKG_VERSION")), + ) + .send() + .await + .map_err(download_error)?; + let status = response.status(); + if matches!(status.as_u16(), 301 | 302 | 303 | 307 | 308) + && let Some(location) = response.headers().get(reqwest::header::LOCATION) + { + if redirects == MAX_DOWNLOAD_REDIRECTS { + return Err(AiMuxError::Other("download exceeded 10 redirects".into())); + } + let location = location.to_str().map_err(download_error)?; + current = parsed.join(location).map_err(download_error)?.to_string(); + drop(response); + continue; + } + if !status.is_success() { + return Err(AiMuxError::Other(format!( + "download failed with HTTP status {status}" + ))); + } + if response + .content_length() + .is_some_and(|length| length > max_bytes as u64) + { + return Err(size_error(max_bytes)); + } + let media_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let mut stream = response.bytes_stream(); + let mut data = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(download_error)?; + if chunk.len() > max_bytes.saturating_sub(data.len()) { + return Err(size_error(max_bytes)); + } + data.extend_from_slice(&chunk); + } + return Ok((data, media_type)); + } + unreachable!("download loop returns a response or redirect error") +} + +fn download_error(error: impl std::fmt::Display) -> AiMuxError { + AiMuxError::Other(format!("download failed: {error}")) +} + +fn size_error(max_bytes: usize) -> AiMuxError { + AiMuxError::Other(format!( + "download exceeded maximum size of {max_bytes} bytes" + )) +} diff --git a/aimux-provider-utils/src/download_guard.rs b/aimux-core/src/download_guard.rs similarity index 97% rename from aimux-provider-utils/src/download_guard.rs rename to aimux-core/src/download_guard.rs index c009a608e..984e8de06 100644 --- a/aimux-provider-utils/src/download_guard.rs +++ b/aimux-core/src/download_guard.rs @@ -10,7 +10,7 @@ use std::net::IpAddr; -use aimux_core::error::AiMuxError; +use crate::error::AiMuxError; /// Compare scheme, host, and effective port. Unparseable inputs are never /// same-origin. Public so providers can build credential allowlists on top @@ -28,7 +28,8 @@ pub fn same_origin(url: &str, origin: &str) -> bool { /// Trust flows only within the trusted origin: the exemption applies to a /// redirect target only when the redirecting URL is itself on the trusted /// origin, so a foreign hop cannot launder a request into it. -pub(crate) fn hop_trusted_origin<'a>( +#[must_use] +pub fn hop_trusted_origin<'a>( trusted_origin: Option<&'a str>, current_url: &str, ) -> Option<&'a str> { @@ -55,7 +56,7 @@ fn host_ip_literal(host: &str) -> Option { /// /// Callers that resolve DNS afterwards rely on this having rejected every /// non-public literal, so keep this the single literal-check site. -pub(crate) fn validate_download_url(url: &str) -> Result { +pub fn validate_download_url(url: &str) -> Result { let parsed = url::Url::parse(url) .map_err(|error| AiMuxError::InvalidArgument(format!("invalid download URL: {error}")))?; if !matches!(parsed.scheme(), "http" | "https") { @@ -93,7 +94,7 @@ pub(crate) fn validate_download_url(url: &str) -> Result { /// A URL same-origin with `trusted_origin` (normally the configured /// `base_url`) is exempt and returns no addresses; self-hosted deployments /// legitimately serve assets from private space on their own origin. -pub(crate) async fn validate_download_target( +pub async fn validate_download_target( url: &str, trusted_origin: Option<&str>, ) -> Result, AiMuxError> { @@ -206,7 +207,7 @@ fn is_public_download_address(address: IpAddr) -> bool { /// request; they leak deployment topology or unlock metadata endpoints when /// forwarded to a provider-supplied host. Mirrors AI SDK's download header /// policy (auth headers survive; the redirect loop clears them cross-origin). -pub(crate) fn sanitize_download_headers(headers: &mut Vec<(String, String)>) { +pub fn sanitize_download_headers(headers: &mut Vec<(String, String)>) { const BLOCKED: &[&str] = &[ "connection", "keep-alive", @@ -237,7 +238,7 @@ pub(crate) fn sanitize_download_headers(headers: &mut Vec<(String, String)>) { } /// Drop caller headers except `User-Agent` when a redirect crosses origin. -pub(crate) fn retain_user_agent(headers: &mut Vec<(String, String)>) { +pub fn retain_user_agent(headers: &mut Vec<(String, String)>) { let user_agent = headers .iter() .find(|(name, _)| name.eq_ignore_ascii_case("user-agent")) diff --git a/aimux-core/src/error.rs b/aimux-core/src/error.rs index e5796b5af..59e779b14 100644 --- a/aimux-core/src/error.rs +++ b/aimux-core/src/error.rs @@ -178,6 +178,12 @@ pub enum AiMuxError { #[error("invalid response data: {0}")] InvalidResponseData(String), + #[error("Tool call \"{tool_call_id}\" not found for approval request \"{approval_id}\".")] + ToolCallNotFoundForApproval { + tool_call_id: String, + approval_id: String, + }, + /// A model requested a tool that was not present in the call's tool set. /// Message templates for the three tool variants match the AI SDK verbatim /// (`NoSuchToolError` / `InvalidToolInputError` / `ToolCallRepairError`): diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs index 08b57761c..fb614db66 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -23,26 +23,37 @@ use crate::message::{ModelMessage, ModelPrompt}; use crate::options::{CallOptions, ResponseFormat, ToolChoice}; use crate::parse_tool_call::{ToolCallRepair, parse_tool_call}; use crate::result::{ - FilePart, GenerateContent, GenerateResult, ReasoningPart, SourcePart, StreamResult, - StreamTextResultAggregated, + 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 /// start or reset the first/chunk output timers. -fn is_output_chunk(part: &StreamPart) -> bool { +fn is_output_chunk(part: &StreamPart) -> bool { match part { StreamPart::TextDelta { delta, .. } | StreamPart::ReasoningDelta { delta, .. } | StreamPart::ToolInputDelta { delta, .. } => !delta.is_empty(), - StreamPart::ToolCall(_) | StreamPart::File { .. } => true, + StreamPart::ToolCall(_) | StreamPart::File(_) | StreamPart::ReasoningFile(_) => true, _ => false, } } +async fn resolve_file( + mut file: GeneratedFile, + abort_signal: Option<&AbortSignal>, +) -> Result { + file.data = crate::shared::GeneratedFileData::Data { + data: crate::download::resolve_generated_file_data(&file.data, abort_signal).await?, + }; + Ok(file) +} + // ───────────────────────────────────────────────────────────────────────────── // User-facing options // ───────────────────────────────────────────────────────────────────────────── @@ -67,7 +78,7 @@ pub struct GenerateTextOptions { pub tools: Option>, pub tool_choice: Option, pub headers: Option>, - pub provider_options: Option>, + pub provider_options: Option, /// Top-level reasoning effort. pub reasoning: Option, /// System instructions prepended to the prompt. @@ -149,6 +160,9 @@ impl GenerateTextOptions { #[derive(Debug, Serialize, Deserialize, TS)] #[ts(export)] pub struct GenerateTextResult { + /// Ordered generated content with parsed tool calls. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub content: Vec, /// The generated text (concatenated from all text content parts). pub text: String, /// Tool calls requested by the model. @@ -170,10 +184,10 @@ pub struct GenerateTextResult { pub reasoning_text: String, /// Sources / citations (search-preview models). #[serde(default)] - pub sources: Vec, + pub sources: Vec, /// Files generated by the model. #[serde(default)] - pub files: Vec, + pub files: Vec, /// Assistant messages ready to append to the prompt for the next turn /// (solves the multi-turn "manually build assistant message" footgun). #[serde(default)] @@ -185,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, + pub provider_metadata: Option, /// Response metadata (id, timestamp, model_id). Mirrored from /// `raw.response` for top-level convenience. #[serde(default)] @@ -217,7 +231,7 @@ pub struct GenerateObjectResult { pub reasoning: Option, /// Provider-specific metadata (e.g. Anthropic cache info). #[serde(default)] - pub provider_metadata: Option, + pub provider_metadata: Option, /// Response metadata (id, timestamp, model_id). #[serde(default)] pub response: crate::types::ResponseMetadata, @@ -318,8 +332,8 @@ impl StreamTextResult { pub async fn consume(self) -> Result { let mut text = String::new(); let mut tool_calls: Vec = Vec::new(); - let mut sources: Vec = Vec::new(); - let mut files: Vec = Vec::new(); + let mut sources: Vec = Vec::new(); + let mut files: Vec = Vec::new(); let mut warnings: Vec = Vec::new(); let mut finish_reason = FinishReason { unified: crate::types::FinishReasonUnified::Stop, @@ -327,7 +341,7 @@ impl StreamTextResult { }; let mut raw_finish_reason: Option = None; let mut usage = Usage::default(); - let mut finish_provider_metadata: Option = None; + let mut finish_provider_metadata: Option = None; let mut response: Option = None; let mut rm = crate::response_messages::ResponseMessageBuilder::new(); @@ -355,54 +369,53 @@ impl StreamTextResult { } match part { StreamPart::TextStart { - provider_metadata, .. - } => rm.text_start(provider_metadata), + id, + provider_metadata, + } => rm.text_start(id, provider_metadata), StreamPart::TextDelta { + id, delta, provider_metadata, - .. } => { text.push_str(&delta); - rm.text_delta(&delta, provider_metadata); + rm.text_delta(id, &delta, provider_metadata); } StreamPart::TextEnd { - provider_metadata, .. - } => rm.text_end(provider_metadata), + id, + provider_metadata, + } => rm.text_end(id, provider_metadata), StreamPart::ReasoningStart { - provider_metadata, .. - } => rm.reasoning_start(provider_metadata), + id, + provider_metadata, + } => rm.reasoning_start(id, provider_metadata), StreamPart::ReasoningDelta { + id, delta, provider_metadata, - .. - } => rm.reasoning_delta(&delta, provider_metadata), + } => rm.reasoning_delta(id, &delta, provider_metadata), StreamPart::ReasoningEnd { - provider_metadata, .. - } => rm.reasoning_end(provider_metadata), + id, + provider_metadata, + } => rm.reasoning_end(id, provider_metadata), StreamPart::ToolCall(call) => { rm.tool_call(&call); tool_calls.push(call); } StreamPart::ToolResult(result) => rm.tool_result(result), - StreamPart::Source { - id, - source_type, - url, - title, - .. - } => { - sources.push(SourcePart { - id, - source_type, - url, - title, - }); + StreamPart::Source(source) => { + rm.source(source.clone()); + sources.push(source); } - StreamPart::File { - data, media_type, .. - } => { - files.push(FilePart { data, media_type }); + StreamPart::File(file) => { + rm.file(&file, false); + files.push(file); } + StreamPart::ReasoningFile(output) => rm.file(&output.file, true), + StreamPart::Custom { + kind, + provider_metadata, + } => rm.custom(kind, provider_metadata), + StreamPart::ToolApprovalRequest(approval) => rm.approval(&approval), StreamPart::StreamStart { warnings: w, .. } => { warnings = w; } @@ -424,17 +437,7 @@ impl StreamTextResult { StreamPart::Error { error } if provider_error.is_none() => { provider_error = Some(error); } - StreamPart::ResponseMetadata { - id, - timestamp, - model_id, - } => { - response = Some(crate::types::ResponseMetadata { - id, - timestamp, - model_id, - }); - } + StreamPart::ResponseMetadata(meta) => response = Some(meta), _ => {} } } @@ -459,16 +462,21 @@ impl StreamTextResult { let crate::response_messages::ResponseMessages { messages: response_messages, + content, reasoning, } = rm.finish(); let reasoning_text = reasoning .iter() - .map(|r| r.text.as_str()) + .filter_map(|r| match r { + ReasoningPart::Text(r) => Some(r.text.as_str()), + ReasoningPart::File(_) => None, + }) .collect::>() .join(""); Ok(StreamTextResultAggregated { + content, text, reasoning, reasoning_text, @@ -519,12 +527,22 @@ pub async fn generate_text( prompt: impl Into, options: GenerateTextOptions, ) -> Result { - // 1. Convert user prompt to provider-facing prompt. + let (messages, instructions) = split_prompt(prompt.into(), options.instructions.as_deref()); + let lm_prompt = convert_to_language_model_prompt(&messages, instructions)?; + generate_text_from_language_model_prompt(model, lm_prompt, messages, options).await +} + +// Replay already has a provider prompt. Keep it intact instead of converting +// through user content, which cannot represent every provider part. +pub(crate) async fn generate_text_from_language_model_prompt( + model: &dyn LanguageModel, + lm_prompt: crate::language_model_message::LanguageModelPrompt, + messages: Vec, + options: GenerateTextOptions, +) -> Result { let repair_tool_call = options.repair_tool_call.clone(); let tools = options.tools.clone(); let operation_instructions = options.instructions.clone(); - let (messages, instructions) = split_prompt(prompt.into(), operation_instructions.as_deref()); - let lm_prompt = convert_to_language_model_prompt(&messages, instructions); // 2. Build CallOptions. let mut call_options = options.into_call_options(lm_prompt); @@ -615,6 +633,20 @@ pub async fn generate_text( let mut files = Vec::new(); // Build the assistant response message content parts in parallel. let mut rm = crate::response_messages::ResponseMessageBuilder::new(); + for content in &result.content { + if let GenerateContent::ToolCall(call) = content { + tool_calls.push( + parse_tool_call( + call.clone(), + tools.as_deref(), + repair_tool_call.as_ref(), + &messages, + operation_instructions.as_deref(), + ) + .await, + ); + } + } for content in &result.content { match content { GenerateContent::Text { @@ -625,58 +657,71 @@ pub async fn generate_text( rm.text(t, provider_metadata.as_ref()); } GenerateContent::ToolCall(call) => { - let parsed = parse_tool_call( - call.clone(), - tools.as_deref(), - repair_tool_call.as_ref(), - &messages, - operation_instructions.as_deref(), - ) - .await; - rm.tool_call(&parsed); - tool_calls.push(parsed); + let parsed = tool_calls + .iter() + .find(|parsed| parsed.tool_call_id == call.tool_call_id) + .expect("every tool call was parsed above"); + rm.tool_call(parsed); } - GenerateContent::Reasoning { - text: rtext, - provider_metadata, - } => { - rm.reasoning(rtext, provider_metadata.as_ref()); + GenerateContent::Reasoning(reasoning) => rm.reasoning(reasoning), + GenerateContent::Source(source) => { + rm.source(source.clone()); + sources.push(source.clone()); } - GenerateContent::Source { - id, - source_type, - url, - title, - .. - } => { - sources.push(SourcePart { - id: id.clone(), - source_type: source_type.clone(), - url: url.clone(), - title: title.clone(), - }); + GenerateContent::File(file) => { + let file = timeout::run( + resolve_file(file.clone(), abort_signal.as_ref()), + abort_signal.as_ref(), + operation_timeout, + ) + .await?; + rm.file(&file, false); + files.push(file); } - GenerateContent::File { - data, media_type, .. - } => { - files.push(FilePart { - data: data.clone(), - media_type: media_type.clone(), + GenerateContent::ReasoningFile(file) => { + let file = timeout::run( + resolve_file(file.clone(), abort_signal.as_ref()), + abort_signal.as_ref(), + operation_timeout, + ) + .await?; + rm.file(&file, true); + } + GenerateContent::Custom { + kind, + provider_metadata, + } => rm.custom(kind.clone(), provider_metadata.clone()), + GenerateContent::ToolApprovalRequest(approval) => { + let call = tool_calls + .iter() + .find(|call| call.tool_call_id == approval.tool_call_id) + .ok_or_else(|| AiMuxError::ToolCallNotFoundForApproval { + tool_call_id: approval.tool_call_id.clone(), + approval_id: approval.approval_id.clone(), + })?; + rm.approval(&ToolApprovalRequestOutput { + approval_id: approval.approval_id.clone(), + tool_call: call.clone(), + reason: None, + is_automatic: None, + signature: None, }); } - // Provider-executed results stay in the assistant message so the - // provider can replay its own server-tool transcript next turn. GenerateContent::ToolResult(result) => rm.tool_result(result.clone()), } } let crate::response_messages::ResponseMessages { messages: response_messages, + content, reasoning, } = rm.finish(); let reasoning_text = reasoning .iter() - .map(|r| r.text.as_str()) + .filter_map(|r| match r { + ReasoningPart::Text(r) => Some(r.text.as_str()), + ReasoningPart::File(_) => None, + }) .collect::>() .join(""); @@ -693,10 +738,11 @@ pub async fn generate_text( // Extract fields before moving `result` into `raw`. let raw_finish_reason = result.finish_reason.raw.clone(); let provider_metadata = result.provider_metadata.clone(); - let response = result.response.clone(); + let response = result.response.clone().map(Into::into).unwrap_or_default(); let usage = result.usage.clone(); Ok(GenerateTextResult { + content, text, tool_calls, finish_reason: result.finish_reason.clone(), @@ -776,7 +822,12 @@ pub async fn generate_object( Some(text_result.reasoning_text.clone()) }; let provider_metadata = text_result.raw.provider_metadata.clone(); - let response = text_result.raw.response.clone(); + let response = text_result + .raw + .response + .clone() + .map(Into::into) + .unwrap_or_default(); Ok(GenerateObjectResult { object, finish_reason, @@ -833,7 +884,7 @@ pub async fn stream_text( let tools = options.tools.clone(); let operation_instructions = options.instructions.clone(); let (messages, instructions) = split_prompt(prompt.into(), operation_instructions.as_deref()); - let lm_prompt = convert_to_language_model_prompt(&messages, instructions); + let lm_prompt = convert_to_language_model_prompt(&messages, instructions)?; // 2. Build CallOptions. let mut call_options = options.into_call_options(lm_prompt); @@ -931,9 +982,11 @@ pub async fn stream_text( // 解构避免部分 move(result 各字段去向不同)。 let StreamResult { stream, - request_body, - response_headers, + request, + response, } = result; + let request_body = request.and_then(|request| request.body); + let response_headers = response.and_then(|response| response.headers); // The consumption phase keeps observing the same deadlines: the // first-chunk budget armed at operation start continues until the first @@ -951,6 +1004,7 @@ pub async fn stream_text( // the stream's terminal fuse — `Finish` and non-recoverable errors must // end the stream under every configuration, or a provider that keeps the // connection open after `Finish` hangs a consumer reading to end-of-stream. + let file_abort_signal = abort_signal.clone(); let stream: Pin> + Send>> = { let mut stream = stream; let chunk_ms = stream_timeout.chunk_ms; @@ -1030,8 +1084,9 @@ pub async fn stream_text( }) }; let mut stream = stream; - let stream: Pin> + Send>> = - Box::pin(async_stream::stream! { + let stream: Pin> + Send>> = Box::pin( + async_stream::stream! { + let mut tool_calls = HashMap::new(); while let Some(item) = stream.next().await { match item { Ok(StreamPart::ToolCall(raw)) => { @@ -1042,13 +1097,42 @@ pub async fn stream_text( &messages, operation_instructions.as_deref(), ).await; + tool_calls.insert(parsed.tool_call_id.clone(), parsed.clone()); yield Ok(StreamPart::ToolCall(parsed)); } - Ok(part) => yield Ok(part.map_tool_call(|_| unreachable!("matched above"))), + Ok(StreamPart::File(file)) => { + match timeout::run(resolve_file(file, file_abort_signal.as_ref()), file_abort_signal.as_ref(), operation_timeout).await { + Ok(file) => yield Ok(StreamPart::File(file)), + Err(error) => yield Ok(StreamPart::Error { error }), + } + } + Ok(StreamPart::ReasoningFile(file)) => { + match timeout::run(resolve_file(file, file_abort_signal.as_ref()), file_abort_signal.as_ref(), operation_timeout).await { + Ok(file) => yield Ok(StreamPart::ReasoningFile(ReasoningFileOutput { + provider_metadata: file.provider_metadata.clone(), file, + })), + Err(error) => yield Ok(StreamPart::Error { error }), + } + } + Ok(StreamPart::ToolApprovalRequest(approval)) => { + if let Some(call) = tool_calls.get(&approval.tool_call_id) { + yield Ok(StreamPart::ToolApprovalRequest(ToolApprovalRequestOutput { + approval_id: approval.approval_id, tool_call: call.clone(), + reason: None, is_automatic: None, signature: None, + })); + } else { + yield Ok(StreamPart::Error { error: AiMuxError::ToolCallNotFoundForApproval { + tool_call_id: approval.tool_call_id, approval_id: approval.approval_id, + }}); + } + } + Ok(part) => yield Ok(part.map_payloads(|_| unreachable!("matched above"), |_| unreachable!("matched above")) + .map_reasoning_file(|file| ReasoningFileOutput { provider_metadata: file.provider_metadata.clone(), file })), Err(error) => yield Err(error), } } - }); + }, + ); // 录制开启时才包装(终结时写 outcome + 传输封闭);关闭时零成本透传。 let stream = crate::recording::RecordingOutcomeStream::new( stream, @@ -1273,456 +1357,3 @@ pub async fn stream_text_as_openai( stream_options, )) } - -#[cfg(test)] -mod operation_retry_tests { - use std::sync::atomic::{AtomicUsize, Ordering}; - - use async_trait::async_trait; - - use super::*; - use crate::{ApiCallError, LanguageModel, prelude::StreamPart}; - - enum StreamBehavior { - Normal, - FinishThenPending, - ProviderErrorThenFinish, - ParseErrorThenEvent, - TransportErrorThenPending, - NeverReturns, - RetryableFirstError, - NonRetryableFirstError, - } - - struct StreamModel { - behavior: StreamBehavior, - calls: AtomicUsize, - } - - impl StreamModel { - fn new(behavior: StreamBehavior) -> Self { - Self { - behavior, - calls: AtomicUsize::new(0), - } - } - - fn api_error(status: u16, retryable: bool) -> AiMuxError { - AiMuxError::ApiCall(Box::new(ApiCallError { - status_code: Some(status), - response_headers: Some(HashMap::from([("retry-after-ms".into(), "0".into())])), - is_retryable: retryable, - ..ApiCallError::new( - "stream setup failed", - "https://example.test/stream", - serde_json::json!({}), - ) - })) - } - - fn single_event_stream() -> StreamResult { - StreamResult { - stream: Box::pin(futures::stream::iter([Ok(StreamPart::TextDelta { - id: "text-1".into(), - delta: "hello".into(), - provider_metadata: None, - })])), - request_body: None, - response_headers: None, - } - } - - fn finish_stream() -> StreamResult { - let finish = StreamPart::Finish { - finish_reason: crate::types::FinishReason { - unified: crate::types::FinishReasonUnified::Stop, - raw: Some("stop".into()), - }, - usage: Usage::default(), - provider_metadata: None, - }; - StreamResult { - stream: Box::pin( - futures::stream::iter([Ok(finish)]).chain(futures::stream::pending()), - ), - request_body: None, - response_headers: None, - } - } - - fn provider_error_then_finish_stream() -> StreamResult { - let finish = StreamPart::Finish { - finish_reason: crate::types::FinishReason { - unified: crate::types::FinishReasonUnified::Error, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }; - StreamResult { - stream: Box::pin( - futures::stream::iter([ - Ok(StreamPart::Error { - error: AiMuxError::Other("provider error".into()), - }), - Ok(finish), - ]) - .chain(futures::stream::pending()), - ), - request_body: None, - response_headers: None, - } - } - - fn parse_error_then_event_stream() -> StreamResult { - StreamResult { - stream: Box::pin(futures::stream::iter([ - Err(AiMuxError::JsonParse("malformed SSE data".into())), - Ok(StreamPart::TextDelta { - id: "text-1".into(), - delta: "after-error".into(), - provider_metadata: None, - }), - ])), - request_body: None, - response_headers: None, - } - } - } - - #[async_trait] - impl LanguageModel for StreamModel { - fn provider(&self) -> &str { - "test" - } - - fn model_id(&self) -> &str { - "stream-model" - } - - async fn do_generate(&self, _options: &CallOptions) -> Result { - Err(AiMuxError::Other("unused".into())) - } - - async fn do_stream(&self, _options: &CallOptions) -> Result { - let attempt = self.calls.fetch_add(1, Ordering::SeqCst); - match self.behavior { - StreamBehavior::Normal => Ok(Self::single_event_stream()), - StreamBehavior::FinishThenPending => Ok(Self::finish_stream()), - StreamBehavior::ProviderErrorThenFinish => { - Ok(Self::provider_error_then_finish_stream()) - } - StreamBehavior::ParseErrorThenEvent => Ok(Self::parse_error_then_event_stream()), - StreamBehavior::TransportErrorThenPending => Ok(StreamResult { - stream: Box::pin( - futures::stream::iter([Err(AiMuxError::ApiCall(Box::new(ApiCallError { - is_retryable: true, - ..ApiCallError::new( - "connection reset", - "https://example.test/stream", - serde_json::json!({}), - ) - })))]) - .chain(futures::stream::pending()), - ), - request_body: None, - response_headers: None, - }), - StreamBehavior::NeverReturns => std::future::pending().await, - StreamBehavior::RetryableFirstError if attempt == 0 => { - Err(Self::api_error(429, true)) - } - StreamBehavior::RetryableFirstError => Ok(Self::single_event_stream()), - StreamBehavior::NonRetryableFirstError => Err(Self::api_error(400, false)), - } - } - } - - fn retry_options() -> GenerateTextOptions { - GenerateTextOptions { - max_retries: Some(2), - ..Default::default() - } - } - - #[tokio::test] - async fn stream_setup_preserves_the_peeked_first_event() { - let model = StreamModel::new(StreamBehavior::Normal); - let mut result = stream_text(&model, "hello", retry_options()).await.unwrap(); - let first = result.stream.next().await.unwrap().unwrap(); - assert!(matches!(first, StreamPart::TextDelta { delta, .. } if delta == "hello")); - assert!(result.stream.next().await.is_none()); - } - - #[tokio::test] - async fn late_consumer_still_receives_a_first_chunk_delivered_on_time() { - // The provider yields immediately; the first-chunk budget measures - // that arrival, not when the caller gets around to polling. Sleeping - // past the budget before the first poll must not turn delivered - // output into a timeout. - let model = StreamModel::new(StreamBehavior::Normal); - let options = GenerateTextOptions { - max_retries: Some(0), - timeout: Some(crate::options::TimeoutConfiguration { - first_chunk_ms: Some(1), - ..Default::default() - }), - ..Default::default() - }; - let mut result = stream_text(&model, "hello", options).await.unwrap(); - - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - - assert!(matches!( - result.stream.next().await.unwrap().unwrap(), - StreamPart::TextDelta { delta, .. } if delta == "hello" - )); - assert!(result.stream.next().await.is_none()); - } - - #[tokio::test] - async fn caller_abort_still_cancels_the_returned_stream() { - let model = StreamModel::new(StreamBehavior::Normal); - let abort_signal = AbortSignal::new(); - let options = GenerateTextOptions { - max_retries: Some(0), - abort_signal: Some(abort_signal.clone()), - ..Default::default() - }; - let mut result = stream_text(&model, "hello", options).await.unwrap(); - - abort_signal.abort(); - - assert!(matches!( - result.stream.next().await.unwrap(), - Err(AiMuxError::Aborted(message)) if message == "request aborted" - )); - } - - #[tokio::test] - async fn terminal_stream_part_stops_stream_consumption() { - let model = StreamModel::new(StreamBehavior::FinishThenPending); - let options = GenerateTextOptions { - max_retries: Some(0), - timeout: Some(crate::options::TimeoutConfiguration { - total_ms: Some(50), - ..Default::default() - }), - ..Default::default() - }; - let mut result = stream_text(&model, "hello", options).await.unwrap(); - - assert!(matches!( - result.stream.next().await.unwrap().unwrap(), - StreamPart::Finish { .. } - )); - tokio::time::sleep(std::time::Duration::from_millis(60)).await; - assert!(result.stream.next().await.is_none()); - } - - #[tokio::test] - async fn provider_error_part_does_not_hide_the_final_finish() { - let model = StreamModel::new(StreamBehavior::ProviderErrorThenFinish); - let abort_signal = AbortSignal::new(); - let options = GenerateTextOptions { - max_retries: Some(0), - abort_signal: Some(abort_signal.clone()), - ..Default::default() - }; - let mut result = stream_text(&model, "hello", options).await.unwrap(); - - assert!(matches!( - result.stream.next().await.unwrap().unwrap(), - StreamPart::Error { .. } - )); - assert!(matches!( - result.stream.next().await.unwrap().unwrap(), - StreamPart::Finish { finish_reason, .. } - if finish_reason.unified == crate::types::FinishReasonUnified::Error - )); - abort_signal.abort(); - assert!(result.stream.next().await.is_none()); - } - - #[tokio::test] - async fn timeout_wrapper_continues_after_a_malformed_stream_frame() { - let model = StreamModel::new(StreamBehavior::ParseErrorThenEvent); - let options = GenerateTextOptions { - max_retries: Some(0), - timeout: Some(crate::options::TimeoutConfiguration { - total_ms: Some(1_000), - ..Default::default() - }), - ..Default::default() - }; - let mut result = stream_text(&model, "hello", options).await.unwrap(); - - assert!(matches!( - result.stream.next().await.unwrap(), - Err(AiMuxError::JsonParse(_)) - )); - assert!(matches!( - result.stream.next().await.unwrap().unwrap(), - StreamPart::TextDelta { delta, .. } if delta == "after-error" - )); - } - - #[tokio::test] - async fn abort_wrapper_continues_after_a_malformed_stream_frame() { - let model = StreamModel::new(StreamBehavior::ParseErrorThenEvent); - let options = GenerateTextOptions { - max_retries: Some(0), - abort_signal: Some(AbortSignal::new()), - ..Default::default() - }; - let mut result = stream_text(&model, "hello", options).await.unwrap(); - - assert!(matches!( - result.stream.next().await.unwrap(), - Err(AiMuxError::JsonParse(_)) - )); - assert!(matches!( - result.stream.next().await.unwrap().unwrap(), - StreamPart::TextDelta { delta, .. } if delta == "after-error" - )); - } - - #[tokio::test] - async fn first_chunk_timeout_bounds_the_stream_setup_phase() { - // Providers await their first SSE event inside do_stream; a - // 200-then-silence server must be bounded by first_chunk_ms alone. - let model = StreamModel::new(StreamBehavior::NeverReturns); - let options = GenerateTextOptions { - max_retries: Some(0), - timeout: Some(crate::options::TimeoutConfiguration { - first_chunk_ms: Some(5), - ..Default::default() - }), - ..Default::default() - }; - let error = stream_text(&model, "hello", options).await.unwrap_err(); - assert!( - matches!(error, AiMuxError::Timeout(ref message) if message == "First chunk timeout of 5ms exceeded"), - "got {error:?}" - ); - } - - #[tokio::test] - async fn transport_error_ends_the_wrapped_stream() { - let model = StreamModel::new(StreamBehavior::TransportErrorThenPending); - let abort_signal = AbortSignal::new(); - let options = GenerateTextOptions { - max_retries: Some(0), - abort_signal: Some(abort_signal.clone()), - ..Default::default() - }; - let mut result = stream_text(&model, "hello", options).await.unwrap(); - - assert!(matches!( - result.stream.next().await.unwrap(), - Err(AiMuxError::ApiCall(_)) - )); - // Without treating Err as terminal this next() would hang on the - // pending tail of the dead source stream. - assert!(result.stream.next().await.is_none()); - } - - #[tokio::test] - async fn retryable_first_stream_error_retries_before_returning_stream() { - let model = StreamModel::new(StreamBehavior::RetryableFirstError); - let mut result = stream_text(&model, "hello", retry_options()).await.unwrap(); - assert_eq!(model.calls.load(Ordering::SeqCst), 2); - assert!(matches!( - result.stream.next().await.unwrap().unwrap(), - StreamPart::TextDelta { delta, .. } if delta == "hello" - )); - } - - #[tokio::test] - async fn non_retryable_first_stream_error_is_returned_unchanged() { - let model = StreamModel::new(StreamBehavior::NonRetryableFirstError); - let error = stream_text(&model, "hello", retry_options()) - .await - .unwrap_err(); - assert!( - matches!(error, AiMuxError::ApiCall(ref detail) if detail.status_code == Some(400)) - ); - assert_eq!(model.calls.load(Ordering::SeqCst), 1); - } -} - -#[cfg(test)] -mod stream_aggregation_tests { - use super::*; - use crate::types::FinishReasonUnified; - - fn result_from(parts: Vec>) -> StreamTextResult { - StreamTextResult { - stream: Box::pin(futures::stream::iter(parts)), - request_body: None, - response_headers: None, - } - } - - fn delta(text: &str) -> TextStreamPart { - StreamPart::TextDelta { - id: "text-1".into(), - delta: text.into(), - provider_metadata: None, - } - } - - fn finish() -> TextStreamPart { - StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: Some("stop".into()), - }, - usage: Usage::default(), - provider_metadata: None, - } - } - - #[tokio::test] - async fn truncated_stream_with_partial_output_does_not_claim_a_normal_stop() { - let result = result_from(vec![Ok(delta("par"))]).consume().await.unwrap(); - assert_eq!(result.text, "par"); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Other); - - // text() retains the partial result (AI SDK semantics). - let text = result_from(vec![Ok(delta("par"))]).text().await.unwrap(); - assert_eq!(text, "par"); - } - - #[tokio::test] - async fn empty_incomplete_stream_is_an_error() { - let error = result_from(vec![]).consume().await.unwrap_err(); - assert!( - matches!(&error, AiMuxError::InvalidResponseData(message) if message.contains("without a finish chunk")) - ); - let error = result_from(vec![]).text().await.unwrap_err(); - assert!(matches!(error, AiMuxError::InvalidResponseData(_))); - } - - #[tokio::test] - async fn stream_with_finish_still_reports_the_provider_finish_reason() { - let result = result_from(vec![Ok(delta("hi")), Ok(finish())]) - .consume() - .await - .unwrap(); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - } - - #[tokio::test] - async fn provider_error_still_wins_after_draining_a_trailing_finish() { - let parts = vec![ - Ok(delta("partial")), - Ok(StreamPart::Error { - error: AiMuxError::Other("provider error".into()), - }), - Ok(finish()), - ]; - let error = result_from(parts).consume().await.unwrap_err(); - assert!(matches!(error, AiMuxError::Other(message) if message == "provider error")); - } -} diff --git a/aimux-core/src/language_model_message.rs b/aimux-core/src/language_model_message.rs index 3f9500c6e..2f8f920ee 100644 --- a/aimux-core/src/language_model_message.rs +++ b/aimux-core/src/language_model_message.rs @@ -1,65 +1,501 @@ -//! Provider-facing message types. +//! Provider-facing prompt types. //! -//! `LanguageModelPrompt` is the standardized prompt format that providers receive -//! in `CallOptions.prompt`. It is converted from `ModelMessage` (user-facing) -//! by the `generate_text` / `stream_text` functions. +//! Aligned with Vercel AI SDK `LanguageModelV4Prompt` / `LanguageModelV4Message` +//! (`provider/src/language-model/v4/language-model-v4-prompt.ts`): a prompt is +//! a list of messages, a message is a union by role, and there is exactly one +//! file part. `convert_to_language_model_prompt` is the single conversion from +//! the user-facing `ModelMessage`s. use crate::content::ContentPart; -use crate::message::Role; +use crate::error::AiMuxError; +use crate::message::{MessageContent, ModelMessage, Role}; +use crate::shared::{FileBytes, FileData, GeneratedFileData, SharedProviderOptions}; +use std::borrow::Cow; +use std::collections::HashMap; + use serde::{Deserialize, Serialize}; use serde_json::Value; use ts_rs::TS; -/// A single provider-facing message. +/// The standardized prompt passed to `LanguageModel::do_generate` / `do_stream`. +pub type LanguageModelPrompt = Vec; + +/// A single provider-facing message, a union by role. /// -/// Unlike `ModelMessage` (which can have string content), this always has -/// `content: Vec` — strings are normalized to `ContentPart::Text`. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, TS)] +/// `provider_options` is message-level (e.g. `anthropic.cacheControl`), +/// mirroring `LanguageModelV4Message.providerOptions`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "role", rename_all = "snake_case")] #[ts(export)] -pub struct LanguageModelPromptMessage { - pub role: Role, - pub content: Vec, - /// Message-level provider-specific options (e.g. - /// `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, +pub enum LanguageModelMessage { + System { + content: String, + provider_options: Option, + }, + User { + content: Vec, + provider_options: Option, + }, + Assistant { + content: Vec, + provider_options: Option, + }, + Tool { + content: Vec, + provider_options: Option, + }, } -/// The standardized prompt passed to `LanguageModel::do_generate` / `do_stream`. -pub type LanguageModelPrompt = Vec; +/// Text content part. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct TextPart { + pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_options: Option, +} + +/// File content part (the only file part; `data` is a tagged `FileData`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct FilePart { + pub data: FileData, + pub media_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filename: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_options: Option, +} + +/// Reasoning / thinking content part. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +#[ts(rename = "LanguageModelReasoningPart")] +pub struct ReasoningPart { + pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_options: Option, +} + +/// Tool call content part. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ToolCallPart { + pub tool_call_id: String, + pub tool_name: String, + pub input: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_executed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_options: Option, +} + +/// Tool result content part. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ToolResultPart { + pub tool_call_id: String, + pub tool_name: String, + pub output: ToolResultOutput, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_options: Option, +} + +/// The provider-facing result of a tool call. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "kebab-case")] +#[ts(export)] +pub enum ToolResultOutput { + Text { + value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_options: Option, + }, + Json { + value: Value, + #[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: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_options: Option, + }, + Content { + value: Vec, + }, +} + +/// Content within a tool result. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "kebab-case")] +#[ts(export)] +pub enum ToolResultContent { + Text(TextPart), + File(FilePart), + Custom { + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_options: Option, + }, +} + +/// A file generated as part of reasoning. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ReasoningFilePart { + pub data: GeneratedFileData, + pub media_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_options: Option, +} + +/// Provider-specific content identified by its provider and kind. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct CustomPart { + pub kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_options: Option, +} + +/// The user's decision for a provider-executed tool approval request. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ToolApprovalResponsePart { + 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, +} + +/// Parts allowed in a user message. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "kebab-case")] +#[ts(export)] +pub enum UserPart { + Text(TextPart), + File(FilePart), +} + +/// Parts allowed in an assistant message. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "kebab-case")] +#[ts(export)] +pub enum AssistantPart { + Text(TextPart), + File(FilePart), + Reasoning(ReasoningPart), + ReasoningFile(ReasoningFilePart), + Custom(CustomPart), + ToolCall(ToolCallPart), + ToolResult(ToolResultPart), +} + +/// Parts allowed in a tool message. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "kebab-case")] +#[ts(export)] +pub enum ToolPart { + ToolResult(ToolResultPart), + ToolApprovalResponse(ToolApprovalResponsePart), +} + +impl LanguageModelMessage { + /// User message with a single text part. + #[must_use] + pub fn user_text(text: impl Into) -> Self { + Self::User { + content: vec![UserPart::Text(TextPart { + text: text.into(), + provider_options: None, + })], + provider_options: None, + } + } +} + +/// Map a user-facing part onto the widest provider part (the assistant union); +/// the role then narrows it. +fn to_assistant_part( + part: &ContentPart, + tool_names: &HashMap, +) -> Result { + let file = |data, media_type: &String, filename: &Option, po: &Option<_>| { + AssistantPart::File(FilePart { + data, + media_type: media_type.clone(), + filename: filename.clone(), + provider_options: po.clone(), + }) + }; + Ok(match part { + ContentPart::Text { + text, + provider_options, + } => AssistantPart::Text(TextPart { + text: text.clone(), + provider_options: provider_options.clone(), + }), + ContentPart::Image { + image, + media_type, + provider_options, + } => file( + FileData::Data { + data: FileBytes::Binary(image.clone()), + }, + media_type, + &None, + provider_options, + ), + ContentPart::File { + data, + media_type, + filename, + provider_options, + } => file( + FileData::Data { + data: FileBytes::Binary(data.clone()), + }, + media_type, + filename, + provider_options, + ), + ContentPart::FileBase64 { + data, + media_type, + filename, + provider_options, + } => file( + FileData::Data { + data: FileBytes::Base64(data.clone()), + }, + media_type, + filename, + provider_options, + ), + ContentPart::FileUrl { + url, + media_type, + provider_options, + } => file( + FileData::Url { + url: url.clone(), + original_url: None, + }, + media_type, + &None, + provider_options, + ), + ContentPart::FileReference { + media_type, + reference, + filename, + provider_options, + } => file( + FileData::Reference { + reference: serde_json::from_value(reference.clone()).map_err(|e| { + AiMuxError::InvalidPrompt(format!("invalid file reference: {e}")) + })?, + }, + media_type, + filename, + provider_options, + ), + ContentPart::Reasoning { + text, + provider_options, + .. + } => AssistantPart::Reasoning(ReasoningPart { + text: text.clone(), + provider_options: provider_options.clone(), + }), + ContentPart::ToolCall { + tool_call_id, + tool_name, + input, + provider_executed, + provider_options, + } => AssistantPart::ToolCall(ToolCallPart { + tool_call_id: tool_call_id.clone(), + tool_name: tool_name.clone(), + input: input.clone(), + provider_executed: *provider_executed, + provider_options: provider_options.clone(), + }), + ContentPart::ToolResult { + tool_call_id, + result, + tool_name, + is_error, + provider_options, + .. + } => AssistantPart::ToolResult(ToolResultPart { + tool_call_id: tool_call_id.clone(), + tool_name: tool_name + .as_ref() + .or_else(|| tool_names.get(tool_call_id)) + .cloned() + .ok_or_else(|| { + AiMuxError::InvalidPrompt(format!( + "tool result {tool_call_id} has no tool name or preceding tool call" + )) + })?, + output: if *is_error == Some(true) { + ToolResultOutput::ErrorText { + value: match result { + Value::Null => "unknown error".into(), + Value::String(value) => value.clone(), + _ => result.to_string(), + }, + provider_options: None, + } + } else if let Value::String(value) = result { + ToolResultOutput::Text { + value: value.clone(), + provider_options: None, + } + } else { + ToolResultOutput::Json { + value: result.clone(), + provider_options: None, + } + }, + provider_options: provider_options.clone(), + }), + ContentPart::Custom { + kind, + provider_options, + } => AssistantPart::Custom(CustomPart { + kind: kind.clone(), + provider_options: provider_options.clone(), + }), + ContentPart::ReasoningFile { + data, + media_type, + provider_options, + } => AssistantPart::ReasoningFile(ReasoningFilePart { + data: data.clone(), + media_type: media_type.clone(), + provider_options: provider_options.clone(), + }), + ContentPart::ToolApprovalRequest { .. } => { + return Err(AiMuxError::InvalidPrompt( + "tool approval requests are only allowed in assistant messages".into(), + )); + } + }) +} /// Convert user-facing `ModelMessage`s into a `LanguageModelPrompt`. /// -/// - String content is normalized to a single `ContentPart::Text`. +/// - String content is normalized to a single text part. +/// - The five user-facing file variants become one `FilePart`. /// - If `instructions` is provided, it is prepended as a system message. -#[must_use] +/// +/// # Errors +/// +/// `InvalidPrompt` when a part is not allowed for its role (user: text/file; +/// tool: tool-result), a system message is not plain text, or a file +/// reference is not a `{ provider: id }` map. pub fn convert_to_language_model_prompt( - messages: &[crate::message::ModelMessage], + messages: &[ModelMessage], instructions: Option<&str>, -) -> LanguageModelPrompt { +) -> Result { let mut result = Vec::new(); - - // Prepend system instructions if provided. if let Some(instructions) = instructions { - result.push(LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text(instructions)], + result.push(LanguageModelMessage::System { + content: instructions.to_string(), provider_options: None, }); } + let mut tool_names = HashMap::new(); for msg in messages { - let parts = match &msg.content { - crate::message::MessageContent::Text(text) => vec![ContentPart::text(text)], - crate::message::MessageContent::Parts(parts) => parts.clone(), + let bad = |what: &str| AiMuxError::InvalidPrompt(format!("{:?} message: {what}", msg.role)); + if msg.role == Role::System { + match &msg.content { + MessageContent::Text(text) => result.push(LanguageModelMessage::System { + content: text.clone(), + provider_options: None, + }), + _ => return Err(bad("system content must be plain text")), + } + continue; + } + let parts: Cow<'_, [ContentPart]> = match &msg.content { + MessageContent::Text(text) => Cow::Owned(vec![ContentPart::text(text)]), + MessageContent::Parts(parts) => Cow::Borrowed(parts), }; - result.push(LanguageModelPromptMessage { - role: msg.role, - content: parts, - provider_options: None, + if msg.role == Role::Assistant { + for part in parts.iter() { + if let ContentPart::ToolCall { + tool_call_id, + tool_name, + .. + } = part + { + tool_names.insert(tool_call_id.clone(), tool_name.clone()); + } + } + } + let parts = parts + .iter() + .filter(|part| { + msg.role != Role::Assistant + || !matches!(part, ContentPart::ToolApprovalRequest { .. }) + }) + .filter(|part| { + !matches!(&msg.content, MessageContent::Parts(_)) + || !matches!(part, ContentPart::Text { text, provider_options } + if text.is_empty() && (msg.role == Role::User + || (msg.role == Role::Assistant && provider_options.is_none()))) + }) + .map(|part| to_assistant_part(part, &tool_names)) + .collect::, _>>()?; + let provider_options = None; + result.push(match msg.role { + Role::System => unreachable!(), + Role::Assistant => LanguageModelMessage::Assistant { + content: parts, + provider_options, + }, + Role::User => LanguageModelMessage::User { + content: parts + .into_iter() + .map(|p| match p { + AssistantPart::Text(t) => Ok(UserPart::Text(t)), + AssistantPart::File(f) => Ok(UserPart::File(f)), + _ => Err(bad("only text and file parts are allowed")), + }) + .collect::>()?, + provider_options, + }, + Role::Tool => LanguageModelMessage::Tool { + content: parts + .into_iter() + .map(|p| match p { + AssistantPart::ToolResult(r) => Ok(ToolPart::ToolResult(r)), + _ => Err(bad("only tool-result parts are allowed")), + }) + .collect::>()?, + provider_options, + }, }); } - result + Ok(result) } diff --git a/aimux-core/src/lib.rs b/aimux-core/src/lib.rs index 27c5c84b9..8dbd7a26f 100644 --- a/aimux-core/src/lib.rs +++ b/aimux-core/src/lib.rs @@ -22,6 +22,9 @@ pub mod composite; pub mod content; pub mod decision_model; mod decision_replay; +pub(crate) mod download; +#[doc(hidden)] +pub mod download_guard; pub mod embedding_model; pub mod error; pub mod files_model; @@ -103,7 +106,11 @@ pub mod prelude { pub use crate::reranking_model::{ RerankingCallOptions, RerankingModel, RerankingResult, rerank, }; - pub use crate::result::{GenerateResult, StreamResult}; + pub use crate::result::{ + GenerateResponse, GenerateResult, GeneratedFile, RawToolApprovalRequest, ReasoningOutput, + ReasoningPart, Source, StreamResponse, StreamResult, TextContent, + ToolApprovalRequestOutput, + }; pub use crate::router::{ FallbackPolicy, Router, RouterConfig, RouterModel, RuleRouter, WeightedRouter, }; @@ -115,8 +122,9 @@ pub mod prelude { init_session_store, list_sessions, session_calls, }; pub use crate::shared::{ - AspectRatio, FileBytes, FileData, SharedHeaders, SharedProviderMetadata, - SharedProviderOptions, SharedProviderReference, Size, + AspectRatio, FileBytes, FileData, GeneratedFileData, JsonObject, SharedHeaders, + SharedProviderMetadata, SharedProviderOptions, SharedProviderReference, Size, + provider_namespace, }; pub use crate::speech_model::{SpeechCallOptions, SpeechModel, SpeechResult, generate_speech}; pub use crate::stream_part::{StreamPart, TextStreamPart}; @@ -129,7 +137,10 @@ pub mod prelude { TranscriptionResult, TranscriptionStreamOptions, TranscriptionStreamPart, TranscriptionStreamResult, stream_transcribe, transcribe, }; - pub use crate::types::{FinishReason, FinishReasonUnified, ReasoningEffort, Usage, Warning}; + pub use crate::types::{ + FinishReason, FinishReasonUnified, InputTokenUsage, OutputTokenUsage, ReasoningEffort, + Usage, Warning, + }; pub use crate::video_model::{ VideoCallOptions, VideoModel, VideoOperationStart, VideoOperationStatus, VideoPollConfig, VideoPollOptions, VideoResult, generate_video, diff --git a/aimux-core/src/moa.rs b/aimux-core/src/moa.rs index 5111ced50..edf4b3000 100644 --- a/aimux-core/src/moa.rs +++ b/aimux-core/src/moa.rs @@ -151,8 +151,8 @@ impl MoaModel { usage = add_usage(usage, &res.usage); let mid = res .response - .model_id - .clone() + .as_ref() + .and_then(|response| response.model_id.clone()) .unwrap_or_else(|| format!("ref-{i}")); texts.push((mid, extract_text(&res.content))); } @@ -301,8 +301,8 @@ impl LanguageModel for MoaModel { // cache probing (RFC-0015 fingerprinting). Ok(StreamResult { stream: Box::pin(stream), - request_body: None, - response_headers: None, + request: None, + response: None, }) } } diff --git a/aimux-core/src/openai_output.rs b/aimux-core/src/openai_output.rs index 12ffb37bf..bc26fa3ae 100644 --- a/aimux-core/src/openai_output.rs +++ b/aimux-core/src/openai_output.rs @@ -35,12 +35,11 @@ use ts_rs::TS; use crate::error::AiMuxError; use crate::parse_tool_call::raw_tool_call_text; -use crate::result::GenerateContent; -use crate::result::GenerateResult; -use crate::shared::FileData; +use crate::result::{GenerateContent, GenerateResult, GeneratedFile, ReasoningOutput, Source}; +use crate::shared::GeneratedFileData; use crate::stream_part::{StreamPart, TextStreamPart}; use crate::tool::{ToolCall, ToolResult}; -use crate::types::{FinishReason, FinishReasonUnified, Usage}; +use crate::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; // ───────────────────────────────────────────────────────────────────────────── // Non-streaming response types @@ -238,7 +237,7 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio GenerateContent::Text { text, .. } => { content_text.push_str(text); } - GenerateContent::Reasoning { text, .. } => { + GenerateContent::Reasoning(ReasoningOutput { text, .. }) => { reasoning_text.push_str(text); } GenerateContent::ToolCall(call) => { @@ -259,12 +258,12 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio }, }); } - GenerateContent::Source { url, title, .. } => { + GenerateContent::Source(Source::Url { url, title, .. }) => { // Map to OpenAI url_citation annotation. let mut ann = serde_json::json!({ "type": "url_citation", "url_citation": { - "url": url.clone().unwrap_or_default(), + "url": url, } }); if let Some(t) = title { @@ -272,21 +271,23 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio } annotations.push(ann); // Also append URL to content so non-annotation-aware clients see it. - if let Some(u) = url { - if !content_text.is_empty() { - content_text.push('\n'); - } - content_text.push_str(u); + if !content_text.is_empty() { + content_text.push('\n'); } + content_text.push_str(url); } - GenerateContent::File { + GenerateContent::File(GeneratedFile { data, media_type, .. - } => { + }) => { if !content_text.is_empty() { content_text.push('\n'); } content_text.push_str(&file_data_to_text(data, media_type)); } + GenerateContent::Custom { .. } + | GenerateContent::Source(Source::Document { .. }) + | GenerateContent::ReasoningFile(_) + | GenerateContent::ToolApprovalRequest(_) => {} GenerateContent::ToolResult(ToolResult { tool_name, result, @@ -349,13 +350,13 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio // id / model / created let id = result .response - .id - .clone() + .as_ref() + .and_then(|response| response.id.clone()) .unwrap_or_else(|| format!("chatcmpl-{}", random_id())); let model = result .response - .model_id - .clone() + .as_ref() + .and_then(|response| response.model_id.clone()) .unwrap_or_else(|| model.to_string()); let created = now_unix(); @@ -551,7 +552,7 @@ impl StreamState { } } - StreamPart::ResponseMetadata { id, model_id, .. } => { + StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. }) => { if let Some(id) = id { self.id = id.clone(); } @@ -808,9 +809,9 @@ impl StreamState { chunks.push(chunk); } - StreamPart::File { + StreamPart::File(GeneratedFile { data, media_type, .. - } => { + }) => { if let Some(c) = self.ensure_started() { chunks.push(c); } @@ -828,11 +829,11 @@ impl StreamState { chunks.push(chunk); } - StreamPart::Source { url, .. } => { + StreamPart::Source(Source::Url { url, .. }) => { if let Some(c) = self.ensure_started() { chunks.push(c); } - let text = url.clone().unwrap_or_default(); + let text = url.clone(); if !text.is_empty() { let mut chunk = self.base_chunk(); chunk.choices = vec![ChatCompletionChunkChoice { @@ -867,7 +868,11 @@ impl StreamState { } } - StreamPart::Raw { .. } => { /* ignore */ } + StreamPart::Raw { .. } + | StreamPart::Source(Source::Document { .. }) + | StreamPart::Custom { .. } + | StreamPart::ReasoningFile(_) + | StreamPart::ToolApprovalRequest(_) => { /* no OpenAI chat equivalent */ } } chunks @@ -987,11 +992,11 @@ fn usage_to_openai(usage: &Usage) -> ChatCompletionUsage { } } -/// Convert [`FileData`] to a text representation (degraded — placed in content). -fn file_data_to_text(data: &FileData, media_type: &str) -> String { +/// Convert [`GeneratedFileData`] to a text representation (degraded — placed in content). +fn file_data_to_text(data: &GeneratedFileData, media_type: &str) -> String { match data { - FileData::Url { url } => url.clone(), - FileData::Data { data } => match data { + GeneratedFileData::Url { url, .. } => url.clone(), + GeneratedFileData::Data { data } => match data { crate::shared::FileBytes::Base64(b64) => { format!("data:{media_type};base64,{b64}") } @@ -1001,8 +1006,6 @@ fn file_data_to_text(data: &FileData, media_type: &str) -> String { format!("data:{media_type};base64,{b64}") } }, - FileData::Reference { reference } => serde_json::to_string(reference).unwrap_or_default(), - FileData::Text { text } => text.clone(), } } diff --git a/aimux-core/src/options.rs b/aimux-core/src/options.rs index d7a4010ac..e9a6a302a 100644 --- a/aimux-core/src/options.rs +++ b/aimux-core/src/options.rs @@ -10,6 +10,7 @@ use ts_rs::TS; use crate::AbortSignal; use crate::language_model_message::LanguageModelPrompt; +use crate::shared::SharedProviderOptions; pub use crate::tool::{FunctionTool, FunctionToolInputExample, ProviderTool, Tool, ToolChoice}; use crate::types::ReasoningEffort; @@ -108,7 +109,7 @@ pub struct CallOptions { pub headers: Option>, /// Provider-specific options (keyed by provider name). - pub provider_options: Option>, + pub provider_options: Option, /// Top-level reasoning effort. Maps to OpenAI `reasoning_effort` and /// Anthropic `thinking` config. diff --git a/aimux-core/src/recording.rs b/aimux-core/src/recording.rs index 22dff9ba9..25ad2fc6f 100644 --- a/aimux-core/src/recording.rs +++ b/aimux-core/src/recording.rs @@ -1626,11 +1626,12 @@ impl RecordingOutcomeStream { } } -impl futures::Stream for RecordingOutcomeStream +impl futures::Stream for RecordingOutcomeStream where - S: futures::Stream, crate::AiMuxError>> + Unpin, + S: futures::Stream, crate::AiMuxError>> + + Unpin, { - type Item = Result, crate::AiMuxError>; + type Item = Result, crate::AiMuxError>; fn poll_next( mut self: std::pin::Pin<&mut Self>, @@ -1883,7 +1884,8 @@ mod tests { crate::language_model_message::convert_to_language_model_prompt( &[crate::message::ModelMessage::user("ping")], None, - ), + ) + .unwrap(), ) } diff --git a/aimux-core/src/replay.rs b/aimux-core/src/replay.rs index 6246fa03d..8d641d7a0 100644 --- a/aimux-core/src/replay.rs +++ b/aimux-core/src/replay.rs @@ -16,10 +16,13 @@ use async_trait::async_trait; use std::sync::Arc; use crate::error::AiMuxError; -use crate::generate::{GenerateTextOptions, GenerateTextResult, generate_text}; +use crate::generate::{ + GenerateTextOptions, GenerateTextResult, generate_text_from_language_model_prompt, +}; use crate::language_model::LanguageModel; -use crate::language_model_message::LanguageModelPrompt; -use crate::message::{MessageContent, ModelMessage, ModelPrompt}; +use crate::language_model_message::{ + AssistantPart, LanguageModelMessage, LanguageModelPrompt, UserPart, +}; use crate::options::CallOptions; use crate::recording::Recording; use crate::result::{GenerateContent, GenerateResult, StreamResult}; @@ -249,10 +252,42 @@ impl ReplayMatcher for ScoreMatcher { } /// 消息的首个文本内容(非文本消息回退到序列化)。 -fn message_text(m: &crate::language_model_message::LanguageModelPromptMessage) -> String { - match m.content.first() { - Some(crate::content::ContentPart::Text { text, .. }) => text.clone(), - _ => serde_json::to_string(&m.content).unwrap_or_default(), +fn message_text(m: &LanguageModelMessage) -> String { + match m { + LanguageModelMessage::System { content, .. } => content.clone(), + LanguageModelMessage::User { content, .. } => match content.first() { + Some(UserPart::Text(part)) => part.text.clone(), + _ => serde_json::to_string(content).unwrap_or_default(), + }, + LanguageModelMessage::Assistant { content, .. } => match content.first() { + Some(AssistantPart::Text(part)) => part.text.clone(), + _ => serde_json::to_string(content).unwrap_or_default(), + }, + LanguageModelMessage::Tool { content, .. } => { + serde_json::to_string(content).unwrap_or_default() + } + } +} + +fn same_message_content(a: &LanguageModelMessage, b: &LanguageModelMessage) -> bool { + match (a, b) { + ( + LanguageModelMessage::System { content: a, .. }, + LanguageModelMessage::System { content: b, .. }, + ) => a == b, + ( + LanguageModelMessage::User { content: a, .. }, + LanguageModelMessage::User { content: b, .. }, + ) => a == b, + ( + LanguageModelMessage::Assistant { content: a, .. }, + LanguageModelMessage::Assistant { content: b, .. }, + ) => a == b, + ( + LanguageModelMessage::Tool { content: a, .. }, + LanguageModelMessage::Tool { content: b, .. }, + ) => a == b, + _ => false, } } @@ -268,7 +303,7 @@ fn match_score(options: &CallOptions, rec: &Recording) -> u64 { .prompt .iter() .zip(rec.input.prompt.iter()) - .take_while(|(a, b)| a.role == b.role && a.content == b.content) + .take_while(|(a, b)| same_message_content(a, b)) .count(); // 字符级 LCP:第一个不同消息(或双方最后一个消息),在文本内容上计算 // (避免 JSON 结构前缀的伪匹配)。任一 prompt 为空时无消息可比,LCP=0 @@ -343,7 +378,7 @@ impl ReplayMatcher for PrefixMatcher { .prompt .iter() .zip(r.input.prompt.iter()) - .take_while(|(a, b)| a.role == b.role && a.content == b.content) + .take_while(|(a, b)| same_message_content(a, b)) .count(); if common < r.input.prompt.len() { continue; // 录制未被输入完整包含,不命中。 @@ -516,7 +551,7 @@ fn parse_usage(v: &serde_json::Value) -> Result { let prompt_details = &u["prompt_tokens_details"]; let completion_details = &u["completion_tokens_details"]; Ok(Usage { - input_tokens: crate::types::TokenUsage { + input_tokens: crate::types::InputTokenUsage { total: u32_from_json(&u["prompt_tokens"], "prompt_tokens")?, no_cache: None, cache_read: u32_from_json( @@ -524,21 +559,16 @@ fn parse_usage(v: &serde_json::Value) -> Result { "prompt_tokens_details.cached_tokens", )?, cache_write: None, - text: None, - reasoning: None, }, - output_tokens: crate::types::TokenUsage { + output_tokens: crate::types::OutputTokenUsage { total: u32_from_json(&u["completion_tokens"], "completion_tokens")?, - no_cache: None, - cache_read: None, - cache_write: None, text: None, reasoning: u32_from_json( &completion_details["reasoning_tokens"], "completion_tokens_details.reasoning_tokens", )?, }, - raw: Some(u.clone()), + raw: u.as_object().cloned(), }) } @@ -620,13 +650,15 @@ fn rebuild_generate_result(rec: &Recording) -> Result Result { || v.get("model").and_then(|x| x.as_str()).is_some()) { response_meta_emitted = true; - parts.push(Ok(StreamPart::ResponseMetadata { + parts.push(Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: v["id"].as_str().map(std::string::ToString::to_string), timestamp: None, model_id: v["model"].as_str().map(std::string::ToString::to_string), - })); + }))); } // usage(含 usage-only 末帧 choices:[]+usage)→ 累积到 Finish.usage。 @@ -880,8 +912,8 @@ fn rebuild_stream_result(rec: &Recording) -> Result { Ok(StreamResult { stream: Box::pin(futures::stream::iter(parts)), - request_body: None, - response_headers: None, + request: None, + response: None, }) } @@ -918,8 +950,8 @@ pub struct ReplayOverrides { /// # Errors /// /// Returns `AiMuxError::JsonParse` when the recorded call options cannot be -/// deserialized, and propagates any error from the re-sent `generate_text` -/// call. +/// deserialized, `AiMuxError::InvalidPrompt` for inline text file data without +/// a user-facing representation, and propagates errors from `generate_text`. pub async fn replay_with_model( recording: &Recording, model: &dyn LanguageModel, @@ -947,26 +979,11 @@ pub async fn replay_with_model( } } - // 3. 转回用户侧类型,经 generate_text 重发。 - let prompt = model_prompt_from_lm(&call_options.prompt); + // Recorded options already contain the complete provider-facing prompt. + // Repair callbacks are runtime-only and are not restored from recordings. + let prompt = std::mem::take(&mut call_options.prompt); let options = generate_options_from_call_options(call_options); - generate_text(model, prompt, options).await -} - -/// `LanguageModelPrompt`(provider 侧)→ `ModelPrompt`(用户侧)。 -/// -/// 逐消息 `provider_options` 丢弃(用户侧无对应字段;语义不丢,仅 cacheControl -/// 之类的 provider 提示不参与重放)。 -fn model_prompt_from_lm(prompt: &LanguageModelPrompt) -> ModelPrompt { - ModelPrompt::Messages( - prompt - .iter() - .map(|m| ModelMessage { - role: m.role, - content: MessageContent::Parts(m.content.clone()), - }) - .collect(), - ) + generate_text_from_language_model_prompt(model, prompt, Vec::new(), options).await } /// `CallOptions` → `GenerateTextOptions`(record 侧到用户侧的反向映射)。 @@ -1012,14 +1029,7 @@ mod tests { use futures::StreamExt; fn sample_options(text: &str, temperature: Option) -> CallOptions { - let prompt = vec![crate::language_model_message::LanguageModelPromptMessage { - role: crate::message::Role::User, - content: vec![crate::content::ContentPart::Text { - text: text.to_string(), - provider_options: None, - }], - provider_options: None, - }]; + let prompt = vec![LanguageModelMessage::user_text(text.to_string())]; GenerateTextOptions { temperature, ..Default::default() @@ -1030,14 +1040,7 @@ mod tests { /// 构造 OpenAI 格式录制。 fn openai_recording(trace_id: &str, prompt_text: &str, reply: &str, finish: &str) -> Recording { let input_prompt: crate::language_model_message::LanguageModelPrompt = - vec![crate::language_model_message::LanguageModelPromptMessage { - role: crate::message::Role::User, - content: vec![crate::content::ContentPart::Text { - text: prompt_text.to_string(), - provider_options: None, - }], - provider_options: None, - }]; + vec![LanguageModelMessage::user_text(prompt_text.to_string())]; let body = serde_json::json!({ "id": "chatcmpl-mock", "object": "chat.completion", @@ -1198,39 +1201,6 @@ mod tests { assert!(matcher.r#match(&req3, &recs).is_err()); } - #[test] - fn exact_matcher_different_provider_options_misses() { - // A8:provider_options 纳入规范键——非脱敏值不同 → miss。 - let mut rec = openai_recording("t1", "ping", "pong", "stop"); - let mut call = sample_options("ping", Some(0.7)); - call.provider_options = Some( - [("openai".into(), serde_json::json!({ "foo": 1 }))] - .into_iter() - .collect(), - ); - rec.input.options = serde_json::to_value(&call).unwrap(); - let recs = [rec]; - let matcher = ExactMatcher::new("openai", "gpt-4o"); - - // foo 值不同 → miss。 - let mut req = sample_options("ping", Some(0.7)); - req.provider_options = Some( - [("openai".into(), serde_json::json!({ "foo": 2 }))] - .into_iter() - .collect(), - ); - assert!(matcher.r#match(&req, &recs).is_err()); - - // 完全一致 → hit(对照)。 - let mut req2 = sample_options("ping", Some(0.7)); - req2.provider_options = Some( - [("openai".into(), serde_json::json!({ "foo": 1 }))] - .into_iter() - .collect(), - ); - assert!(matcher.r#match(&req2, &recs).is_ok()); - } - #[test] fn exact_matcher_redacted_values_match_any() { // A8:录制侧 headers 经 redact_json 脱敏(authorization→"[REDACTED]"); @@ -1371,22 +1341,8 @@ mod tests { let matcher = PrefixMatcher::new("openai", "gpt-4o"); // 输入两条消息 ["hello", "world"]:rec 是消息级前缀 → 命中。 let prompt = vec![ - crate::language_model_message::LanguageModelPromptMessage { - role: crate::message::Role::User, - content: vec![crate::content::ContentPart::Text { - text: "hello".into(), - provider_options: None, - }], - provider_options: None, - }, - crate::language_model_message::LanguageModelPromptMessage { - role: crate::message::Role::User, - content: vec![crate::content::ContentPart::Text { - text: "world".into(), - provider_options: None, - }], - provider_options: None, - }, + LanguageModelMessage::user_text("hello"), + LanguageModelMessage::user_text("world"), ]; let opts = GenerateTextOptions::default().into_call_options(prompt); let recs = [rec.clone()]; @@ -1394,16 +1350,8 @@ mod tests { assert_eq!(hit.call_id, "t1"); // 首消息不同 → 不命中(整条消息级前缀,非字符级)。 - let opts2 = GenerateTextOptions::default().into_call_options(vec![ - crate::language_model_message::LanguageModelPromptMessage { - role: crate::message::Role::User, - content: vec![crate::content::ContentPart::Text { - text: "hi".into(), - provider_options: None, - }], - provider_options: None, - }, - ]); + let opts2 = GenerateTextOptions::default() + .into_call_options(vec![LanguageModelMessage::user_text("hi")]); assert!(matcher.r#match(&opts2, &[rec]).is_err()); } @@ -1412,14 +1360,7 @@ mod tests { // 多消息录制:rec-a = ["hello"],rec-b = ["hello", "world"]。 let mut rec_a = openai_recording("ta", "hello", "a", "stop"); let mut rec_b = openai_recording("tb", "hello world", "b", "stop"); - let mk = |text: &str| crate::language_model_message::LanguageModelPromptMessage { - role: crate::message::Role::User, - content: vec![crate::content::ContentPart::Text { - text: text.into(), - provider_options: None, - }], - provider_options: None, - }; + let mk = |text: &str| LanguageModelMessage::user_text(text); rec_a.input.prompt = vec![mk("hello")]; rec_b.input.prompt = vec![mk("hello"), mk("world")]; @@ -1466,7 +1407,13 @@ mod tests { assert_eq!(text, "pong"); assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); assert_eq!(result.usage.input_tokens.total, Some(5)); - assert_eq!(result.response.model_id.as_deref(), Some("gpt-4o")); + assert_eq!( + result + .response + .as_ref() + .and_then(|response| response.model_id.as_deref()), + Some("gpt-4o") + ); } #[test] @@ -1692,7 +1639,7 @@ mod tests { // 首帧 id/model → ResponseMetadata。 assert!(parts.iter().any(|p| matches!( p, - StreamPart::ResponseMetadata { id, model_id, .. } + StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. }) if id.as_deref() == Some("chatcmpl-1") && model_id.as_deref() == Some("gpt-4o") ))); // ToolInputStart。 @@ -1771,7 +1718,7 @@ mod tests { }); assert!(parts.iter().any(|p| matches!( p, - StreamPart::ResponseMetadata { id, model_id, .. } + StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. }) if id.as_deref() == Some("chatcmpl-2") && model_id.as_deref() == Some("gpt-4o") ))); let deltas: Vec = parts @@ -1841,7 +1788,13 @@ mod tests { }; assert_eq!(text, "pong"); // 证明取的是第 1 次(id=chatcmpl-ok),而非失败的 exchange[0](id=chatcmpl-mock)。 - assert_eq!(result.response.id.as_deref(), Some("chatcmpl-ok")); + assert_eq!( + result + .response + .as_ref() + .and_then(|response| response.id.as_deref()), + Some("chatcmpl-ok") + ); } #[test] @@ -1875,117 +1828,4 @@ mod tests { assert_eq!(model.recordings().len(), 1); let _ = std::fs::remove_dir_all(&dir); } - - // ── P4 请求回放 ───────────────────────────────────────────────────── - - /// 测试用 echo model:把收到的 prompt 文本原样回显。 - #[derive(Clone)] - struct EchoModel { - provider: &'static str, - model_id: &'static str, - } - - impl EchoModel { - fn new() -> Self { - Self { - provider: "openai", - model_id: "gpt-4o", - } - } - } - - #[async_trait] - impl LanguageModel for EchoModel { - fn provider(&self) -> &str { - self.provider - } - fn model_id(&self) -> &str { - self.model_id - } - async fn do_generate(&self, options: &CallOptions) -> Result { - let text = options - .prompt - .iter() - .filter_map(|m| match m.content.first() { - Some(crate::content::ContentPart::Text { text, .. }) => Some(text.clone()), - _ => None, - }) - .collect::>() - .join(" "); - Ok(GenerateResult { - content: vec![GenerateContent::Text { - text, - provider_metadata: None, - }], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: Some("stop".into()), - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: ResponseMetadata::default(), - request_body: None, - response_headers: None, - }) - } - async fn do_stream(&self, _options: &CallOptions) -> Result { - unreachable!("not used in tests") - } - } - - /// 构造带 options 的录制(temperature/max_output_tokens 有值)。 - /// options 用真实 CallOptions 序列化(与录制路径一致,全字段 round-trip)。 - fn optioned_recording(prompt_text: &str) -> Recording { - let mut rec = openai_recording("t1", prompt_text, "pong", "stop"); - let mut call = sample_options(prompt_text, Some(0.7)); - call.max_output_tokens = Some(128); - rec.input.options = serde_json::to_value(&call).unwrap(); - rec - } - - #[test] - fn replay_with_model_rebuilds_input_and_resends() { - let rec = optioned_recording("hello"); - let model = EchoModel::new(); - let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(async { replay_with_model(&rec, &model, None).await.unwrap() }); - assert_eq!(result.text, "hello"); - } - - #[test] - fn replay_with_model_applies_overrides() { - let rec = optioned_recording("hello"); - let model = EchoModel::new(); - let overrides = ReplayOverrides { - prompt: Some(vec![ - crate::language_model_message::LanguageModelPromptMessage { - role: crate::message::Role::User, - content: vec![crate::content::ContentPart::Text { - text: "overridden".into(), - provider_options: None, - }], - provider_options: None, - }, - ]), - ..Default::default() - }; - let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(async { - replay_with_model(&rec, &model, Some(&overrides)) - .await - .unwrap() - }); - assert_eq!(result.text, "overridden"); - } - - #[test] - fn replay_with_model_bad_input_options_errors() { - let mut rec = openai_recording("t1", "hello", "pong", "stop"); - rec.input.options = serde_json::json!({ "not": "call-options" }); - let model = EchoModel::new(); - let rt = tokio::runtime::Runtime::new().unwrap(); - let err = rt.block_on(async { replay_with_model(&rec, &model, None).await.unwrap_err() }); - assert!(matches!(err, AiMuxError::JsonParse(_)), "{err}"); - } } diff --git a/aimux-core/src/response_messages.rs b/aimux-core/src/response_messages.rs index 5c290702e..c3239b6e3 100644 --- a/aimux-core/src/response_messages.rs +++ b/aimux-core/src/response_messages.rs @@ -1,17 +1,21 @@ //! Assemble the assistant message replayed to the model on the next turn. //! -//! Rust port of the AI SDK's `toResponseMessages` -//! (`packages/ai/src/generate-text/to-response-messages.ts`): text and -//! reasoning arrive as deltas and must be flushed as positioned segments the -//! moment a part of a different kind lands, so the replayed transcript keeps -//! provider order. Tool calls/results and reasoning signatures are replayed -//! verbatim — Anthropic/Bedrock thinking signatures must round-trip exactly. +//! Rust port of the AI SDK's `toResponseMessages` and streamed content +//! assembly. Text and reasoning segments retain their first position while +//! deltas update them by ID. Provider metadata is replayed on the next turn. +use std::collections::HashMap; + +use base64::Engine; use serde_json::Value; use crate::content::ContentPart; use crate::message::{MessageContent, ModelMessage, Role}; -use crate::result::ReasoningPart; +use crate::result::{ + GenerateContent, GeneratedFile, ReasoningFileOutput, ReasoningOutput, ReasoningPart, Source, + TextContent, ToolApprovalRequestOutput, +}; +use crate::shared::{FileBytes, GeneratedFileData, SharedProviderMetadata}; /// Match the AI SDK's response-message safety rule for invalid tool calls: /// malformed primitive input must not be replayed as a prompt tool-call input. @@ -28,33 +32,27 @@ pub(crate) fn response_tool_call_input(input: &Value, invalid: Option) -> /// Reasoning signature echoed back on the next turn (Anthropic: /// `provider_metadata.anthropic.signature`; Bedrock: `.bedrock.signature` / /// `.amazonBedrock.signature`). -pub(crate) fn extract_reasoning_signature(provider_metadata: Option<&Value>) -> Option { +pub(crate) fn extract_reasoning_signature( + provider_metadata: Option<&SharedProviderMetadata>, +) -> Option { let metadata = provider_metadata?; ["anthropic", "bedrock", "amazonBedrock"] - .iter() + .into_iter() .find_map(|ns| metadata.get(ns)?.get("signature")?.as_str()) .map(str::to_owned) } -/// Accumulates response-message content parts in provider order. -/// -/// Streaming feeds it per-event (`text_start`/`text_delta`/…); the -/// non-streaming path feeds whole segments (`text`/`reasoning`). Both paths -/// share the flush discipline and the tool-call/result placement rules. +/// Accumulates generated content in provider order, updating streamed segments by ID. #[derive(Default)] pub(crate) struct ResponseMessageBuilder { - parts: Vec, - text_buf: String, - text_provider_options: Option, - reasoning_buf: String, - reasoning_provider_options: Option, - reasoning: Vec, + content: Vec, + text_indexes: HashMap, + reasoning_indexes: HashMap, } -/// What the builder produced: the replayable assistant message plus the -/// reasoning aggregate surfaced on the result. pub(crate) struct ResponseMessages { pub messages: Vec, + pub content: Vec, pub reasoning: Vec, } @@ -63,156 +61,228 @@ impl ResponseMessageBuilder { Self::default() } - // ── Streaming events ──────────────────────────────────────────────── - - pub fn text_start(&mut self, provider_metadata: Option) { - self.flush_reasoning(); - // A new text segment establishes its position immediately; flush a - // preceding implicit segment before starting it. - self.flush_text(); - self.text_provider_options = provider_metadata; + pub fn text_start(&mut self, id: String, metadata: Option) { + self.text_delta(id, "", metadata); } - pub fn text_delta(&mut self, delta: &str, provider_metadata: Option) { - self.flush_reasoning(); - self.text_buf.push_str(delta); - if provider_metadata.is_some() { - self.text_provider_options = provider_metadata; + pub fn text_delta( + &mut self, + id: String, + delta: &str, + metadata: Option, + ) { + let index = *self.text_indexes.entry(id).or_insert_with(|| { + let index = self.content.len(); + self.content.push(GenerateContent::Text { + text: String::new(), + provider_metadata: None, + }); + index + }); + if let GenerateContent::Text { + text, + provider_metadata, + } = &mut self.content[index] + { + text.push_str(delta); + if metadata.is_some() { + *provider_metadata = metadata; + } } } - pub fn text_end(&mut self, provider_metadata: Option) { - if provider_metadata.is_some() { - self.text_provider_options = provider_metadata; - } - self.flush_text(); + pub fn text_end(&mut self, id: String, metadata: Option) { + self.text_delta(id.clone(), "", metadata); + self.text_indexes.remove(&id); } - pub fn reasoning_start(&mut self, provider_metadata: Option) { - self.flush_text(); - self.flush_reasoning(); - self.reasoning_provider_options = provider_metadata; + pub fn reasoning_start(&mut self, id: String, metadata: Option) { + self.reasoning_delta(id, "", metadata); } - pub fn reasoning_delta(&mut self, delta: &str, provider_metadata: Option) { - self.flush_text(); - self.reasoning_buf.push_str(delta); - if provider_metadata.is_some() { - self.reasoning_provider_options = provider_metadata; + pub fn reasoning_delta( + &mut self, + id: String, + delta: &str, + metadata: Option, + ) { + let index = *self.reasoning_indexes.entry(id).or_insert_with(|| { + let index = self.content.len(); + self.content + .push(GenerateContent::Reasoning(ReasoningOutput { + text: String::new(), + provider_metadata: None, + })); + index + }); + if let GenerateContent::Reasoning(reasoning) = &mut self.content[index] { + reasoning.text.push_str(delta); + if metadata.is_some() { + reasoning.provider_metadata = metadata; + } } } - pub fn reasoning_end(&mut self, provider_metadata: Option) { - self.flush_text(); - if provider_metadata.is_some() { - self.reasoning_provider_options = provider_metadata; - } - self.flush_reasoning(); + pub fn reasoning_end(&mut self, id: String, metadata: Option) { + self.reasoning_delta(id.clone(), "", metadata); + self.reasoning_indexes.remove(&id); } - // ── Whole segments (non-streaming) ────────────────────────────────── + pub fn text(&mut self, text: &str, metadata: Option<&SharedProviderMetadata>) { + self.content.push(GenerateContent::Text { + text: text.to_owned(), + provider_metadata: metadata.cloned(), + }); + } - pub fn text(&mut self, text: &str, provider_metadata: Option<&Value>) { - if !text.is_empty() { - self.parts.push(ContentPart::Text { - text: text.to_owned(), - provider_options: provider_metadata.cloned(), - }); - } + pub fn reasoning(&mut self, reasoning: &ReasoningOutput) { + self.content + .push(GenerateContent::Reasoning(reasoning.clone())); } - pub fn reasoning(&mut self, text: &str, provider_metadata: Option<&Value>) { - // Pushed unconditionally: redacted thinking has empty text but its - // provider metadata must still be replayed. - self.reasoning.push(ReasoningPart { - text: text.to_owned(), + pub fn custom(&mut self, kind: String, provider_metadata: Option) { + self.content.push(GenerateContent::Custom { + kind, + provider_metadata, }); - let signature = extract_reasoning_signature(provider_metadata); - self.parts.push(ContentPart::Reasoning { - text: text.to_owned(), - signature, - provider_options: provider_metadata.cloned(), + } + + pub fn file(&mut self, file: &GeneratedFile, reasoning: bool) { + self.content.push(if reasoning { + GenerateContent::ReasoningFile(ReasoningFileOutput { + file: file.clone(), + provider_metadata: file.provider_metadata.clone(), + }) + } else { + GenerateContent::File(file.clone()) }); } - // ── Tool parts (both paths) ───────────────────────────────────────── + pub fn approval(&mut self, approval: &ToolApprovalRequestOutput) { + self.content + .push(GenerateContent::ToolApprovalRequest(approval.clone())); + } pub fn tool_call(&mut self, call: &crate::tool::ToolCall) { - self.flush_text(); - self.flush_reasoning(); - self.parts.push(ContentPart::ToolCall { - tool_call_id: call.tool_call_id.clone(), - tool_name: call.tool_name.clone(), - input: response_tool_call_input(&call.input, call.invalid), - provider_executed: call.provider_executed, - provider_options: call.provider_metadata.clone(), - }); + self.content.push(GenerateContent::ToolCall(call.clone())); } pub fn tool_result(&mut self, result: crate::tool::ToolResult) { - // Preliminary server-tool results are transient stream updates. The - // provider contract requires a later final result, and only that - // final value belongs in the replay transcript for the next turn. - if result.preliminary == Some(true) { - return; + if result.preliminary != Some(true) { + self.content.push(GenerateContent::ToolResult(result)); } - self.flush_text(); - self.flush_reasoning(); - self.parts.push(ContentPart::ToolResult { - tool_call_id: result.tool_call_id, - tool_name: Some(result.tool_name), - result: result.result, - is_error: result.is_error, - preliminary: result.preliminary, - dynamic: result.dynamic, - provider_options: result.provider_metadata, - }); } - // ── Finalization ──────────────────────────────────────────────────── + pub fn source(&mut self, source: Source) { + self.content.push(GenerateContent::Source(source)); + } - pub fn finish(mut self) -> ResponseMessages { - self.flush_text(); - self.flush_reasoning(); - let messages = if self.parts.is_empty() { + pub fn finish(self) -> ResponseMessages { + let mut parts = Vec::new(); + let mut reasoning = Vec::new(); + for content in &self.content { + parts.push(match content { + GenerateContent::Text { + text, + provider_metadata, + } => { + if text.is_empty() { + continue; + } + ContentPart::Text { + text: text.clone(), + provider_options: provider_metadata.clone(), + } + } + GenerateContent::Reasoning(output) => { + reasoning.push(ReasoningPart::Text(output.clone())); + ContentPart::Reasoning { + text: output.text.clone(), + signature: extract_reasoning_signature(output.provider_metadata.as_ref()), + provider_options: output.provider_metadata.clone(), + } + } + GenerateContent::Custom { + kind, + provider_metadata, + } => ContentPart::Custom { + kind: kind.clone(), + provider_options: provider_metadata.clone(), + }, + GenerateContent::File(file) => match &file.data { + GeneratedFileData::Data { data } => ContentPart::FileBase64 { + data: file_base64(data), + media_type: file.media_type.clone(), + filename: None, + provider_options: file.provider_metadata.clone(), + }, + GeneratedFileData::Url { url, .. } => ContentPart::FileUrl { + url: url.clone(), + media_type: file.media_type.clone(), + provider_options: file.provider_metadata.clone(), + }, + }, + GenerateContent::ReasoningFile(output) => { + reasoning.push(ReasoningPart::File(output.clone())); + ContentPart::ReasoningFile { + data: match &output.file.data { + GeneratedFileData::Data { data } => GeneratedFileData::Data { + data: FileBytes::Base64(file_base64(data)), + }, + url => url.clone(), + }, + media_type: output.file.media_type.clone(), + provider_options: output.provider_metadata.clone(), + } + } + GenerateContent::ToolApprovalRequest(approval) => { + ContentPart::ToolApprovalRequest { + approval_id: approval.approval_id.clone(), + tool_call_id: approval.tool_call.tool_call_id.clone(), + reason: approval.reason.clone(), + is_automatic: approval.is_automatic, + signature: approval.signature.clone(), + input_schema_input: None, + } + } + GenerateContent::ToolCall(call) => ContentPart::ToolCall { + tool_call_id: call.tool_call_id.clone(), + tool_name: call.tool_name.clone(), + input: response_tool_call_input(&call.input, call.invalid), + provider_executed: call.provider_executed, + provider_options: call.provider_metadata.clone(), + }, + GenerateContent::ToolResult(result) => ContentPart::ToolResult { + tool_call_id: result.tool_call_id.clone(), + tool_name: Some(result.tool_name.clone()), + result: result.result.clone(), + is_error: result.is_error, + preliminary: result.preliminary, + dynamic: result.dynamic, + provider_options: result.provider_metadata.clone(), + }, + GenerateContent::Source(_) => continue, + }); + } + let messages = if parts.is_empty() { Vec::new() } else { vec![ModelMessage { role: Role::Assistant, - content: MessageContent::Parts(self.parts), + content: MessageContent::Parts(parts), }] }; ResponseMessages { messages, - reasoning: self.reasoning, - } - } - - fn flush_text(&mut self) { - if !self.text_buf.is_empty() { - self.parts.push(ContentPart::Text { - text: std::mem::take(&mut self.text_buf), - provider_options: self.text_provider_options.take(), - }); - } else { - self.text_provider_options = None; + content: self.content, + reasoning, } } +} - fn flush_reasoning(&mut self) { - if self.reasoning_buf.is_empty() && self.reasoning_provider_options.is_none() { - return; - } - let text = std::mem::take(&mut self.reasoning_buf); - if !text.is_empty() { - self.reasoning.push(ReasoningPart { text: text.clone() }); - } - let provider_options = self.reasoning_provider_options.take(); - let signature = extract_reasoning_signature(provider_options.as_ref()); - self.parts.push(ContentPart::Reasoning { - text, - signature, - provider_options, - }); +fn file_base64(data: &FileBytes) -> String { + match data { + FileBytes::Base64(data) => data.clone(), + FileBytes::Binary(data) => base64::engine::general_purpose::STANDARD.encode(data), } } diff --git a/aimux-core/src/result.rs b/aimux-core/src/result.rs index c3c9811df..aad084f05 100644 --- a/aimux-core/src/result.rs +++ b/aimux-core/src/result.rs @@ -1,4 +1,4 @@ -//! Result types for `do_generate` and `do_stream`. +//! Result types for `do_generate` and `do_stream`. use std::collections::HashMap; @@ -8,7 +8,8 @@ use std::pin::Pin; use ts_rs::TS; use crate::error::AiMuxError; -use crate::shared::FileData; +pub use crate::shared::GeneratedFileData; +use crate::shared::RequestInfo; use crate::stream_part::StreamPart; use crate::tool::{RawToolCall, ToolResult}; use crate::types::{FinishReason, ProviderMetadata, ResponseMetadata, Usage, Warning}; @@ -16,7 +17,7 @@ use crate::types::{FinishReason, ProviderMetadata, ResponseMetadata, Usage, Warn /// A content item in the generation result. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[ts(export)] -pub enum GenerateContent { +pub enum GenerateContent { /// Generated text. Text { text: String, @@ -25,73 +26,129 @@ pub enum GenerateContent { }, /// A tool call requested by the model. `input` is the provider's raw /// argument text; `generate_text` owns parsing, validation, and repair. - ToolCall(RawToolCall), + ToolCall(C), /// A source / citation (e.g. URL citation from search-preview models). - Source { - id: String, - source_type: String, - url: Option, - title: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - provider_metadata: Option, - }, + Source(Source), /// A reasoning / thinking segment produced by the model. - /// - /// Mirrors the Vercel AI SDK `reasoning` content type. `provider_metadata` - /// carries provider-specific data such as the Anthropic thinking-block - /// signature (`providerMetadata.anthropic.signature`). - Reasoning { - text: String, - provider_metadata: Option, - }, + Reasoning(ReasoningOutput), /// A file generated by the model (e.g. an image or document). - /// - /// Mirrors the Vercel AI SDK `file` content type. `data` is a tagged union - /// covering base64 data, URL, or provider reference. - File { - data: FileData, - media_type: String, + File(GeneratedFile), + /// A file generated as part of reasoning. + ReasoningFile(R), + /// Provider-specific content. + Custom { + kind: String, #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, + /// Approval required for a provider-executed tool call. + ToolApprovalRequest(A), /// A tool result from a provider-executed tool (e.g. xAI file_search, /// web_search). Emitted alongside the preceding `ToolCall` when the /// provider executes the tool server-side. ToolResult(ToolResult), } -// ── M7: top-level aggregation types ───────────────────────────────────────── -// -// Simplified views extracted from `GenerateContent` for the top-level fields of -// `GenerateTextResult`. They omit provider-internal metadata so the common case -// (reading reasoning/sources/files) doesn't require digging through `raw.content`. +/// Content exposed by text generation after tool-call parsing. +pub type TextContent = + GenerateContent; + +// Export the user-facing alias without changing its Rust representation. +#[cfg(test)] +#[derive(TS)] +#[ts(export, rename = "TextContent", as = "TextContent")] +struct TextContentBinding; -/// A reasoning / thinking segment, extracted to the top level (M7). +/// Provider approval request for a provider-executed call. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[ts(export)] -pub struct ReasoningPart { - /// The reasoning text. - pub text: String, +pub struct RawToolApprovalRequest { + pub approval_id: String, + pub tool_call_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_metadata: Option, } -/// A source / citation, extracted to the top level (M7). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +/// Approval request exposed by text generation. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export)] -pub struct SourcePart { - pub id: String, - pub source_type: String, +pub struct ToolApprovalRequestOutput { + pub approval_id: String, + pub tool_call: crate::tool::ToolCall, #[serde(default, skip_serializing_if = "Option::is_none")] - pub url: Option, + pub reason: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub title: Option, + pub is_automatic: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signature: Option, +} + +/// A source / citation (e.g. URL citation from search-preview models). The +/// payload of both `GenerateContent::Source` and `StreamPart::Source`, and the +/// element of the text results' `sources`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(tag = "source_type", rename_all = "lowercase")] +pub enum Source { + 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, + }, } -/// A file generated by the model, extracted to the top level (M7). +/// A file generated by the model (e.g. an image or document). Mirrors the Vercel +/// AI SDK `file` content type: `data` is a tagged union covering base64 data, +/// or URL. The payload of both `GenerateContent::File` and +/// `StreamPart::File`, and the element of the text results' `files`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[ts(export)] -pub struct FilePart { - pub data: FileData, +pub struct GeneratedFile { + pub data: GeneratedFileData, pub media_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_metadata: Option, +} + +/// A reasoning / thinking segment produced by the model. Mirrors the Vercel AI +/// SDK `reasoning` content type; `provider_metadata` carries provider-specific +/// data such as the Anthropic thinking-block signature +/// (`providerMetadata.anthropic.signature`). The payload of +/// `GenerateContent::Reasoning` and the element of the text results' `reasoning`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ReasoningOutput { + pub text: String, + pub provider_metadata: Option, +} + +/// Text or a generated file produced during reasoning. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(untagged)] +#[ts(export)] +pub enum ReasoningPart { + Text(ReasoningOutput), + File(ReasoningFileOutput), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ReasoningFileOutput { + pub file: GeneratedFile, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_metadata: Option, } /// Aggregated result of `stream_text().consume()` (M11). Mirrors @@ -100,6 +157,9 @@ pub struct FilePart { #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export)] pub struct StreamTextResultAggregated { + /// Ordered content from the last generation step. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub content: Vec, /// The generated text (concatenated `TextDelta`). pub text: String, /// Reasoning / thinking segments. @@ -113,10 +173,10 @@ pub struct StreamTextResultAggregated { pub tool_calls: Vec, /// Sources / citations. #[serde(default)] - pub sources: Vec, + pub sources: Vec, /// Files generated by the model. #[serde(default)] - pub files: Vec, + pub files: Vec, /// Why generation stopped. pub finish_reason: crate::types::FinishReason, /// Raw provider-specific finish reason string. @@ -134,7 +194,7 @@ pub struct StreamTextResultAggregated { pub warnings: Vec, /// Provider-specific metadata from the Finish chunk. #[serde(default)] - pub provider_metadata: Option, + pub provider_metadata: Option, /// Response metadata (id, timestamp, model_id) if emitted by the stream. #[serde(default)] pub response: Option, @@ -144,8 +204,9 @@ pub struct StreamTextResultAggregated { } /// Result of `LanguageModel::do_generate` (non-streaming). -#[derive(Debug, Serialize, Deserialize, TS)] -#[ts(export)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(from = "GenerateResultWire", into = "GenerateResultWire")] +#[ts(export, as = "GenerateResultWire")] pub struct GenerateResult { /// Generated content items (text, tool calls, etc.). pub content: Vec, @@ -157,30 +218,143 @@ pub struct GenerateResult { pub warnings: Vec, /// Provider-specific metadata. pub provider_metadata: Option, - /// Response metadata (id, timestamp, model_id). - pub response: ResponseMetadata, - /// The request body that was sent (for debugging). - pub request_body: Option, - /// Response headers. - pub response_headers: Option>, + /// Optional response information. + pub response: Option, + /// Optional request information. + pub request: Option, } /// Result of `LanguageModel::do_stream` (streaming). pub struct StreamResult { /// The stream of `StreamPart` items. pub stream: Pin> + Send>>, - /// The request body that was sent (for debugging). - pub request_body: Option, - /// Response headers. - pub response_headers: Option>, + pub request: Option, + pub response: Option, } impl std::fmt::Debug for StreamResult { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("StreamResult") - .field("request_body", &self.request_body) - .field("response_headers", &self.response_headers) + .field("request", &self.request) + .field("response", &self.response) .field("stream", &"") .finish() } } + +/// Response metadata and HTTP details from a generation call. +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct GenerateResponse { + pub id: Option, + pub timestamp: Option, + pub model_id: Option, + pub headers: Option>, + pub body: Option, +} + +impl From for GenerateResponse { + fn from(metadata: ResponseMetadata) -> Self { + Self { + id: metadata.id, + timestamp: metadata.timestamp, + model_id: metadata.model_id, + ..Default::default() + } + } +} + +impl From for ResponseMetadata { + fn from(response: GenerateResponse) -> Self { + Self { + id: response.id, + timestamp: response.timestamp, + model_id: response.model_id, + } + } +} + +/// HTTP details from a streaming call. +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct StreamResponse { + pub headers: Option>, +} + +// Preserve the established binding wire shape while exposing upstream's +// request and response objects to Rust callers. +#[derive(Serialize, Deserialize, TS)] +struct GenerateResultWire { + content: Vec, + finish_reason: FinishReason, + usage: Usage, + warnings: Vec, + provider_metadata: Option, + #[ts(inline)] + response: Option, + request_body: Option, + response_headers: Option>, +} + +#[derive(Serialize, Deserialize, TS)] +struct GenerateResponseWire { + id: Option, + timestamp: Option, + model_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, +} + +impl From for GenerateResult { + fn from(wire: GenerateResultWire) -> Self { + Self { + content: wire.content, + finish_reason: wire.finish_reason, + usage: wire.usage, + warnings: wire.warnings, + provider_metadata: wire.provider_metadata, + request: wire + .request_body + .map(|body| RequestInfo { body: Some(body) }), + response: wire + .response + .map(|meta| GenerateResponse { + id: meta.id, + timestamp: meta.timestamp, + model_id: meta.model_id, + headers: wire.response_headers.clone(), + body: meta.body, + }) + .or_else(|| { + wire.response_headers.map(|headers| GenerateResponse { + headers: Some(headers), + ..Default::default() + }) + }), + } + } +} + +impl From for GenerateResultWire { + fn from(result: GenerateResult) -> Self { + let response_headers = result + .response + .as_ref() + .and_then(|response| response.headers.clone()); + Self { + content: result.content, + finish_reason: result.finish_reason, + usage: result.usage, + warnings: result.warnings, + provider_metadata: result.provider_metadata, + request_body: result.request.and_then(|request| request.body), + response_headers, + response: result.response.map(|meta| GenerateResponseWire { + id: meta.id, + timestamp: meta.timestamp, + model_id: meta.model_id, + body: meta.body, + }), + } + } +} diff --git a/aimux-core/src/router.rs b/aimux-core/src/router.rs index 3a0745d74..41b5858db 100644 --- a/aimux-core/src/router.rs +++ b/aimux-core/src/router.rs @@ -290,531 +290,3 @@ impl LanguageModel for RouterModel { .await } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::recording::{ - HttpExchange, OutcomeRecord, ProviderRecord, Recorder, RecordingContext, ResponseRecord, - }; - use crate::result::GenerateResult; - use crate::stream_part::StreamPart; - use crate::types::{FinishReason, FinishReasonUnified, Usage}; - use async_trait::async_trait; - use futures::stream; - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; - - /// A mock child that either succeeds with `text` or always fails. - struct MockChild { - name: &'static str, - text: String, - fail: bool, - retry_failures: usize, - calls: Arc, - } - - #[async_trait] - impl LanguageModel for MockChild { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - self.name - } - async fn do_generate(&self, _options: &CallOptions) -> Result { - let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1; - if self.fail { - return Err(AiMuxError::Other(format!("{} always fails", self.name))); - } - if attempt <= self.retry_failures { - return Err(retryable_error(self.name)); - } - Ok(GenerateResult { - content: vec![crate::result::GenerateContent::Text { - text: self.text.clone(), - provider_metadata: None, - }], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - async fn do_stream(&self, _options: &CallOptions) -> Result { - let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1; - if self.fail { - return Err(AiMuxError::Other(format!("{} always fails", self.name))); - } - if attempt <= self.retry_failures { - return Err(retryable_error(self.name)); - } - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::TextDelta { - id: "t1".into(), - delta: self.text.clone(), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]; - Ok(StreamResult { - stream: Box::pin(stream::iter(parts)), - request_body: None, - response_headers: None, - }) - } - } - - fn retryable_error(name: &str) -> AiMuxError { - AiMuxError::ApiCall(Box::new(crate::ApiCallError { - status_code: Some(503), - response_headers: Some(std::collections::HashMap::from([( - "retry-after-ms".into(), - "0".into(), - )])), - is_retryable: true, - ..crate::ApiCallError::new( - format!("{name} retryable failure"), - "https://example.test", - serde_json::json!({}), - ) - })) - } - - fn retry_child( - name: &'static str, - text: &str, - retry_failures: usize, - ) -> (ChildModel, Arc) { - let calls = Arc::new(AtomicUsize::new(0)); - ( - Arc::new(MockChild { - name, - text: text.into(), - fail: false, - retry_failures, - calls: calls.clone(), - }), - calls, - ) - } - - struct CountingRuleRouter(Arc); - - impl Router for CountingRuleRouter { - fn route( - &self, - prompt: &LanguageModelPrompt, - models: &[ChildModel], - ) -> Result { - self.0.fetch_add(1, Ordering::SeqCst); - RuleRouter.route(prompt, models) - } - } - - fn child(name: &'static str, text: &str, fail: bool) -> ChildModel { - Arc::new(MockChild { - name, - text: text.into(), - fail, - retry_failures: 0, - calls: Arc::new(AtomicUsize::new(0)), - }) - } - - fn opts() -> CallOptions { - CallOptions { - prompt: vec![], - ..Default::default() - } - } - - #[tokio::test] - async fn routes_to_first_with_rule_router() { - let router = RouterModel::new( - vec![child("a", "from-a", false), child("b", "from-b", false)], - Box::new(RuleRouter), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - let r = router.do_generate(&opts()).await.unwrap(); - let text = crate::composite::extract_text(&r.content); - assert_eq!(text, "from-a"); - } - - #[tokio::test] - async fn weighted_router_picks_max() { - let router = RouterModel::new( - vec![child("a", "from-a", false), child("b", "from-b", false)], - Box::new(WeightedRouter::new(vec![1.0, 5.0])), - FallbackPolicy::None, - RouterConfig::default(), - ); - let r = router.do_generate(&opts()).await.unwrap(); - assert_eq!(crate::composite::extract_text(&r.content), "from-b"); - } - - #[tokio::test] - async fn fallback_on_error_walks_remaining() { - // Primary fails, second fails, third succeeds. - let router = RouterModel::new( - vec![ - child("a", "from-a", true), - child("b", "from-b", true), - child("c", "from-c", false), - ], - Box::new(RuleRouter), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - let r = router.do_generate(&opts()).await.unwrap(); - assert_eq!(crate::composite::extract_text(&r.content), "from-c"); - } - - #[tokio::test] - async fn fallback_none_propagates_error() { - let router = RouterModel::new( - vec![child("a", "from-a", true), child("b", "from-b", false)], - Box::new(RuleRouter), - FallbackPolicy::None, - RouterConfig::default(), - ); - let err = router.do_generate(&opts()).await.unwrap_err(); - assert!(err.to_string().contains("always fails")); - } - - #[tokio::test] - async fn all_fail_returns_last_error() { - let router = RouterModel::new( - vec![child("a", "from-a", true), child("b", "from-b", true)], - Box::new(RuleRouter), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - let err = router.do_generate(&opts()).await.unwrap_err(); - assert!(err.to_string().contains("always fails")); - } - - #[tokio::test] - async fn stream_routes_without_fallback() { - // Primary is selected by RuleRouter; even though it would fail, stream - // does not fall back. Use a healthy primary to confirm the happy path. - let router = RouterModel::new( - vec![child("a", "from-a", false), child("b", "from-b", false)], - Box::new(RuleRouter), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - let result = router.do_stream(&opts()).await.unwrap(); - use futures::StreamExt; - let mut s = result.stream; - let mut text = String::new(); - while let Some(part) = s.next().await { - if let Ok(StreamPart::TextDelta { delta, .. }) = part { - text.push_str(&delta); - } - } - assert_eq!(text, "from-a"); - } - - #[tokio::test] - async fn single_child_failure_preserves_real_error() { - // B1 regression guard: a single failing child must surface its real - // error, not a generic "all models failed". - let router = RouterModel::new( - vec![child("solo", "solo-out", true)], - Box::new(RuleRouter), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - let err = router.do_generate(&opts()).await.unwrap_err(); - assert!( - err.to_string().contains("solo always fails"), - "expected the real child error, got: {err}" - ); - } - - #[tokio::test] - async fn single_child_success_works() { - let router = RouterModel::new( - vec![child("solo", "solo-out", false)], - Box::new(RuleRouter), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - let r = router.do_generate(&opts()).await.unwrap(); - assert_eq!(crate::composite::extract_text(&r.content), "solo-out"); - } - - #[tokio::test] - async fn out_of_bounds_router_index_is_invalid_argument() { - // A buggy/hostile Router returning an OOB index must not panic; it - // surfaces as InvalidArgument. - struct OobRouter; - impl Router for OobRouter { - fn route( - &self, - _prompt: &LanguageModelPrompt, - _models: &[ChildModel], - ) -> Result { - Ok(99) - } - } - let router = RouterModel::new( - vec![child("a", "from-a", false)], - Box::new(OobRouter), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - let err = router.do_generate(&opts()).await.unwrap_err(); - match err { - AiMuxError::InvalidArgument(msg) => { - assert!(msg.contains("out-of-bounds"), "got: {msg}"); - } - other => panic!("expected InvalidArgument, got {other:?}"), - } - } - - #[tokio::test] - async fn weighted_router_ties_pick_earliest() { - // S2 regression guard: equal weights resolve to the lowest index. - let router = RouterModel::new( - vec![child("a", "from-a", false), child("b", "from-b", false)], - Box::new(WeightedRouter::new(vec![1.0, 1.0])), - FallbackPolicy::None, - RouterConfig::default(), - ); - let r = router.do_generate(&opts()).await.unwrap(); - assert_eq!(crate::composite::extract_text(&r.content), "from-a"); - } - - #[tokio::test] - async fn empty_models_is_error() { - let router = RouterModel::new( - vec![], - Box::new(RuleRouter), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - let err = router.do_generate(&opts()).await.unwrap_err(); - assert!(err.to_string().contains("no models")); - } - - #[tokio::test] - async fn stream_does_not_fallback_when_routed_child_fails() { - // G3: the key streaming invariant — even with FallbackPolicy::OnError, - // a failing primary is NOT retried via the backup during do_stream. - // (StreamStart is already emitted by the time a retry would matter.) - let router = RouterModel::new( - vec![ - child("primary", "primary-out", true), - child("backup", "backup-out", false), - ], - Box::new(RuleRouter), - FallbackPolicy::OnError, // would fall back in do_generate, must NOT here - RouterConfig::default(), - ); - let err = router.do_stream(&opts()).await.unwrap_err(); - assert!( - err.to_string().contains("primary always fails"), - "do_stream must surface the primary error, not fall back; got: {err}" - ); - } - - #[tokio::test] - async fn usable_as_dyn_language_model_via_generate_text() { - // G7: the RFCs' headline claim — composite models drop into the - // generate_text free function unchanged via &dyn LanguageModel. - let router = RouterModel::new( - vec![child("a", "works through trait object", false)], - Box::new(RuleRouter), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - let r = crate::generate::generate_text( - &router, - "hello", - crate::generate::GenerateTextOptions::default(), - ) - .await - .unwrap(); - assert_eq!(r.text, "works through trait object"); - } - - #[tokio::test] - async fn retries_each_child_before_falling_back_without_rerouting() { - let (primary, primary_calls) = retry_child("primary", "primary", usize::MAX); - let (backup, backup_calls) = retry_child("backup", "backup", 1); - let route_calls = Arc::new(AtomicUsize::new(0)); - let router = RouterModel::new( - vec![primary, backup], - Box::new(CountingRuleRouter(route_calls.clone())), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - assert_eq!(router.retry_config().max_retries, 0); - - let result = crate::generate::generate_text( - &router, - "hello", - crate::generate::GenerateTextOptions { - max_retries: Some(2), - ..Default::default() - }, - ) - .await - .unwrap(); - - assert_eq!(result.text, "backup"); - assert_eq!(route_calls.load(Ordering::SeqCst), 1); - assert_eq!(primary_calls.load(Ordering::SeqCst), 3); - assert_eq!(backup_calls.load(Ordering::SeqCst), 2); - } - - /// Children must receive a step-labeled child recording context so their - /// exchanges and retry attempts are recorded as nested steps. - #[tokio::test] - async fn children_receive_step_labeled_recording_contexts() { - struct StepProbe { - seen: std::sync::Mutex>>, - } - - #[async_trait] - impl LanguageModel for StepProbe { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "probe" - } - async fn do_generate( - &self, - options: &CallOptions, - ) -> Result { - self.seen.lock().unwrap().push( - options - .recording_context - .as_ref() - .and_then(RecordingContext::step), - ); - Err(AiMuxError::Other("probe fails".into())) - } - async fn do_stream(&self, _: &CallOptions) -> Result { - unreachable!() - } - } - - struct NoopRecorder; - impl Recorder for NoopRecorder { - fn record_input(&self, _: &str, _: &CallOptions, _: &str, _: &str) {} - fn record_provider(&self, _: &str, _: &ProviderRecord) {} - fn record_exchange(&self, _: &str, _: &HttpExchange) {} - fn record_exchange_update( - &self, - _: &str, - _: u32, - _: u32, - _: &ResponseRecord, - _: Option, - ) { - } - fn record_outcome(&self, _: &str, _: &OutcomeRecord) {} - fn flush(&self) {} - } - - let probe = Arc::new(StepProbe { - seen: std::sync::Mutex::new(Vec::new()), - }); - let router = RouterModel::new( - vec![probe.clone()], - Box::new(CountingRuleRouter(Arc::new(AtomicUsize::new(0)))), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - - let options = CallOptions { - recording_context: Some(RecordingContext::new("call-1", Arc::new(NoopRecorder))), - ..CallOptions::default() - }; - let _ = router.do_generate(&options).await; - - let seen = probe.seen.lock().unwrap(); - assert!(!seen.is_empty()); - assert_eq!(seen[0].as_deref(), Some("router[0]:mock/probe")); - } - - /// Even when every child fails and the caller asked for per-call retries, - /// the composite operation must run exactly once: the exhausted child's - /// `Retry` error passes through the outer retry loop instead of replaying - /// routing and fallback. - #[tokio::test] - async fn all_children_failing_does_not_replay_the_composite() { - let (primary, primary_calls) = retry_child("primary", "primary", usize::MAX); - let (backup, backup_calls) = retry_child("backup", "backup", usize::MAX); - let route_calls = Arc::new(AtomicUsize::new(0)); - let router = RouterModel::new( - vec![primary, backup], - Box::new(CountingRuleRouter(route_calls.clone())), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - - let error = crate::generate::generate_text( - &router, - "hello", - crate::generate::GenerateTextOptions { - max_retries: Some(2), - ..Default::default() - }, - ) - .await - .unwrap_err(); - - assert!(matches!(error, crate::AiMuxError::Retry(_)), "{error:?}"); - assert_eq!(route_calls.load(Ordering::SeqCst), 1); - assert_eq!(primary_calls.load(Ordering::SeqCst), 3); - assert_eq!(backup_calls.load(Ordering::SeqCst), 3); - } - - #[tokio::test] - async fn retries_stream_setup_on_selected_child_without_fallback_or_rerouting() { - let (primary, primary_stream_calls) = retry_child("primary", "primary", 1); - let (backup, backup_stream_calls) = retry_child("backup", "backup", 0); - let route_calls = Arc::new(AtomicUsize::new(0)); - let router = RouterModel::new( - vec![primary, backup], - Box::new(CountingRuleRouter(route_calls.clone())), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - - let _result = crate::generate::stream_text( - &router, - "hello", - crate::generate::GenerateTextOptions { - max_retries: Some(2), - ..Default::default() - }, - ) - .await - .unwrap(); - - assert_eq!(route_calls.load(Ordering::SeqCst), 1); - assert_eq!(primary_stream_calls.load(Ordering::SeqCst), 2); - assert_eq!(backup_stream_calls.load(Ordering::SeqCst), 0); - } -} diff --git a/aimux-core/src/session.rs b/aimux-core/src/session.rs index ead68fb6f..315c00c0e 100644 --- a/aimux-core/src/session.rs +++ b/aimux-core/src/session.rs @@ -452,25 +452,6 @@ fn civil_from_days(z: i64) -> (i64, u32, u32) { #[cfg(test)] mod tests { use super::*; - use crate::content::ContentPart; - use crate::language_model_message::LanguageModelPromptMessage; - use crate::message::Role; - - fn msg(text: &str) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::Text { - text: text.to_string(), - provider_options: None, - }], - provider_options: None, - } - } - - fn prompt(messages: Vec) -> LanguageModelPrompt { - messages - } - /// `recorded_at` must look like RFC 3339 UTC: `YYYY-MM-DDTHH:MM:SS.mmmZ`. fn is_rfc3339(s: &str) -> bool { let b = s.as_bytes(); @@ -606,105 +587,6 @@ mod tests { assert!(store.list_sessions().is_empty()); } - // ── SessionInferer ────────────────────────────────────────────────────── - - #[test] - fn inferer_disabled_returns_none() { - let mut inf = SessionInferer::new(false); - assert!(inf.resolve(None, &prompt(vec![msg("hi")])).is_none()); - } - - #[test] - fn inferer_explicit_wins_and_is_remembered() { - let mut inf = SessionInferer::new(true); - let p1 = prompt(vec![msg("hi")]); - let (id, source) = inf.resolve(Some("sess-1"), &p1).unwrap(); - assert_eq!(id, "sess-1"); - assert_eq!(source, SessionSource::Explicit); - - // Continuation without an explicit id joins the remembered session. - let p2 = prompt(vec![msg("hi"), msg("hello")]); - let (id2, source2) = inf.resolve(None, &p2).unwrap(); - assert_eq!(id2, "sess-1"); - assert_eq!(source2, SessionSource::Inferred); - } - - #[test] - fn inferer_strong_prefix_continuation_groups_calls() { - let mut inf = SessionInferer::new(true); - let p1 = prompt(vec![msg("u1")]); - let (id1, source1) = inf.resolve(None, &p1).unwrap(); - assert!(id1.starts_with("auto-")); - assert_eq!(source1, SessionSource::Inferred); - - // Full-prefix continuation → same session. - let p2 = prompt(vec![msg("u1"), msg("a1"), msg("u2")]); - let (id2, _) = inf.resolve(None, &p2).unwrap(); - assert_eq!(id2, id1); - - // Equality (retry of the same step) → same session. - let (id3, _) = inf.resolve(None, &p1).unwrap(); - assert_eq!(id3, id1); - } - - #[test] - fn inferer_distinct_prompts_start_new_sessions() { - let mut inf = SessionInferer::new(true); - let (id1, _) = inf - .resolve(None, &prompt(vec![msg("what is rust")])) - .unwrap(); - let (id2, _) = inf - .resolve(None, &prompt(vec![msg("tell me a joke")])) - .unwrap(); - assert_ne!(id1, id2); - } - - #[test] - fn inferer_prefix_is_directional() { - let mut inf = SessionInferer::new(true); - let (id1, _) = inf.resolve(None, &prompt(vec![msg("abc")])).unwrap(); - // Message-level prefix continuation: leading messages identical and - // the new prompt is longer → same session. - let (id2, _) = inf - .resolve(None, &prompt(vec![msg("abc"), msg("a1"), msg("u2")])) - .unwrap(); - assert_eq!(id2, id1); - // A prompt that diverges on the first message → new session. - let (id3, _) = inf.resolve(None, &prompt(vec![msg("abd")])).unwrap(); - assert_ne!(id3, id1, "abd is not a prefix continuation"); - } - - #[test] - fn inferer_chain_continuation_stays_in_session() { - let mut inf = SessionInferer::new(true); - let p1 = prompt(vec![msg("common")]); - let p2 = prompt(vec![msg("common"), msg("b")]); - let p3 = prompt(vec![msg("common"), msg("b"), msg("c")]); - let (id1, _) = inf.resolve(None, &p1).unwrap(); - let (id2, _) = inf.resolve(None, &p2).unwrap(); - let (id3, _) = inf.resolve(None, &p3).unwrap(); - assert_eq!(id2, id1); - assert_eq!(id3, id1); - } - - #[test] - fn inferer_capacity_evicts_oldest_prompt() { - let mut inf = SessionInferer::with_capacity(true, 2); - let p1 = prompt(vec![msg("one")]); - let (id1, _) = inf.resolve(None, &p1).unwrap(); - let p2 = prompt(vec![msg("two")]); - let (id2, _) = inf.resolve(None, &p2).unwrap(); - let p3 = prompt(vec![msg("three")]); - let (id3, _) = inf.resolve(None, &p3).unwrap(); - assert_ne!(id1, id2); - assert_ne!(id2, id3); - - // p1 was evicted; a new call extending it starts a NEW session. - let p1_ext = prompt(vec![msg("one"), msg("more")]); - let (id4, _) = inf.resolve(None, &p1_ext).unwrap(); - assert_ne!(id4, id1, "evicted prompt no longer matches"); - } - // ── helpers ───────────────────────────────────────────────────────────── #[test] diff --git a/aimux-core/src/shared.rs b/aimux-core/src/shared.rs index 705f3c945..c1ca198f6 100644 --- a/aimux-core/src/shared.rs +++ b/aimux-core/src/shared.rs @@ -33,12 +33,38 @@ pub type SharedHeaders = HashMap; /// ```json /// { "anthropic": { "cacheControl": { "type": "ephemeral" } } } /// ``` -pub type SharedProviderOptions = HashMap; +pub type SharedProviderOptions = HashMap; /// Additional provider-specific metadata (outputs), keyed by provider name. /// /// Aligned with V4 `SharedV4ProviderMetadata` (`Record`). -pub type SharedProviderMetadata = HashMap; +pub type SharedProviderMetadata = HashMap; + +/// A JSON object (`JSONObject` in the AI SDK). +pub type JsonObject = serde_json::Map; + +/// Build a one-namespace [`SharedProviderMetadata`] / [`SharedProviderOptions`] +/// (they are the same type) from a JSON object literal. +/// +/// `provider_namespace("openai", json!({ "itemId": "x" }))?` is +/// `{ "openai": { "itemId": "x" } }`. +/// +/// Several namespaces: `extend` one map with another. +/// +/// # Errors +/// +/// Returns an invalid argument error when the value is not a JSON object. +pub fn provider_namespace( + namespace: &str, + object: Value, +) -> Result { + let Value::Object(object) = object else { + return Err(crate::AiMuxError::InvalidArgument(format!( + "invalid {namespace} provider options: expected a JSON object" + ))); + }; + Ok(HashMap::from([(namespace.to_string(), object)])) +} /// A mapping of provider names to provider-specific file identifiers. /// @@ -73,13 +99,31 @@ pub enum FileData { /// Raw bytes (`Uint8Array`) or a base64-encoded string. Data { data: FileBytes }, /// A URL that points to the file. - Url { url: String }, + Url { + url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + original_url: Option, + }, /// A provider reference (`{ [provider]: id }`). Reference { reference: SharedProviderReference }, /// Inline text content (e.g. an inline text document). Text { text: String }, } +/// Data or a URL returned for a generated file. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub enum GeneratedFileData { + Data { + data: FileBytes, + }, + Url { + url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + original_url: Option, + }, +} + /// Image/video size in `{width}x{height}` format (e.g. `"1024x1024"`). /// /// Newtype that enforces the `WxH` format used by V4 image `size` and video diff --git a/aimux-core/src/stream_part.rs b/aimux-core/src/stream_part.rs index 5e8888d6e..20ff84d43 100644 --- a/aimux-core/src/stream_part.rs +++ b/aimux-core/src/stream_part.rs @@ -1,4 +1,4 @@ -//! `StreamPart` — the individual chunk types emitted by `do_stream`. +//! `StreamPart` — the individual chunk types emitted by `do_stream`. //! //! Aligned with V4 `LanguageModelV4StreamPart`. v0.1 implements the P0/P1 variants; //! P2 variants (reasoning, file, source) are defined but not yet emitted by providers. @@ -8,9 +8,11 @@ use serde_json::Value; use ts_rs::TS; use crate::error::AiMuxError; -use crate::shared::FileData; +use crate::result::{ + GeneratedFile, RawToolApprovalRequest, ReasoningFileOutput, Source, ToolApprovalRequestOutput, +}; use crate::tool::{RawToolCall, ToolCall, ToolResult}; -use crate::types::{FinishReason, ProviderMetadata, Usage, Warning}; +use crate::types::{FinishReason, ProviderMetadata, ResponseMetadata, Usage, Warning}; /// A single chunk in the stream returned by `do_stream`. /// @@ -18,8 +20,8 @@ use crate::types::{FinishReason, ProviderMetadata, Usage, Warning}; /// [`StreamPart`] (raw argument text); `stream_text` emits [`TextStreamPart`] /// (parsed and validated [`ToolCall`]). #[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export, concrete(C = ToolCall))] -pub enum StreamPart { +#[ts(export, concrete(C = ToolCall, A = ToolApprovalRequestOutput, R = ReasoningFileOutput))] +pub enum StreamPart { // ── P0: text ── /// Start of a text segment. TextStart { @@ -43,7 +45,9 @@ pub enum StreamPart { // ── P0: stream lifecycle ── /// First chunk — carries warnings from the provider. - StreamStart { warnings: Vec }, + StreamStart { + warnings: Vec, + }, /// Final chunk — carries usage, finish reason, and metadata. Finish { finish_reason: FinishReason, @@ -51,7 +55,9 @@ pub enum StreamPart { provider_metadata: Option, }, /// An error occurred mid-stream. - Error { error: AiMuxError }, + Error { + error: AiMuxError, + }, // ── P1: tool calls ── /// Start of a tool call's input streaming. @@ -93,15 +99,14 @@ pub enum StreamPart { // ── P2: file ── /// A file generated by the model (e.g. an image or document). - /// - /// Mirrors the Vercel AI SDK `file` stream part. `data` is a tagged union - /// covering base64 data, URL, or provider reference. - File { - data: FileData, - media_type: String, + File(GeneratedFile), + ReasoningFile(R), + Custom { + kind: String, #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, + ToolApprovalRequest(A), // ── P2: reasoning ── ReasoningStart { @@ -126,38 +131,62 @@ pub enum StreamPart { // ── P2: metadata ── /// Response metadata (id, timestamp, model_id). - ResponseMetadata { - id: Option, - timestamp: Option, - model_id: Option, - }, + ResponseMetadata(ResponseMetadata), /// A source / citation (e.g. URL citation from search-preview models). - Source { - id: String, - source_type: String, - url: Option, - title: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - provider_metadata: Option, - }, + Source(Source), /// A raw chunk from the provider (for debugging, when `include_raw_chunks` is set). - Raw { raw_value: Value }, + Raw { + raw_value: Value, + }, } /// The stream `stream_text` and later stages produce: tool calls are parsed. -pub type TextStreamPart = StreamPart; +pub type TextStreamPart = StreamPart; -impl StreamPart { +impl StreamPart { /// Replace the tool-call payload, leaving every other variant untouched. - pub fn map_tool_call(self, f: impl FnOnce(C) -> D) -> StreamPart { + pub fn map_tool_call(self, f: impl FnOnce(C) -> D) -> StreamPart { + self.map_payloads(f, |approval| approval) + } + + /// Replace the approval payload, leaving other variants untouched. + pub fn map_tool_approval_request(self, f: impl FnOnce(A) -> B) -> StreamPart { + self.map_payloads(|call| call, f) + } + + /// Replace tool call and approval payloads. + pub fn map_payloads( + self, + call: impl FnOnce(C) -> D, + approval: impl FnOnce(A) -> B, + ) -> StreamPart { + self.map_all(call, approval, |file| file) + } + + /// Replace a reasoning file with its user-facing output wrapper. + pub fn map_reasoning_file(self, f: impl FnOnce(R) -> F) -> StreamPart { + self.map_all(|call| call, |approval| approval, f) + } + + fn map_all( + self, + call: impl FnOnce(C) -> D, + approval: impl FnOnce(A) -> B, + file: impl FnOnce(R) -> F, + ) -> StreamPart { // The generic parameter forces every variant to be rebuilt. macro_rules! carry { ($($variant:ident { $($field:ident),* })*) => { match self { - StreamPart::ToolCall(call) => StreamPart::ToolCall(f(call)), + StreamPart::ToolCall(value) => StreamPart::ToolCall(call(value)), + StreamPart::ToolApprovalRequest(value) => StreamPart::ToolApprovalRequest(approval(value)), StreamPart::ToolResult(result) => StreamPart::ToolResult(result), + StreamPart::File(file) => StreamPart::File(file), + StreamPart::ReasoningFile(value) => StreamPart::ReasoningFile(file(value)), + StreamPart::ResponseMetadata(meta) => StreamPart::ResponseMetadata(meta), + StreamPart::Source(source) => StreamPart::Source(source), $(StreamPart::$variant { $($field),* } => StreamPart::$variant { $($field),* },)* } }; @@ -172,12 +201,10 @@ impl StreamPart { ToolInputStart { id, tool_name, provider_executed, dynamic, title, provider_metadata } ToolInputDelta { id, delta, provider_metadata } ToolInputEnd { id, provider_metadata } - File { data, media_type, provider_metadata } ReasoningStart { id, provider_metadata } ReasoningDelta { id, delta, provider_metadata } ReasoningEnd { id, provider_metadata } - ResponseMetadata { id, timestamp, model_id } - Source { id, source_type, url, title, provider_metadata } + Custom { kind, provider_metadata } Raw { raw_value } } } diff --git a/aimux-core/src/tool.rs b/aimux-core/src/tool.rs index 421c26500..2c95fb05c 100644 --- a/aimux-core/src/tool.rs +++ b/aimux-core/src/tool.rs @@ -1,12 +1,11 @@ //! Tool / function-calling types. -use std::collections::HashMap; - use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use ts_rs::TS; use crate::error::AiMuxError; +use crate::shared::SharedProviderOptions; use crate::types::ProviderMetadata; /// A tool definition passed to the model in `CallOptions.tools`. @@ -27,7 +26,7 @@ pub struct FunctionTool { /// (e.g. `{"anthropic": {"eagerInputStreaming": true}}`). Aligned with the /// V4 `providerOptions` field on function tools. #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider_options: Option>, + pub provider_options: Option, /// Example inputs for the tool (V4 `inputExamples`), used by some providers /// to emit `input_examples` in the request body. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/aimux-core/src/trace/layer.rs b/aimux-core/src/trace/layer.rs index 2ceec13a4..c5a73b349 100644 --- a/aimux-core/src/trace/layer.rs +++ b/aimux-core/src/trace/layer.rs @@ -226,7 +226,7 @@ impl RecordCtx { output_total: usage.output_tokens.total.map(u64::from), output_text: usage.output_tokens.text.map(u64::from), output_reasoning: usage.output_tokens.reasoning.map(u64::from), - raw: usage.raw.clone(), + raw: usage.raw.clone().map(serde_json::Value::Object), } } @@ -531,10 +531,19 @@ impl LanguageModel for TraceLayer { let ctx = self.make_record_ctx(options, None, None, Arc::new(AtomicU64::new(u64::MAX))); match self.inner.do_generate(options).await { Ok(result) => { - let request_id = result.response.id.clone(); + let request_id = result + .response + .as_ref() + .and_then(|response| response.id.clone()); let mut rec_ctx = ctx; - rec_ctx.request_body = result.request_body.clone(); - rec_ctx.response_headers = result.response_headers.clone(); + rec_ctx.request_body = result + .request + .as_ref() + .and_then(|request| request.body.clone()); + rec_ctx.response_headers = result + .response + .as_ref() + .and_then(|response| response.headers.clone()); rec_ctx.record(&result.usage, None, request_id); Ok(result) } @@ -558,8 +567,14 @@ impl LanguageModel for TraceLayer { }; let rec_ctx = Arc::new(RecordCtx { - request_body: result.request_body.clone(), - response_headers: result.response_headers.clone(), + request_body: result + .request + .as_ref() + .and_then(|request| request.body.clone()), + response_headers: result + .response + .as_ref() + .and_then(|response| response.headers.clone()), ..ctx }); let ttft_obs = ttft.clone(); @@ -574,6 +589,8 @@ impl LanguageModel for TraceLayer { Ok(StreamPart::TextDelta { .. }) | Ok(StreamPart::ReasoningDelta { .. }) | Ok(StreamPart::ToolCall(_)) + | Ok(StreamPart::File(_)) + | Ok(StreamPart::ReasoningFile(_)) ); if is_model_output { ttft_obs.store(started.elapsed().as_millis() as u64, Ordering::Relaxed); @@ -595,8 +612,8 @@ impl LanguageModel for TraceLayer { Ok(StreamResult { stream: Box::pin(guarded), - request_body: result.request_body, - response_headers: result.response_headers, + request: result.request, + response: result.response, }) } } diff --git a/aimux-core/src/types.rs b/aimux-core/src/types.rs index 5ed3ce700..0f9e54730 100644 --- a/aimux-core/src/types.rs +++ b/aimux-core/src/types.rs @@ -31,17 +31,18 @@ pub enum FinishReasonUnified { #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[ts(export)] pub struct Usage { - pub input_tokens: TokenUsage, - pub output_tokens: TokenUsage, + pub input_tokens: InputTokenUsage, + pub output_tokens: OutputTokenUsage, /// Raw usage information from the provider (opaque, provider-specific). #[serde(default, skip_serializing_if = "Option::is_none")] - pub raw: Option, + #[ts(type = "Record | null")] + pub raw: Option>, } -/// Token usage detail (with cache breakdown). +/// Input token usage detail. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[ts(export)] -pub struct TokenUsage { +pub struct InputTokenUsage { /// Total tokens. pub total: Option, /// Tokens that were not served from cache. @@ -53,6 +54,13 @@ pub struct TokenUsage { /// Tokens written to cache. #[serde(skip_serializing_if = "Option::is_none")] pub cache_write: Option, +} + +/// Output token usage detail. +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct OutputTokenUsage { + pub total: Option, /// Text (non-reasoning) tokens (output side). #[serde(skip_serializing_if = "Option::is_none")] pub text: Option, @@ -158,5 +166,5 @@ pub struct ResponseMetadata { pub model_id: Option, } -/// Provider-specific metadata (opaque to core). -pub type ProviderMetadata = Value; +/// Provider-specific metadata: namespace -> JSON object. +pub type ProviderMetadata = crate::shared::SharedProviderMetadata; diff --git a/aimux-core/src/video_model.rs b/aimux-core/src/video_model.rs index 8833c4eed..459086e03 100644 --- a/aimux-core/src/video_model.rs +++ b/aimux-core/src/video_model.rs @@ -517,22 +517,14 @@ async fn start_and_poll_one_batch( /// the other phase set once both phases report the *same* provider key — e.g. /// a start call's `job_id` disappearing once the completion call also reports /// metadata under `x_provider`. Objects under a shared key are unioned -/// instead, with `b` (the later phase) winning a field collision. Anything -/// that is not a pair of objects has nothing to union, so `b` replaces `a`. +/// instead, with `b` (the later phase) winning a field collision. fn merge_provider_metadata( a: Option, b: Option, ) -> Option { let Some(mut merged) = a else { return b }; - for (provider, b_value) in b.into_iter().flatten() { - let value = match (merged.remove(&provider), b_value) { - (Some(serde_json::Value::Object(mut a_obj)), serde_json::Value::Object(b_obj)) => { - a_obj.extend(b_obj); - serde_json::Value::Object(a_obj) - } - (_, b_value) => b_value, - }; - merged.insert(provider, value); + for (provider, b_obj) in b.into_iter().flatten() { + merged.entry(provider).or_default().extend(b_obj); } Some(merged) } @@ -790,23 +782,6 @@ mod tests { assert!(messages[1].contains("from status"), "{messages:?}"); } - #[test] - fn merge_provider_metadata_unions_same_provider_key_across_phases() { - let phase = |value| Some(SharedProviderMetadata::from([("fal".to_string(), value)])); - let merged = merge_provider_metadata( - phase(serde_json::json!({ "job_id": "job-1", "region": "us-east" })), - phase(serde_json::json!({ "region": "eu-west", "seed": 42 })), - ) - .expect("both phases reported metadata"); - - // `job_id` is start-only and must survive instead of being dropped by - // an `entry().or_insert()` collision on the shared provider key. - assert_eq!( - merged["fal"], - serde_json::json!({ "job_id": "job-1", "region": "eu-west", "seed": 42 }) - ); - } - #[tokio::test] async fn poll_timeout_fails_a_generation_that_never_completes() { let model = ScriptedVideoModel::new(vec![]); diff --git a/aimux-core/tests/cache_probe_test.rs b/aimux-core/tests/cache_probe_test.rs index bf7094cd0..8c47ef3fd 100644 --- a/aimux-core/tests/cache_probe_test.rs +++ b/aimux-core/tests/cache_probe_test.rs @@ -1,25 +1,8 @@ -//! RFC-0015 cache-probe tests: prototype scenario migration (§9, 8 scenarios) -//! + TraceLayer end-to-end probes. -//! -//! Layer 1: `judge` unit scenarios — exact control over every input. -//! Layer 2: `TraceLayer` + `RingTraceStore` + `RuleAuditor` end-to-end with a -//! mock model serving synthetic bodies (4 bytes per token, hex-encoded, so -//! the byte proxy `LCPb/4` matches the token counts). +//! Cache audit judgment scenarios. -use std::sync::Arc; - -use futures::executor::block_on; - -use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateResult, StreamResult}; -use aimux_core::stream_part::StreamPart; use aimux_core::trace::{ - JudgmentInput, LcpInput, ProviderAuditSpec, RingTraceStore, SessionStats, TraceFilter, - TraceLayer, VerdictKind, judge, matrix as verdict_matrix, + JudgmentInput, LcpInput, ProviderAuditSpec, VerdictKind, judge, matrix as verdict_matrix, }; -use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, TokenUsage, Usage}; // ───────────────────────────────────────────────────────────────────────────── // Layer 1: judge unit scenarios (prototype §9) @@ -360,590 +343,3 @@ fn missing_usage_is_unknown() { let v = judge(&inp); assert_eq!(v.kind, VerdictKind::Unknown, "{}", v.describe()); } - -// ───────────────────────────────────────────────────────────────────────────── -// Layer 2: TraceLayer end-to-end (mock model, synthetic bodies) -// ───────────────────────────────────────────────────────────────────────────── - -/// 4 bytes per token, hex-encoded — the byte proxy LCPb/4 matches token -/// counts exactly (system segment is shared across sessions). -fn token_body(system_tokens: u64, conv_tokens: u64, conv_start: u64) -> serde_json::Value { - let mut s = String::new(); - for i in 0..system_tokens { - s.push_str(&format!("{:04x}", i & 0xffff)); - } - for i in 0..conv_tokens { - s.push_str(&format!("{:04x}", (conv_start + i) & 0xffff)); - } - serde_json::json!({ - "model": "mock-model", - "messages": [{"role": "user", "content": s}], - }) -} - -struct MockModel { - body: serde_json::Value, - usage: Usage, - provider: &'static str, - model: &'static str, -} - -impl MockModel { - fn new(body: serde_json::Value, claimed: u64, prompt_tokens: u64) -> Self { - Self::with_identity("mock", "mock-1", body, claimed, prompt_tokens) - } - - /// Configurable identity (B1: same model_id, different provider). - fn with_identity( - provider: &'static str, - model: &'static str, - body: serde_json::Value, - claimed: u64, - prompt_tokens: u64, - ) -> Self { - Self { - body, - usage: Usage { - input_tokens: TokenUsage { - total: Some(prompt_tokens as u32), - no_cache: Some((prompt_tokens - claimed) as u32), - cache_read: Some(claimed as u32), - cache_write: Some(0), - ..Default::default() - }, - output_tokens: TokenUsage { - total: Some(10), - text: Some(10), - ..Default::default() - }, - raw: None, - }, - provider, - model, - } - } -} - -#[async_trait::async_trait] -impl LanguageModel for MockModel { - fn provider(&self) -> &str { - self.provider - } - - fn model_id(&self) -> &str { - self.model - } - - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: self.usage.clone(), - warnings: vec![], - provider_metadata: None, - response: ResponseMetadata { - id: Some("req-1".into()), - timestamp: None, - model_id: Some("mock-1".into()), - }, - request_body: Some(self.body.clone()), - response_headers: None, - }) - } - - async fn do_stream(&self, _options: &CallOptions) -> Result { - let usage = self.usage.clone(); - let body = self.body.clone(); - Ok(StreamResult { - stream: Box::pin(async_stream::stream! { - yield Ok(StreamPart::StreamStart { warnings: vec![] }); - yield Ok(StreamPart::TextDelta { - id: "t1".into(), - delta: "hi".into(), - provider_metadata: None, - }); - yield Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage, - provider_metadata: None, - }); - }), - request_body: Some(body), - response_headers: None, - }) - } -} - -fn model_opts(session_id: Option<&str>) -> CallOptions { - let mut o = CallOptions::new(Default::default()); - o.session_id = session_id.map(std::string::ToString::to_string); - o -} - -/// End-to-end: append-only loop with claimed == shared block prefix → -/// Trusted; overclaim → SuspectOverclaim; aggregation + session chain + -/// JSONL work. Bodies > 4 KiB so block-aligned prefixes match. -#[test] -fn trace_layer_end_to_end() { - let store = Arc::new(RingTraceStore::new()); - let inner: Arc = Arc::new(MockModel::new( - token_body(1024, 512, 0), // 6144 B → block 1 matches later calls - 0, - 1536, - )); - let layer = Arc::new(TraceLayer::new(inner, store.clone()).with_rules_auditor(true)); - - // Call 1: first request — claimed 0 → Trusted. - let r1 = block_on(layer.do_generate(&model_opts(Some("sess-1")))).unwrap(); - assert!(r1.usage.input_tokens.total.is_some()); - - // Call 2: same session, prefix extended; claimed matches the shared - // first block (1024 tokens) → Trusted. - let inner2: Arc = Arc::new(MockModel::new( - token_body(1024, 1536, 0), // 10240 B → block 1 shared with call 1 - 1024, // claimed == shared block tokens - 2560, - )); - let layer2 = Arc::new(TraceLayer::new(inner2, store.clone()).with_rules_auditor(true)); - block_on(layer2.do_generate(&model_opts(Some("sess-1")))).unwrap(); - - // Call 3: overclaim — claimed exceeds the client LCP bound (1024 + τ). - let inner3: Arc = Arc::new(MockModel::new( - token_body(1024, 1536, 0), - 1200, // > 1024 + 128 → SuspectOverclaim - 2560, - )); - let layer3 = Arc::new(TraceLayer::new(inner3, store.clone()).with_rules_auditor(true)); - block_on(layer3.do_generate(&model_opts(Some("sess-1")))).unwrap(); - - let recs = store.aggregate(&TraceFilter::default()); - assert_eq!(recs.len(), 1); - let s = &recs[0]; - assert_eq!(s.requests, 3); - // 2 Trusted + 1 SuspectOverclaim. - assert_eq!(s.verdict_counts.get("Trusted"), Some(&2)); - assert_eq!(s.verdict_counts.get("SuspectOverclaim"), Some(&1)); - assert!(s.reported_hit_rate.is_some()); - - // Session chain: 3 records, stable prefix (LCP grows monotonically). - let chain = store.session_chain("sess-1").expect("chain exists"); - assert_eq!(chain.record_ids.len(), 3); - assert!(chain.prefix_stability >= 0.5); - - // JSONL export round-trips. - let mut buf = Vec::new(); - store.export_jsonl(&mut buf).unwrap(); - let lines: Vec<&[u8]> = buf - .split(|b| *b == b'\n') - .filter(|l| !l.is_empty()) - .collect(); - assert_eq!(lines.len(), 3, "one TraceRecord per line"); -} - -/// Streaming path records TTFT + usage from the Finish part. -#[test] -fn trace_layer_stream_records_ttft_and_usage() { - let store = Arc::new(RingTraceStore::new()); - let inner: Arc = Arc::new(MockModel::new(token_body(1024, 512, 0), 0, 1536)); - let layer = Arc::new(TraceLayer::new(inner, store.clone()).with_rules_auditor(true)); - - let mut result = block_on(layer.do_stream(&model_opts(Some("sess-1")))).unwrap(); - // Consume the stream fully (records on completion). - while let Some(_part) = block_on(async { futures::StreamExt::next(&mut result.stream).await }) { - } - - let recs = store.aggregate(&TraceFilter::default()); - assert_eq!(recs[0].requests, 1); - let rec = store.session_chain("sess-1").unwrap(); - assert_eq!(rec.record_ids.len(), 1); - - // Verify TTFT was captured on the stored record. - let json = { - let mut buf = Vec::new(); - store.export_jsonl(&mut buf).unwrap(); - String::from_utf8(buf).unwrap() - }; - assert!( - json.contains("\"ttft_ms\":"), - "streamed record must carry ttft_ms: {json}" - ); -} - -/// StreamPart::Error (a provider-reported mid-stream error) is recorded. -#[test] -fn trace_layer_records_stream_part_error() { - struct MidStreamError; - #[async_trait::async_trait] - impl LanguageModel for MidStreamError { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "mock-stream-err" - } - async fn do_generate(&self, _: &CallOptions) -> Result { - unreachable!() - } - async fn do_stream(&self, _: &CallOptions) -> Result { - Ok(StreamResult { - stream: Box::pin(async_stream::stream! { - yield Ok(StreamPart::StreamStart { warnings: vec![] }); - // Provider-reported mid-stream error (Ok(Error{..})). - yield Ok(StreamPart::Error { error: AiMuxError::InvalidResponseData("mid-stream failure".into()) }); - }), - request_body: None, - response_headers: None, - }) - } - } - - let store = Arc::new(RingTraceStore::new()); - let layer = Arc::new(TraceLayer::new(Arc::new(MidStreamError), store.clone())); - let mut result = block_on(layer.do_stream(&model_opts(Some("sess-err")))).unwrap(); - while let Some(_p) = block_on(async { futures::StreamExt::next(&mut result.stream).await }) {} - - let recs = store.aggregate(&TraceFilter::default()); - assert_eq!(recs[0].requests, 1); - assert_eq!(recs[0].errors, 1, "provider-reported stream error recorded"); - let json = { - let mut buf = Vec::new(); - store.export_jsonl(&mut buf).unwrap(); - String::from_utf8(buf).unwrap() - }; - assert!(json.contains("mid-stream failure"), "{json}"); -} - -/// Transport-level `Err` from the stream is also recorded. -#[test] -fn trace_layer_records_stream_transport_error() { - struct TransportErr; - #[async_trait::async_trait] - impl LanguageModel for TransportErr { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "mock-transport-err" - } - async fn do_generate(&self, _: &CallOptions) -> Result { - unreachable!() - } - async fn do_stream(&self, _: &CallOptions) -> Result { - Ok(StreamResult { - stream: Box::pin(async_stream::stream! { - yield Ok(StreamPart::StreamStart { warnings: vec![] }); - yield Err(AiMuxError::InvalidResponseData("transport failure".into())); - }), - request_body: None, - response_headers: None, - }) - } - } - - let store = Arc::new(RingTraceStore::new()); - let layer = Arc::new(TraceLayer::new(Arc::new(TransportErr), store.clone())); - let mut result = block_on(layer.do_stream(&model_opts(Some("sess-err")))).unwrap(); - while let Some(_p) = block_on(async { futures::StreamExt::next(&mut result.stream).await }) {} - - let recs = store.aggregate(&TraceFilter::default()); - assert_eq!(recs[0].errors, 1, "transport error recorded"); -} - -/// Failures (Err from do_generate) are still recorded, without a verdict. -#[test] -fn trace_layer_records_failures() { - struct Failing; - #[async_trait::async_trait] - impl LanguageModel for Failing { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "mock-fail" - } - async fn do_generate(&self, _: &CallOptions) -> Result { - Err(AiMuxError::Other("boom".into())) - } - async fn do_stream(&self, _: &CallOptions) -> Result { - Err(AiMuxError::Other("boom".into())) - } - } - - let store = Arc::new(RingTraceStore::new()); - let layer = Arc::new(TraceLayer::new(Arc::new(Failing), store.clone())); - assert!(block_on(layer.do_generate(&model_opts(Some("sess-err")))).is_err()); - - let recs = store.aggregate(&TraceFilter::default()); - assert_eq!(recs[0].requests, 1); - assert_eq!(recs[0].errors, 1); - let json = { - let mut buf = Vec::new(); - store.export_jsonl(&mut buf).unwrap(); - String::from_utf8(buf).unwrap() - }; - assert!(json.contains("\"error\":\"boom\""), "{json}"); -} - -/// Aggregation: client_upper_bound_hit_rate sums per-record LCP token upper -/// bounds only — records without LCP evidence contribute 0, never the full -/// request length (RFC-0015 §5.2). -#[test] -fn aggregate_uses_lcp_upper_bound_not_full_length() { - use aimux_core::trace::TraceStats; - - let store = Arc::new(RingTraceStore::new()); - - // Call 1: first request — no history → no LCP upper bound. - let inner1: Arc = - Arc::new(MockModel::new(token_body(1024, 512, 0), 0, 1536)); - let layer1 = Arc::new(TraceLayer::new(inner1, store.clone()).with_rules_auditor(true)); - block_on(layer1.do_generate(&model_opts(Some("sess-1")))).unwrap(); - - // Call 2: same-session prefix continuation → block-1 LCP upper bound - // (4096 B → 1024 tokens). - let inner2: Arc = - Arc::new(MockModel::new(token_body(1024, 1536, 0), 1024, 2560)); - let layer2 = Arc::new(TraceLayer::new(inner2, store.clone()).with_rules_auditor(true)); - block_on(layer2.do_generate(&model_opts(Some("sess-1")))).unwrap(); - - let stats: Vec = store.aggregate(&TraceFilter::default()); - assert_eq!(stats.len(), 1); - let s = &stats[0]; - assert_eq!(s.requests, 2); - let rate = s.client_upper_bound_hit_rate.expect("rate present"); - // LCP upper contributions: call1 = 0 (no match), call2 = 2×4096B/4 = - // 2048 tokens; total input = 1536+2560. - let expected = 2048.0 / (1536.0 + 2560.0); - assert!( - (rate - expected).abs() < 1e-9, - "client upper bound must be LCP-based: {rate} vs {expected}" - ); - assert!( - rate < 0.9, - "full-length estimate would be ~1.0 — LCP evidence must cap it: {rate}" - ); -} - -/// R-2.2 cluster-routing exemption: a stable prefix with zero claimed hits is -/// the normal signature of a load-balanced cluster without sticky routing — -/// without route-affinity evidence it must NOT be flagged as a broken prefix. -#[test] -fn r22_suppressed_without_route_affinity_evidence() { - let spec = spec_for("openai", "gpt-4o"); - let stats = |rounds: u32| SessionStats { - same_session_rounds: rounds, - prefix_stable: true, - lcp_gt_1024: true, - }; - - // 4 rounds, stable prefix, claimed=0 — cluster signature. - let mut inp = base_input(spec); - inp.claimed = 0; - inp.session_stats = Some(stats(4)); - inp = with_lcp(inp, 8192, true); - let v = judge(&inp); - assert_eq!( - v.kind, - VerdictKind::Trusted, - "no route-affinity evidence → not a broken-prefix warning: {}", - v.describe() - ); - assert!(!v.violated.iter().any(|r| r == "R-2.2"), "{}", v.describe()); - assert!( - v.notes - .iter() - .any(|n| n.contains("cluster routing artifact")), - "suppression must be explained: {}", - v.describe() - ); - - // With route affinity KNOWN (single node / sticky / global cache), the - // same signature IS the broken-prefix diagnostic. - let mut aff = base_input(spec); - aff.claimed = 0; - aff.route_affinity_known = true; - aff.session_stats = Some(stats(4)); - aff = with_lcp(aff, 8192, true); - let va = judge(&aff); - assert_eq!(va.kind, VerdictKind::SuspectUnderclaim, "{}", va.describe()); - assert_eq!(va.confidence, aimux_core::trace::VerdictConfidence::Low); -} - -// ───────────────────────────────────────────────────────────────────────────── -// B1 / C4-2 / F1 regression tests -// ───────────────────────────────────────────────────────────────────────────── - -/// B1: `scope_key` isolates by (provider, base_url, model_id). Two TraceLayers -/// over the SAME model_id but DIFFERENT providers sharing one RingTraceStore -/// must not cross-match LCP history. (Before the fix `scope_key` hashed only -/// the model_id, so beta would have matched alpha's prefix.) -#[test] -fn scope_key_isolates_by_provider_same_model_id() { - let store = Arc::new(RingTraceStore::new()); - let body = token_body(1024, 512, 0); // > 4 KiB → block-aligned prefix - - // Provider "alpha", model "shared-m": two identical calls. Call 2 must - // match call 1 (same provider → same scope) → LCP upper bound present. - let a1: Arc = Arc::new(MockModel::with_identity( - "alpha", - "shared-m", - body.clone(), - 0, - 1536, - )); - let layer_a = Arc::new(TraceLayer::new(a1, store.clone()).with_rules_auditor(true)); - block_on(layer_a.do_generate(&model_opts(Some("sa")))).unwrap(); - - let a2: Arc = Arc::new(MockModel::with_identity( - "alpha", - "shared-m", - body.clone(), - 0, - 1536, - )); - let layer_a2 = Arc::new(TraceLayer::new(a2, store.clone()).with_rules_auditor(true)); - block_on(layer_a2.do_generate(&model_opts(Some("sa")))).unwrap(); - - // Provider "beta", SAME model "shared-m", SAME body — must NOT match - // alpha's records (different provider → different scope_key). - let b1: Arc = Arc::new(MockModel::with_identity( - "beta", - "shared-m", - body.clone(), - 0, - 1536, - )); - let layer_b = Arc::new(TraceLayer::new(b1, store.clone()).with_rules_auditor(true)); - block_on(layer_b.do_generate(&model_opts(Some("sb")))).unwrap(); - - let recs = store.aggregate(&TraceFilter::default()); - // Two groups: (alpha, shared-m) and (beta, shared-m). - let alpha = recs - .iter() - .find(|r| r.provider == "alpha") - .expect("alpha group"); - let beta = recs - .iter() - .find(|r| r.provider == "beta") - .expect("beta group"); - assert_eq!(alpha.requests, 2); - assert_eq!(beta.requests, 1); - // alpha call 2 matched call 1 → client upper bound present. - assert!( - alpha.client_upper_bound_hit_rate.is_some(), - "same-provider continuation must produce an LCP upper bound" - ); - // beta is isolated → no LCP evidence (would be Some if scope leaked). - assert!( - beta.client_upper_bound_hit_rate.is_none(), - "different provider must NOT cross-match LCP history (B1)" - ); -} - -/// C4-2 negative case: when an independent invariant (here R-1.6b quantization) -/// ALSO fires, the overclaim does NOT rely solely on the byte proxy, so it is -/// NOT downgraded to Unknown — it stays SuspectOverclaim (capped at Medium by -/// the byte-proxy F2 rule, but still an overclaim). -#[test] -fn c42_byte_proxy_overclaim_with_independent_evidence_is_not_unknown() { - let spec = spec_for("openai", "gpt-4o"); // gran=128 - // u = 512 tokens (LCP upper 2048 B / 4). claimed=700 > 640 (u+τ) → R-1.1, - // and 700 % 128 ≠ 0 → R-1.6b (independent). byte_proxy = true. - let mut inp = base_input(spec); - inp.claimed = 700; - inp = with_lcp(inp, 1536, true); - let v = judge(&inp); - assert_eq!( - v.kind, - VerdictKind::SuspectOverclaim, - "independent evidence (R-1.6b) keeps the overclaim: {}", - v.describe() - ); - assert!(v.violated.iter().any(|r| r == "R-1.6b")); - assert!(v.violated.iter().any(|r| r == "R-1.1")); - // Byte-proxy R-1.1 is capped at Medium (F2); the independent R-1.6b is B. - assert_eq!( - v.confidence, - aimux_core::trace::VerdictConfidence::Medium, - "{}", - v.describe() - ); -} - -/// F1: a stream dropped before completion (take(N) / abort / timeout) must -/// still record its trace — the Drop guard writes it with a non-success marker. -#[test] -fn trace_layer_stream_dropped_early_still_records() { - struct LongStream; - #[async_trait::async_trait] - impl LanguageModel for LongStream { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "mock-long" - } - async fn do_generate(&self, _: &CallOptions) -> Result { - unreachable!() - } - async fn do_stream(&self, _: &CallOptions) -> Result { - Ok(StreamResult { - stream: Box::pin(async_stream::stream! { - yield Ok(StreamPart::StreamStart { warnings: vec![] }); - // Several deltas but NO Finish — the caller drops mid-stream. - for i in 0..5u32 { - yield Ok(StreamPart::TextDelta { - id: "t".into(), - delta: format!("chunk{i}"), - provider_metadata: None, - }); - } - yield Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }); - }), - request_body: Some(token_body(1024, 512, 0)), - response_headers: None, - }) - } - } - - let store = Arc::new(RingTraceStore::new()); - let layer = Arc::new(TraceLayer::new(Arc::new(LongStream), store.clone())); - let mut result = block_on(layer.do_stream(&model_opts(Some("sess-drop")))).unwrap(); - // Consume only 2 parts, then drop the stream (no Finish seen). - let mut n = 0; - while let Some(_p) = block_on(async { futures::StreamExt::next(&mut result.stream).await }) { - n += 1; - if n >= 2 { - break; - } - } - drop(result); // early drop — must NOT lose the record (F1) - - let recs = store.aggregate(&TraceFilter::default()); - assert_eq!( - recs[0].requests, 1, - "early-dropped stream must still record its trace (F1)" - ); - let json = { - let mut buf = Vec::new(); - store.export_jsonl(&mut buf).unwrap(); - String::from_utf8(buf).unwrap() - }; - assert!( - json.contains("stream dropped before completion"), - "dropped stream record must carry the non-success marker: {json}" - ); -} diff --git a/aimux-core/tests/error_value_golden_test.rs b/aimux-core/tests/error_value_golden_test.rs index 8df2b9aad..7426f4758 100644 --- a/aimux-core/tests/error_value_golden_test.rs +++ b/aimux-core/tests/error_value_golden_test.rs @@ -185,116 +185,6 @@ fn error_value_snapshots_plain_variants() { } } -/// The variant set is a cross-language contract of its own: bindings switch on -/// the wire tag. Adding or removing one is a breaking change (16 variants — -/// the per-status avatars `Auth`/`ModelNotFound`/`RateLimited` are gone, -/// `Http`/`Provider` folded into `ApiCall` (a failed exchange is an `ApiCall` -/// error classified by `status_code`, transport failures included), and the -/// legacy catch-all `Tool` — never constructed anywhere — is replaced by the -/// typed `NoSuchTool`/`InvalidToolInput`/`ToolCallRepair`). -/// Compile-time pin: adding an `AiMuxError` variant breaks this match, so -/// the wire-tag list below and every binding's switch must be updated in the -/// same change (the runtime assertion alone cannot see additions). -#[allow(dead_code)] -fn variant_addition_breaks_this_match(error: &AiMuxError) { - match error { - AiMuxError::ApiCall(_) - | AiMuxError::Retry(_) - | AiMuxError::JsonParse(_) - | AiMuxError::InvalidResponseData(_) - | AiMuxError::NoSuchTool { .. } - | AiMuxError::InvalidToolInput { .. } - | AiMuxError::ToolCallRepair { .. } - | AiMuxError::InvalidArgument(_) - | AiMuxError::InvalidPrompt(_) - | AiMuxError::TokenExpired(_) - | AiMuxError::UnsupportedFunctionality(_) - | AiMuxError::NoSuchModel { .. } - | AiMuxError::NoSuchProvider { .. } - | AiMuxError::Timeout(_) - | AiMuxError::Aborted(_) - | AiMuxError::Other(_) => {} - } -} - -#[test] -fn variant_set_is_exactly_sixteen() { - let all = [ - AiMuxError::ApiCall(Box::new(api_error("x"))), - AiMuxError::Retry(RetryError { - reason: RetryErrorReason::MaxRetriesExceeded, - errors: vec![AiMuxError::ApiCall(Box::new(api_error("x")))], - }), - AiMuxError::JsonParse("x".into()), - AiMuxError::InvalidResponseData("x".into()), - AiMuxError::NoSuchTool { - tool_name: "x".into(), - available_tools: None, - tool_input: None, - }, - AiMuxError::InvalidToolInput { - tool_name: "x".into(), - tool_input: "{}".into(), - cause: "x".into(), - }, - AiMuxError::ToolCallRepair { - original_error: Box::new(AiMuxError::NoSuchTool { - tool_name: "x".into(), - available_tools: None, - tool_input: None, - }), - cause: Box::new(AiMuxError::Other("x".into())), - }, - AiMuxError::InvalidArgument("x".into()), - AiMuxError::InvalidPrompt("x".into()), - AiMuxError::TokenExpired("x".into()), - AiMuxError::UnsupportedFunctionality("x".into()), - AiMuxError::NoSuchModel { - model_id: "x".into(), - model_type: String::new(), - }, - AiMuxError::NoSuchProvider { - provider_id: "x".into(), - }, - AiMuxError::Timeout("x".into()), - AiMuxError::Aborted("request aborted".into()), - AiMuxError::Other("x".into()), - ]; - // The wire tag IS the variant name (externally-tagged serde JSON). - let mut names: Vec = all - .iter() - .map(|e| match serde_json::to_value(e).unwrap() { - serde_json::Value::Object(m) => m.into_iter().next().unwrap().0, - serde_json::Value::String(s) => s, // unit variants (Aborted) - v => panic!("unexpected error_value shape: {v}"), - }) - .collect(); - names.sort_unstable(); - names.dedup(); - assert_eq!( - names, - [ - "Aborted", - "ApiCall", - "InvalidArgument", - "InvalidPrompt", - "InvalidResponseData", - "InvalidToolInput", - "JsonParse", - "NoSuchModel", - "NoSuchProvider", - "NoSuchTool", - "Other", - "Retry", - "Timeout", - "TokenExpired", - "ToolCallRepair", - "UnsupportedFunctionality", - ], - "variant set changed" - ); -} - /// Request context is required. Payloads from the pre-context schema are /// deliberately rejected instead of silently fabricating an empty URL/body. #[test] diff --git a/aimux-core/tests/tool_input_test.rs b/aimux-core/tests/tool_input_test.rs index 655d94d3c..3360f1bca 100644 --- a/aimux-core/tests/tool_input_test.rs +++ b/aimux-core/tests/tool_input_test.rs @@ -2,19 +2,8 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use aimux_core::error::AiMuxError; -use aimux_core::generate::{ - GenerateTextOptions, generate_text, generate_text_as_openai, stream_text, stream_text_as_openai, -}; -use aimux_core::language_model::LanguageModel; -use aimux_core::openai_output::OpenAiStreamOptions; -use aimux_core::options::CallOptions; use aimux_core::parse_tool_call::{ToolCallRepair, parse_tool_call}; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::{FunctionTool, RawToolCall, Tool, ToolCall}; -use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; -use async_trait::async_trait; -use futures::StreamExt; +use aimux_core::tool::{FunctionTool, RawToolCall, Tool}; use serde_json::json; fn weather_tool() -> Tool { @@ -303,408 +292,3 @@ fn weather_tool_schema() -> serde_json::Value { "additionalProperties": false }) } - -struct RawToolModel { - tool_name: &'static str, - input: &'static str, - leading_text: bool, - stream_input: bool, -} - -impl RawToolModel { - fn new(input: &'static str) -> Self { - Self { - tool_name: "weather", - input, - leading_text: false, - stream_input: false, - } - } - - fn named(mut self, tool_name: &'static str) -> Self { - self.tool_name = tool_name; - self - } - - fn with_streamed_input(mut self) -> Self { - self.stream_input = true; - self - } -} - -#[async_trait] -impl LanguageModel for RawToolModel { - fn provider(&self) -> &str { - "mock" - } - - fn model_id(&self) -> &str { - "raw-tool-model" - } - - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![GenerateContent::ToolCall(RawToolCall { - tool_call_id: "call-1".into(), - tool_name: self.tool_name.into(), - input: self.input.to_string(), - provider_executed: None, - dynamic: None, - provider_metadata: None, - })], - finish_reason: FinishReason { - unified: FinishReasonUnified::ToolCalls, - raw: Some("tool_calls".into()), - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - - async fn do_stream(&self, _options: &CallOptions) -> Result { - let mut parts = vec![Ok(StreamPart::StreamStart { warnings: vec![] })]; - if self.leading_text { - parts.push(Ok(StreamPart::TextDelta { - id: "text-1".into(), - delta: "ready".into(), - provider_metadata: None, - })); - } - if self.stream_input { - parts.extend([ - Ok(StreamPart::ToolInputStart { - id: "call-1".into(), - tool_name: self.tool_name.into(), - provider_executed: None, - dynamic: None, - title: None, - provider_metadata: None, - }), - Ok(StreamPart::ToolInputDelta { - id: "call-1".into(), - delta: self.input.into(), - provider_metadata: None, - }), - Ok(StreamPart::ToolInputEnd { - id: "call-1".into(), - provider_metadata: None, - }), - ]); - } - parts.extend([ - Ok(StreamPart::ToolCall(RawToolCall { - tool_call_id: "call-1".into(), - tool_name: self.tool_name.into(), - input: self.input.to_string(), - provider_executed: None, - dynamic: None, - provider_metadata: None, - })), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::ToolCalls, - raw: Some("tool_calls".into()), - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]); - Ok(StreamResult { - stream: Box::pin(futures::stream::iter(parts)), - request_body: None, - response_headers: None, - }) - } -} - -#[tokio::test] -async fn generate_text_parses_provider_raw_input_at_the_core_boundary() { - let model = RawToolModel::new(r#"{"city":"Singapore"}"#); - let result = generate_text( - &model, - "weather", - GenerateTextOptions { - tools: Some(vec![weather_tool()]), - ..Default::default() - }, - ) - .await - .unwrap(); - - assert_eq!(result.tool_calls[0].input, json!({ "city": "Singapore" })); - assert_eq!(result.tool_calls[0].invalid, None); - assert!(matches!( - &result.raw.content[0], - GenerateContent::ToolCall(RawToolCall { input, .. }) - if input == r#"{"city":"Singapore"}"# - )); -} - -#[tokio::test] -async fn stream_text_parses_provider_raw_input_at_the_core_boundary() { - let model = RawToolModel::new(r#"{"city":"Singapore"}"#); - let mut result = stream_text( - &model, - "weather", - GenerateTextOptions { - tools: Some(vec![weather_tool()]), - ..Default::default() - }, - ) - .await - .unwrap(); - - while let Some(part) = result.stream.next().await { - if let StreamPart::ToolCall(ToolCall { input, invalid, .. }) = part.unwrap() { - assert_eq!(input, json!({ "city": "Singapore" })); - assert_eq!(invalid, None); - return; - } - } - panic!("expected a parsed tool call"); -} - -#[tokio::test] -async fn openai_outputs_preserve_invalid_raw_tool_arguments() { - let raw_input = r#"{"city":"Singapore"#; - let model = RawToolModel::new(raw_input); - let completion = generate_text_as_openai( - &model, - "weather", - GenerateTextOptions { - tools: Some(vec![weather_tool()]), - ..Default::default() - }, - ) - .await - .unwrap(); - assert_eq!( - completion.choices[0].message.tool_calls.as_ref().unwrap()[0] - .function - .arguments, - raw_input - ); - - let result = stream_text_as_openai( - &model, - "weather", - GenerateTextOptions { - tools: Some(vec![weather_tool()]), - ..Default::default() - }, - OpenAiStreamOptions::default(), - ) - .await - .unwrap(); - let chunks = result - .stream - .collect::>() - .await - .into_iter() - .collect::, _>>() - .unwrap(); - - let arguments = chunks - .iter() - .flat_map(|chunk| &chunk.choices) - .filter_map(|choice| choice.delta.tool_calls.as_ref()) - .flatten() - .find_map(|tool_call| tool_call.function.arguments.as_deref()) - .expect("expected complete tool call arguments"); - assert_eq!(arguments, raw_input); -} - -/// RFC-0035: the non-streaming OpenAI output reflects repair; the stream does -/// not — tool-input deltas are the provider's text, forwarded as they arrive, -/// exactly as in the AI SDK. -#[tokio::test] -async fn openai_repair_shows_in_the_completion_but_not_in_the_stream() { - let repair = ToolCallRepair::new(|context| async move { - Ok(Some(RawToolCall { - tool_name: "weather".into(), - input: r#"{"city":"Singapore","days":3}"#.into(), - ..context.tool_call - })) - }); - let model = RawToolModel::new(r#"{"place":"Singapore"}"#) - .named("forecast") - .with_streamed_input(); - let options = GenerateTextOptions { - tools: Some(vec![weather_tool()]), - repair_tool_call: Some(repair.clone()), - ..Default::default() - }; - - let completion = generate_text_as_openai(&model, "weather", options) - .await - .unwrap(); - let non_stream_call = &completion.choices[0].message.tool_calls.as_ref().unwrap()[0]; - - let stream = stream_text_as_openai( - &model, - "weather", - GenerateTextOptions { - tools: Some(vec![weather_tool()]), - repair_tool_call: Some(repair), - ..Default::default() - }, - OpenAiStreamOptions::default(), - ) - .await - .unwrap(); - let chunks = stream - .stream - .collect::>() - .await - .into_iter() - .collect::, _>>() - .unwrap(); - let stream_calls = chunks - .iter() - .flat_map(|chunk| &chunk.choices) - .filter_map(|choice| choice.delta.tool_calls.as_ref()) - .flatten() - .collect::>(); - assert_eq!(non_stream_call.function.name, "weather"); - assert_eq!( - non_stream_call.function.arguments, - r#"{"city":"Singapore","days":3}"# - ); - - // The stream opened on the provider's name before repair could run, and - // its deltas carry the provider's own text. - assert_eq!(stream_calls[0].function.name.as_deref(), Some("forecast")); - let streamed_arguments = stream_calls - .iter() - .filter_map(|call| call.function.arguments.as_deref()) - .collect::(); - assert_eq!(streamed_arguments, r#"{"place":"Singapore"}"#); -} - -#[tokio::test] -async fn openai_stream_without_repair_preserves_provider_tool_input_deltas() { - let model = RawToolModel::new(r#"{"city":"Singapore"}"#).with_streamed_input(); - let result = stream_text_as_openai( - &model, - "weather", - GenerateTextOptions { - tools: Some(vec![weather_tool()]), - ..Default::default() - }, - OpenAiStreamOptions::default(), - ) - .await - .unwrap(); - let chunks = result - .stream - .collect::>() - .await - .into_iter() - .collect::, _>>() - .unwrap(); - let tool_calls = chunks - .iter() - .flat_map(|chunk| &chunk.choices) - .filter_map(|choice| choice.delta.tool_calls.as_ref()) - .flatten() - .collect::>(); - - assert_eq!(tool_calls.len(), 2); - assert_eq!(tool_calls[0].id.as_deref(), Some("call-1")); - assert_eq!(tool_calls[0].function.name.as_deref(), Some("weather")); - assert_eq!(tool_calls[0].function.arguments.as_deref(), Some("")); - assert_eq!(tool_calls[1].id, None); - assert_eq!(tool_calls[1].function.name, None); - assert_eq!( - tool_calls[1].function.arguments.as_deref(), - Some(r#"{"city":"Singapore"}"#) - ); -} - -async fn assert_openai_arguments( - model: &RawToolModel, - options: GenerateTextOptions, - expected: &str, -) { - let completion = generate_text_as_openai(model, "weather", options.clone()) - .await - .unwrap(); - let call = &completion.choices[0].message.tool_calls.as_ref().unwrap()[0]; - assert_eq!(call.function.name, model.tool_name); - assert_eq!(call.function.arguments, expected); - - let result = stream_text_as_openai(model, "weather", options, OpenAiStreamOptions::default()) - .await - .unwrap(); - let chunks = result - .stream - .collect::>() - .await - .into_iter() - .collect::, _>>() - .unwrap(); - let calls: Vec<_> = chunks - .iter() - .flat_map(|chunk| &chunk.choices) - .filter_map(|choice| choice.delta.tool_calls.as_ref()) - .flatten() - .collect(); - assert_eq!( - calls.iter().find_map(|call| call.function.name.as_deref()), - Some(model.tool_name) - ); - let arguments: String = calls - .iter() - .filter_map(|call| call.function.arguments.as_deref()) - .collect(); - assert_eq!(arguments, expected); -} - -#[tokio::test] -async fn invalid_repair_keeps_original_arguments_with_original_name() { - for name in ["forecast", "weather"] { - let repair = ToolCallRepair::new(|context| async move { - Ok(Some(RawToolCall { - tool_name: "weather".into(), - input: r#"{"city":7}"#.into(), - ..context.tool_call - })) - }); - let model = RawToolModel::new(r#"{"place":"Tokyo"}"#) - .named(name) - .with_streamed_input(); - assert_openai_arguments( - &model, - GenerateTextOptions { - tools: Some(vec![weather_tool()]), - repair_tool_call: Some(repair), - ..Default::default() - }, - r#"{"place":"Tokyo"}"#, - ) - .await; - } -} - -#[tokio::test] -async fn unknown_tool_preserves_raw_arguments_with_and_without_input_deltas() { - for input in [r#""hello""#, "hello", "null", " { \"city\": \"Tokyo\" } "] { - for deltas in [false, true] { - let mut model = RawToolModel::new(input).named("unknown"); - model.stream_input = deltas; - assert_openai_arguments( - &model, - GenerateTextOptions { - tools: Some(vec![weather_tool()]), - ..Default::default() - }, - input, - ) - .await; - } - } -} diff --git a/aimux-ffi/src/lib.rs b/aimux-ffi/src/lib.rs index f91a2be3d..8f4c7ef81 100644 --- a/aimux-ffi/src/lib.rs +++ b/aimux-ffi/src/lib.rs @@ -469,7 +469,9 @@ fn aimux_error_code_of(err: &AiMuxError) -> i32 { AiMuxError::ApiCall { .. } => AIMUX_E_API_CALL, AiMuxError::Retry(_) => AIMUX_E_RETRY, AiMuxError::JsonParse(_) => AIMUX_E_JSON_PARSE, - AiMuxError::InvalidResponseData(_) => AIMUX_E_INVALID_RESPONSE_DATA, + AiMuxError::InvalidResponseData(_) | AiMuxError::ToolCallNotFoundForApproval { .. } => { + AIMUX_E_INVALID_RESPONSE_DATA + } AiMuxError::NoSuchTool { .. } => AIMUX_E_NO_SUCH_TOOL, AiMuxError::InvalidToolInput { .. } => AIMUX_E_INVALID_TOOL_INPUT, AiMuxError::ToolCallRepair { .. } => AIMUX_E_TOOL_CALL_REPAIR, @@ -2487,7 +2489,7 @@ pub extern "C" fn aimux_transcription_generate( #[derive(serde::Deserialize, Default)] struct TranscriptionSessionFfiOptions { input_audio_format: Option, - provider_options: Option>, + provider_options: Option, headers: Option>, include_raw_chunks: Option, timeout: Option, @@ -4175,227 +4177,6 @@ mod tests { } } - /// A tiny mock model for composite-FFI tests (returns fixed text). - struct MockText { - provider: &'static str, - model_id: &'static str, - text: &'static str, - } - #[async_trait::async_trait] - impl aimux_core::LanguageModel for MockText { - fn provider(&self) -> &str { - self.provider - } - fn model_id(&self) -> &str { - self.model_id - } - async fn do_generate( - &self, - _options: &aimux_core::options::CallOptions, - ) -> Result { - Ok(aimux_core::result::GenerateResult { - content: vec![aimux_core::result::GenerateContent::Text { - text: self.text.into(), - provider_metadata: None, - }], - finish_reason: aimux_core::types::FinishReason { - unified: aimux_core::types::FinishReasonUnified::Stop, - raw: None, - }, - usage: aimux_core::types::Usage::default(), - warnings: vec![], - provider_metadata: None, - response: aimux_core::types::ResponseMetadata { - model_id: Some(self.model_id.into()), - ..Default::default() - }, - request_body: None, - response_headers: None, - }) - } - async fn do_stream( - &self, - _options: &aimux_core::options::CallOptions, - ) -> Result { - unimplemented!() - } - } - - fn mock_handle(provider: &'static str, model_id: &'static str, text: &'static str) -> u64 { - intern_model(Arc::new(MockText { - provider, - model_id, - text, - })) - } - - /// Streams: delta, recoverable Err(JsonParse), delta, Finish. - struct RecoverableFrameModel; - #[async_trait::async_trait] - impl aimux_core::LanguageModel for RecoverableFrameModel { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "recoverable" - } - async fn do_generate( - &self, - _options: &aimux_core::options::CallOptions, - ) -> Result { - unimplemented!() - } - async fn do_stream( - &self, - _options: &aimux_core::options::CallOptions, - ) -> Result { - let delta = |text: &str| aimux_core::stream_part::StreamPart::TextDelta { - id: "1".into(), - delta: text.into(), - provider_metadata: None, - }; - Ok(aimux_core::result::StreamResult { - stream: Box::pin(futures::stream::iter([ - Ok(delta("a")), - Err(AiMuxError::JsonParse("bad frame".into())), - Ok(delta("b")), - Ok(aimux_core::stream_part::StreamPart::Finish { - finish_reason: aimux_core::types::FinishReason { - unified: aimux_core::types::FinishReasonUnified::Stop, - raw: None, - }, - usage: aimux_core::types::Usage::default(), - provider_metadata: None, - }), - ])), - request_body: None, - response_headers: None, - }) - } - } - - /// The pump must deliver a recoverable frame error as a StreamPart::Error - /// data part and keep going — parts after it still arrive and on_done - /// fires (the pre-fix pump returned the error and skipped on_done). - #[test] - fn recoverable_frame_error_does_not_end_the_ffi_stream() { - struct Collected { - parts: Vec, - done: bool, - } - extern "C-unwind" fn on_part(json: *const c_char, ctx: *mut c_void) { - let collected = unsafe { &mut *(ctx as *mut Collected) }; - let text = unsafe { std::ffi::CStr::from_ptr(json) } - .to_string_lossy() - .into_owned(); - collected.parts.push(text); - } - extern "C-unwind" fn on_done(ctx: *mut c_void) { - let collected = unsafe { &mut *(ctx as *mut Collected) }; - collected.done = true; - } - - let handle = intern_model(Arc::new(RecoverableFrameModel)); - let mut collected = Collected { - parts: Vec::new(), - done: false, - }; - let prompt = std::ffi::CString::new("\"hi\"").unwrap(); - let e = aimux_stream_text( - handle, - prompt.as_ptr(), - std::ptr::null(), - Some(on_part), - Some(on_done), - &mut collected as *mut Collected as *mut c_void, - ); - assert!(e.is_null(), "{}", msg(e)); - assert!( - collected.done, - "on_done must fire after a recoverable frame" - ); - let error_index = collected - .parts - .iter() - .position(|p| p.contains("\"Error\"")) - .expect("the recoverable frame arrives as a StreamPart::Error part"); - assert!( - collected.parts[error_index + 1..] - .iter() - .any(|p| p.contains("\"b\"")), - "parts after the recoverable frame still arrive: {:?}", - collected.parts - ); - } - - /// On the OpenAI-compatible path every item is a ChatCompletionChunk, so a - /// recoverable frame error is skipped — no error item, no truncation, and - /// on_done still fires. - #[test] - fn recoverable_frame_error_is_skipped_on_the_openai_stream() { - struct Collected { - parts: Vec, - done: bool, - } - extern "C-unwind" fn on_part(json: *const c_char, ctx: *mut c_void) { - let collected = unsafe { &mut *(ctx as *mut Collected) }; - let text = unsafe { std::ffi::CStr::from_ptr(json) } - .to_string_lossy() - .into_owned(); - collected.parts.push(text); - } - extern "C-unwind" fn on_done(ctx: *mut c_void) { - let collected = unsafe { &mut *(ctx as *mut Collected) }; - collected.done = true; - } - - let handle = intern_model(Arc::new(RecoverableFrameModel)); - let mut collected = Collected { - parts: Vec::new(), - done: false, - }; - let prompt = std::ffi::CString::new("\"hi\"").unwrap(); - let e = aimux_stream_text_as_openai( - handle, - prompt.as_ptr(), - std::ptr::null(), - Some(on_part), - Some(on_done), - &mut collected as *mut Collected as *mut c_void, - ); - assert!(e.is_null(), "{}", msg(e)); - assert!( - collected.done, - "on_done must fire after a recoverable frame" - ); - assert!( - collected.parts.iter().all(|p| !p.contains("\"error\"")), - "no error item may ride the chunk-typed wire: {:?}", - collected.parts - ); - assert!( - collected.parts.iter().any(|p| p.contains("\"b\"")), - "chunks after the recoverable frame still arrive: {:?}", - collected.parts - ); - } - - #[test] - fn router_new_builds_router_model() { - let handles = [ - mock_handle("mock", "primary", "primary-out"), - mock_handle("mock", "backup", "backup-out"), - ]; - let mut h = zero_err(); - let e = aimux_router_new(handles.as_ptr(), handles.len(), std::ptr::null(), &mut h); - assert!(e.is_null(), "{}", msg(e)); - assert!(h != 0, "router handle must be non-zero"); - // Confirm it resolves to a model. - let model = get_model(h).expect("router handle resolves"); - assert_eq!(model.provider(), "router"); - assert_eq!(model.model_id(), "router"); - } - #[test] fn router_new_rejects_empty_handles() { // len == 0 (with or without a NULL array) is "no usable child", not a @@ -4412,112 +4193,6 @@ mod tests { assert_eq!(expect_ffi_error(e), "handles: must not be NULL"); } - #[test] - fn router_new_rejects_bad_json() { - let handles = [mock_handle("mock", "m", "x")]; - let bad = std::ffi::CString::new("{not json").unwrap(); - let mut h = zero_err(); - let e = aimux_router_new(handles.as_ptr(), handles.len(), bad.as_ptr(), &mut h); - assert_eq!(h, 0); - assert!(expect_ffi_error(e).starts_with("config_json: invalid JSON:")); - } - - #[test] - fn router_new_treats_empty_config_as_defaults() { - // S1 guard: empty string / "null" config must NOT be a JSON_PARSE error - // (consistent with other config-bearing FFI entry points). - let handles = [mock_handle("mock", "m", "x")]; - for cfg in ["", " ", "null"] { - let c = std::ffi::CString::new(cfg).unwrap(); - let mut h = zero_err(); - let e = aimux_router_new(handles.as_ptr(), handles.len(), c.as_ptr(), &mut h); - assert!(e.is_null(), "router with config {cfg:?}: {}", msg(e)); - assert!(h != 0); - } - } - - #[test] - fn moa_new_builds_moa_model() { - let refs = [ - mock_handle("mock", "ref-a", "A"), - mock_handle("mock", "ref-b", "B"), - ]; - let agg = mock_handle("mock", "aggregator", "agg"); - let mut h = zero_err(); - let e = aimux_moa_new(refs.as_ptr(), refs.len(), agg, std::ptr::null(), &mut h); - assert!(e.is_null(), "{}", msg(e)); - assert!(h != 0, "moa handle must be non-zero"); - let model = get_model(h).expect("moa handle resolves"); - assert_eq!(model.provider(), "moa"); - assert_eq!(model.model_id(), "moa"); - } - - #[test] - fn moa_new_rejects_bad_aggregator() { - let refs = [mock_handle("mock", "ref-a", "A")]; - let mut h = zero_err(); - // aggregator handle 999999 does not exist. - let e = aimux_moa_new(refs.as_ptr(), refs.len(), 999_999, std::ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!( - expect_ffi_error(e), - "invalid or expired moa aggregator handle" - ); - } - - #[test] - fn composites_reject_any_dead_member_handle() { - // A dead handle inside the array is a caller bug: reported, never - // silently dropped (the composite would otherwise run with fewer - // members than the caller believes). - let live = mock_handle("mock", "live", "L"); - let dead = mock_handle("mock", "dead", "D"); - aimux_drop_handle(dead); - let handles = [live, dead]; - - let mut h = zero_err(); - let e = aimux_router_new(handles.as_ptr(), handles.len(), std::ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!( - expect_ffi_error(e), - "invalid or expired router child handle" - ); - - let agg = mock_handle("mock", "aggregator", "agg"); - let e = aimux_moa_new( - handles.as_ptr(), - handles.len(), - agg, - std::ptr::null(), - &mut h, - ); - assert_eq!(h, 0); - assert_eq!( - expect_ffi_error(e), - "invalid or expired moa reference handle" - ); - - // NULL with ref_len > 0 is a null pointer, same as router. - let e = aimux_moa_new(std::ptr::null(), 2, agg, std::ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!(expect_ffi_error(e), "reference_handles: must not be NULL"); - } - - #[test] - fn moa_new_allows_zero_references() { - // 0 references is valid (degrades to aggregator-only). - let agg = mock_handle("mock", "aggregator", "agg"); - let mut h = zero_err(); - let e = aimux_moa_new(std::ptr::null(), 0, agg, std::ptr::null(), &mut h); - assert!(e.is_null(), "{}", msg(e)); - assert!(h != 0, "moa with 0 references should succeed"); - } - - // ── Transcription streaming sessions (RFC-0028 Phase 2) ────────────── - - /// A mock transcription model whose `do_stream` echoes: one delta per - /// received audio chunk, then final + finish after the audio ends. - /// Honors the abort signal. struct MockStreamingTranscriber; #[async_trait::async_trait] @@ -4833,50 +4508,4 @@ mod tests { pusher.join().unwrap(); drop_handle(model); } - - #[test] - fn transcription_session_new_rejects_bad_inputs() { - let model = mock_transcriber_handle(); - let mut h = zero_err(); - // Bad JSON. - let bad = std::ffi::CString::new("{not json").unwrap(); - let e = aimux_transcription_session_new(model, 0, bad.as_ptr(), &mut h); - assert_eq!(h, 0); - assert!(expect_ffi_error(e).starts_with("opts_json: invalid JSON:")); - - // Non-transcription model handle. - let lang = mock_handle("mock", "m", "x"); - let e = aimux_transcription_session_new(lang, 0, std::ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!( - expect_ffi_error(e), - "invalid or expired transcription handle" - ); - - // Bad abort handle. - let e = aimux_transcription_session_new(model, 999_999, std::ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!(expect_ffi_error(e), "invalid or expired abort handle"); - - // NULL out-params on next_part. - let mut part: *mut c_char = std::ptr::null_mut(); - assert_eq!( - expect_ffi_error(aimux_transcription_next_part( - 0, - 0, - std::ptr::null_mut(), - std::ptr::null_mut() - )), - "out_part: must not be NULL" - ); - assert_eq!( - expect_ffi_error(aimux_transcription_next_part( - 0, - 0, - &mut part, - std::ptr::null_mut() - )), - "out_state: must not be NULL" - ); - } } diff --git a/aimux-ffi/src/transcription_session.rs b/aimux-ffi/src/transcription_session.rs index d6d71054b..a40909b12 100644 --- a/aimux-ffi/src/transcription_session.rs +++ b/aimux-ffi/src/transcription_session.rs @@ -51,7 +51,7 @@ const JOIN_TIMEOUT: Duration = Duration::from_secs(5); #[derive(Default)] pub struct SessionOptions { pub input_audio_format: Option, - pub provider_options: Option>, + pub provider_options: Option, pub headers: Option>, pub include_raw_chunks: bool, pub timeout: Option, diff --git a/aimux-provider-utils/src/lib.rs b/aimux-provider-utils/src/lib.rs index 48e0df249..6b2dd0ff9 100644 --- a/aimux-provider-utils/src/lib.rs +++ b/aimux-provider-utils/src/lib.rs @@ -8,7 +8,8 @@ //! `@ai-sdk/provider-utils`. Operation retry and timeout live in `aimux-core`. pub mod api_key; -mod download_guard; +#[doc(hidden)] +pub use aimux_core::download_guard; pub mod extract_response_headers; pub mod get_from_api; pub mod handle_fetch_error; diff --git a/aimux-provider-utils/tests/http_recording_test.rs b/aimux-provider-utils/tests/http_recording_test.rs index 58dac8160..09ba83e15 100644 --- a/aimux-provider-utils/tests/http_recording_test.rs +++ b/aimux-provider-utils/tests/http_recording_test.rs @@ -62,18 +62,12 @@ fn request(url: String, call_id: &str, recorder: Arc) -> HttpReque } fn finish_call(recorder: &dyn Recorder, call_id: &str) { - use aimux_core::content::ContentPart; - use aimux_core::language_model_message::LanguageModelPromptMessage; - use aimux_core::message::Role; + use aimux_core::language_model_message::LanguageModelMessage; use aimux_core::options::CallOptions; recorder.record_input( call_id, - &CallOptions::new(vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("hi")], - ..Default::default() - }]), + &CallOptions::new(vec![LanguageModelMessage::user_text("hi")]), "openai", "gpt-test", ); diff --git a/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs b/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs index a1ce8b16e..1687dd785 100644 --- a/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs +++ b/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs @@ -7,12 +7,14 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; use aimux_core::tool::RawToolCall; +use aimux_core::types::ProviderMetadata; use aimux_provider_utils::{ StreamingToolCallDelta, StreamingToolCallTracker, TrackerError, TypeValidation, }; -use serde_json::{Value, json}; +use serde_json::json; /// The TS tracker's four events, projected out of [`StreamPart`] so the /// assertions below read like the upstream suite. @@ -38,7 +40,7 @@ enum Part { tool_name: String, /// The raw argument text. input: String, - provider_metadata: Option, + provider_metadata: Option, }, } @@ -1043,7 +1045,9 @@ mod metadata { let mut h = Harness::new(); h.delta(StreamingToolCallDelta { - provider_metadata: Some(json!({ "google": { "thoughtSignature": "sig123" } })), + provider_metadata: Some( + provider_namespace("google", json!({ "thoughtSignature": "sig123" })).unwrap(), + ), ..start(0, "call_1", "fn", "{\"incomplete") }) .unwrap(); @@ -1055,7 +1059,9 @@ mod metadata { tool_call_id: "call_1".into(), tool_name: "fn".into(), input: "{\"incomplete".into(), - provider_metadata: Some(json!({ "google": { "thoughtSignature": "sig123" } })), + provider_metadata: Some( + provider_namespace("google", json!({ "thoughtSignature": "sig123" })).unwrap(), + ), }) ); } @@ -1066,7 +1072,9 @@ mod metadata { h.delta(start(0, "call_1", "fn", "{")).unwrap(); h.delta(StreamingToolCallDelta { - provider_metadata: Some(json!({ "custom": { "key": "value" } })), + provider_metadata: Some( + provider_namespace("custom", json!({ "key": "value" })).unwrap(), + ), ..cont(Some(0), "}") }) .unwrap(); diff --git a/aimux-providers/src/anthropic/cache_control.rs b/aimux-providers/src/anthropic/cache_control.rs index 9216eda9e..66e1d0ffb 100644 --- a/aimux-providers/src/anthropic/cache_control.rs +++ b/aimux-providers/src/anthropic/cache_control.rs @@ -7,6 +7,7 @@ //! set on a non-cacheable context such as a thinking block) are dropped with a //! `Warning::Unsupported`. +use aimux_core::shared::JsonObject; use aimux_core::types::Warning; use serde_json::Value; @@ -15,13 +16,12 @@ const MAX_CACHE_BREAKPOINTS: u32 = 4; /// Extract the `cache_control` value from provider metadata, mirroring the TS /// `getCacheControl` helper. Accepts both the camelCase `cacheControl` and the -/// snake_case `cache_control` keys under `providerOptions.anthropic`. +/// snake_case `cache_control` keys in the `providerOptions.anthropic` namespace. /// /// The value is passed through unchanged (the Anthropic API validates it). #[must_use] -pub fn extract_cache_control(provider_options: Option<&Value>) -> Option { - let opts = provider_options?; - let anthropic = opts.get("anthropic")?; +pub fn extract_cache_control(anthropic: Option<&JsonObject>) -> Option { + let anthropic = anthropic?; anthropic .get("cacheControl") .or_else(|| anthropic.get("cache_control")) @@ -46,8 +46,8 @@ impl CacheControlValidator { /// Resolve the `cache_control` value (if any) for a content block, applying /// the validation rules. /// - /// - `provider_options` is the part- or message-level provider metadata to - /// read `anthropic.cacheControl` from. + /// - `anthropic` is the part- or message-level Anthropic namespace to + /// read `cacheControl` from. /// - `context_type` is a human-readable label for the block kind (e.g. /// `"user message part"`, `"thinking block"`) used in warning messages. /// - `can_cache` is `false` for contexts that cannot carry cache_control @@ -58,11 +58,11 @@ impl CacheControlValidator { /// or `None` when there is none or it was rejected. pub fn get_cache_control( &mut self, - provider_options: Option<&Value>, + anthropic: Option<&JsonObject>, context_type: &str, can_cache: bool, ) -> Option { - let cache_control_value = extract_cache_control(provider_options); + let cache_control_value = extract_cache_control(anthropic); let cc = cache_control_value?; diff --git a/aimux-providers/src/anthropic/convert.rs b/aimux-providers/src/anthropic/convert.rs index 670501354..af679e5f5 100644 --- a/aimux-providers/src/anthropic/convert.rs +++ b/aimux-providers/src/anthropic/convert.rs @@ -18,13 +18,15 @@ //! conversion with `send_reasoning = false`, discarding the betas and //! warnings. -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeSet, HashSet}; -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; -use aimux_core::language_model_message::LanguageModelPrompt; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, + ToolCallPart, ToolPart, ToolResultContent, ToolResultOutput, ToolResultPart, UserPart, +}; use aimux_core::options::{CallOptions, Tool}; +use aimux_core::shared::{FileBytes, FileData, SharedProviderOptions}; use aimux_core::types::{FinishReason, FinishReasonUnified, ReasoningEffort, Warning}; use serde_json::{Map, Value, json}; @@ -132,127 +134,108 @@ pub fn convert_prompt_to_anthropic_full_with_tools( } for msg in prompt { - match msg.role { - Role::System => { - // A system message interrupts any in-flight user/assistant group. + let role = match msg { + LanguageModelMessage::System { .. } => None, + LanguageModelMessage::User { .. } | LanguageModelMessage::Tool { .. } => { + Some(Eff::User) + } + LanguageModelMessage::Assistant { .. } => Some(Eff::Assistant), + }; + if let Some(role) = role { + seen_non_system = true; + if last != Some(role) { flush(&mut messages, &mut last, &mut acc); - let n = msg.content.len(); - let mut blocks: Vec = Vec::new(); - for (idx, p) in msg.content.iter().enumerate() { - if let ContentPart::Text { - text, - provider_options, - } = p - { - let is_last = idx + 1 == n; - // cache_control: part-level first, then message-level - // for the last text part (system content is a string in - // the TS model, so message-level is the primary path). - let cache_control = match validator.get_cache_control( - provider_options.as_ref(), - "system message part", - true, - ) { - Some(v) => Some(v), - None => { - if is_last { - validator.get_cache_control( - msg.provider_options.as_ref(), - "system message", - true, - ) - } else { - None - } - } - }; - let mut block = json!({ "type": "text", "text": text }); - if let Some(cc) = cache_control { - block["cache_control"] = cc; - } - blocks.push(block); - } - // Non-text parts on a system message are ignored. - } - + } + last = Some(role); + } + match msg { + LanguageModelMessage::System { + content, + provider_options, + } => { + flush(&mut messages, &mut last, &mut acc); + let cc = validator.get_cache_control( + provider_options.as_ref().and_then(|o| o.get("anthropic")), + "system message", + true, + ); + let blocks = vec![apply_cc(json!({ "type": "text", "text": content }), cc)]; if !seen_non_system { - // Initial system block(s) become the top-level system prompt. system.extend(blocks); } else { - // A mid-conversation system message is emitted inline. messages.push(json!({ "role": "system", "content": blocks })); betas.insert(BETA_MID_CONVERSATION_SYSTEM.to_string()); } } - Role::User | Role::Tool => { - seen_non_system = true; - if last != Some(Eff::User) { - flush(&mut messages, &mut last, &mut acc); + LanguageModelMessage::User { + content, + provider_options, + } => { + for (idx, part) in content.iter().enumerate() { + acc.push(match part { + UserPart::Text(TextPart { + text, + provider_options: part_options, + }) => { + let cc = resolve_cache_control( + &mut validator, + part_options.as_ref(), + provider_options.as_ref(), + idx + 1 == content.len(), + "user message part", + "user message", + ); + apply_cc(json!({ "type": "text", "text": text }), cc) + } + UserPart::File(file) => convert_file_part( + file, + &mut betas, + &mut validator, + provider_options.as_ref(), + idx + 1 == content.len(), + "user message part", + "user message", + )?, + }); } - let (part_ctx, msg_ctx) = match msg.role { - Role::Tool => ("tool result part", "tool result message"), - _ => ("user message part", "user message"), - }; - let n = msg.content.len(); - for (idx, p) in msg.content.iter().enumerate() { - let is_last_part = idx + 1 == n; - if let Some(block) = convert_part_to_anthropic( - p, + } + LanguageModelMessage::Tool { + content, + provider_options, + } => { + for (idx, part) in content.iter().enumerate() { + let ToolPart::ToolResult(part) = part else { + continue; + }; + acc.push(convert_tool_result( + part, + &mut validator, + &mut betas, + &mut warnings, + idx + 1 == content.len(), + provider_options.as_ref(), + )?); + } + } + LanguageModelMessage::Assistant { + content, + provider_options, + } => { + for (idx, part) in content.iter().enumerate() { + if let Some(block) = convert_assistant_part( + part, send_reasoning, tool_names, + &mcp_tool_use_ids, &mut betas, &mut warnings, &mut validator, - is_last_part, - msg.provider_options.as_ref(), - part_ctx, - msg_ctx, + idx + 1 == content.len(), + provider_options.as_ref(), )? { acc.push(block); } } - last = Some(Eff::User); - } - Role::Assistant => { - seen_non_system = true; - if last != Some(Eff::Assistant) { - flush(&mut messages, &mut last, &mut acc); - } - let n = msg.content.len(); - for (idx, p) in msg.content.iter().enumerate() { - let is_last_part = idx + 1 == n; - // Assistant-role tool results are provider-executed results; - // Anthropic only accepts a bare `tool_result` in a *user* - // message, so they take a dedicated path. - let block = if let ContentPart::ToolResult { .. } = p { - convert_assistant_tool_result( - p, - tool_names, - &mcp_tool_use_ids, - &mut warnings, - &mut validator, - is_last_part, - msg.provider_options.as_ref(), - ) - } else { - convert_part_to_anthropic( - p, - send_reasoning, - tool_names, - &mut betas, - &mut warnings, - &mut validator, - is_last_part, - msg.provider_options.as_ref(), - "assistant message part", - "assistant message", - )? - }; - if let Some(block) = block { - acc.push(block); - } - } - last = Some(Eff::Assistant); } } } @@ -338,165 +321,248 @@ fn top_level_media_type(media_type: &str) -> &str { media_type.split('/').next().unwrap_or("") } -/// Convert a single content part into an Anthropic content block. -/// -/// Returns `None` when the part should be omitted entirely (e.g. a reasoning -/// part that is dropped). `betas` and `warnings` are appended to as side -/// effects. -/// -/// `cache_control` is resolved from the part's `provider_options.anthropic -/// .cacheControl`, falling back to the message-level `provider_options` for the -/// last part of a message (mirroring the TS SDK). Thinking blocks reject -/// cache_control (validated with a warning). -#[allow(clippy::too_many_arguments)] -fn convert_part_to_anthropic( - part: &ContentPart, - send_reasoning: bool, - tool_names: &ToolNameMapping, - betas: &mut BTreeSet, - warnings: &mut Vec, +fn resolve_cache_control( validator: &mut CacheControlValidator, + part_options: Option<&SharedProviderOptions>, + message_options: Option<&SharedProviderOptions>, is_last_part: bool, - message_provider_options: Option<&Value>, - part_context_type: &str, - message_context_type: &str, -) -> Result, AiMuxError> { - // Resolve cache_control = part-level ?? (is_last_part ? message-level). - let resolve_cc = - |validator: &mut CacheControlValidator, part_opts: Option<&Value>| match validator - .get_cache_control(part_opts, part_context_type, true) - { - Some(v) => Some(v), - None => { - if is_last_part { - validator.get_cache_control( - message_provider_options, - message_context_type, - true, - ) - } else { - None - } - } - }; - - let apply_cc = |block: Value, cc: Option| -> Value { - match cc { - Some(c) => { - let mut b = block; - b["cache_control"] = c; - b + part_context: &str, + message_context: &str, +) -> Option { + validator + .get_cache_control( + part_options.and_then(|o| o.get("anthropic")), + part_context, + true, + ) + .or_else(|| { + if is_last_part { + validator.get_cache_control( + message_options.and_then(|o| o.get("anthropic")), + message_context, + true, + ) + } else { + None } - None => block, - } - }; - - Ok(Some(match part { - ContentPart::Text { - text, - provider_options, - } => { - let cc = resolve_cc(validator, provider_options.as_ref()); - apply_cc(json!({ "type": "text", "text": text }), cc) - } + }) +} - ContentPart::Image { - image, - media_type, - provider_options, - } => { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(image); - let cc = resolve_cc(validator, provider_options.as_ref()); - apply_cc( - json!({ - "type": "image", - "source": { - "type": "base64", - "media_type": media_type, - "data": b64, - } - }), - cc, - ) - } +fn apply_cc(mut block: Value, cc: Option) -> Value { + if let Some(cc) = cc { + block["cache_control"] = cc; + } + block +} - ContentPart::File { - data, - media_type, - filename, - provider_options, +fn convert_file_part( + file: &FilePart, + betas: &mut BTreeSet, + validator: &mut CacheControlValidator, + message_options: Option<&SharedProviderOptions>, + is_last_part: bool, + part_context: &str, + message_context: &str, +) -> Result { + let FilePart { + data, + media_type, + filename, + provider_options, + } = file; + let block = match data { + FileData::Data { + data: FileBytes::Binary(bytes), } => { - let full = resolve_full_media_type(media_type, data)?; - let block = route_file_bytes(&full, data, filename.as_deref(), betas)?; - let cc = resolve_cc(validator, provider_options.as_ref()); - apply_cc(block, cc) + let full = resolve_full_media_type(media_type, bytes)?; + route_file_bytes(&full, bytes, filename.as_deref(), betas)? } - - ContentPart::FileBase64 { - data, - media_type, - filename, - provider_options, + FileData::Data { + data: FileBytes::Base64(data), } => { use base64::Engine; let bytes = base64::engine::general_purpose::STANDARD .decode(data) .unwrap_or_default(); let full = resolve_full_media_type(media_type, &bytes)?; - let block = route_file_base64(&full, data, &bytes, filename.as_deref(), betas)?; - let cc = resolve_cc(validator, provider_options.as_ref()); - apply_cc(block, cc) + route_file_base64(&full, data, &bytes, filename.as_deref(), betas)? } - - ContentPart::FileUrl { - url, - media_type, - provider_options, - } => { - let block = route_file_url(media_type, url, betas)?; - let cc = resolve_cc(validator, provider_options.as_ref()); - apply_cc(block, cc) - } - - ContentPart::FileReference { - media_type, - reference, - provider_options, - .. - } => { + FileData::Url { url, .. } => route_file_url(media_type, url, betas)?, + FileData::Reference { reference } => { let file_id = resolve_anthropic_reference(reference)?; betas.insert(BETA_FILES_API.to_string()); let container_upload = provider_options .as_ref() .and_then(|o| o.get("anthropic")) .and_then(|a| a.get("containerUpload")) - .and_then(serde_json::Value::as_bool) + .and_then(Value::as_bool) .unwrap_or(false); - let block = if container_upload { - json!({ "type": "container_upload", "file_id": file_id }) + if container_upload { + return Ok(json!({ "type": "container_upload", "file_id": file_id })); } else if top_level_media_type(media_type) == "image" { json!({ "type": "image", "source": { "type": "file", "file_id": file_id } }) } else { json!({ "type": "document", "source": { "type": "file", "file_id": file_id } }) - }; - // container_upload blocks do not carry cache_control in the TS SDK. - let cc = if container_upload { - None - } else { - resolve_cc(validator, provider_options.as_ref()) - }; - apply_cc(block, cc) + } + } + FileData::Text { text } => { + let options = provider_options.as_ref().and_then(|o| o.get("anthropic")); + let mut block = json!({ "type": "document", "source": { + "type": "text", "media_type": "text/plain", "data": text, + }}); + if let Some(title) = options + .and_then(|o| o.get("title")) + .and_then(Value::as_str) + .or(filename.as_deref()) + { + block["title"] = json!(title); + } + if let Some(context) = options + .and_then(|o| o.get("context")) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + block["context"] = json!(context); + } + if options + .and_then(|o| o.get("citations")) + .and_then(|c| c.get("enabled")) + .and_then(Value::as_bool) + == Some(true) + { + block["citations"] = json!({ "enabled": true }); + } + block + } + }; + let cc = resolve_cache_control( + validator, + provider_options.as_ref(), + message_options, + is_last_part, + part_context, + message_context, + ); + Ok(apply_cc(block, cc)) +} + +fn convert_tool_result( + part: &ToolResultPart, + validator: &mut CacheControlValidator, + betas: &mut BTreeSet, + warnings: &mut Vec, + is_last_part: bool, + message_provider_options: Option<&SharedProviderOptions>, +) -> Result { + let ToolResultPart { + tool_call_id, + output, + provider_options, + .. + } = part; + + if let ToolResultOutput::Content { value } = output { + for item in value { + if let ToolResultContent::File(file) = item + && let FileData::Data { data } = &file.data + { + use base64::Engine; + let bytes = match data { + FileBytes::Binary(bytes) => bytes.clone(), + FileBytes::Base64(data) => base64::engine::general_purpose::STANDARD + .decode(data) + .unwrap_or_default(), + }; + resolve_full_media_type(&file.media_type, &bytes)?; + } + } + } + let (content, is_error) = resolve_tool_result_output(output, betas, warnings); + let mut block = json!({ + "type": "tool_result", + "tool_use_id": tool_call_id, + "content": content, + }); + if is_error { + block["is_error"] = json!(true); + } + // cache_control: part ?? output ?? (is_last_part ? message). + let cc = match validator.get_cache_control( + provider_options.as_ref().and_then(|o| o.get("anthropic")), + "tool result part", + true, + ) { + Some(v) => Some(v), + None => match extract_tool_result_output_provider_options(output) { + Some(out_opts) => { + validator.get_cache_control(out_opts.get("anthropic"), "tool result output", true) + } + None => { + if is_last_part { + validator.get_cache_control( + message_provider_options.and_then(|o| o.get("anthropic")), + "tool result message", + true, + ) + } else { + None + } + } + }, + }; + Ok(apply_cc(block, cc)) +} + +/// Convert an assistant part, preserving reasoning and provider-executed tool results. +/// Part-level cache control falls back to message options on the final part. +#[allow(clippy::too_many_arguments)] +fn convert_assistant_part( + part: &AssistantPart, + send_reasoning: bool, + tool_names: &ToolNameMapping, + mcp_tool_use_ids: &HashSet<&str>, + betas: &mut BTreeSet, + warnings: &mut Vec, + validator: &mut CacheControlValidator, + is_last_part: bool, + message_provider_options: Option<&SharedProviderOptions>, +) -> Result, AiMuxError> { + let resolve_cc = |validator: &mut CacheControlValidator, part_opts| { + resolve_cache_control( + validator, + part_opts, + message_provider_options, + is_last_part, + "assistant message part", + "assistant message", + ) + }; + + Ok(Some(match part { + AssistantPart::Text(TextPart { + text, + provider_options, + }) => { + let cc = resolve_cc(validator, provider_options.as_ref()); + apply_cc(json!({ "type": "text", "text": text }), cc) } - ContentPart::Reasoning { + AssistantPart::File(file) => convert_file_part( + file, + betas, + validator, + message_provider_options, + is_last_part, + "assistant message part", + "assistant message", + )?, + + AssistantPart::Reasoning(ReasoningPart { text, - signature, provider_options, - } => { + }) => { return Ok(convert_reasoning_part( text, - signature.as_deref(), provider_options.as_ref(), send_reasoning, warnings, @@ -504,14 +570,14 @@ fn convert_part_to_anthropic( )); } - ContentPart::ToolCall { + AssistantPart::ToolCall(ToolCallPart { tool_call_id, tool_name, input, provider_executed, provider_options, .. - } => { + }) => { let cc = resolve_cc(validator, provider_options.as_ref()); if *provider_executed == Some(true) { @@ -628,46 +694,17 @@ fn convert_part_to_anthropic( apply_cc(block, cc) } - ContentPart::ToolResult { - tool_call_id, - result, - provider_options, - .. - } => { - let (content, is_error) = resolve_tool_result_output(result); - let mut block = json!({ - "type": "tool_result", - "tool_use_id": tool_call_id, - "content": content, - }); - if is_error { - block["is_error"] = json!(true); - } - // cache_control: part ?? output ?? (is_last_part ? message). - let cc = match validator.get_cache_control( - provider_options.as_ref(), - part_context_type, - true, - ) { - Some(v) => Some(v), - None => match extract_tool_result_output_provider_options(result) { - Some(out_opts) => { - validator.get_cache_control(Some(out_opts), "tool result output", true) - } - None => { - if is_last_part { - validator.get_cache_control( - message_provider_options, - message_context_type, - true, - ) - } else { - None - } - } - }, - }; - apply_cc(block, cc) + AssistantPart::ReasoningFile(_) | AssistantPart::Custom(_) => return Ok(None), + AssistantPart::ToolResult(part) => { + return Ok(convert_assistant_tool_result( + part, + tool_names, + mcp_tool_use_ids, + warnings, + validator, + is_last_part, + message_provider_options, + )); } })) } @@ -683,15 +720,15 @@ fn convert_part_to_anthropic( fn collect_mcp_tool_use_ids(prompt: &LanguageModelPrompt) -> HashSet<&str> { let mut ids = HashSet::new(); for msg in prompt { - if msg.role != Role::Assistant { + let LanguageModelMessage::Assistant { content, .. } = msg else { continue; - } - for part in &msg.content { - if let ContentPart::ToolCall { + }; + for part in content { + if let AssistantPart::ToolCall(ToolCallPart { tool_call_id, provider_options: Some(opts), .. - } = part + }) = part && opts .get("anthropic") .and_then(|a| a.get("type")) @@ -724,7 +761,7 @@ fn result_error_code(value: &Value, fallback: &str) -> String { .to_string() } -/// Convert an assistant-role `ContentPart::ToolResult` into the matching +/// Convert an assistant-role `ToolResultPart` into the matching /// Anthropic provider-executed result block. /// /// Anthropic only accepts a bare `tool_result` block inside a **user** message; @@ -736,41 +773,41 @@ fn result_error_code(value: &Value, fallback: &str) -> String { /// /// Returns `None` when the part is skipped. fn convert_assistant_tool_result( - part: &ContentPart, + part: &ToolResultPart, tool_names: &ToolNameMapping, mcp_tool_use_ids: &HashSet<&str>, warnings: &mut Vec, validator: &mut CacheControlValidator, is_last_part: bool, - message_provider_options: Option<&Value>, + message_provider_options: Option<&SharedProviderOptions>, ) -> Option { - let ContentPart::ToolResult { + let ToolResultPart { tool_call_id, tool_name, - result, - is_error, + output, provider_options, .. - } = part - else { - return None; - }; + } = part; // cache_control: part ?? output ?? (is_last_part ? message) — the same // resolution order the bare `tool_result` path uses. let cache_control = match validator.get_cache_control( - provider_options.as_ref(), + provider_options.as_ref().and_then(|o| o.get("anthropic")), "assistant message part", true, ) { Some(v) => Some(v), - None => match extract_tool_result_output_provider_options(result) { + None => match extract_tool_result_output_provider_options(output) { Some(out_opts) => { - validator.get_cache_control(Some(out_opts), "tool result output", true) + validator.get_cache_control(out_opts.get("anthropic"), "tool result output", true) } None => { if is_last_part { - validator.get_cache_control(message_provider_options, "assistant message", true) + validator.get_cache_control( + message_provider_options.and_then(|o| o.get("anthropic")), + "assistant message", + true, + ) } else { None } @@ -778,14 +815,50 @@ fn convert_assistant_tool_result( }, }; - // The payload is carried bare, with the error flag alongside it. Upstream - // reads both off a `{ type, value }` envelope instead; reconstructing that - // envelope here would mean guessing, and a legitimate payload that happens - // to look like one (`{"type":"error","value":...}`) would be unwrapped and - // have its `is_error` overwritten. The envelope belongs on the type. - let value = result; - let is_error = is_error.unwrap_or(false); - let tool_name = tool_name.as_deref().unwrap_or_default(); + let raw_value = match output { + ToolResultOutput::Json { value, .. } | ToolResultOutput::ErrorJson { value, .. } => { + value.clone() + } + ToolResultOutput::Text { value, .. } | ToolResultOutput::ErrorText { value, .. } => { + json!(value) + } + ToolResultOutput::ExecutionDenied { reason, .. } => { + json!(reason.as_deref().unwrap_or("Tool call execution denied.")) + } + ToolResultOutput::Content { value } => json!(value), + }; + let provider_tool_name = tool_names.to_provider_tool_name(tool_name); + let allowed = match output { + ToolResultOutput::Json { .. } => true, + ToolResultOutput::ErrorJson { .. } => { + mcp_tool_use_ids.contains(tool_call_id.as_str()) + || matches!( + provider_tool_name, + "code_execution" | "web_fetch" | "web_search" | "advisor" + ) + } + ToolResultOutput::ErrorText { .. } => { + provider_tool_name == "code_execution" + && !mcp_tool_use_ids.contains(tool_call_id.as_str()) + } + _ => false, + }; + if !allowed { + warnings.push(Warning::Other { + message: format!( + "provider executed tool result output for tool {tool_name} is not supported" + ), + }); + return None; + } + let value = &raw_value; + let is_error = matches!( + output, + ToolResultOutput::ErrorText { .. } + | ToolResultOutput::ErrorJson { .. } + | ToolResultOutput::ExecutionDenied { .. } + ); + let tool_name = tool_name.as_str(); let payload_type = value.get("type").and_then(|t| t.as_str()); let mut block = if mcp_tool_use_ids.contains(tool_call_id.as_str()) { @@ -797,6 +870,23 @@ fn convert_assistant_tool_result( }) } else { let (block_type, content) = match tool_names.to_provider_tool_name(tool_name) { + "code_execution" if is_error => { + let error = if let Value::String(raw) = value { + serde_json::from_str(raw).unwrap_or(json!({})) + } else { + value.clone() + }; + let code_error = error.get("type").and_then(Value::as_str) + == Some("code_execution_tool_result_error"); + ( + if code_error { + "code_execution_tool_result" + } else { + "bash_code_execution_tool_result" + }, + json!({ "type": if code_error { "code_execution_tool_result_error" } else { "bash_code_execution_tool_result_error" }, "error_code": error.get("errorCode").and_then(Value::as_str).unwrap_or("unknown") }), + ) + } "code_execution" => match assistant_code_execution_content(value, payload_type) { Some(v) => v, None => { @@ -1016,21 +1106,31 @@ fn assistant_advisor_content(value: &Value, payload_type: Option<&str>) -> Value /// returned (e.g. `{ type: 'text', value, providerOptions }`). /// - When the output is a `content` output whose `value` is an array of content /// parts, the `providerOptions` of the first part that has one is returned. -fn extract_tool_result_output_provider_options(output: &Value) -> Option<&Value> { - if let Some(opts) = output.get("providerOptions") { - return Some(opts); - } - let output_type = output.get("type").and_then(|t| t.as_str()); - if output_type == Some("content") - && let Some(value) = output.get("value").and_then(|v| v.as_array()) - { - for part in value { - if let Some(opts) = part.get("providerOptions") { - return Some(opts); - } +fn extract_tool_result_output_provider_options( + output: &ToolResultOutput, +) -> Option<&SharedProviderOptions> { + match output { + ToolResultOutput::Text { + provider_options, .. } + | ToolResultOutput::Json { + provider_options, .. + } + | ToolResultOutput::ErrorText { + provider_options, .. + } + | ToolResultOutput::ErrorJson { + provider_options, .. + } + | ToolResultOutput::ExecutionDenied { + provider_options, .. + } => provider_options.as_ref(), + ToolResultOutput::Content { value } => value.iter().find_map(|part| match part { + ToolResultContent::Text(part) => part.provider_options.as_ref(), + ToolResultContent::File(part) => part.provider_options.as_ref(), + ToolResultContent::Custom { provider_options } => provider_options.as_ref(), + }), } - None } /// Route an inline-bytes file part. `full_media_type` is the resolved @@ -1202,7 +1302,10 @@ fn detect_document_media_type(bytes: &[u8]) -> Option<&'static str> { /// Resolve a file part's media type to a full `type/subtype` form, mirroring /// the TS `resolveFullMediaType`. When the media type is already a full type it /// is returned as-is; otherwise the subtype is sniffed from the inline bytes. -fn resolve_full_media_type(media_type: &str, bytes: &[u8]) -> Result { +pub(crate) fn resolve_full_media_type( + media_type: &str, + bytes: &[u8], +) -> Result { if is_full_media_type(media_type) { return Ok(media_type.to_string()); } @@ -1224,8 +1327,7 @@ fn resolve_full_media_type(media_type: &str, bytes: &[u8]) -> Result, - provider_options: Option<&Value>, + provider_options: Option<&SharedProviderOptions>, send_reasoning: bool, warnings: &mut Vec, validator: &mut CacheControlValidator, @@ -1244,20 +1346,20 @@ fn convert_reasoning_part( .and_then(|a| a.get("redactedData")) .and_then(|v| v.as_str()); - // #6: Fall back to `providerOptions.anthropic.signature` when the - // `signature` field is None (upstream convert-to-anthropic-prompt.ts:669-690). - let effective_signature = signature.or_else(|| { - provider_options - .and_then(|o| o.get("anthropic")) - .and_then(|a| a.get("signature")) - .and_then(|v| v.as_str()) - }); + let effective_signature = provider_options + .and_then(|o| o.get("anthropic")) + .and_then(|a| a.get("signature")) + .and_then(Value::as_str); if let Some(sig) = effective_signature { // Thinking blocks cannot carry cache_control directly — they are cached // implicitly when in previous assistant turns. Validate to emit a // helpful warning if a value was set. - validator.get_cache_control(provider_options, "thinking block", false); + validator.get_cache_control( + provider_options.and_then(|o| o.get("anthropic")), + "thinking block", + false, + ); Some(json!({ "type": "thinking", "thinking": text, @@ -1265,7 +1367,11 @@ fn convert_reasoning_part( })) } else if let Some(data) = redacted_data { // Redacted thinking blocks likewise cannot carry cache_control. - validator.get_cache_control(provider_options, "redacted thinking block", false); + validator.get_cache_control( + provider_options.and_then(|o| o.get("anthropic")), + "redacted thinking block", + false, + ); Some(json!({ "type": "redacted_thinking", "data": data, @@ -1279,23 +1385,22 @@ fn convert_reasoning_part( } /// Resolve the Anthropic file id from a provider-reference object, mirroring the -/// TS `resolveProviderReference`. Panics when no `anthropic` key is present, -/// matching the TS `UnsupportedFunctionalityError`. -fn resolve_anthropic_reference(reference: &Value) -> Result { - if let Some(id) = reference.get("anthropic").and_then(|v| v.as_str()) { +/// TS `resolveProviderReference`. Returns an error when the key is missing. +fn resolve_anthropic_reference( + reference: &std::collections::HashMap, +) -> Result { + if let Some(id) = reference.get("anthropic") { return Ok(id.to_string()); } - let providers: Vec<&str> = reference - .as_object() - .map(|o| o.keys().map(String::as_str).collect()) - .unwrap_or_default(); + let mut providers: Vec<&str> = reference.keys().map(String::as_str).collect(); + providers.sort_unstable(); Err(AiMuxError::InvalidArgument(format!( "No provider reference found for provider 'anthropic'. Available providers: {}", providers.join(", ") ))) } -/// Resolve a `ContentPart::ToolResult` `output` value into the Anthropic +/// Resolve a `ToolResultPart` `output` value into the Anthropic /// `tool_result.content` (and whether it is an error), mirroring the TS SDK. /// /// The V4 `tool-result` `output` is a discriminated `{ type, value }` object: @@ -1304,20 +1409,45 @@ fn resolve_anthropic_reference(reference: &Value) -> Result /// - `error` → `value` as-is with `is_error: true`, /// - `content` → `value` as-is (an array of content blocks), /// - anything else → the `output` is passed through unchanged. -fn resolve_tool_result_output(output: &Value) -> (Value, bool) { - if let (Some(t), Some(v)) = ( - output.get("type").and_then(|x| x.as_str()), - output.get("value"), - ) { - match t { - "json" => (Value::String(v.to_string()), false), - "text" => (v.clone(), false), - "error" => (v.clone(), true), - "content" => (v.clone(), false), - _ => (output.clone(), false), - } - } else { - (output.clone(), false) +fn resolve_tool_result_output( + output: &ToolResultOutput, + betas: &mut BTreeSet, + warnings: &mut Vec, +) -> (Value, bool) { + match output { + ToolResultOutput::Text { value, .. } => (json!(value), false), + ToolResultOutput::ErrorText { value, .. } => (json!(value), true), + ToolResultOutput::Json { value, .. } => (json!(value.to_string()), false), + ToolResultOutput::ErrorJson { value, .. } => (json!(value.to_string()), true), + ToolResultOutput::ExecutionDenied { reason, .. } => (json!(reason.as_deref().unwrap_or("Tool call execution denied.")), false), + ToolResultOutput::Content { value } => (json!(value.iter().filter_map(|part| match part { + ToolResultContent::Text(part) => Some(json!({ "type": "text", "text": part.text })), + ToolResultContent::File(part) => { + use base64::Engine; + let top_level = part.media_type.split('/').next(); + let source = match &part.data { + FileData::Url { url, .. } => json!({ "type": "url", "url": url }), + FileData::Data { data } => { + let (data, bytes) = match data { + FileBytes::Binary(bytes) => (base64::engine::general_purpose::STANDARD.encode(bytes), bytes.clone()), + FileBytes::Base64(data) => (data.clone(), base64::engine::general_purpose::STANDARD.decode(data).unwrap_or_default()), + }; + let full = resolve_full_media_type(&part.media_type, &bytes).ok()?; + if top_level != Some("image") && full != "application/pdf" { warnings.push(Warning::Other { message: "unsupported tool content file media type".to_string() }); return None; } + if full == "application/pdf" { betas.insert("pdfs-2024-09-25".to_string()); } + json!({ "type": "base64", "media_type": full, "data": data }) + }, + _ => { warnings.push(Warning::Other { message: "unsupported tool content file part".to_string() }); return None; }, + }; + Some(json!({ "type": if top_level == Some("image") { "image" } else { "document" }, "source": source })) + }, + ToolResultContent::Custom { provider_options } => { + if let Some(options) = provider_options.as_ref().and_then(|options| options.get("anthropic")) + && options.get("type").and_then(Value::as_str) == Some("tool-reference") { + Some(json!({ "type": "tool_reference", "tool_name": options.get("toolName") })) + } else { warnings.push(Warning::Other { message: "unsupported custom tool content part".to_string() }); None } + }, + }).collect::>()), false), } } @@ -1566,8 +1696,225 @@ pub struct RequestBodyResult { pub betas: BTreeSet, } +fn parse_anthropic_option_object(shape: &str, value: &Value) -> Result { + let object = value.as_object().ok_or(())?; + let parsed = crate::openai::convert::parse_option_fields(object, "anthropic", |key, value| { + let kind = match (shape, key) { + ("options", "sendReasoning" | "disableParallelToolUse" | "toolStreaming") => "bool", + ("options", "structuredOutputMode") => "outputFormat|jsonTool|auto", + ("options", "thinking") => "thinking", + ("options", "cacheControl") => "cache", + ("options", "metadata") => "metadata", + ("options", "mcpServers") => "[]mcp", + ("options", "container") => "container", + ("options", "effort") => "low|medium|high|xhigh|max", + ("options", "taskBudget") => "taskBudget", + ("options", "speed") | ("fallback", "speed") => "fast|standard", + ("options", "serviceTier") => "auto|standard_only", + ("options", "inferenceGeo") => "us|global", + ("options", "fallbacks") => "fallbacks", + ("options", "anthropicBeta") => "[]string", + ("options", "safeguards") => "[]safeguard", + ("options", "compaction") => "compaction", + ("options", "contextManagement") => "context", + ("thinking", "type") => "adaptive|enabled|disabled|between_tools", + ("thinking", "display") + if object.get("type").and_then(Value::as_str) == Some("adaptive") => + { + "omitted|summarized|updates" + } + ("thinking", "budgetTokens") + if object.get("type").and_then(Value::as_str) == Some("enabled") => + { + "number" + } + ("thinking", "blockBinding") + if object + .get("type") + .is_none_or(|t| t.as_str() == Some("adaptive")) => + { + "binding" + } + ("binding", "prefixMismatchBehavior") => "error|drop_block", + ("cache", "type") => "ephemeral", + ("cache", "ttl") => "5m|1h", + ("metadata", "userId") => "string", + ("mcp", "type") => "url", + ("mcp", "name" | "url") => "string", + ("mcp", "authorizationToken") => "?string", + ("mcp", "toolConfiguration") => "?toolConfiguration", + ("toolConfiguration", "enabled") => "?bool", + ("toolConfiguration", "allowedTools") => "?[]string", + ("container", "id") | ("skill", "version") => "string", + ("container", "skills") => "[]skill", + ("skill", "type") => "anthropic|custom", + ("skill", "skillId") + if object.get("type").and_then(Value::as_str) == Some("anthropic") => + { + "string" + } + ("skill", "providerReference") + if object.get("type").and_then(Value::as_str) == Some("custom") => + { + "stringRecord" + } + ("taskBudget", "type") => "tokens", + ("taskBudget", "total") => "total", + ("taskBudget", "remaining") => "remaining", + ("fallback", "model") => "string", + ("fallback", "max_tokens") => "integer", + ("fallback", "thinking" | "output_config") | ("safeguard", "classifierContext") => { + "record" + } + ("safeguard", "type") => "dangerous_tool_use", + ("compaction", "type") => "summarize", + ("compaction", "instructions") => "string", + ("edit", "instructions") + if object.get("type").and_then(Value::as_str) == Some("compact_20260112") => + { + "string" + } + ("context", "edits") => "[]edit", + ("edit", "type") => "clear_tool_uses_20250919|clear_thinking_20251015|compact_20260112", + ("edit", "trigger") + if object.get("type").and_then(Value::as_str) + == Some("clear_tool_uses_20250919") => + { + "trigger" + } + ("edit", "trigger") + if object.get("type").and_then(Value::as_str) == Some("compact_20260112") => + { + "inputTokens" + } + ("edit", "clearAtLeast") + if object.get("type").and_then(Value::as_str) + == Some("clear_tool_uses_20250919") => + { + "inputTokens" + } + ("edit", "keep") + if object.get("type").and_then(Value::as_str) + == Some("clear_tool_uses_20250919") => + { + "toolUses" + } + ("edit", "keep") + if object.get("type").and_then(Value::as_str) + == Some("clear_thinking_20251015") => + { + "keepThinking" + } + ("edit", "clearToolInputs") + if object.get("type").and_then(Value::as_str) + == Some("clear_tool_uses_20250919") => + { + "bool" + } + ("edit", "excludeTools") + if object.get("type").and_then(Value::as_str) + == Some("clear_tool_uses_20250919") => + { + "[]string" + } + ("edit", "pauseAfterCompaction") + if object.get("type").and_then(Value::as_str) == Some("compact_20260112") => + { + "bool" + } + ("trigger", "type") => "input_tokens|tool_uses", + ("inputTokens", "type") => "input_tokens", + ("toolUses", "type") => "tool_uses", + ("thinkingTurns", "type") => "thinking_turns", + ("trigger" | "inputTokens" | "toolUses" | "thinkingTurns", "value") => "number", + _ => return None, + }; + Some(parse_anthropic_option_value(kind, value)) + }) + .map_err(|_| ())?; + let required: &[&str] = match shape { + "thinking" if !object.contains_key("type") => &["blockBinding"], + "thinking" | "cache" | "compaction" | "safeguard" | "edit" => &["type"], + "binding" => &["prefixMismatchBehavior"], + "mcp" => &["type", "name", "url"], + "skill" if object.get("type").and_then(Value::as_str) == Some("custom") => { + &["type", "providerReference"] + } + "skill" => &["type", "skillId"], + "taskBudget" => &["type", "total"], + "fallback" => &["model"], + "context" => &["edits"], + "trigger" | "inputTokens" | "toolUses" | "thinkingTurns" => &["type", "value"], + _ => &[], + }; + if required.iter().any(|key| !parsed.contains_key(*key)) { + return Err(()); + } + Ok(Value::Object(parsed)) +} + +fn parse_anthropic_option_value(kind: &str, value: &Value) -> Result { + if let Some(kind) = kind.strip_prefix('?') { + return if value.is_null() { + Ok(Value::Null) + } else { + parse_anthropic_option_value(kind, value) + }; + } + if let Some(kind) = kind.strip_prefix("[]") { + return value + .as_array() + .ok_or(())? + .iter() + .map(|v| parse_anthropic_option_value(kind, v)) + .collect::, _>>() + .map(Value::Array); + } + let valid = match kind { + "bool" => value.is_boolean(), + "string" => value.is_string(), + "number" => value.is_number(), + "integer" => value + .as_f64() + .is_some_and(|n| n.fract() == 0.0 && n.abs() <= 9_007_199_254_740_991.0), + "total" => value + .as_f64() + .is_some_and(|n| n.fract() == 0.0 && (20000.0..=9_007_199_254_740_991.0).contains(&n)), + "remaining" => value + .as_f64() + .is_some_and(|n| n.fract() == 0.0 && (0.0..=9_007_199_254_740_991.0).contains(&n)), + "record" => value.is_object(), + "stringRecord" => value + .as_object() + .is_some_and(|o| o.values().all(Value::is_string)), + "fallbacks" => { + return if value.as_str() == Some("default") { + Ok(value.clone()) + } else { + parse_anthropic_option_value("[]fallback", value) + }; + } + "keepThinking" => { + return if value.as_str() == Some("all") { + Ok(value.clone()) + } else { + parse_anthropic_option_object("thinkingTurns", value) + }; + } + "thinking" | "binding" | "cache" | "metadata" | "mcp" | "toolConfiguration" + | "container" | "skill" | "taskBudget" | "fallback" | "safeguard" | "compaction" + | "context" | "edit" | "trigger" | "inputTokens" | "toolUses" | "thinkingTurns" => { + return parse_anthropic_option_object(kind, value); + } + _ => value + .as_str() + .is_some_and(|s| kind.split('|').any(|allowed| allowed == s)), + }; + if valid { Ok(value.clone()) } else { Err(()) } +} + /// Read a value from `provider_options["anthropic"][key]`. -fn anthropic_option(options: &Option>, key: &str) -> Option { +fn anthropic_option(options: &Option, key: &str) -> Option { options .as_ref() .and_then(|m| m.get("anthropic")) @@ -1977,7 +2324,10 @@ fn append_anthropic_mcp_servers( if let Some(at) = server.get("authorizationToken") { o.insert("authorization_token".to_string(), at.clone()); } - if let Some(tc) = server.get("toolConfiguration") { + if let Some(tc) = server + .get("toolConfiguration") + .filter(|value| !value.is_null()) + { let mut tc_obj = Map::new(); if let Some(at) = tc.get("allowedTools") { tc_obj.insert("allowed_tools".to_string(), at.clone()); @@ -2095,6 +2445,17 @@ pub fn build_request_body_with_warnings( options: &CallOptions, stream: bool, ) -> Result { + let mut parsed_options = options.clone(); + if let Some(namespaces) = parsed_options.provider_options.as_mut() + && let Some(raw) = namespaces.get("anthropic") + { + let parsed = parse_anthropic_option_object("options", &Value::Object(raw.clone())) + .map_err(|()| { + AiMuxError::InvalidArgument("invalid anthropic provider options".to_string()) + })?; + namespaces.insert("anthropic".to_string(), parsed.as_object().unwrap().clone()); + } + let options = &parsed_options; let mut warnings: Vec = Vec::new(); let mut betas: BTreeSet = BTreeSet::new(); let caps = get_model_capabilities(model_id); @@ -2220,44 +2581,3 @@ pub fn parse_stop_reason(s: &str) -> FinishReason { raw: Some(s.to_string()), } } - -#[cfg(test)] -mod tests { - use super::*; - - fn opts_with_anthropic_thinking(thinking: serde_json::Value) -> CallOptions { - let mut provider = std::collections::HashMap::new(); - provider.insert("anthropic".to_string(), thinking); - CallOptions { - provider_options: Some(provider), - ..CallOptions::new(LanguageModelPrompt::default()) - } - } - - /// Regression (audit finding on the M11 split): when `thinking.enabled` is - /// set without an explicit `budgetTokens`, the body must carry both the - /// default `budget_tokens: 1024` **and** the adjusted `max_tokens` - /// (base + 1024), exactly like the pre-split implementation. - #[test] - fn thinking_enabled_without_budget_writes_default_budget() { - let opts = opts_with_anthropic_thinking(json!({ - "thinking": { "type": "enabled" } - })); - let req = build_request_body_with_warnings("claude-sonnet-4-5", &opts, false).unwrap(); - assert_eq!(req.body["thinking"]["type"], json!("enabled")); - assert_eq!(req.body["thinking"]["budget_tokens"], json!(1024)); - // claude-sonnet-4-5 caps: 64000 tokens, +1024 thinking budget. - assert_eq!(req.body["max_tokens"], json!(65024)); - } - - /// Explicit `budgetTokens` must be preserved (no default override). - #[test] - fn thinking_enabled_with_explicit_budget_keeps_it() { - let opts = opts_with_anthropic_thinking(json!({ - "thinking": { "type": "enabled", "budgetTokens": 4096 } - })); - let req = build_request_body_with_warnings("claude-sonnet-4-5", &opts, false).unwrap(); - assert_eq!(req.body["thinking"]["budget_tokens"], json!(4096)); - assert_eq!(req.body["max_tokens"], json!(64000 + 4096)); - } -} diff --git a/aimux-providers/src/anthropic/files.rs b/aimux-providers/src/anthropic/files.rs index 2f08101b1..4bc7b46cc 100644 --- a/aimux-providers/src/anthropic/files.rs +++ b/aimux-providers/src/anthropic/files.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use async_trait::async_trait; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::json; use aimux_core::error::AiMuxError; use aimux_core::files_model::{Files, UploadFileCallOptions, UploadFileData, UploadFileResult}; @@ -184,9 +184,7 @@ impl Files for AnthropicFiles { provider_reference: provider_ref, media_type: Some(result_media_type), filename: result_filename, - provider_metadata: Some( - std::iter::once(("anthropic".to_string(), Value::Object(metadata))).collect(), - ), + provider_metadata: Some(HashMap::from([("anthropic".to_string(), metadata)])), warnings: Vec::new(), }) } diff --git a/aimux-providers/src/anthropic/mod.rs b/aimux-providers/src/anthropic/mod.rs index 5f2c5e39c..404c74750 100644 --- a/aimux-providers/src/anthropic/mod.rs +++ b/aimux-providers/src/anthropic/mod.rs @@ -12,6 +12,7 @@ pub mod types; pub mod usage; use std::collections::HashMap; +use std::sync::Arc; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; @@ -38,7 +39,7 @@ pub(crate) fn anthropic_failed_response_handler() const ANTHROPIC_API_URL: &str = "https://api.anthropic.com"; /// Configuration for the Anthropic provider. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct AnthropicConfig { /// API key sent via the `x-api-key` header. Empty when authenticating with /// an `auth_token` instead. @@ -64,6 +65,25 @@ pub struct AnthropicConfig { /// (config_snapshot 记为 "explicit");`Some("env:VAR")` = 来自环境变量; /// `Some("none")` = 本地/匿名占位。不存明文之外的信息。 pub api_key_source: Option, + /// Generator for source IDs. Defaults to a 16-character alphanumeric ID. + pub generate_id: Option String + Send + Sync>>, +} + +impl std::fmt::Debug for AnthropicConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AnthropicConfig") + .field("api_key", &"") + .field("auth_token", &self.auth_token.is_some()) + .field("base_url", &self.base_url) + .field("api_version", &self.api_version) + .field("name", &self.name) + .field("headers", &self.headers) + .field("retry_config", &self.retry_config) + .field("body_overrides", &self.body_overrides) + .field("api_key_source", &self.api_key_source) + .field("generate_id", &self.generate_id.is_some()) + .finish() + } } impl AnthropicConfig { @@ -80,9 +100,20 @@ impl AnthropicConfig { retry_config: RetryConfig::default(), body_overrides: None, api_key_source: None, + generate_id: None, } } + /// Set the generator used for source IDs. + #[must_use] + pub fn with_generate_id(mut self, generate_id: F) -> Self + where + F: Fn() -> String + Send + Sync + 'static, + { + self.generate_id = Some(Arc::new(generate_id)); + self + } + /// Start a builder for more involved configurations (e.g. `auth_token`, /// custom `name`, extra `headers`). The builder validates that `api_key` /// and `auth_token` are not both provided. @@ -166,7 +197,7 @@ impl AnthropicConfig { /// Builder for [`AnthropicConfig`] supporting both `api_key` and `auth_token` /// authentication, with conflict validation mirroring the TS `createAnthropic`. -#[derive(Debug, Default, Clone)] +#[derive(Default, Clone)] pub struct AnthropicConfigBuilder { api_key: Option, auth_token: Option, @@ -174,6 +205,21 @@ pub struct AnthropicConfigBuilder { name: Option, headers: Option>, body_overrides: Option, + generate_id: Option String + Send + Sync>>, +} + +impl std::fmt::Debug for AnthropicConfigBuilder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AnthropicConfigBuilder") + .field("api_key", &self.api_key.is_some()) + .field("auth_token", &self.auth_token.is_some()) + .field("base_url", &self.base_url) + .field("name", &self.name) + .field("headers", &self.headers) + .field("body_overrides", &self.body_overrides) + .field("generate_id", &self.generate_id.is_some()) + .finish() + } } impl AnthropicConfigBuilder { @@ -213,6 +259,15 @@ impl AnthropicConfigBuilder { self } + #[must_use] + pub fn generate_id(mut self, generate_id: F) -> Self + where + F: Fn() -> String + Send + Sync + 'static, + { + self.generate_id = Some(Arc::new(generate_id)); + self + } + /// Build the config, validating that `api_key` and `auth_token` are not /// both set and that a provided `base_url` is non-empty. /// @@ -243,6 +298,7 @@ impl AnthropicConfigBuilder { retry_config: RetryConfig::default(), body_overrides: self.body_overrides, api_key_source: None, + generate_id: self.generate_id, }) } } diff --git a/aimux-providers/src/anthropic/model.rs b/aimux-providers/src/anthropic/model.rs index f09e83560..ef9155db7 100644 --- a/aimux-providers/src/anthropic/model.rs +++ b/aimux-providers/src/anthropic/model.rs @@ -22,12 +22,21 @@ use super::tool_name_mapping::ToolNameMapping; pub struct AnthropicModel { model_id: String, config: AnthropicConfig, + generate_id: std::sync::Arc String + Send + Sync>, } impl AnthropicModel { #[must_use] pub fn new(model_id: String, config: AnthropicConfig) -> Self { - Self { model_id, config } + let generate_id = config + .generate_id + .clone() + .unwrap_or_else(|| std::sync::Arc::new(super::stream::generate_id)); + Self { + model_id, + config, + generate_id, + } } fn endpoint(&self) -> String { @@ -133,6 +142,7 @@ impl LanguageModel for AnthropicModel { options.abort_signal.clone(), options.recording_context.clone(), &ToolNameMapping::new(options.tools.as_deref()), + self.generate_id.as_ref(), ) .await } @@ -151,6 +161,7 @@ impl LanguageModel for AnthropicModel { options.abort_signal.clone(), options.recording_context.clone(), ToolNameMapping::new(options.tools.as_deref()), + self.generate_id.clone(), ) .await } diff --git a/aimux-providers/src/anthropic/stream.rs b/aimux-providers/src/anthropic/stream.rs index 4f5e8dc71..92604e3dd 100644 --- a/aimux-providers/src/anthropic/stream.rs +++ b/aimux-providers/src/anthropic/stream.rs @@ -18,16 +18,18 @@ use aimux_core::tool::RawToolCall; use aimux_core::tool::ToolResult; use std::collections::HashMap; +use std::hash::{BuildHasher, RandomState}; use std::sync::atomic::{AtomicU64, Ordering}; use futures::StreamExt; use aimux_core::AbortSignal; use aimux_core::error::AiMuxError; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source, StreamResult}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; -use aimux_core::types::Warning; -use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, TokenUsage, Usage}; +use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; +use aimux_core::types::{ProviderMetadata, Warning}; use aimux_provider_utils::{HttpBody, HttpRequest}; use serde_json::{Value, json}; @@ -125,16 +127,19 @@ fn str_field(v: &Value, key: &str) -> Option { v.get(key).and_then(|x| x.as_str()).map(str::to_string) } -/// Process-wide counter backing [`generate_source_id`]. -static SOURCE_ID_COUNTER: AtomicU64 = AtomicU64::new(0); - -/// Unique id for a `Source` derived from a web-search result. Upstream calls -/// `this.generateId()` at the same points. -fn generate_source_id() -> String { - format!( - "anthropic-source-{}", - SOURCE_ID_COUNTER.fetch_add(1, Ordering::Relaxed) - ) +/// Generate a non-cryptographic, 16-character alphanumeric ID, like `generateId`. +#[must_use] +pub(crate) fn generate_id() -> String { + const ALPHABET: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let counter = COUNTER.fetch_add(1, Ordering::Relaxed); + let random = RandomState::new(); + (0..16) + .map(|index| { + let value = random.hash_one((counter, index)); + char::from(ALPHABET[(value % ALPHABET.len() as u64) as usize]) + }) + .collect() } /// `web_search_result` → the camel-cased shape upstream exposes (:1243-1249). @@ -347,7 +352,9 @@ pub(crate) fn initial_tool_input(input: &Value) -> String { } } -pub(crate) fn tool_call_caller_metadata(caller: Option<&ToolCallCaller>) -> Option { +pub(crate) fn tool_call_caller_metadata( + caller: Option<&ToolCallCaller>, +) -> Option { let caller = match caller? { ToolCallCaller::CodeExecution20250825 { tool_id } => json!({ "type": "code_execution_20250825", @@ -359,7 +366,10 @@ pub(crate) fn tool_call_caller_metadata(caller: Option<&ToolCallCaller>) -> Opti }), ToolCallCaller::Direct => json!({ "type": "direct" }), }; - Some(json!({ "anthropic": { "caller": caller } })) + Some( + provider_namespace("anthropic", json!({ "caller": caller })) + .expect("provider metadata must be an object"), + ) } fn is_tool_search_provider_name(name: &str) -> bool { @@ -398,6 +408,7 @@ pub(crate) fn stream_parts_for_result_block( names: &ToolNameMapping, mcp_tool_calls: &HashMap, server_tool_calls: &HashMap, + generate_id: &(dyn Fn() -> String + Send + Sync), ) -> Vec { let tool_result = |tool_name: String, (result, is_error): (Value, Option), tool_use_id: &str| { @@ -442,16 +453,21 @@ pub(crate) fn stream_parts_for_result_block( ), tool_use_id, )]; - parts.extend(results.iter().map(|result| StreamPart::Source { - id: generate_source_id(), - source_type: "url".to_string(), - url: str_field(result, "url"), - title: str_field(result, "title"), - provider_metadata: Some(json!({ - "anthropic": { - "pageAge": result.get("page_age").cloned().unwrap_or(Value::Null), - } - })), + parts.extend(results.iter().map(|result| { + StreamPart::Source(Source::Url { + id: generate_id(), + url: str_field(result, "url").unwrap_or_default(), + title: str_field(result, "title"), + provider_metadata: Some( + provider_namespace( + "anthropic", + json!({ + "pageAge": result.get("page_age").cloned().unwrap_or(Value::Null), + }), + ) + .expect("provider metadata must be an object"), + ), + }) })); parts } @@ -518,9 +534,11 @@ pub(crate) fn stream_parts_for_result_block( preliminary: None, dynamic: Some(true), provider_metadata: call.map(|(_, server)| { - json!({ - "anthropic": { "type": "mcp-tool-use", "serverName": server } - }) + provider_namespace( + "anthropic", + json!({ "type": "mcp-tool-use", "serverName": server }), + ) + .expect("provider metadata must be an object") }), })] } @@ -540,6 +558,7 @@ pub(crate) fn stream_parts_for_result_block( pub(crate) fn parse_anthropic_content( blocks: &[ContentBlock], names: &ToolNameMapping, + generate_id: &(dyn Fn() -> String + Send + Sync), ) -> Vec { let mut content = Vec::new(); // Result blocks inherit information from their matching calls. Index the @@ -590,12 +609,13 @@ pub(crate) fn parse_anthropic_content( thinking, signature, } => { - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text: thinking.clone(), - provider_metadata: Some(json!({ - "anthropic": { "signature": signature } - })), - }); + provider_metadata: Some( + provider_namespace("anthropic", json!({ "signature": signature })) + .expect("provider metadata must be an object"), + ), + })); } // Provider-executed (server-side) tool calls are surfaced as tool // calls so they round-trip on follow-up turns. @@ -625,19 +645,24 @@ pub(crate) fn parse_anthropic_content( input: input.to_string(), provider_executed: Some(true), dynamic: Some(true), - provider_metadata: Some(json!({ - "anthropic": { "type": "mcp-tool-use", "serverName": server_name } - })), + provider_metadata: Some( + provider_namespace( + "anthropic", + json!({ "type": "mcp-tool-use", "serverName": server_name }), + ) + .expect("provider metadata must be an object"), + ), })); } // Redacted thinking — upstream emits as reasoning with redactedData ContentBlock::RedactedThinking { data } => { - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text: String::new(), - provider_metadata: Some(json!({ - "anthropic": { "redactedData": data } - })), - }); + provider_metadata: Some( + provider_namespace("anthropic", json!({ "redactedData": data })) + .expect("provider metadata must be an object"), + ), + })); } // ── Server-tool result blocks → GenerateContent::ToolResult ── // Payload shapes mirror the TS `doGenerate` switch one-for-one @@ -666,18 +691,21 @@ pub(crate) fn parse_anthropic_content( // Each result also becomes a `Source` — that is how the // URLs and titles reach `result.sources`. for result in results { - content.push(GenerateContent::Source { - id: generate_source_id(), - source_type: "url".to_string(), - url: str_field(result, "url"), + content.push(GenerateContent::Source(Source::Url { + id: generate_id(), + url: str_field(result, "url").unwrap_or_default(), title: str_field(result, "title"), - provider_metadata: Some(json!({ - "anthropic": { - "pageAge": result.get("page_age").cloned() - .unwrap_or(Value::Null), - } - })), - }); + provider_metadata: Some( + provider_namespace( + "anthropic", + json!({ + "pageAge": result.get("page_age").cloned() + .unwrap_or(Value::Null), + }), + ) + .expect("provider metadata must be an object"), + ), + })); } } None => content.push(GenerateContent::ToolResult(ToolResult { @@ -797,9 +825,11 @@ pub(crate) fn parse_anthropic_content( preliminary: None, dynamic: Some(true), provider_metadata: call.map(|(_, server)| { - json!({ - "anthropic": { "type": "mcp-tool-use", "serverName": server } - }) + provider_namespace( + "anthropic", + json!({ "type": "mcp-tool-use", "serverName": server }), + ) + .expect("provider metadata must be an object") }), })); } @@ -826,6 +856,7 @@ pub(crate) async fn anthropic_generate_core( abort_signal: Option, recording_context: Option, tool_names: &ToolNameMapping, + generate_id: &(dyn Fn() -> String + Send + Sync), ) -> Result { let (request, request_body) = build_anthropic_request( endpoint, @@ -843,9 +874,10 @@ pub(crate) async fn anthropic_generate_core( ) .await?; + let response_body = resp.raw_value; let data: AnthropicResponse = resp.value; - let content = parse_anthropic_content(&data.content, tool_names); + let content = parse_anthropic_content(&data.content, tool_names, generate_id); let finish_reason = data .stop_reason @@ -867,13 +899,16 @@ pub(crate) async fn anthropic_generate_core( usage, warnings, provider_metadata: None, - response: ResponseMetadata { - id: Some(data.id), - timestamp: None, - model_id: Some(data.model), - }, - request_body: Some(body), - response_headers: None, + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: response_body, + headers: None, + ..aimux_core::result::GenerateResponse::from(ResponseMetadata { + id: Some(data.id), + timestamp: None, + model_id: Some(data.model), + }) + }), }) } @@ -895,7 +930,7 @@ enum BlockState { dynamic: Option, provider_tool_name: Option, provider_tool_input_type: Option, - provider_metadata: Option, + provider_metadata: Option, first_delta: bool, }, Thinking { @@ -924,6 +959,7 @@ pub(crate) async fn anthropic_stream_core( abort_signal: Option, recording_context: Option, tool_names: ToolNameMapping, + generate_id: std::sync::Arc String + Send + Sync>, ) -> Result { let (request, request_body) = build_anthropic_request( endpoint, @@ -988,11 +1024,11 @@ pub(crate) async fn anthropic_stream_core( final_usage = super::usage::usage_from_anthropic(usage); } if !response_meta_emitted { - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: Some(message.id.clone()), timestamp: None, model_id: Some(message.model.clone()), - }); + })); response_meta_emitted = true; } } @@ -1093,12 +1129,10 @@ pub(crate) async fn anthropic_stream_core( input: input.to_string(), provider_executed: Some(true), dynamic: Some(true), - provider_metadata: Some(json!({ - "anthropic": { - "type": "mcp-tool-use", - "serverName": server_name, - } - })), + provider_metadata: Some(provider_namespace("anthropic", json!({ + "type": "mcp-tool-use", + "serverName": server_name, + })).expect("provider metadata must be an object")), })); } // Redacted thinking — emit as ReasoningStart. @@ -1106,9 +1140,7 @@ pub(crate) async fn anthropic_stream_core( let id = index.to_string(); yield Ok(StreamPart::ReasoningStart { id: id.clone(), - provider_metadata: Some(json!({ - "anthropic": { "redactedData": data } - })), + provider_metadata: Some(provider_namespace("anthropic", json!({ "redactedData": data })).expect("provider metadata must be an object")), }); blocks.insert( index, @@ -1129,6 +1161,7 @@ pub(crate) async fn anthropic_stream_core( &tool_names, &mcp_tool_calls, &server_tool_calls, + generate_id.as_ref(), ) { yield Ok(part); } @@ -1273,7 +1306,7 @@ pub(crate) async fn anthropic_stream_core( yield Ok(StreamPart::ReasoningEnd { id: index.to_string(), provider_metadata: signature.map(|s| { - json!({ "anthropic": { "signature": s } }) + provider_namespace("anthropic", json!({ "signature": s })).expect("provider metadata must be an object") }), }); } @@ -1325,11 +1358,10 @@ pub(crate) async fn anthropic_stream_core( let text_tokens = reasoning_tokens .zip(output_total) .map(|(r, t)| t.saturating_sub(r)); - final_usage.output_tokens = TokenUsage { + final_usage.output_tokens = aimux_core::types::OutputTokenUsage { total: output_total, text: text_tokens, reasoning: reasoning_tokens, - ..Default::default() }; } } @@ -1391,7 +1423,9 @@ pub(crate) async fn anthropic_stream_core( Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } diff --git a/aimux-providers/src/anthropic/usage.rs b/aimux-providers/src/anthropic/usage.rs index 9f991357c..a132f06d0 100644 --- a/aimux-providers/src/anthropic/usage.rs +++ b/aimux-providers/src/anthropic/usage.rs @@ -7,7 +7,7 @@ use serde::Deserialize; use serde_json::Value; -use aimux_core::types::{TokenUsage, Usage}; +use aimux_core::types::Usage; use crate::anthropic::types::AnthropicUsage; @@ -148,20 +148,18 @@ pub fn usage_from_anthropic(usage: &AnthropicUsage) -> Usage { Ok(v) => { let r = convert_anthropic_usage(&v, None); Usage { - input_tokens: TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(r.input_tokens.total as u32), no_cache: Some(r.input_tokens.no_cache as u32), cache_read: Some(r.input_tokens.cache_read as u32), cache_write: Some(r.input_tokens.cache_write as u32), - ..Default::default() }, - output_tokens: TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(r.output_tokens.total as u32), text: r.output_tokens.text.map(|t| t as u32), reasoning: r.output_tokens.reasoning.map(|t| t as u32), - ..Default::default() }, - raw: Some(r.raw), + raw: r.raw.as_object().cloned(), } } Err(_) => Usage::default(), diff --git a/aimux-providers/src/anthropic_aws/model.rs b/aimux-providers/src/anthropic_aws/model.rs index ec2d4d431..a1831e853 100644 --- a/aimux-providers/src/anthropic_aws/model.rs +++ b/aimux-providers/src/anthropic_aws/model.rs @@ -173,6 +173,7 @@ impl LanguageModel for AnthropicAwsModel { options.abort_signal.clone(), options.recording_context.clone(), &ToolNameMapping::new(options.tools.as_deref()), + &crate::anthropic::stream::generate_id, ) .await } @@ -190,6 +191,7 @@ impl LanguageModel for AnthropicAwsModel { options.abort_signal.clone(), options.recording_context.clone(), ToolNameMapping::new(options.tools.as_deref()), + std::sync::Arc::new(crate::anthropic::stream::generate_id), ) .await } diff --git a/aimux-providers/src/assemblyai.rs b/aimux-providers/src/assemblyai.rs index 3bae05556..8c0683aa0 100644 --- a/aimux-providers/src/assemblyai.rs +++ b/aimux-providers/src/assemblyai.rs @@ -281,8 +281,7 @@ impl TranscriptionModel for AssemblyAITranscriptionModel { // Parse and forward provider options. if let Some(ref po) = options.provider_options - && let Some(aai) = po.get("assemblyai") - && let Some(obj) = aai.as_object() + && let Some(obj) = po.get("assemblyai") { for (k, v) in obj { body.insert(k.clone(), v.clone()); @@ -412,7 +411,7 @@ impl TranscriptionModel for AssemblyAITranscriptionModel { ); } if !aai_meta.is_empty() { - md.insert("assemblyai".to_string(), Value::Object(aai_meta)); + md.insert("assemblyai".to_string(), aai_meta); provider_metadata = Some(md); } diff --git a/aimux-providers/src/aws_polly.rs b/aimux-providers/src/aws_polly.rs index 5216192fd..63f9d4c67 100644 --- a/aimux-providers/src/aws_polly.rs +++ b/aimux-providers/src/aws_polly.rs @@ -446,8 +446,7 @@ struct PollySpeechProviderOptions { fn parse_polly_provider_options( options: Option<&SharedProviderOptions>, ) -> Option { - let provider_opts = options.and_then(|opts| opts.get("aws_polly"))?; - let opts = provider_opts.as_object()?; + let opts = options.and_then(|opts| opts.get("aws_polly"))?; Some(PollySpeechProviderOptions { engine: opts diff --git a/aimux-providers/src/azure/responses.rs b/aimux-providers/src/azure/responses.rs index 17926a4ff..800cd428a 100644 --- a/aimux-providers/src/azure/responses.rs +++ b/aimux-providers/src/azure/responses.rs @@ -263,7 +263,7 @@ impl LanguageModel for AzureResponsesModel { async fn do_generate(&self, options: &CallOptions) -> Result { let headers = self.build_headers(options.headers.as_ref()).await?; - let request_result = build_responses_request_body(&self.deployment, options, false); + let request_result = build_responses_request_body(&self.deployment, options, false)?; let mut body = request_result.body; // Apply Azure assistant- file ID prefix passthrough. @@ -300,7 +300,7 @@ impl LanguageModel for AzureResponsesModel { async fn do_stream(&self, options: &CallOptions) -> Result { let headers = self.build_headers(options.headers.as_ref()).await?; - let request_result = build_responses_request_body(&self.deployment, options, true); + let request_result = build_responses_request_body(&self.deployment, options, true)?; let mut body = request_result.body; let warnings = request_result.warnings; let provider_key = provider_key().to_string(); @@ -347,8 +347,10 @@ impl LanguageModel for AzureResponsesModel { Ok(StreamResult { stream, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } diff --git a/aimux-providers/src/bedrock/convert.rs b/aimux-providers/src/bedrock/convert.rs index 2b5842dc4..a911d34c7 100644 --- a/aimux-providers/src/bedrock/convert.rs +++ b/aimux-providers/src/bedrock/convert.rs @@ -14,10 +14,12 @@ //! - Consecutive same-role messages are merged into a single message (matching //! the TS `groupIntoBlocks` behaviour). -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::LanguageModelPrompt; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, + ToolCallPart, ToolPart, ToolResultContent, ToolResultOutput, UserPart, +}; use aimux_core::options::{CallOptions, ToolChoice}; +use aimux_core::shared::{FileBytes, FileData, SharedProviderOptions}; use aimux_core::tool::{FunctionTool, Tool}; use aimux_core::types::{FinishReason, FinishReasonUnified}; use base64::Engine; @@ -34,8 +36,14 @@ use serde_json::{Value, json}; /// messages into a single `assistant` block. Assistant text is trimmed when it /// is the last content part of the last message of the last block; empty /// assistant text is dropped unless the message also carries reasoning. -#[must_use] -pub fn convert_prompt_to_bedrock(prompt: &LanguageModelPrompt) -> (Vec, Vec) { +/// +/// # Errors +/// +/// Returns an unsupported-functionality error for unsupported tool result content. +pub fn convert_prompt_to_bedrock( + prompt: &LanguageModelPrompt, +) -> Result<(Vec, Vec), aimux_core::error::AiMuxError> { + validate_tool_result_content(prompt)?; #[derive(Clone, Copy, PartialEq, Eq)] enum Blk { System, @@ -46,10 +54,10 @@ pub fn convert_prompt_to_bedrock(prompt: &LanguageModelPrompt) -> (Vec, V // Group consecutive same-block messages (user+tool fold into User). let mut blocks: Vec<(Blk, Vec)> = Vec::new(); for (i, msg) in prompt.iter().enumerate() { - let b = match msg.role { - Role::System => Blk::System, - Role::User | Role::Tool => Blk::User, - Role::Assistant => Blk::Assistant, + let b = match msg { + LanguageModelMessage::System { .. } => Blk::System, + LanguageModelMessage::User { .. } | LanguageModelMessage::Tool { .. } => Blk::User, + LanguageModelMessage::Assistant { .. } => Blk::Assistant, }; if blocks.last().map(|(lb, _)| *lb) == Some(b) { blocks.last_mut().unwrap().1.push(i); @@ -68,49 +76,84 @@ pub fn convert_prompt_to_bedrock(prompt: &LanguageModelPrompt) -> (Vec, V match blk { Blk::System => { for &i in idxs { - let msg = &prompt[i]; - for p in &msg.content { - if let ContentPart::Text { text, .. } = p { - system.push(json!({ "text": text })); - } - if let Some(cp) = part_cache_point(p) { - system.push(cp); - } + let LanguageModelMessage::System { + content, + provider_options, + } = &prompt[i] + else { + unreachable!() + }; + system.push(json!({ "text": content })); + if let Some(cp) = cache_point(provider_options) { + system.push(cp); } } } Blk::User => { let mut content: Vec = Vec::new(); for &i in idxs { - let msg = &prompt[i]; - for p in &msg.content { - push_user_part(p, &mut content, &mut document_counter); - if let Some(cp) = part_cache_point(p) { - content.push(cp); + match &prompt[i] { + LanguageModelMessage::User { content: parts, .. } => { + for part in parts { + let provider_options = match part { + UserPart::Text(part) => { + content.push(json!({ "text": part.text })); + &part.provider_options + } + UserPart::File(file) => { + if !push_file_part( + file, + &mut content, + &mut document_counter, + ) { + continue; + } + &file.provider_options + } + }; + if let Some(cp) = cache_point(provider_options) { + content.push(cp); + } + } + } + LanguageModelMessage::Tool { content: parts, .. } => { + for part in parts { + let ToolPart::ToolResult(part) = part else { + continue; + }; + let result_content = + resolve_tool_result_output(&part.output, &mut document_counter); + content.push(json!({ + "toolResult": { + "toolUseId": part.tool_call_id, + "content": result_content, + } + })); + } } + _ => unreachable!(), } } - messages.push(json!({ "role": "user", "content": content })); + append_user_message(&mut messages, content); } Blk::Assistant => { let mut content: Vec = Vec::new(); let num_msgs = idxs.len(); for (mj, &i) in idxs.iter().enumerate() { let is_last_message = mj == num_msgs - 1; - let msg = &prompt[i]; - let has_reasoning = msg - .content + let LanguageModelMessage::Assistant { content: parts, .. } = &prompt[i] else { + unreachable!() + }; + let has_reasoning = parts .iter() - .any(|p| matches!(p, ContentPart::Reasoning { .. })); - let num_parts = msg.content.len(); - for (kj, p) in msg.content.iter().enumerate() { + .any(|p| matches!(p, AssistantPart::Reasoning(_))); + let num_parts = parts.len(); + for (kj, p) in parts.iter().enumerate() { let is_last_content_part = kj == num_parts - 1; match p { - ContentPart::Text { text, .. } => { + AssistantPart::Text(TextPart { text, .. }) => { // Skip empty text unless the message has reasoning. - if text.trim().is_empty() && !has_reasoning { - // skipped - } else { + if !text.trim().is_empty() || has_reasoning { let t = if is_last_block && is_last_message && is_last_content_part { @@ -121,11 +164,30 @@ pub fn convert_prompt_to_bedrock(prompt: &LanguageModelPrompt) -> (Vec, V content.push(json!({ "text": t })); } } - ContentPart::Reasoning { + AssistantPart::Reasoning(ReasoningPart { text, - signature: Some(sig), - .. - } => { + provider_options, + }) => { + let options = provider_options.as_ref().and_then(|options| { + options + .get("amazonBedrock") + .or_else(|| options.get("bedrock")) + }); + let Some(sig) = options + .and_then(|options| options.get("signature")) + .and_then(Value::as_str) + else { + if let Some(redacted) = + options.and_then(|options| options.get("redactedContent")) + { + content.push(json!({ "reasoningContent": { "redactedContent": redacted } })); + } else if let Some(redacted) = + options.and_then(|options| options.get("redactedData")) + { + content.push(json!({ "reasoningContent": { "redactedReasoning": { "data": redacted } } })); + } + continue; + }; // Only signed reasoning is replayed; unsigned // reasoning is intentionally omitted. content.push(json!({ @@ -137,13 +199,25 @@ pub fn convert_prompt_to_bedrock(prompt: &LanguageModelPrompt) -> (Vec, V } })); } - ContentPart::Reasoning { .. } => {} - ContentPart::ToolCall { + AssistantPart::ToolResult(part) => { + if !content.is_empty() { + messages.push(json!({ "role": "assistant", "content": std::mem::take(&mut content) })); + } + let mut result = vec![ + json!({ "toolResult": { "toolUseId": part.tool_call_id, "content": resolve_tool_result_output(&part.output, &mut document_counter) } }), + ]; + if let Some(cp) = cache_point(&part.provider_options) { + result.push(cp); + } + append_user_message(&mut messages, result); + continue; + } + AssistantPart::ToolCall(ToolCallPart { tool_call_id, tool_name, input, .. - } => { + }) => { let input_val = if input.is_object() { input.clone() } else { @@ -159,79 +233,187 @@ pub fn convert_prompt_to_bedrock(prompt: &LanguageModelPrompt) -> (Vec, V } _ => {} } - if let Some(cp) = part_cache_point(p) { + let provider_options = match p { + AssistantPart::Text(part) => &part.provider_options, + AssistantPart::File(part) + if matches!( + part.data, + FileData::Data { .. } | FileData::Text { .. } + ) => + { + &part.provider_options + } + _ => continue, + }; + if let Some(cp) = cache_point(provider_options) { content.push(cp); } } } - messages.push(json!({ "role": "assistant", "content": content })); + if !content.is_empty() { + messages.push(json!({ "role": "assistant", "content": content })); + } } } } - (system, messages) + Ok((system, messages)) } -/// Push a single user/tool content part into a Bedrock content array. -fn push_user_part(part: &ContentPart, content: &mut Vec, doc_counter: &mut u32) { - match part { - ContentPart::Text { text, .. } => { - content.push(json!({ "text": text })); - } - ContentPart::Image { - image, media_type, .. - } => { - let b64 = base64::engine::general_purpose::STANDARD.encode(image); - push_file_block(&b64, media_type, None, &None, content, doc_counter); - } - ContentPart::File { - data, - media_type, - filename, - provider_options, - } => { - let b64 = base64::engine::general_purpose::STANDARD.encode(data); - push_file_block( - &b64, - media_type, - filename.as_deref(), - provider_options, - content, - doc_counter, - ); - } - ContentPart::FileBase64 { - data, - media_type, - filename, - provider_options, - } => { - // `data` is already a base64 string — use verbatim as `bytes`. - push_file_block( - data, - media_type, - filename.as_deref(), - provider_options, - content, - doc_counter, - ); +fn append_user_message(messages: &mut Vec, content: Vec) { + if let Some(last) = messages.last_mut() + && last.get("role").and_then(Value::as_str) == Some("user") + && let Some(parts) = last.get_mut("content").and_then(Value::as_array_mut) + { + parts.extend(content); + } else { + messages.push(json!({ "role": "user", "content": content })); + } +} + +pub(crate) fn validate_tool_result_content( + prompt: &LanguageModelPrompt, +) -> Result<(), aimux_core::error::AiMuxError> { + for message in prompt { + let results: Vec<&ToolResultOutput> = match message { + LanguageModelMessage::Tool { content, .. } => content + .iter() + .filter_map(|part| { + if let ToolPart::ToolResult(part) = part { + Some(&part.output) + } else { + None + } + }) + .collect(), + LanguageModelMessage::Assistant { content, .. } => content + .iter() + .filter_map(|part| { + if let AssistantPart::ToolResult(part) = part { + Some(&part.output) + } else { + None + } + }) + .collect(), + _ => continue, + }; + for output in results { + if let ToolResultOutput::Content { value } = output { + for part in value { + let unsupported = match part { + ToolResultContent::Text(_) => false, + ToolResultContent::File(file) => { + let allowed_data = matches!(file.data, FileData::Data { .. }) + || matches!(&file.data, FileData::Url { url, .. } if url.starts_with("s3:")); + if !allowed_data { + return Err( + aimux_core::error::AiMuxError::UnsupportedFunctionality( + "tool result content part".to_string(), + ), + ); + } + let media_type = crate::google::convert::tool_file_media_type(file)?; + let supported = matches!( + media_type.as_str(), + "image/jpeg" + | "image/png" + | "image/gif" + | "image/webp" + | "video/x-matroska" + | "video/quicktime" + | "video/mp4" + | "video/webm" + | "video/x-flv" + | "video/mpeg" + | "video/mpg" + | "video/wmv" + | "video/x-ms-wmv" + | "video/3gpp" + | "application/pdf" + | "text/csv" + | "application/msword" + | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + | "application/vnd.ms-excel" + | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + | "text/html" + | "text/plain" + | "text/markdown" + ); + if !supported { + let kind = match media_type.split('/').next() { + Some("image") => "image", + Some("video") => "video", + _ => "file", + }; + return Err( + aimux_core::error::AiMuxError::UnsupportedFunctionality( + format!("{kind} mime type: {media_type}"), + ), + ); + } + matches!(file.data, FileData::Url { .. }) + && !(media_type.starts_with("image/") + || media_type.starts_with("video/")) + } + ToolResultContent::Custom { .. } => true, + }; + if unsupported { + return Err(aimux_core::error::AiMuxError::UnsupportedFunctionality( + "tool result content part".to_string(), + )); + } + } + } } - ContentPart::ToolResult { - tool_call_id, - result, - .. - } => { - let result_content = resolve_tool_result_output(result, doc_counter); + } + Ok(()) +} + +/// Push a supported file into a Bedrock content array. +fn push_file_part(file: &FilePart, content: &mut Vec, doc_counter: &mut u32) -> bool { + let b64 = match &file.data { + FileData::Data { + data: FileBytes::Binary(bytes), + } => base64::engine::general_purpose::STANDARD.encode(bytes), + FileData::Data { + data: FileBytes::Base64(data), + } => data.clone(), + FileData::Text { text } => base64::engine::general_purpose::STANDARD.encode(text), + FileData::Url { url, .. } + if url.starts_with("s3:") && file.media_type.starts_with("image/") => + { content.push(json!({ - "toolResult": { - "toolUseId": tool_call_id, - "content": result_content, + "image": { + "format": mime_to_image_format(&file.media_type), + "source": { "s3Location": { "uri": url } } } })); + return true; } - // Variants not yet modelled for Bedrock; no test exercises these paths. - _ => {} - } + FileData::Url { .. } | FileData::Reference { .. } => return false, + }; + let is_text = matches!(file.data, FileData::Text { .. }); + let media_type = if is_text + && !file + .media_type + .split_once('/') + .is_some_and(|(_, subtype)| !subtype.is_empty() && subtype != "*") + { + "text/plain" + } else { + &file.media_type + }; + push_file_block( + &b64, + media_type, + file.filename.as_deref(), + &file.provider_options, + is_text, + content, + doc_counter, + ); + true } /// Build a Bedrock `image` or `document` block from an already-base64 `bytes` @@ -243,12 +425,13 @@ fn push_file_block( b64: &str, media_type: &str, filename: Option<&str>, - provider_options: &Option, + provider_options: &Option, + is_text: bool, content: &mut Vec, doc_counter: &mut u32, ) { let top_level = media_type.split('/').next().unwrap_or(""); - if top_level == "image" { + if top_level == "image" && !is_text { let format = mime_to_image_format(media_type); content.push(json!({ "image": { "format": format, "source": { "bytes": b64 } } @@ -275,23 +458,9 @@ fn push_file_block( /// Extract a `{ cachePoint: {...} }` block from a part's `providerOptions` /// (`bedrock.cachePoint` or `amazonBedrock.cachePoint`), if present. -fn part_cache_point(part: &ContentPart) -> Option { - let po = match part { - ContentPart::Text { - provider_options, .. - } - | ContentPart::Image { - provider_options, .. - } - | ContentPart::File { - provider_options, .. - } - | ContentPart::FileBase64 { - provider_options, .. - } => provider_options.as_ref()?, - _ => return None, - }; - for key in &["bedrock", "amazonBedrock"] { +fn cache_point(provider_options: &Option) -> Option { + let po = provider_options.as_ref()?; + for key in ["bedrock", "amazonBedrock"] { if let Some(cp) = po.get(key).and_then(|v| v.get("cachePoint")) { return Some(json!({ "cachePoint": cp.clone() })); } @@ -301,11 +470,11 @@ fn part_cache_point(part: &ContentPart) -> Option { /// Whether `citations.enabled` is set on a part's `bedrock`/`amazonBedrock` /// provider options. -fn citations_enabled(provider_options: &Option) -> bool { +fn citations_enabled(provider_options: &Option) -> bool { let Some(po) = provider_options.as_ref() else { return false; }; - for key in &["bedrock", "amazonBedrock"] { + for key in ["bedrock", "amazonBedrock"] { if let Some(enabled) = po .get(key) .and_then(|b| b.get("citations")) @@ -320,57 +489,50 @@ fn citations_enabled(provider_options: &Option) -> bool { /// Resolve a tool result `output` value into Bedrock's `toolResult.content` /// array, mirroring the TS SDK. -fn resolve_tool_result_output(output: &Value, doc_counter: &mut u32) -> Vec { - let (t, v) = match ( - output.get("type").and_then(|x| x.as_str()), - output.get("value"), - ) { - (Some(t), Some(v)) => (t, v), - _ => return vec![json!({ "text": output.to_string() })], - }; - match t { - "json" | "error-json" => vec![json!({ "text": v.to_string() })], - "text" | "error" | "error-text" => vec![json!({ "text": v })], - "execution-denied" => { - let reason = v.as_str().unwrap_or("Tool call execution denied."); - vec![json!({ "text": reason })] +fn resolve_tool_result_output(output: &ToolResultOutput, doc_counter: &mut u32) -> Vec { + match output { + ToolResultOutput::Json { value, .. } | ToolResultOutput::ErrorJson { value, .. } => { + vec![json!({ "text": value.to_string() })] } - "content" => { - if let Some(arr) = v.as_array() { - let mut out = Vec::new(); - for part in arr { - convert_tool_result_content_part(part, &mut out, doc_counter); - } - out - } else { - vec![json!({ "text": output.to_string() })] - } - } - _ => vec![json!({ "text": output.to_string() })], - } -} - -/// Convert a single content part inside a tool-result `content` array. -fn convert_tool_result_content_part(part: &Value, content: &mut Vec, doc_counter: &mut u32) { - let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or(""); - match part_type { - "text" => { - let text = part.get("text").and_then(|t| t.as_str()).unwrap_or(""); - content.push(json!({ "text": text })); + ToolResultOutput::Text { value, .. } | ToolResultOutput::ErrorText { value, .. } => { + vec![json!({ "text": value })] } - "file" => { - let media_type = part.get("mediaType").and_then(|m| m.as_str()).unwrap_or(""); - // Tool-result file parts carry an already-base64 `data` string. - let b64 = part - .get("data") - .and_then(|d| d.get("data")) - .and_then(|d| d.as_str()) - .unwrap_or(""); - let filename = part.get("filename").and_then(|f| f.as_str()); - push_file_block(b64, media_type, filename, &None, content, doc_counter); + ToolResultOutput::ExecutionDenied { reason, .. } => { + vec![json!({ "text": reason.as_deref().unwrap_or("Tool call execution denied.") })] } - _ => { - content.push(json!({ "text": part.to_string() })); + ToolResultOutput::Content { value } => { + let mut content = Vec::new(); + for part in value { + match part { + ToolResultContent::Text(part) => content.push(json!({ "text": part.text })), + ToolResultContent::File(part) => { + let mut resolved = part.clone(); + if let Ok(media_type) = crate::google::convert::tool_file_media_type(part) { + resolved.media_type = media_type; + } + let part = &resolved; + if let FileData::Url { url, .. } = &part.data { + let format = part.media_type.split('/').nth(1).unwrap_or(""); + let source = json!({ "s3Location": { "uri": url } }); + content.push(if part.media_type.starts_with("image/") { json!({ "image": { "format": mime_to_image_format(&part.media_type), "source": source } }) } else { json!({ "video": { "format": format, "source": source } }) }); + } else if part.media_type.starts_with("video/") { + if let FileData::Data { data } = &part.data { + let bytes = match data { + FileBytes::Binary(bytes) => { + base64::engine::general_purpose::STANDARD.encode(bytes) + } + FileBytes::Base64(data) => data.clone(), + }; + content.push(json!({ "video": { "format": part.media_type.split('/').nth(1).unwrap_or(""), "source": { "bytes": bytes } } })); + } + } else { + push_file_part(part, &mut content, doc_counter); + } + } + ToolResultContent::Custom { .. } => {} + } + } + content } } } @@ -520,15 +682,21 @@ pub fn prepare_tools( /// and `inferenceConfig.maxTokens` is bumped by `budgetTokens`. The /// `reasoningConfig` key never appears in the request body. User-supplied /// `additionalModelRequestFields` are merged with the derived `thinking` field. -#[must_use] -pub fn build_request_body(model_id: &str, options: &CallOptions) -> Value { - let (system, messages) = convert_prompt_to_bedrock(&options.prompt); +/// +/// # Errors +/// +/// Returns an unsupported-functionality error for unsupported tool result content. +pub fn build_request_body( + model_id: &str, + options: &CallOptions, +) -> Result { + let (system, messages) = convert_prompt_to_bedrock(&options.prompt)?; // Extract Bedrock-specific provider options. let bedrock_opts = options .provider_options .as_ref() - .and_then(|po| po.get("bedrock")); + .and_then(|po| po.get("amazonBedrock").or_else(|| po.get("bedrock"))); let reasoning_config = bedrock_opts.and_then(|bo| bo.get("reasoningConfig")); let user_amrf = bedrock_opts.and_then(|bo| bo.get("additionalModelRequestFields")); @@ -615,7 +783,7 @@ pub fn build_request_body(model_id: &str, options: &CallOptions) -> Value { body.insert("toolConfig".to_string(), tool_config); } - Value::Object(body) + Ok(Value::Object(body)) } /// Map a Bedrock `stopReason` to the unified `FinishReason`. @@ -645,7 +813,7 @@ pub fn map_finish_reason(reason: &str) -> FinishReason { /// cases); `outputTokens.text` mirrors the TS `outputTokens.text` field. #[must_use] pub fn convert_usage(usage: Option<&BedrockUsage>) -> aimux_core::types::Usage { - use aimux_core::types::{TokenUsage, Usage}; + use aimux_core::types::Usage; let Some(usage) = usage else { return Usage::default(); @@ -657,20 +825,21 @@ pub fn convert_usage(usage: Option<&BedrockUsage>) -> aimux_core::types::Usage { let cache_write = usage.cache_write_input_tokens.unwrap_or(0); Usage { - input_tokens: TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(input + cache_read + cache_write), no_cache: Some(input), cache_read: Some(cache_read), cache_write: Some(cache_write), - ..Default::default() }, - output_tokens: TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(output), text: Some(output), ..Default::default() }, // RFC-0015 P0-3: keep the raw provider usage payload. - raw: Some(serde_json::to_value(usage).unwrap_or(serde_json::Value::Null)), + raw: serde_json::to_value(usage) + .ok() + .and_then(|value| value.as_object().cloned()), } } diff --git a/aimux-providers/src/bedrock/model.rs b/aimux-providers/src/bedrock/model.rs index 8d515761c..e88314a8a 100644 --- a/aimux-providers/src/bedrock/model.rs +++ b/aimux-providers/src/bedrock/model.rs @@ -15,9 +15,12 @@ use futures::{StreamExt, stream::BoxStream}; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, StreamResult}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; +use aimux_core::types::{ + FinishReason, FinishReasonUnified, ProviderMetadata, ResponseMetadata, Usage, +}; use serde_json::json; @@ -194,7 +197,7 @@ impl LanguageModel for BedrockModel { } async fn do_generate(&self, options: &CallOptions) -> Result { - let body = build_request_body(&self.model_id, options); + let body = build_request_body(&self.model_id, options)?; let body_str = serde_json::to_string(&body).unwrap_or_default(); let url = self.endpoint(false); let headers = self.build_headers(&body_str, &url, options.headers.as_ref())?; @@ -214,6 +217,7 @@ impl LanguageModel for BedrockModel { ) .await?; + let response_body = resp.raw_value; let response_headers = resp.response_headers; let data: BedrockConverseResponse = resp.value; @@ -250,18 +254,21 @@ impl LanguageModel for BedrockModel { usage, warnings: Vec::new(), provider_metadata: None, - response: ResponseMetadata { - id: request_id, - timestamp: response_headers.get("date").cloned(), - model_id: Some(self.model_id.clone()), - }, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: response_body, + headers: Some(response_headers.clone()), + ..aimux_core::result::GenerateResponse::from(ResponseMetadata { + id: request_id, + timestamp: response_headers.get("date").cloned(), + model_id: Some(self.model_id.clone()), + }) + }), }) } async fn do_stream(&self, options: &CallOptions) -> Result { - let body = build_request_body(&self.model_id, options); + let body = build_request_body(&self.model_id, options)?; let body_str = serde_json::to_string(&body).unwrap_or_default(); let url = self.endpoint(true); let headers = self.build_headers(&body_str, &url, options.headers.as_ref())?; @@ -310,11 +317,11 @@ impl LanguageModel for BedrockModel { let stream = async_stream::stream! { yield Ok(StreamPart::StreamStart { warnings: vec![] }); - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: request_id, timestamp: response_timestamp, model_id: Some(model_id.clone()), - }); + })); let messages = super::event_stream::decode_messages(&response_bytes); @@ -592,11 +599,10 @@ impl LanguageModel for BedrockModel { let provider_metadata = if finish_meta.is_empty() { None } else { - let payload = serde_json::Value::Object(finish_meta); - Some(json!({ - "amazonBedrock": payload, - "bedrock": payload, - })) + Some(HashMap::from([ + ("amazonBedrock".to_string(), finish_meta.clone()), + ("bedrock".to_string(), finish_meta), + ])) }; yield Ok(StreamPart::Finish { @@ -611,8 +617,10 @@ impl LanguageModel for BedrockModel { Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } @@ -620,12 +628,15 @@ impl LanguageModel for BedrockModel { /// Wrap the accumulated reasoning signature as provider_metadata in the same /// dual-key shape the non-streaming path emits (`amazonBedrock` + `bedrock`), /// so consumers reading either key see it. -fn reasoning_signature_meta(sig: Option) -> Option { +fn reasoning_signature_meta(sig: Option) -> Option { sig.map(|s| { - json!({ - "amazonBedrock": { "signature": &s }, - "bedrock": { "signature": s } - }) + let mut metadata = provider_namespace("amazonBedrock", json!({ "signature": &s })) + .expect("provider metadata must be an object"); + metadata.extend( + provider_namespace("bedrock", json!({ "signature": s })) + .expect("provider metadata must be an object"), + ); + metadata }) } @@ -664,29 +675,36 @@ fn extract_content(block: &BedrockContentBlock, content: &mut Vec>, + provider_options: Option<&SharedProviderOptions>, ) -> BedrockRerankingOptions { let mut opts = BedrockRerankingOptions::default(); if let Some(po) = provider_options { diff --git a/aimux-providers/src/black_forest_labs.rs b/aimux-providers/src/black_forest_labs.rs index ebfa09af5..1482fc9e8 100644 --- a/aimux-providers/src/black_forest_labs.rs +++ b/aimux-providers/src/black_forest_labs.rs @@ -286,7 +286,7 @@ impl ImageModel for BlackForestLabsImageModel { } // Forward BFL provider options - if let Some(bfl) = bfl_opts.and_then(|v| v.as_object()) { + if let Some(bfl) = bfl_opts { let map: &[(&str, &str)] = &[ ("steps", "steps"), ("guidance", "guidance"), @@ -520,7 +520,7 @@ impl ImageModel for BlackForestLabsImageModel { img_meta.insert("outputMegapixels".into(), o.clone()); } bfl_meta.insert("images".into(), json!([Value::Object(img_meta)])); - metadata.insert("blackForestLabs".into(), Value::Object(bfl_meta)); + metadata.insert("blackForestLabs".into(), bfl_meta); Ok(ImageResult { images: ImageOutputs::Binary(vec![image_bytes]), diff --git a/aimux-providers/src/cartesia.rs b/aimux-providers/src/cartesia.rs index cc0bb87eb..a6123a18e 100644 --- a/aimux-providers/src/cartesia.rs +++ b/aimux-providers/src/cartesia.rs @@ -576,8 +576,7 @@ struct CartesiaSpeechProviderOptions { fn parse_cartesia_provider_options( options: Option<&SharedProviderOptions>, ) -> Option { - let provider_opts = options.and_then(|opts| opts.get("cartesia"))?; - let opts = provider_opts.as_object()?; + let opts = options.and_then(|opts| opts.get("cartesia"))?; Some(CartesiaSpeechProviderOptions { container: opts diff --git a/aimux-providers/src/codex.rs b/aimux-providers/src/codex.rs index ccf99d3e1..5cf6b16f7 100644 --- a/aimux-providers/src/codex.rs +++ b/aimux-providers/src/codex.rs @@ -258,7 +258,7 @@ impl CodexModel { options: &CallOptions, stream: bool, ) -> Result<(Value, Vec), AiMuxError> { - let mut result = build_responses_request_body(&self.model_id, options, stream); + let mut result = build_responses_request_body(&self.model_id, options, stream)?; if self.config.mode == CodexMode::Subscription { result.body["store"] = Value::Bool(false); } @@ -425,8 +425,10 @@ impl CodexModel { Ok(StreamResult { stream, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } diff --git a/aimux-providers/src/cohere/convert.rs b/aimux-providers/src/cohere/convert.rs index d34a5fe8c..0c2d866a2 100644 --- a/aimux-providers/src/cohere/convert.rs +++ b/aimux-providers/src/cohere/convert.rs @@ -3,10 +3,13 @@ //! Mirrors the TS `convert-to-cohere-chat-prompt.ts`, //! `cohere-prepare-tools.ts`, and `map-cohere-finish-reason.ts`. -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::LanguageModelPrompt; -use aimux_core::message::Role; +use aimux_core::error::AiMuxError; +use aimux_core::language_model_message::{ + AssistantPart, LanguageModelMessage, LanguageModelPrompt, TextPart, ToolCallPart, ToolPart, + ToolResultPart, UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; +use aimux_core::shared::{FileBytes, FileData, SharedProviderOptions}; use aimux_core::tool::Tool; use aimux_core::types::{FinishReason, FinishReasonUnified, ReasoningEffort, Warning}; use serde_json::{Value, json}; @@ -118,72 +121,90 @@ pub struct ConvertedPrompt { /// - Non-image file parts are extracted as `documents` (RAG), not in the message. /// - Assistant content is a plain string; with tool calls, `content` is omitted. /// - Tool messages are flat `{role:"tool", content, tool_call_id}`. -#[must_use] -pub fn convert_prompt_to_cohere(prompt: &LanguageModelPrompt) -> ConvertedPrompt { +/// +/// # Errors +/// Returns an error for unsupported text file data. +pub fn convert_prompt_to_cohere( + prompt: &LanguageModelPrompt, +) -> Result { let mut messages = Vec::new(); let mut documents = Vec::new(); for msg in prompt { - match msg.role { - Role::System => { - let text = join_text_parts(&msg.content); - messages.push(json!({ "role": "system", "content": text })); + match msg { + LanguageModelMessage::System { content, .. } => { + messages.push(json!({ "role": "system", "content": content })); } - Role::User => { + LanguageModelMessage::User { content, .. } => { let mut parts: Vec = Vec::new(); let mut has_image = false; - for part in &msg.content { + for part in content { match part { - ContentPart::Text { text, .. } => { + UserPart::Text(TextPart { text, .. }) => { if !text.is_empty() { parts.push(json!({ "type": "text", "text": text })); } } - ContentPart::Image { - image, media_type, .. - } => { - has_image = true; + UserPart::File(file) => { use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(image); - parts.push(json!({ - "type": "image_url", - "image_url": { - "url": format!("data:{};base64,{}", media_type, b64), - } - })); - } - ContentPart::File { - data, - media_type, - filename, - .. - } => { - let top_level = media_type.split('/').next().unwrap_or(""); - if top_level == "image" { - has_image = true; - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(data); - parts.push(json!({ - "type": "image_url", - "image_url": { - "url": format!("data:{};base64,{}", media_type, b64), + if file.media_type.split('/').next() == Some("image") { + let url = match &file.data { + FileData::Data { data } => { + let b64 = match data { + FileBytes::Binary(bytes) => { + base64::engine::general_purpose::STANDARD + .encode(bytes) + } + FileBytes::Base64(data) => data.clone(), + }; + let media_type = + crate::google::convert::tool_file_media_type(file)?; + format!("data:{media_type};base64,{b64}") + } + FileData::Url { url, .. } => url.clone(), + FileData::Reference { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "image file parts with provider references".to_string(), + )); + } + FileData::Text { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "image file parts with text data".to_string(), + )); } - })); + }; + has_image = true; + parts.push( + json!({ "type": "image_url", "image_url": { "url": url } }), + ); } else { - // Non-image files become RAG documents. - // Mirrors TS: `documents.push({ data: { text, title: - // part.filename } })` — `title` is only present when a - // filename is supplied. - let text = String::from_utf8_lossy(data).to_string(); + let text = match &file.data { + FileData::Data { + data: FileBytes::Binary(bytes), + } => String::from_utf8_lossy(bytes).into_owned(), + FileData::Data { + data: FileBytes::Base64(data), + } => data.clone(), + FileData::Text { text } => text.clone(), + FileData::Url { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "File URL data: URLs should be downloaded by the AI SDK and not reach this point. This indicates a configuration issue.".to_string(), + )); + } + FileData::Reference { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "file parts with provider references".to_string(), + )); + } + }; let mut doc_data = json!({ "text": text }); - if let Some(fname) = filename { - doc_data["title"] = json!(fname); + if let Some(filename) = &file.filename { + doc_data["title"] = json!(filename); } documents.push(json!({ "data": doc_data })); } } - _ => {} } } @@ -206,29 +227,23 @@ pub fn convert_prompt_to_cohere(prompt: &LanguageModelPrompt) -> ConvertedPrompt messages.push(json!({ "role": "user", "content": text })); } } - Role::Assistant => { - let text = join_text_parts(&msg.content); - let has_tool_calls = msg - .content + LanguageModelMessage::Assistant { content, .. } => { + let text = join_text_parts(content); + let has_tool_calls = content .iter() - .any(|p| matches!(p, ContentPart::ToolCall { .. })); + .any(|p| matches!(p, AssistantPart::ToolCall(_))); if has_tool_calls { - let tool_calls: Vec = msg - .content + let tool_calls: Vec = content .iter() .filter_map(|p| match p { - ContentPart::ToolCall { + AssistantPart::ToolCall(ToolCallPart { tool_call_id, tool_name, input, .. - } => { - let arguments = if input.is_null() { - "{}".to_string() - } else { - input.to_string() - }; + }) => { + let arguments = input.to_string(); Some(json!({ "id": tool_call_id, "type": "function", @@ -249,50 +264,44 @@ pub fn convert_prompt_to_cohere(prompt: &LanguageModelPrompt) -> ConvertedPrompt messages.push(json!({ "role": "assistant", "content": text })); } } - Role::Tool => { - for part in &msg.content { - if let ContentPart::ToolResult { + LanguageModelMessage::Tool { content, .. } => { + for part in content { + let ToolPart::ToolResult(ToolResultPart { tool_call_id, - result, + output, .. - } = part - { - let content = tool_result_to_content(result); - messages.push(json!({ - "role": "tool", - "content": content, - "tool_call_id": tool_call_id, - })); - } + }) = part + else { + continue; + }; + let content = crate::openai::convert::tool_result_to_content(output); + messages.push(json!({ + "role": "tool", + "content": content, + "tool_call_id": tool_call_id, + })); } } } } - ConvertedPrompt { + Ok(ConvertedPrompt { messages, documents, - } + }) } -fn join_text_parts(content: &[ContentPart]) -> String { +fn join_text_parts(content: &[AssistantPart]) -> String { content .iter() .filter_map(|p| match p { - ContentPart::Text { text, .. } => Some(text.as_str()), + AssistantPart::Text(TextPart { text, .. }) => Some(text.as_str()), _ => None, }) .collect::>() .join("") } -fn tool_result_to_content(output: &Value) -> Value { - match output { - Value::String(s) => Value::String(s.clone()), - other => Value::String(other.to_string()), - } -} - // ── Request body ──────────────────────────────────────────────────────────── /// Result of building a Cohere request body, including warnings collected from @@ -308,13 +317,15 @@ pub struct RequestBodyResult { /// Mirrors the TS `getArgs` in `cohere-chat-language-model.ts`: assembles the /// model id, messages, documents, sampling settings, response format, tools, /// and the `thinking` config resolved from `reasoning` / provider options. -#[must_use] +/// +/// # Errors +/// Returns an error for unsupported text file data. pub fn build_request_body( model_id: &str, options: &CallOptions, stream: bool, -) -> RequestBodyResult { - let converted = convert_prompt_to_cohere(&options.prompt); +) -> Result { + let converted = convert_prompt_to_cohere(&options.prompt)?; let mut warnings: Vec = Vec::new(); let mut body = json!({ @@ -379,11 +390,11 @@ pub fn build_request_body( } // Reasoning / thinking. - if let Some(thinking) = resolve_cohere_thinking(options.reasoning, &options.provider_options) { + if let Some(thinking) = resolve_cohere_thinking(options.reasoning, &options.provider_options)? { body["thinking"] = thinking; } - RequestBodyResult { body, warnings } + Ok(RequestBodyResult { body, warnings }) } // ── Reasoning / thinking ──────────────────────────────────────────────────── @@ -413,44 +424,61 @@ fn reasoning_budget_percentage(reasoning: ReasoningEffort) -> Option { /// - `reasoning: None` (not specified) → no `thinking` field. /// - `reasoning: "none"` → `{ type: "disabled" }`. /// - other reasoning levels → `{ type: "enabled", token_budget: }`. -#[must_use] +/// +/// # Errors +/// Returns a parse error for invalid Cohere thinking options. pub fn resolve_cohere_thinking( reasoning: Option, - provider_options: &Option>, -) -> Option { + provider_options: &Option, +) -> Result, AiMuxError> { // Provider options take precedence. if let Some(po) = provider_options && let Some(cohere) = po.get("cohere") && let Some(thinking) = cohere.get("thinking") { + let thinking = thinking.as_object().ok_or_else(|| { + AiMuxError::InvalidArgument("invalid cohere provider options".to_string()) + })?; + let thinking = + crate::openai::convert::parse_option_fields(thinking, "cohere", |key, value| { + let valid = match key { + "type" => value + .as_str() + .is_some_and(|value| matches!(value, "enabled" | "disabled")), + "tokenBudget" => value.is_number(), + _ => return None, + }; + Some(if valid { Ok(value.clone()) } else { Err(()) }) + })?; let t_type = thinking .get("type") .and_then(|v| v.as_str()) .unwrap_or("enabled") .to_string(); let mut obj = json!({ "type": t_type }); - if let Some(budget) = thinking - .get("tokenBudget") - .and_then(serde_json::Value::as_u64) - { - obj["token_budget"] = json!(budget); + if let Some(budget) = thinking.get("tokenBudget") { + obj["token_budget"] = budget.clone(); } - return Some(obj); + return Ok(Some(obj)); } - let reasoning = reasoning?; + let Some(reasoning) = reasoning else { + return Ok(None); + }; if reasoning == ReasoningEffort::ProviderDefault { - return None; + return Ok(None); } if reasoning == ReasoningEffort::None { - return Some(json!({ "type": "disabled" })); + return Ok(Some(json!({ "type": "disabled" }))); } - let pct = reasoning_budget_percentage(reasoning)?; + let Some(pct) = reasoning_budget_percentage(reasoning) else { + return Ok(None); + }; let max_tokens = DEFAULT_REASONING_MAX_TOKENS; let raw = (max_tokens as f64 * pct).round() as u32; let budget = max_tokens.min(1024.max(raw)); - Some(json!({ "type": "enabled", "token_budget": budget })) + Ok(Some(json!({ "type": "enabled", "token_budget": budget }))) } /// Parse Cohere finish reason string into `FinishReason`. diff --git a/aimux-providers/src/cohere/model.rs b/aimux-providers/src/cohere/model.rs index a4a983a61..59438d9c8 100644 --- a/aimux-providers/src/cohere/model.rs +++ b/aimux-providers/src/cohere/model.rs @@ -14,7 +14,8 @@ use serde_json::{Value, json}; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source, StreamResult}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; @@ -63,19 +64,20 @@ impl CohereModel { /// - `output.total = output_tokens` fn convert_usage(tokens: &TokenPair) -> Usage { Usage { - input_tokens: aimux_core::types::TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(tokens.input_tokens), no_cache: Some(tokens.input_tokens), cache_read: None, cache_write: None, - ..Default::default() }, - output_tokens: aimux_core::types::TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(tokens.output_tokens), ..Default::default() }, // RFC-0015 P0-3: keep the raw provider usage payload. - raw: Some(serde_json::to_value(tokens).unwrap_or(serde_json::Value::Null)), + raw: serde_json::to_value(tokens) + .ok() + .and_then(|value| value.as_object().cloned()), } } @@ -120,7 +122,7 @@ impl LanguageModel for CohereModel { } async fn do_generate(&self, options: &CallOptions) -> Result { - let body_result = build_request_body(&self.model_id, options, false); + let body_result = build_request_body(&self.model_id, options, false)?; let body = body_result.body.clone(); let headers = self.build_headers(options.headers.as_ref()); let resp = aimux_provider_utils::post_json_to_api( @@ -138,6 +140,7 @@ impl LanguageModel for CohereModel { ) .await?; + let response_body = resp.raw_value; let response_headers = resp.response_headers; let data: ChatResponse = resp.value; @@ -160,10 +163,10 @@ impl LanguageModel for CohereModel { // Mirrors TS: a `thinking` content item becomes a // `reasoning` content item. Empty thinking is dropped. if !thinking.is_empty() { - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text: thinking.clone(), provider_metadata: None, - }); + })); } } } @@ -186,6 +189,7 @@ impl LanguageModel for CohereModel { .and_then(|src| src.get("document")) .and_then(|d| d.get("title")) .and_then(|t| t.as_str()) + .filter(|s| !s.is_empty()) .map(std::string::ToString::to_string) .unwrap_or_else(|| "Document".to_string()); @@ -207,13 +211,13 @@ impl LanguageModel for CohereModel { { cohere_meta.insert("citationType".to_string(), Value::String(t.to_string())); } - content.push(GenerateContent::Source { + content.push(GenerateContent::Source(Source::Document { id: format!("citation-{i}"), - source_type: "document".to_string(), - url: None, - title: Some(title), - provider_metadata: Some(json!({ "cohere": cohere_meta })), - }); + media_type: "text/plain".to_string(), + title, + filename: None, + provider_metadata: Some(HashMap::from([("cohere".to_string(), cohere_meta)])), + })); } } @@ -248,18 +252,21 @@ impl LanguageModel for CohereModel { usage, warnings: body_result.warnings, provider_metadata: None, - response: ResponseMetadata { - id: data.generation_id, - timestamp: None, - model_id: None, - }, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: response_body, + headers: Some(response_headers), + ..aimux_core::result::GenerateResponse::from(ResponseMetadata { + id: data.generation_id, + timestamp: None, + model_id: None, + }) + }), }) } async fn do_stream(&self, options: &CallOptions) -> Result { - let body_result = build_request_body(&self.model_id, options, true); + let body_result = build_request_body(&self.model_id, options, true)?; let body = body_result.body.clone(); let headers = self.build_headers(options.headers.as_ref()); let endpoint = self.endpoint(); @@ -321,11 +328,11 @@ impl LanguageModel for CohereModel { Ok(parsed) => { match parsed.event_type.as_str() { "message-start" => { - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: parsed.id.clone(), timestamp: None, model_id: None, - }); + })); } "content-start" => { @@ -566,14 +573,16 @@ impl LanguageModel for CohereModel { } else { final_usage }, - provider_metadata: Some(serde_json::json!({ "cohere": {} })), + provider_metadata: Some(provider_namespace("cohere", json!({})).expect("provider metadata must be an object")), }); }; Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } diff --git a/aimux-providers/src/cohere/reranking.rs b/aimux-providers/src/cohere/reranking.rs index 9a2c91814..e8b58a9a4 100644 --- a/aimux-providers/src/cohere/reranking.rs +++ b/aimux-providers/src/cohere/reranking.rs @@ -14,6 +14,7 @@ use aimux_core::reranking_model::{ RerankingCallOptions, RerankingDocuments, RerankingModel, RerankingRank, RerankingResponse, RerankingResult, }; +use aimux_core::shared::SharedProviderOptions; use aimux_core::types::Warning; use aimux_provider_utils::HttpRequest; @@ -28,7 +29,7 @@ struct CohereRerankingOptions { } fn parse_cohere_reranking_options( - provider_options: Option<&HashMap>, + provider_options: Option<&SharedProviderOptions>, ) -> CohereRerankingOptions { let mut opts = CohereRerankingOptions::default(); if let Some(po) = provider_options diff --git a/aimux-providers/src/decision_support.rs b/aimux-providers/src/decision_support.rs index 88889b393..687f84a60 100644 --- a/aimux-providers/src/decision_support.rs +++ b/aimux-providers/src/decision_support.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use aimux_core::AiMuxError; use aimux_core::decision_model::{DecisionCallOptions, DecisionImage}; -use aimux_core::shared::FileBytes; +use aimux_core::shared::{FileBytes, SharedProviderMetadata}; use aimux_provider_utils::HttpRequest; use base64::Engine; use serde_json::Value; @@ -105,6 +105,17 @@ pub(crate) fn invalid(message: impl Into) -> AiMuxError { AiMuxError::InvalidResponseData(message.into()) } +/// Provider metadata keeps the whole response object under the provider key. +pub(crate) fn response_metadata( + provider: &str, + raw: &Value, +) -> Result { + let object = raw.as_object().cloned().ok_or_else(|| { + AiMuxError::InvalidResponseData(format!("{provider} response must be an object")) + })?; + Ok(HashMap::from([(provider.into(), object)])) +} + pub(crate) fn options( request: &DecisionCallOptions, provider: &str, @@ -115,9 +126,6 @@ pub(crate) fn options( .and_then(|options| options.get(provider)) { None => Ok(serde_json::Map::new()), - Some(Value::Object(options)) => Ok(options.clone()), - Some(_) => Err(AiMuxError::InvalidArgument(format!( - "{provider} decision options must be an object" - ))), + Some(options) => Ok(options.clone()), } } diff --git a/aimux-providers/src/deepgram.rs b/aimux-providers/src/deepgram.rs index 2647b7359..1fcfa51c9 100644 --- a/aimux-providers/src/deepgram.rs +++ b/aimux-providers/src/deepgram.rs @@ -17,7 +17,7 @@ use serde::Deserialize; use serde_json::Value; use aimux_core::error::AiMuxError; -use aimux_core::shared::Warning; +use aimux_core::shared::{SharedProviderOptions, Warning}; use aimux_core::transcription_model::{ AudioInput, TranscriptionCallOptions, TranscriptionModel, TranscriptionRequest, TranscriptionResponse, TranscriptionResult, TranscriptionSegment, @@ -136,7 +136,7 @@ struct DeepgramOptions { diarize: Option, } -fn parse_deepgram_options(provider_options: Option<&HashMap>) -> DeepgramOptions { +fn parse_deepgram_options(provider_options: Option<&SharedProviderOptions>) -> DeepgramOptions { let mut opts = DeepgramOptions::default(); if let Some(po) = provider_options && let Some(dg) = po.get("deepgram") diff --git a/aimux-providers/src/elevenlabs.rs b/aimux-providers/src/elevenlabs.rs index 8ab7f5e59..5664f933a 100644 --- a/aimux-providers/src/elevenlabs.rs +++ b/aimux-providers/src/elevenlabs.rs @@ -457,8 +457,7 @@ struct ElevenLabsPronunciationLocator { fn parse_elevenlabs_provider_options( options: Option<&SharedProviderOptions>, ) -> Option { - let provider_opts = options.and_then(|opts| opts.get("elevenlabs"))?; - let opts = provider_opts.as_object()?; + let opts = options.and_then(|opts| opts.get("elevenlabs"))?; let voice_settings = opts .get("voiceSettings") diff --git a/aimux-providers/src/fal.rs b/aimux-providers/src/fal.rs index 32460f8fd..4bd3daa9c 100644 --- a/aimux-providers/src/fal.rs +++ b/aimux-providers/src/fal.rs @@ -540,7 +540,7 @@ impl ImageModel for FalImageModel { body.insert("mask_url".into(), json!(Self::file_to_data_uri(mask)?)); } - if let Some(fal) = fal_opts.and_then(|v| v.as_object()) { + if let Some(fal) = fal_opts { let map: &[(&str, &str)] = &[ ("imageUrl", "image_url"), ("maskUrl", "mask_url"), @@ -678,7 +678,7 @@ impl ImageModel for FalImageModel { } } } - metadata.insert("fal".into(), Value::Object(fm)); + metadata.insert("fal".into(), fm); Ok(ImageResult { images: ImageOutputs::Binary(downloaded), diff --git a/aimux-providers/src/gladia.rs b/aimux-providers/src/gladia.rs index fbaa7af68..a8dcf356d 100644 --- a/aimux-providers/src/gladia.rs +++ b/aimux-providers/src/gladia.rs @@ -16,7 +16,7 @@ use serde_json::{Map, Value, json}; use aimux_core::error::{AiMuxError, ApiCallError}; use aimux_core::retry; -use aimux_core::shared::{SharedProviderMetadata, Warning}; +use aimux_core::shared::{SharedProviderMetadata, Warning, provider_namespace}; use aimux_core::transcription_model::{ AudioInput, TranscriptionCallOptions, TranscriptionModel, TranscriptionRequest, TranscriptionResponse, TranscriptionResult, TranscriptionSegment, @@ -265,10 +265,8 @@ impl TranscriptionModel for GladiaTranscriptionModel { && let Some(gladia) = po.get("gladia") { // Forward all provider options as-is (the API uses snake_case). - if let Some(obj) = gladia.as_object() { - for (k, v) in obj { - body.insert(k.clone(), v.clone()); - } + for (k, v) in gladia { + body.insert(k.clone(), v.clone()); } } @@ -359,11 +357,8 @@ impl TranscriptionModel for GladiaTranscriptionModel { let timestamp = chrono::Utc::now().to_rfc3339(); - let provider_metadata: Option = { - let mut md = HashMap::new(); - md.insert("gladia".to_string(), raw_body.clone()); - Some(md) - }; + let provider_metadata: Option = + Some(provider_namespace("gladia", raw_body.clone())?); return Ok(TranscriptionResult { text: result.transcription.full_transcript, segments, diff --git a/aimux-providers/src/google/convert.rs b/aimux-providers/src/google/convert.rs index dff5039d1..43fbca977 100644 --- a/aimux-providers/src/google/convert.rs +++ b/aimux-providers/src/google/convert.rs @@ -18,11 +18,14 @@ //! fields we actually read back are `text`, function tool parts, and native //! provider-executed tool parts. -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::LanguageModelPrompt; -use aimux_core::message::Role; +use aimux_core::error::AiMuxError; +use aimux_core::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, + ToolCallPart, ToolPart, ToolResultContent, ToolResultOutput, ToolResultPart, UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; -use aimux_core::result::GenerateContent; +use aimux_core::result::{GenerateContent, Source}; +use aimux_core::shared::{FileBytes, FileData, JsonObject, SharedProviderOptions}; use aimux_core::tool::{FunctionTool, Tool}; use aimux_core::types::{FinishReason, FinishReasonUnified, Warning}; use base64::Engine; @@ -69,9 +72,9 @@ enum ProviderMetadataNamespace { /// cross-provider fallback. The public Google provider uses the inverse /// fallback so transcripts survive gateway/provider failover. fn read_provider_options( - provider_options: Option<&Value>, + provider_options: Option<&SharedProviderOptions>, namespace: ProviderMetadataNamespace, -) -> Option<&Value> { +) -> Option<&JsonObject> { let provider_options = provider_options?; match namespace { ProviderMetadataNamespace::Google => provider_options @@ -100,24 +103,25 @@ fn read_provider_options( /// as a string (JSON-stringified for non-string outputs, matching the TS /// `output.type === 'json'` path). /// -/// Thought signatures from per-part provider options are echoed as a +/// Thought signatures from provider options are echoed as a /// `thoughtSignature` sibling of the `functionCall` part. #[must_use] pub fn convert_to_google_messages(prompt: &LanguageModelPrompt) -> GooglePrompt { - convert_to_google_messages_for_namespace(prompt, ProviderMetadataNamespace::Google) + convert_to_google_messages_for_namespace(prompt, ProviderMetadataNamespace::Google, true) } fn convert_to_google_messages_for_namespace( prompt: &LanguageModelPrompt, namespace: ProviderMetadataNamespace, + supports_function_response_parts: bool, ) -> GooglePrompt { let mut system_parts: Vec = Vec::new(); let mut contents: Vec = Vec::new(); let mut system_messages_allowed = true; for msg in prompt { - match msg.role { - Role::System => { + match msg { + LanguageModelMessage::System { content, .. } => { if !system_messages_allowed { // The TS SDK throws `UnsupportedFunctionalityError` here: // system messages are only valid at the start of the @@ -129,31 +133,54 @@ fn convert_to_google_messages_for_namespace( // TODO: change the signature to `Result` to match TS semantics. continue; } - for part in &msg.content { - if let ContentPart::Text { text, .. } = part { - system_parts.push(json!({ "text": text })); - } - } + system_parts.push(json!({ "text": content })); } - Role::User => { + LanguageModelMessage::User { content, .. } => { system_messages_allowed = false; - let parts = convert_user_parts(&msg.content); + let parts = convert_user_parts(content, namespace); contents.push(json!({ "role": "user", "parts": parts })); } - Role::Assistant => { + LanguageModelMessage::Assistant { content, .. } => { system_messages_allowed = false; - let parts = convert_assistant_parts(&msg.content, namespace); + let parts = convert_assistant_parts(content, namespace); if !parts.is_empty() { contents.push(json!({ "role": "model", "parts": parts })); } } - Role::Tool => { + LanguageModelMessage::Tool { content, .. } => { system_messages_allowed = false; // Gemini folds tool results into a `user`-role message as // `functionResponse` parts. We emit them as their own user // turn (matching how the TS SDK pushes a new `{role:'user'}` // entry per tool-role message). - let parts = convert_tool_parts(&msg.content); + let mut ordinary = Vec::new(); + for part in content { + if let ToolPart::ToolResult(result) = part + && let Some(options) = + read_provider_options(result.provider_options.as_ref(), namespace) + && let (Some(server_id), Some(server_type)) = ( + options.get("serverToolCallId"), + options.get("serverToolType"), + ) + && let Some(last) = contents.last_mut() + && last.get("role").and_then(Value::as_str) == Some("model") + && let Some(parts) = last.get_mut("parts").and_then(Value::as_array_mut) + { + let response = match &result.output { + ToolResultOutput::Json { value, .. } => value.clone(), + _ => json!({}), + }; + let mut value = json!({ "toolResponse": { "toolType": server_type, "response": response, "id": server_id } }); + if let Some(signature) = options.get("thoughtSignature") { + value["thoughtSignature"] = signature.clone(); + } + parts.push(value); + } else { + ordinary.push(part.clone()); + } + } + let parts = + convert_tool_parts(&ordinary, namespace, supports_function_response_parts); if !parts.is_empty() { contents.push(json!({ "role": "user", "parts": parts })); } @@ -174,32 +201,61 @@ fn convert_to_google_messages_for_namespace( } /// Convert user-role content parts into Google parts. -fn convert_user_parts(content: &[ContentPart]) -> Vec { +fn convert_user_parts(content: &[UserPart], namespace: ProviderMetadataNamespace) -> Vec { let mut parts = Vec::new(); for part in content { match part { - ContentPart::Text { text, .. } => { + UserPart::Text(TextPart { text, .. }) => { parts.push(json!({ "text": text })); } - ContentPart::Image { - image, media_type, .. - } => { - let b64 = base64::engine::general_purpose::STANDARD.encode(image); - parts.push(json!({ - "inlineData": { "mimeType": media_type, "data": b64 } - })); - } - ContentPart::File { + UserPart::File(FilePart { data, media_type, .. - } => { - let b64 = base64::engine::general_purpose::STANDARD.encode(data); + }) => { + let inline_media_type = if matches!(data, FileData::Text { .. }) + && !media_type + .split_once('/') + .is_some_and(|(_, subtype)| !subtype.is_empty() && subtype != "*") + { + "text/plain" + } else { + media_type.as_str() + }; + let data = match data { + FileData::Data { + data: FileBytes::Binary(bytes), + } => base64::engine::general_purpose::STANDARD.encode(bytes), + FileData::Text { text } => { + base64::engine::general_purpose::STANDARD.encode(text.as_bytes()) + } + FileData::Data { + data: FileBytes::Base64(data), + } => data.clone(), + FileData::Url { url, original_url } => { + let uri = if url.starts_with("gs:") { + original_url.as_deref().unwrap_or(url) + } else { + url + }; + parts.push(json!({ + "fileData": { "mimeType": media_type, "fileUri": uri } + })); + continue; + } + FileData::Reference { reference } => { + if namespace == ProviderMetadataNamespace::Google + && let Some(reference) = reference.get("google") + { + parts.push(json!({ + "fileData": { "mimeType": media_type, "fileUri": reference } + })); + } + continue; + } + }; parts.push(json!({ - "inlineData": { "mimeType": media_type, "data": b64 } + "inlineData": { "mimeType": inline_media_type, "data": data } })); } - // Tool calls / results inside a user message are unusual; skip - // them rather than trying to stringify a `ContentPart`. - _ => {} } } if parts.is_empty() { @@ -213,16 +269,16 @@ fn convert_user_parts(content: &[ContentPart]) -> Vec { /// - `Text` → `{ text }` (skipped when empty, matching the TS SDK). /// - `ToolCall` → `{ functionCall: { id?, name, args } }`. fn convert_assistant_parts( - content: &[ContentPart], + content: &[AssistantPart], namespace: ProviderMetadataNamespace, ) -> Vec { let mut parts = Vec::new(); for part in content { match part { - ContentPart::Text { + AssistantPart::Text(TextPart { text, provider_options, - } => { + }) => { if !text.is_empty() { let mut p = json!({ "text": text }); // Echo thoughtSignature from provider_options if present @@ -236,33 +292,28 @@ fn convert_assistant_parts( parts.push(p); } } - ContentPart::Reasoning { + AssistantPart::Reasoning(ReasoningPart { text, - signature, provider_options, - } => { + }) => { if !text.is_empty() { let mut p = json!({ "text": text, "thought": true }); - // Prefer explicit signature field, then fall back to provider_options. - if let Some(sig) = signature.as_ref() { - p["thoughtSignature"] = json!(sig); - } else if let Some(sig) = - read_provider_options(provider_options.as_ref(), namespace) - .and_then(|g| g.get("thoughtSignature")) - .and_then(|v| v.as_str()) + if let Some(sig) = read_provider_options(provider_options.as_ref(), namespace) + .and_then(|g| g.get("thoughtSignature")) + .and_then(Value::as_str) { p["thoughtSignature"] = json!(sig); } parts.push(p); } } - ContentPart::ToolCall { + AssistantPart::ToolCall(ToolCallPart { tool_call_id, tool_name, input, provider_options, .. - } => { + }) => { let google_options = read_provider_options(provider_options.as_ref(), namespace); let server_tool_call_id = google_options .and_then(|options| options.get("serverToolCallId")) @@ -272,7 +323,7 @@ fn convert_assistant_parts( .and_then(|value| value.as_str()); let signature = google_options .and_then(|options| options.get("thoughtSignature")) - .and_then(|value| value.as_str()); + .and_then(Value::as_str); let mut part_value = if let (Some(server_id), Some(server_type)) = (server_tool_call_id, server_tool_type) @@ -296,7 +347,7 @@ fn convert_assistant_parts( } } else { let mut function_call = Map::new(); - if !tool_call_id.is_empty() { + if !tool_call_id.is_empty() && namespace != ProviderMetadataNamespace::Vertex { function_call.insert("id".to_string(), json!(tool_call_id)); } function_call.insert("name".to_string(), json!(tool_name)); @@ -308,15 +359,21 @@ fn convert_assistant_parts( } parts.push(part_value); } - ContentPart::ToolResult { - tool_call_id: _, - tool_name: _, - result, - is_error: _, - preliminary: _, - dynamic: _, + AssistantPart::ToolResult(ToolResultPart { + output, + tool_name, provider_options, - } => { + .. + }) => { + let result = match output { + ToolResultOutput::Json { value, .. } => value.clone(), + _ => json!({}), + }; + if tool_name == "code_execution" && matches!(output, ToolResultOutput::Json { .. }) + { + parts.push(json!({ "codeExecutionResult": result })); + continue; + } // Provider-executed tool result in an assistant message — // upstream convert-to-google-messages.ts:518-540. // If it carries serverToolCallId + serverToolType, emit as @@ -346,9 +403,60 @@ fn convert_assistant_parts( } } } - _ => { - // Files / images in assistant messages: skip. + AssistantPart::ReasoningFile(part) => { + if let aimux_core::shared::GeneratedFileData::Data { data } = &part.data { + let data = match data { + FileBytes::Binary(bytes) => { + base64::engine::general_purpose::STANDARD.encode(bytes) + } + FileBytes::Base64(data) => data.clone(), + }; + let mut value = json!({ "inlineData": { "mimeType": part.media_type, "data": data }, "thought": true }); + if let Some(signature) = + read_provider_options(part.provider_options.as_ref(), namespace) + .and_then(|options| options.get("thoughtSignature")) + { + value["thoughtSignature"] = signature.clone(); + } + parts.push(value); + } + } + AssistantPart::File(file) => { + let mut value = match &file.data { + FileData::Data { data } => { + let data = match data { + FileBytes::Binary(bytes) => { + base64::engine::general_purpose::STANDARD.encode(bytes) + } + FileBytes::Base64(data) => data.clone(), + }; + json!({ "inlineData": { "mimeType": file.media_type, "data": data } }) + } + FileData::Text { text } => { + json!({ "inlineData": { "mimeType": if file.media_type.split_once('/').is_some_and(|(_, subtype)| !subtype.is_empty() && subtype != "*") { file.media_type.as_str() } else { "text/plain" }, "data": base64::engine::general_purpose::STANDARD.encode(text.as_bytes()) } }) + } + FileData::Reference { reference } => { + if let Some(uri) = reference.get("google") { + json!({ "fileData": { "mimeType": file.media_type, "fileUri": uri } }) + } else { + continue; + } + } + FileData::Url { .. } => continue, + }; + if let Some(options) = + read_provider_options(file.provider_options.as_ref(), namespace) + { + if options.get("thought").and_then(Value::as_bool) == Some(true) { + value["thought"] = json!(true); + } + if let Some(signature) = options.get("thoughtSignature") { + value["thoughtSignature"] = signature.clone(); + } + } + parts.push(value); } + AssistantPart::Custom(_) => {} } } parts @@ -360,40 +468,180 @@ fn convert_assistant_parts( /// `{ functionResponse: { id?, name, response: { name, content } } }`. /// `content` is the tool's output serialized to a string (string outputs /// pass through; JSON outputs are stringified). -fn convert_tool_parts(content: &[ContentPart]) -> Vec { +pub(crate) fn tool_file_media_type( + file: &FilePart, +) -> Result { + if let FileData::Data { data } = &file.data { + let bytes = match data { + FileBytes::Binary(bytes) => bytes.clone(), + FileBytes::Base64(data) => base64::engine::general_purpose::STANDARD + .decode(data) + .unwrap_or_default(), + }; + crate::anthropic::convert::resolve_full_media_type(&file.media_type, &bytes) + } else { + crate::anthropic::convert::resolve_full_media_type(&file.media_type, &[]) + } +} + +pub(crate) fn validate_tool_result_files( + prompt: &LanguageModelPrompt, +) -> Result<(), aimux_core::error::AiMuxError> { + for message in prompt { + if let LanguageModelMessage::Tool { content, .. } = message { + for part in content { + if let ToolPart::ToolResult(part) = part + && let ToolResultOutput::Content { value } = &part.output + { + for item in value { + if let ToolResultContent::File(file) = item + && matches!(file.data, FileData::Data { .. }) + { + tool_file_media_type(file)?; + } + } + } + } + } + } + Ok(()) +} + +fn contains_schema_reference(value: &Value) -> bool { + match value { + Value::Object(object) => { + object.contains_key("$ref") || object.values().any(contains_schema_reference) + } + Value::Array(array) => array.iter().any(contains_schema_reference), + _ => false, + } +} + +fn convert_tool_parts( + content: &[ToolPart], + namespace: ProviderMetadataNamespace, + supports_function_response_parts: bool, +) -> Vec { let mut parts = Vec::new(); for part in content { - if let ContentPart::ToolResult { + let ToolPart::ToolResult(ToolResultPart { tool_call_id, tool_name, - result, + output, .. - } = part - { - let content_str = match result { - Value::String(s) => s.clone(), - other => other.to_string(), - }; - let mut function_response = Map::new(); - if !tool_call_id.is_empty() { - function_response.insert("id".to_string(), json!(tool_call_id)); + }) = part + else { + continue; + }; + let response = |content: Value| { + let mut value = json!({ "functionResponse": { "name": tool_name, "response": { "name": tool_name, "content": content } } }); + if namespace != ProviderMetadataNamespace::Vertex && !tool_call_id.is_empty() { + value["functionResponse"]["id"] = json!(tool_call_id); } - // `name` is required by the API and must be the name of the tool - // that was called (Gemini pairs functionResponse with the prior - // functionCall by name; an empty or mismatched name is rejected - // with HTTP 400 on multi-turn tool calls). Prefer the framework - // provided `tool_name`, falling back to the call id for callers - // that never set one. - let name = tool_name - .clone() - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| tool_call_id.clone()); - function_response.insert("name".to_string(), json!(name)); - function_response.insert( - "response".to_string(), - json!({ "name": name, "content": content_str }), - ); - parts.push(json!({ "functionResponse": function_response })); + value + }; + match output { + ToolResultOutput::Content { value } => { + if supports_function_response_parts { + let mut texts = Vec::new(); + let mut files = Vec::new(); + for part in value { + match part { + ToolResultContent::Text(part) => texts.push(part.text.clone()), + ToolResultContent::File(file) => match &file.data { + FileData::Data { data } => { + let data = match data { + FileBytes::Binary(bytes) => { + base64::engine::general_purpose::STANDARD.encode(bytes) + } + FileBytes::Base64(data) => data.clone(), + }; + files.push(json!({ "inlineData": { "mimeType": tool_file_media_type(file).unwrap_or_else(|_| file.media_type.clone()), "data": data } })); + } + FileData::Url { url, .. } if url.starts_with("data:") => { + if let Some((media_type, data)) = url + .strip_prefix("data:") + .and_then(|url| url.split_once(";base64,")) + { + files.push(json!({ "inlineData": { "mimeType": media_type, "data": data } })); + } else { + texts.push( + crate::openai::convert::tool_result_content_value( + std::slice::from_ref(part), + )[0] + .to_string(), + ); + } + } + FileData::Url { url, original_url } + if namespace == ProviderMetadataNamespace::Vertex + && url.starts_with("gs:") + && original_url + .as_deref() + .unwrap_or(url) + .starts_with("gs://") + && matches!( + file.media_type.as_str(), + "image/png" + | "image/jpeg" + | "image/webp" + | "application/pdf" + | "text/plain" + ) => + { + files.push(json!({ "fileData": { "mimeType": file.media_type, "fileUri": original_url.as_deref().unwrap_or(url) } })); + } + _ => texts.push( + crate::openai::convert::tool_result_content_value( + std::slice::from_ref(part), + )[0] + .to_string(), + ), + }, + _ => texts.push( + crate::openai::convert::tool_result_content_value( + std::slice::from_ref(part), + )[0] + .to_string(), + ), + } + } + let mut result = response(json!(if texts.is_empty() { + "Tool executed successfully.".to_string() + } else { + texts.join("\n") + })); + if !files.is_empty() { + result["functionResponse"]["parts"] = json!(files); + } + parts.push(result); + continue; + } + for part in value { + match part { + ToolResultContent::Text(part) => parts.push(response(json!(part.text))), + ToolResultContent::File(file) => if let FileData::Data { data } = &file.data { + let data = match data { FileBytes::Binary(bytes) => base64::engine::general_purpose::STANDARD.encode(bytes), FileBytes::Base64(data) => data.clone() }; + parts.push(json!({ "inlineData": { "mimeType": tool_file_media_type(file).unwrap_or_else(|_| file.media_type.clone()), "data": data } })); + parts.push(json!({ "text": format!("Tool executed successfully and returned this {} as a response", if file.media_type.starts_with("image/") { "image" } else { "file" }) })); + } else { parts.push(json!({ "text": crate::openai::convert::tool_result_content_value(std::slice::from_ref(part))[0].to_string() })); }, + _ => parts.push(json!({ "text": crate::openai::convert::tool_result_content_value(std::slice::from_ref(part))[0].to_string() })), + } + } + } + ToolResultOutput::Text { value, .. } | ToolResultOutput::ErrorText { value, .. } => { + parts.push(response(json!(value))) + } + ToolResultOutput::Json { value, .. } | ToolResultOutput::ErrorJson { value, .. } => { + parts.push(response(if contains_schema_reference(value) { + json!(value.to_string()) + } else { + value.clone() + })) + } + ToolResultOutput::ExecutionDenied { reason, .. } => parts.push(response(json!( + reason.as_deref().unwrap_or("Tool call execution denied.") + ))), } } parts @@ -993,6 +1241,33 @@ fn is_empty_object_schema(obj: &Map) -> bool { && !obj.contains_key("additionalProperties") } +// Image wrappers delegate these fields through the upstream language schema. +#[must_use] +pub(crate) fn image_generation_option(key: &str, value: &Value) -> Value { + let fields: &[&str] = match key { + "thinkingConfig" => &["thinkingBudget", "includeThoughts", "thinkingLevel"], + "imageConfig" => &[ + "aspectRatio", + "imageSize", + "personGeneration", + "prominentPeople", + "imageOutputOptions", + ], + "imageOutputOptions" => &["mimeType", "compressionQuality"], + _ => return value.clone(), + }; + let Some(object) = value.as_object() else { + return value.clone(); + }; + Value::Object( + object + .iter() + .filter(|(key, _)| fields.contains(&key.as_str())) + .map(|(key, value)| (key.clone(), image_generation_option(key, value))) + .collect(), + ) +} + // ── build_request_body ─────────────────────────────────────────────────────── /// Build the Gemini `generateContent` request body from `CallOptions`. @@ -1004,31 +1279,40 @@ fn is_empty_object_schema(obj: &Map) -> bool { /// /// This is the request-body-only entry point; warnings about unsupported tools /// are discarded. Use [`build_request_body_with_warnings`] to surface them. -#[must_use] -pub fn build_request_body(model_id: &str, options: &CallOptions) -> Value { - build_request_body_with_warnings(model_id, options).0 +/// +/// # Errors +/// Returns an error when `structuredOutputs` is not a boolean. +pub fn build_request_body(model_id: &str, options: &CallOptions) -> Result { + build_request_body_with_warnings(model_id, options).map(|(body, _)| body) } /// Build a Vertex Gemini request body using Vertex's provider-metadata /// namespaces when replaying response parts. -#[must_use] -pub(crate) fn build_vertex_request_body(model_id: &str, options: &CallOptions) -> Value { +/// +/// # Errors +/// Returns an error when `structuredOutputs` is not a boolean. +pub(crate) fn build_vertex_request_body( + model_id: &str, + options: &CallOptions, +) -> Result { build_request_body_with_warnings_for_namespace( model_id, options, ProviderMetadataNamespace::Vertex, ) - .0 + .map(|(body, _)| body) } /// Build the Gemini `generateContent` request body **and** collect the tool /// warnings (e.g. unsupported provider-defined tools, mixed function+provider /// tools on pre-Gemini-3 models). -#[must_use] +/// +/// # Errors +/// Returns an error when `structuredOutputs` is not a boolean. pub fn build_request_body_with_warnings( model_id: &str, options: &CallOptions, -) -> (Value, Vec) { +) -> Result<(Value, Vec), AiMuxError> { build_request_body_with_warnings_for_namespace( model_id, options, @@ -1040,11 +1324,35 @@ fn build_request_body_with_warnings_for_namespace( model_id: &str, options: &CallOptions, namespace: ProviderMetadataNamespace, -) -> (Value, Vec) { +) -> Result<(Value, Vec), AiMuxError> { + let names: &[&str] = match namespace { + ProviderMetadataNamespace::Google => &["google"], + ProviderMetadataNamespace::Vertex => &["googleVertex", "vertex", "google"], + }; + let provider_options = options.provider_options.as_ref().and_then(|options| { + names + .iter() + .find_map(|name| options.get(*name).map(|value| (*name, value))) + }); + let structured_outputs = + match provider_options.and_then(|(_, options)| options.get("structuredOutputs")) { + None => true, + Some(Value::Bool(value)) => *value, + Some(_) => { + return Err(AiMuxError::InvalidArgument(format!( + "invalid {} provider options", + provider_options.unwrap().0 + ))); + } + }; let GooglePrompt { system_instruction, contents, - } = convert_to_google_messages_for_namespace(&options.prompt, namespace); + } = convert_to_google_messages_for_namespace( + &options.prompt, + namespace, + model_id.contains("gemini-3"), + ); let mut generation_config = Map::new(); @@ -1079,7 +1387,9 @@ fn build_request_body_with_warnings_for_namespace( && let ResponseFormat::Json { schema, .. } = rf { generation_config.insert("responseMimeType".to_string(), json!("application/json")); - if let Some(s) = schema { + if let Some(s) = schema + && structured_outputs + { let openapi = convert_json_schema_to_openapi_schema(s, true); if !openapi.is_null() { generation_config.insert("responseSchema".to_string(), openapi); @@ -1112,7 +1422,7 @@ fn build_request_body_with_warnings_for_namespace( // the TS SDK. (Some callers include it; the API ignores extra fields.) let _ = model_id; - (Value::Object(body), prepared.warnings) + Ok((Value::Object(body), prepared.warnings)) } // ── finish reason ──────────────────────────────────────────────────────────── @@ -1155,7 +1465,7 @@ pub fn parse_finish_reason(reason: &str, has_tool_calls: bool) -> FinishReason { /// - `output.total = candidatesTokenCount + thoughtsTokenCount` #[must_use] pub fn convert_usage(usage: &super::types::GoogleUsageMetadata) -> aimux_core::types::Usage { - use aimux_core::types::{TokenUsage, Usage}; + use aimux_core::types::Usage; let prompt = usage.prompt_token_count.unwrap_or(0); let candidates = usage.candidates_token_count.unwrap_or(0); @@ -1163,19 +1473,21 @@ pub fn convert_usage(usage: &super::types::GoogleUsageMetadata) -> aimux_core::t let thoughts = usage.thoughts_token_count.unwrap_or(0); Usage { - input_tokens: TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(prompt), no_cache: Some(prompt - cached), cache_read: Some(cached), cache_write: None, - ..Default::default() }, - output_tokens: TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(candidates + thoughts), - ..Default::default() + text: Some(candidates), + reasoning: Some(thoughts), }, // RFC-0015 P0-3: keep the raw provider usage payload. - raw: Some(serde_json::to_value(usage).unwrap_or(serde_json::Value::Null)), + raw: serde_json::to_value(usage) + .ok() + .and_then(|value| value.as_object().cloned()), } } @@ -1210,196 +1522,106 @@ pub fn extract_sources( }; for chunk in chunks { - if let Some(web) = chunk.get("web") { - sources.push(GenerateContent::Source { + if let Some(web) = chunk.get("web").filter(|value| !value.is_null()) { + sources.push(GenerateContent::Source(Source::Url { id: next_id(id_counter), - source_type: "url".to_string(), url: web .get("uri") .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string), + .unwrap_or_default() + .to_string(), title: web .get("title") .and_then(|v| v.as_str()) .map(std::string::ToString::to_string), provider_metadata: None, - }); - } else if let Some(image) = chunk.get("image") { - sources.push(GenerateContent::Source { + })); + } else if let Some(image) = chunk.get("image").filter(|value| !value.is_null()) { + sources.push(GenerateContent::Source(Source::Url { id: next_id(id_counter), - source_type: "url".to_string(), url: image .get("sourceUri") .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string), + .unwrap_or_default() + .to_string(), title: image .get("title") .and_then(|v| v.as_str()) .map(std::string::ToString::to_string), provider_metadata: None, - }); - } else if let Some(rc) = chunk.get("retrievedContext") { - let uri = rc.get("uri").and_then(|v| v.as_str()); - let file_search_store = rc.get("fileSearchStore").and_then(|v| v.as_str()); + })); + } else if let Some(rc) = chunk + .get("retrievedContext") + .filter(|value| !value.is_null()) + { + let uri = rc + .get("uri") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()); + let file_search_store = rc + .get("fileSearchStore") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()); let title = rc.get("title").and_then(|v| v.as_str()); if let Some(uri) = uri { if uri.starts_with("http://") || uri.starts_with("https://") { - sources.push(GenerateContent::Source { + sources.push(GenerateContent::Source(Source::Url { id: next_id(id_counter), - source_type: "url".to_string(), - url: Some(uri.to_string()), + url: uri.to_string(), title: title.map(std::string::ToString::to_string), provider_metadata: None, - }); + })); } else { // Document with a file path (gs://, etc.). - sources.push(GenerateContent::Source { + let media_type = if uri.ends_with(".pdf") { + "application/pdf" + } else if uri.ends_with(".txt") { + "text/plain" + } else if uri.ends_with(".docx") { + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + } else if uri.ends_with(".doc") { + "application/msword" + } else if uri.ends_with(".md") || uri.ends_with(".markdown") { + "text/markdown" + } else { + "application/octet-stream" + }; + sources.push(GenerateContent::Source(Source::Document { id: next_id(id_counter), - source_type: "document".to_string(), - url: None, - title: Some(title.unwrap_or("Unknown Document").to_string()), + media_type: media_type.to_string(), + title: title.unwrap_or("Unknown Document").to_string(), + filename: uri.rsplit('/').next().map(str::to_string), provider_metadata: None, - }); + })); } - } else if file_search_store.is_some() { + } else if let Some(file_search_store) = file_search_store { // New File Search format (no uri, has fileSearchStore). - sources.push(GenerateContent::Source { + sources.push(GenerateContent::Source(Source::Document { id: next_id(id_counter), - source_type: "document".to_string(), - url: None, - title: Some(title.unwrap_or("Unknown Document").to_string()), + media_type: "application/octet-stream".to_string(), + title: title.unwrap_or("Unknown Document").to_string(), + filename: file_search_store.rsplit('/').next().map(str::to_string), provider_metadata: None, - }); + })); } // else: no uri and no fileSearchStore → no source. - } else if let Some(maps) = chunk.get("maps") - && let Some(uri) = maps.get("uri").and_then(|v| v.as_str()) + } else if let Some(maps) = chunk.get("maps").filter(|value| !value.is_null()) + && let Some(uri) = maps + .get("uri") + .and_then(|v| v.as_str()) + .filter(|uri| !uri.is_empty()) { - sources.push(GenerateContent::Source { + sources.push(GenerateContent::Source(Source::Url { id: next_id(id_counter), - source_type: "url".to_string(), - url: Some(uri.to_string()), + url: uri.to_string(), title: maps .get("title") .and_then(|v| v.as_str()) .map(std::string::ToString::to_string), provider_metadata: None, - }); + })); } } sources } - -#[cfg(test)] -mod tests { - use super::*; - use aimux_core::language_model_message::LanguageModelPromptMessage; - - fn assistant_prompt(content: Vec) -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::Assistant, - content, - provider_options: None, - }] - } - - fn code_metadata(namespace: &str, id: &str) -> Value { - json!({ - (namespace): { - "serverToolCallId": id, - "serverToolType": "code_execution", - } - }) - } - - #[test] - fn vertex_replays_native_code_execution_with_multiple_results() { - let call_id = "code-1"; - let prompt = assistant_prompt(vec![ - ContentPart::ToolCall { - tool_call_id: call_id.to_string(), - tool_name: "runCode".to_string(), - input: json!({ "language": "PYTHON", "code": "print(2)" }), - provider_executed: Some(true), - provider_options: Some(code_metadata("googleVertex", call_id)), - }, - ContentPart::ToolResult { - tool_call_id: call_id.to_string(), - tool_name: Some("runCode".to_string()), - result: json!({ "outcome": "OUTCOME_OK", "output": "2" }), - is_error: None, - preliminary: None, - dynamic: None, - provider_options: Some(code_metadata("googleVertex", call_id)), - }, - ContentPart::ToolResult { - tool_call_id: call_id.to_string(), - tool_name: Some("runCode".to_string()), - result: json!({ "outcome": "OUTCOME_OK", "output": "still 2" }), - is_error: None, - preliminary: None, - dynamic: None, - provider_options: Some(code_metadata("googleVertex", call_id)), - }, - ]); - - let converted = - convert_to_google_messages_for_namespace(&prompt, ProviderMetadataNamespace::Vertex); - assert_eq!( - converted.contents[0]["parts"], - json!([ - { "executableCode": { "language": "PYTHON", "code": "print(2)" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "still 2" } }, - ]) - ); - } - - #[test] - fn provider_metadata_namespace_precedence_matches_ai_sdk() { - let content = ContentPart::ToolCall { - tool_call_id: "call-1".to_string(), - tool_name: "weather".to_string(), - input: json!({}), - provider_executed: None, - provider_options: Some(json!({ - "googleVertex": { "thoughtSignature": "google-vertex" }, - "vertex": { "thoughtSignature": "vertex" }, - "google": { "thoughtSignature": "google" }, - })), - }; - let prompt = assistant_prompt(vec![content]); - - let vertex = - convert_to_google_messages_for_namespace(&prompt, ProviderMetadataNamespace::Vertex); - assert_eq!( - vertex.contents[0]["parts"][0]["thoughtSignature"], - "google-vertex" - ); - - let google = - convert_to_google_messages_for_namespace(&prompt, ProviderMetadataNamespace::Google); - assert_eq!(google.contents[0]["parts"][0]["thoughtSignature"], "google"); - } - - #[test] - fn vertex_reads_google_as_cross_namespace_fallback() { - let prompt = assistant_prompt(vec![ContentPart::ToolCall { - tool_call_id: "call-1".to_string(), - tool_name: "weather".to_string(), - input: json!({}), - provider_executed: None, - provider_options: Some(json!({ - "google": { "thoughtSignature": "gateway-signature" }, - })), - }]); - - let converted = - convert_to_google_messages_for_namespace(&prompt, ProviderMetadataNamespace::Vertex); - assert_eq!( - converted.contents[0]["parts"][0]["thoughtSignature"], - "gateway-signature" - ); - } -} diff --git a/aimux-providers/src/google/files.rs b/aimux-providers/src/google/files.rs index d0256391a..ecab1f955 100644 --- a/aimux-providers/src/google/files.rs +++ b/aimux-providers/src/google/files.rs @@ -22,7 +22,7 @@ use serde_json::{Value, json}; use aimux_core::error::{AiMuxError, ApiCallError}; use aimux_core::files_model::{Files, UploadFileCallOptions, UploadFileData, UploadFileResult}; -use aimux_core::shared::FileBytes; +use aimux_core::shared::{FileBytes, SharedProviderOptions}; use aimux_core::types::Warning; use aimux_provider_utils::{HttpBody, HttpRequest, sleep_or_abort}; @@ -40,7 +40,7 @@ struct GoogleFilesUploadOptions { /// Parse provider options for the Google files provider. fn parse_google_files_options( - provider_options: Option<&HashMap>, + provider_options: Option<&SharedProviderOptions>, ) -> GoogleFilesUploadOptions { let mut opts = GoogleFilesUploadOptions::default(); if let Some(po) = provider_options @@ -402,9 +402,7 @@ impl Files for GoogleFiles { provider_reference: provider_ref, media_type: result_media_type, filename: None, - provider_metadata: Some( - std::iter::once(("google".to_string(), Value::Object(metadata))).collect(), - ), + provider_metadata: Some(HashMap::from([("google".to_string(), metadata)])), warnings, }) } diff --git a/aimux-providers/src/google/image.rs b/aimux-providers/src/google/image.rs index 75ff4da8f..098864a03 100644 --- a/aimux-providers/src/google/image.rs +++ b/aimux-providers/src/google/image.rs @@ -17,7 +17,7 @@ use aimux_core::image_model::{ ImageCallOptions, ImageFile, ImageFileData, ImageModel, ImageOutputs, ImageResponse, ImageResult, ImageUsage, }; -use aimux_core::shared::{SharedProviderMetadata, Warning}; +use aimux_core::shared::{SharedProviderMetadata, SharedProviderOptions, Warning}; use aimux_provider_utils::HttpRequest; @@ -283,17 +283,20 @@ impl GoogleImageModel { generation_config.insert("seed".to_string(), json!(seed)); } - // Passthrough provider options (excluding googleSearch) - if let Some(google) = options - .provider_options - .get("google") - .and_then(|v| v.as_object()) - { - for (key, value) in google { - if key == "googleSearch" || key == "personGeneration" || key == "aspectRatio" { - continue; + // Only declared generation-config options survive the upstream language schema. + if let Some(google) = options.provider_options.get("google") { + for key in [ + "audioTimestamp", + "thinkingConfig", + "mediaResolution", + "imageConfig", + ] { + if let Some(value) = google.get(key) { + generation_config.insert( + key.to_string(), + crate::google::convert::image_generation_option(key, value), + ); } - generation_config.insert(key.clone(), value.clone()); } } @@ -386,7 +389,7 @@ struct GoogleImageOptions { } /// Parse Google image provider options from the `"google"` key. -fn parse_google_image_options(provider_options: &HashMap) -> GoogleImageOptions { +fn parse_google_image_options(provider_options: &SharedProviderOptions) -> GoogleImageOptions { let google = provider_options.get("google"); GoogleImageOptions { person_generation: google @@ -427,7 +430,7 @@ fn extract_imagen_metadata(response: &Value) -> SharedProviderMetadata { let images: Vec = (0..predictions).map(|_| json!({})).collect(); let mut google_meta = Map::new(); google_meta.insert("images".to_string(), json!(images)); - metadata.insert("google".to_string(), Value::Object(google_meta)); + metadata.insert("google".to_string(), google_meta); metadata } @@ -493,7 +496,7 @@ fn extract_gemini_result( if let Some(gm) = grounding_metadata { google_meta.insert("groundingMetadata".to_string(), gm); } - metadata.insert("google".to_string(), Value::Object(google_meta)); + metadata.insert("google".to_string(), google_meta); (ImageOutputs::Base64(images), metadata, usage) } diff --git a/aimux-providers/src/google/model.rs b/aimux-providers/src/google/model.rs index 8100748c4..712c51b38 100644 --- a/aimux-providers/src/google/model.rs +++ b/aimux-providers/src/google/model.rs @@ -11,8 +11,10 @@ use serde_json::{Value, json}; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; -use aimux_core::shared::{FileBytes, FileData}; +use aimux_core::result::{ + GenerateContent, GenerateResult, GeneratedFile, ReasoningOutput, Source, StreamResult, +}; +use aimux_core::shared::{FileBytes, GeneratedFileData, provider_namespace}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{ FinishReason, FinishReasonUnified, ProviderMetadata, ResponseMetadata, Usage, @@ -121,8 +123,41 @@ impl LanguageModel for GoogleModel { } async fn do_generate(&self, options: &CallOptions) -> Result { + if options + .prompt + .iter() + .filter_map(|message| match message { + aimux_core::language_model_message::LanguageModelMessage::Assistant { + content, + .. + } => Some(content), + _ => None, + }) + .flatten() + .any(|part| { + matches!( + part, + aimux_core::language_model_message::AssistantPart::ReasoningFile( + aimux_core::language_model_message::ReasoningFilePart { + data: aimux_core::shared::GeneratedFileData::Url { .. }, + .. + } + ) | aimux_core::language_model_message::AssistantPart::File( + aimux_core::language_model_message::FilePart { + data: aimux_core::shared::FileData::Url { .. }, + .. + } + ) + ) + }) + { + return Err(AiMuxError::UnsupportedFunctionality( + "File data URLs in assistant messages are not supported".to_string(), + )); + } + crate::google::convert::validate_tool_result_files(&options.prompt)?; let code_execution_tool_name = code_execution_tool_name(options.tools.as_deref()); - let (body, tool_warnings) = build_request_body_with_warnings(&self.model_id, options); + let (body, tool_warnings) = build_request_body_with_warnings(&self.model_id, options)?; let headers = self.build_headers(options.headers.as_ref()); let resp = aimux_provider_utils::post_json_to_api( HttpRequest::new( @@ -136,6 +171,7 @@ impl LanguageModel for GoogleModel { ) .await?; + let response_body = resp.raw_value; let response_headers = resp.response_headers; let data: GenerateContentResponse = resp.value; @@ -164,16 +200,20 @@ impl LanguageModel for GoogleModel { // Provider metadata: wrap the raw Google metadata under a `google` // key (matching the TS `wrapProviderMetadata`). - let provider_metadata = Some(serde_json::json!({ - "google": { - "promptFeedback": data.prompt_feedback, - "groundingMetadata": candidate.grounding_metadata, - "urlContextMetadata": candidate.url_context_metadata, - "safetyRatings": candidate.safety_ratings, - "usageMetadata": data.usage_metadata, - "finishMessage": candidate.finish_message, - } - })); + let provider_metadata = Some( + provider_namespace( + "google", + json!({ + "promptFeedback": data.prompt_feedback, + "groundingMetadata": candidate.grounding_metadata, + "urlContextMetadata": candidate.url_context_metadata, + "safetyRatings": candidate.safety_ratings, + "usageMetadata": data.usage_metadata, + "finishMessage": candidate.finish_message, + }), + ) + .expect("provider metadata must be an object"), + ); Ok(GenerateResult { content, @@ -181,19 +221,55 @@ impl LanguageModel for GoogleModel { usage, warnings: tool_warnings, provider_metadata, - response: ResponseMetadata { - id: data.response_id, - timestamp: None, - model_id: None, - }, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: response_body, + headers: Some(response_headers), + ..aimux_core::result::GenerateResponse::from(ResponseMetadata { + id: data.response_id, + timestamp: None, + model_id: None, + }) + }), }) } async fn do_stream(&self, options: &CallOptions) -> Result { + if options + .prompt + .iter() + .filter_map(|message| match message { + aimux_core::language_model_message::LanguageModelMessage::Assistant { + content, + .. + } => Some(content), + _ => None, + }) + .flatten() + .any(|part| { + matches!( + part, + aimux_core::language_model_message::AssistantPart::ReasoningFile( + aimux_core::language_model_message::ReasoningFilePart { + data: aimux_core::shared::GeneratedFileData::Url { .. }, + .. + } + ) | aimux_core::language_model_message::AssistantPart::File( + aimux_core::language_model_message::FilePart { + data: aimux_core::shared::FileData::Url { .. }, + .. + } + ) + ) + }) + { + return Err(AiMuxError::UnsupportedFunctionality( + "File data URLs in assistant messages are not supported".to_string(), + )); + } + crate::google::convert::validate_tool_result_files(&options.prompt)?; let code_execution_tool_name = code_execution_tool_name(options.tools.as_deref()); - let (body, tool_warnings) = build_request_body_with_warnings(&self.model_id, options); + let (body, tool_warnings) = build_request_body_with_warnings(&self.model_id, options)?; let headers = self.build_headers(options.headers.as_ref()); let endpoint = self.stream_endpoint(); let resp = aimux_provider_utils::post_json_to_api( @@ -267,11 +343,11 @@ impl LanguageModel for GoogleModel { if !response_metadata_emitted && let Some(id) = &chunk.response_id { response_metadata_emitted = true; - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: Some(id.clone()), timestamp: None, model_id: chunk.model_version.clone(), - }); + })); } if let Some(usage) = &chunk.usage_metadata { @@ -303,21 +379,19 @@ impl LanguageModel for GoogleModel { let chunk_sources = extract_sources(candidate.grounding_metadata.as_ref(), &mut source_id); for src in chunk_sources { - if let GenerateContent::Source { - url: Some(url), - source_type, + if let GenerateContent::Source(Source::Url { + url, id, title, provider_metadata: None, - } = src + }) = src && emitted_source_urls.insert(url.clone()) { - yield Ok(StreamPart::Source { + yield Ok(StreamPart::Source(Source::Url { id, - source_type, - url: Some(url), + url, title, provider_metadata: None, - }); + })); } } @@ -326,10 +400,10 @@ impl LanguageModel for GoogleModel { { for part in parts { // thoughtSignature → provider_metadata (upstream :778-782) - let thought_sig_meta: Option = part + let thought_sig_meta: Option = part .get("thoughtSignature") .and_then(|v| v.as_str()) - .map(|s| json!({ "google": { "thoughtSignature": s } })); + .map(|s| provider_namespace("google", json!({ "thoughtSignature": s })).expect("provider metadata must be an object")); // text part if let Some(text) = part.get("text").and_then(|v| v.as_str()) { @@ -507,12 +581,11 @@ impl LanguageModel for GoogleModel { block_counter += 1; last_server_tool_call_id = Some(id.clone()); let args = tc.get("args").cloned().unwrap_or(json!({})); - let mut server_meta = json!({ - "google": { "serverToolCallId": id, "serverToolType": tool_type } - }); - if let Some(s) = part.get("thoughtSignature").and_then(|v| v.as_str()) { - server_meta["google"]["thoughtSignature"] = json!(s); - } + let server_meta = server_tool_metadata( + &id, + tool_type, + part.get("thoughtSignature").and_then(|v| v.as_str()), + ); yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: id, tool_name: format!("server:{tool_type}"), @@ -536,12 +609,11 @@ impl LanguageModel for GoogleModel { block_counter += 1; let response = tr.get("response").cloned().unwrap_or(json!({})); - let mut server_meta = json!({ - "google": { "serverToolCallId": id, "serverToolType": tool_type } - }); - if let Some(s) = part.get("thoughtSignature").and_then(|v| v.as_str()) { - server_meta["google"]["thoughtSignature"] = json!(s); - } + let server_meta = server_tool_metadata( + &id, + tool_type, + part.get("thoughtSignature").and_then(|v| v.as_str()), + ); yield Ok(StreamPart::ToolResult(ToolResult { tool_call_id: id, tool_name: format!("server:{tool_type}"), @@ -564,13 +636,12 @@ impl LanguageModel for GoogleModel { inline.get("data").and_then(|v| v.as_str()), inline.get("mimeType").and_then(|v| v.as_str()), ) { - // part.thought === true → upstream emits 'reasoning-file'; - // emit plain File (reasoning-file is a separate PR). - yield Ok(StreamPart::File { - data: FileData::Data { data: FileBytes::Base64(data.to_string()) }, + let file = GeneratedFile { + data: GeneratedFileData::Data { data: FileBytes::Base64(data.to_string()) }, media_type: mime.to_string(), provider_metadata: thought_sig_meta.clone(), - }); + }; + yield Ok(if part.get("thought").and_then(serde_json::Value::as_bool).unwrap_or(false) { StreamPart::ReasoningFile(file) } else { StreamPart::File(file) }); } } } @@ -626,16 +697,14 @@ impl LanguageModel for GoogleModel { yield Ok(StreamPart::ReasoningEnd { id, provider_metadata: None}); } - let provider_metadata = Some(serde_json::json!({ - "google": { - "promptFeedback": last_prompt_feedback, - "groundingMetadata": last_grounding_metadata, - "urlContextMetadata": last_url_context_metadata, - "safetyRatings": last_safety_ratings, - "usageMetadata": last_usage_metadata_value, - "finishMessage": last_finish_message, - } - })); + let provider_metadata = Some(provider_namespace("google", json!({ + "promptFeedback": last_prompt_feedback, + "groundingMetadata": last_grounding_metadata, + "urlContextMetadata": last_url_context_metadata, + "safetyRatings": last_safety_ratings, + "usageMetadata": last_usage_metadata_value, + "finishMessage": last_finish_message, + })).expect("provider metadata must be an object")); yield Ok(StreamPart::Finish { finish_reason: if stream_errored { @@ -656,8 +725,10 @@ impl LanguageModel for GoogleModel { Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } @@ -668,7 +739,7 @@ fn server_tool_metadata( tool_call_id: &str, server_tool_type: &str, thought_signature: Option<&str>, -) -> Value { +) -> ProviderMetadata { let mut payload = json!({ "serverToolCallId": tool_call_id, "serverToolType": server_tool_type, @@ -676,7 +747,7 @@ fn server_tool_metadata( if let Some(signature) = thought_signature { payload["thoughtSignature"] = json!(signature); } - json!({ "google": payload }) + provider_namespace("google", payload).expect("provider metadata must be an object") } /// Extract `GenerateContent` items from a non-streaming candidate. @@ -707,10 +778,13 @@ fn extract_content_from_candidate( if let Some(parts) = parts { for part in parts { // thoughtSignature → provider_metadata (upstream :448-451) - let thought_sig_meta: Option = part + let thought_sig_meta: Option = part .get("thoughtSignature") .and_then(|v| v.as_str()) - .map(|s| json!({ "google": { "thoughtSignature": s } })); + .map(|s| { + provider_namespace("google", json!({ "thoughtSignature": s })) + .expect("provider metadata must be an object") + }); // Branch order matches upstream (google-language-model.ts:420-534): // executableCode → codeExecutionResult → text → functionCall @@ -769,10 +843,10 @@ fn extract_content_from_candidate( .and_then(serde_json::Value::as_bool) .unwrap_or(false); if is_thought { - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text: text.to_string(), provider_metadata: thought_sig_meta.clone(), - }); + })); } else { content.push(GenerateContent::Text { text: text.to_string(), @@ -807,15 +881,24 @@ fn extract_content_from_candidate( inline.get("data").and_then(|v| v.as_str()), inline.get("mimeType").and_then(|v| v.as_str()), ) { - // part.thought === true → upstream emits 'reasoning-file'; - // emit plain File (reasoning-file is a separate PR). - content.push(GenerateContent::File { - data: FileData::Data { + let file = GeneratedFile { + data: GeneratedFileData::Data { data: FileBytes::Base64(data.to_string()), }, media_type: mime.to_string(), provider_metadata: thought_sig_meta.clone(), - }); + }; + content.push( + if part + .get("thought") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + GenerateContent::ReasoningFile(file) + } else { + GenerateContent::File(file) + }, + ); } } else if let Some(tc) = part.get("toolCall") { // Server-side tool call (provider-executed, Gemini 3). @@ -827,12 +910,11 @@ fn extract_content_from_candidate( .to_string(); last_server_tool_call_id = Some(id.clone()); let input = tc.get("args").cloned().unwrap_or(json!({})); - let mut server_meta = json!({ - "google": { "serverToolCallId": id, "serverToolType": tool_type } - }); - if let Some(s) = part.get("thoughtSignature").and_then(|v| v.as_str()) { - server_meta["google"]["thoughtSignature"] = json!(s); - } + let server_meta = server_tool_metadata( + &id, + tool_type, + part.get("thoughtSignature").and_then(|v| v.as_str()), + ); content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: id, tool_name: format!("server:{tool_type}"), @@ -854,12 +936,11 @@ fn extract_content_from_candidate( }) .unwrap_or_default(); let response = tr.get("response").cloned().unwrap_or(json!({})); - let mut server_meta = json!({ - "google": { "serverToolCallId": id, "serverToolType": tool_type } - }); - if let Some(s) = part.get("thoughtSignature").and_then(|v| v.as_str()) { - server_meta["google"]["thoughtSignature"] = json!(s); - } + let server_meta = server_tool_metadata( + &id, + tool_type, + part.get("thoughtSignature").and_then(|v| v.as_str()), + ); content.push(GenerateContent::ToolResult(ToolResult { tool_call_id: id, tool_name: format!("server:{tool_type}"), @@ -888,18 +969,31 @@ fn set_provider_metadata(item: &mut GenerateContent, meta: ProviderMetadata) { GenerateContent::Text { provider_metadata, .. } - | GenerateContent::Reasoning { + | GenerateContent::Reasoning(ReasoningOutput { provider_metadata, .. - } + }) | GenerateContent::ToolCall(RawToolCall { provider_metadata, .. }) - | GenerateContent::File { + | GenerateContent::File(GeneratedFile { provider_metadata, .. - } - | GenerateContent::Source { + }) + | GenerateContent::Source(Source::Url { + provider_metadata, .. + }) + | GenerateContent::Source(Source::Document { + provider_metadata, .. + }) + | GenerateContent::ReasoningFile(GeneratedFile { + provider_metadata, .. + }) + | GenerateContent::Custom { provider_metadata, .. } + | GenerateContent::ToolApprovalRequest(aimux_core::result::RawToolApprovalRequest { + provider_metadata, + .. + }) | GenerateContent::ToolResult(ToolResult { provider_metadata, .. }) => { diff --git a/aimux-providers/src/google_pse.rs b/aimux-providers/src/google_pse.rs index 7be6d7e66..cfe817bf9 100644 --- a/aimux-providers/src/google_pse.rs +++ b/aimux-providers/src/google_pse.rs @@ -20,6 +20,7 @@ use aimux_core::provider::Provider; use aimux_core::search_model::{ SearchCallOptions, SearchModel, SearchResponse, SearchResult, SearchResultItem, }; +use aimux_core::shared::SharedProviderOptions; use aimux_provider_utils::{HttpRequest, load_api_key, without_trailing_slash}; /// Fixed model ID for the Google PSE search model. @@ -130,7 +131,7 @@ impl Provider for GooglePseProvider { /// `provider_options["google_pse"]["cx"]`. fn resolve_cx( config_cx: Option<&str>, - provider_options: Option<&HashMap>, + provider_options: Option<&SharedProviderOptions>, ) -> Option { if let Some(cx) = config_cx { return Some(cx.to_string()); diff --git a/aimux-providers/src/huggingface/responses.rs b/aimux-providers/src/huggingface/responses.rs index 3ff7391f6..adb27505e 100644 --- a/aimux-providers/src/huggingface/responses.rs +++ b/aimux-providers/src/huggingface/responses.rs @@ -23,19 +23,19 @@ use base64::Engine; use futures::StreamExt; use serde_json::{Map, Value, json}; -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::error::ApiCallError; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::LanguageModelPrompt; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source, StreamResult}; +use aimux_core::shared::provider_namespace; +use aimux_core::shared::{FileBytes, FileData}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::Tool; -use aimux_core::types::{ - FinishReason, FinishReasonUnified, ResponseMetadata, TokenUsage, Usage, Warning, -}; +use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage, Warning}; use aimux_provider_utils::HttpRequest; @@ -196,6 +196,7 @@ impl LanguageModel for HuggingFaceResponsesModel { ) .await?; + let response_body = resp.raw_value; let response_headers = resp.response_headers; let response: Value = resp.value; @@ -222,16 +223,20 @@ impl LanguageModel for HuggingFaceResponsesModel { finish_reason, usage, warnings: request.warnings, - provider_metadata: Some(json!({ - "huggingface": { "responseId": response_id } - })), - response: ResponseMetadata { - id: response_id, - timestamp: format_timestamp(created_at), - model_id: model, - }, - request_body: Some(body), - response_headers: Some(response_headers), + provider_metadata: Some( + provider_namespace("huggingface", json!({ "responseId": response_id })) + .expect("provider metadata must be an object"), + ), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: response_body, + headers: Some(response_headers), + ..aimux_core::result::GenerateResponse::from(ResponseMetadata { + id: response_id, + timestamp: format_timestamp(created_at), + model_id: model, + }) + }), }) } @@ -313,11 +318,11 @@ impl LanguageModel for HuggingFaceResponsesModel { .get("model") .and_then(|v| v.as_str()) .map(std::string::ToString::to_string); - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: response_id.clone(), timestamp: format_timestamp(created_at), model_id: model, - }); + })); } } @@ -341,9 +346,7 @@ impl LanguageModel for HuggingFaceResponsesModel { .to_string(); yield Ok(StreamPart::TextStart { id: id.clone(), - provider_metadata: Some(json!({ - "huggingface": { "itemId": id } - })), + provider_metadata: Some(provider_namespace("huggingface", json!({ "itemId": id })).expect("provider metadata must be an object")), }); } } @@ -375,9 +378,7 @@ impl LanguageModel for HuggingFaceResponsesModel { .to_string(); yield Ok(StreamPart::ReasoningStart { id: id.clone(), - provider_metadata: Some(json!({ - "huggingface": { "itemId": id } - })), + provider_metadata: Some(provider_namespace("huggingface", json!({ "itemId": id })).expect("provider metadata must be an object")), }); } _ => {} @@ -546,16 +547,16 @@ impl LanguageModel for HuggingFaceResponsesModel { yield Ok(StreamPart::Finish { finish_reason, usage, - provider_metadata: Some(json!({ - "huggingface": { "responseId": response_id } - })), + provider_metadata: Some(provider_namespace("huggingface", json!({ "responseId": response_id })).expect("provider metadata must be an object")), }); }; Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } @@ -732,74 +733,59 @@ pub fn convert_to_huggingface_responses_messages( let mut warnings: Vec = Vec::new(); for msg in prompt { - match msg.role { - Role::System => { - // System content is a plain string (concatenation of text parts). - let content: String = msg - .content - .iter() - .filter_map(|p| match p { - ContentPart::Text { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join(""); + match msg { + LanguageModelMessage::System { content, .. } => { messages.push(json!({ "role": "system", "content": content })); } - - Role::User => { + LanguageModelMessage::User { content, .. } => { let mut parts: Vec = Vec::new(); - for part in &msg.content { + for part in content { match part { - ContentPart::Text { text, .. } => { + UserPart::Text(TextPart { text, .. }) => { parts.push(json!({ "type": "input_text", "text": text })); } - ContentPart::FileBase64 { - data, media_type, .. - } => { - parts.push(convert_file_part_base64(media_type, data)?); - } - ContentPart::FileUrl { - url, media_type, .. - } => { - parts.push(convert_file_part_url(media_type, url)?); - } - ContentPart::FileReference { .. } => { - return Err(AiMuxError::UnsupportedFunctionality( - "file parts with provider references".into(), - )); - } - ContentPart::Image { - image, media_type, .. - } => { - let encoded = base64::engine::general_purpose::STANDARD.encode(image); - parts.push(convert_file_part_base64(media_type, &encoded)?); - } - ContentPart::File { - data, media_type, .. - } => { - let encoded = base64::engine::general_purpose::STANDARD.encode(data); - parts.push(convert_file_part_base64(media_type, &encoded)?); - } - _ => { - // Other part types (tool-call, tool-result, reasoning) - // are not expected in user messages — skip. + UserPart::File(file) => { + parts.push(match &file.data { + FileData::Data { + data: FileBytes::Binary(bytes), + } => { + let encoded = + base64::engine::general_purpose::STANDARD.encode(bytes); + convert_file_part_base64(&file.media_type, &encoded)? + } + FileData::Data { + data: FileBytes::Base64(data), + } => convert_file_part_base64(&file.media_type, data)?, + FileData::Url { url, .. } => { + convert_file_part_url(&file.media_type, url)? + } + FileData::Reference { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "file parts with provider references".into(), + )); + } + FileData::Text { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "text file parts".into(), + )); + } + }); } } } messages.push(json!({ "role": "user", "content": parts })); } - Role::Assistant => { - for part in &msg.content { + LanguageModelMessage::Assistant { content, .. } => { + for part in content { match part { - ContentPart::Text { text, .. } => { + AssistantPart::Text(TextPart { text, .. }) => { messages.push(json!({ "role": "assistant", "content": [{ "type": "output_text", "text": text }] })); } - ContentPart::Reasoning { text, .. } => { + AssistantPart::Reasoning(ReasoningPart { text, .. }) => { messages.push(json!({ "role": "assistant", "content": [{ "type": "output_text", "text": text }] @@ -807,13 +793,13 @@ pub fn convert_to_huggingface_responses_messages( } // Tool calls and tool results are handled by the // Responses API — skip (no warning, matching TS). - ContentPart::ToolCall { .. } | ContentPart::ToolResult { .. } => {} + AssistantPart::ToolCall(_) | AssistantPart::ToolResult(_) => {} _ => {} } } } - Role::Tool => { + LanguageModelMessage::Tool { .. } => { warnings.push(Warning::Unsupported { feature: "tool messages".into(), details: None, @@ -1091,9 +1077,10 @@ fn build_generate_content(response: &Value) -> Result, AiMu let item_id = part.get("id").and_then(|v| v.as_str()).unwrap_or(""); content.push(GenerateContent::Text { text: text.to_string(), - provider_metadata: Some(json!({ - "huggingface": { "itemId": item_id } - })), + provider_metadata: Some( + provider_namespace("huggingface", json!({ "itemId": item_id })) + .expect("provider metadata must be an object"), + ), }); // Process annotations → source parts. @@ -1101,13 +1088,12 @@ fn build_generate_content(response: &Value) -> Result, AiMu for ann in annotations { let url = ann.get("url").and_then(|v| v.as_str()).unwrap_or(""); let title = ann.get("title").and_then(|v| v.as_str()); - content.push(GenerateContent::Source { + content.push(GenerateContent::Source(Source::Url { id: format!("id-{source_id_counter}"), - source_type: "url".to_string(), - url: Some(url.to_string()), + url: url.to_string(), title: title.map(std::string::ToString::to_string), provider_metadata: None, - }); + })); source_id_counter += 1; } } @@ -1122,12 +1108,13 @@ fn build_generate_content(response: &Value) -> Result, AiMu for cp in content_parts { let text = cp.get("text").and_then(|v| v.as_str()).unwrap_or(""); let item_id = part.get("id").and_then(|v| v.as_str()).unwrap_or(""); - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text: text.to_string(), - provider_metadata: Some(json!({ - "huggingface": { "itemId": item_id } - })), - }); + provider_metadata: Some( + provider_namespace("huggingface", json!({ "itemId": item_id })) + .expect("provider metadata must be an object"), + ), + })); } } @@ -1270,21 +1257,19 @@ fn convert_usage(usage: Option<&Value>) -> Usage { .unwrap_or(0) as u32; Usage { - input_tokens: TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(input_tokens), no_cache: Some(input_tokens - cached_tokens), cache_read: Some(cached_tokens), cache_write: None, - ..Default::default() }, - output_tokens: TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(output_tokens), text: Some(output_tokens - reasoning_tokens), reasoning: Some(reasoning_tokens), - ..Default::default() }, // RFC-0015 P0-3: keep the raw provider usage payload. - raw: Some(u.clone()), + raw: u.as_object().cloned(), } } diff --git a/aimux-providers/src/hume.rs b/aimux-providers/src/hume.rs index d71b4a0a6..687b9b71c 100644 --- a/aimux-providers/src/hume.rs +++ b/aimux-providers/src/hume.rs @@ -318,8 +318,7 @@ struct HumeSpeechProviderOptions { fn parse_hume_provider_options( options: Option<&SharedProviderOptions>, ) -> Option { - let provider_opts = options.and_then(|opts| opts.get("hume"))?; - let opts = provider_opts.as_object()?; + let opts = options.and_then(|opts| opts.get("hume"))?; let context = opts.get("context").and_then(|c| c.as_object())?; // The context can be either { generationId: "..." } or { utterances: [...] }. diff --git a/aimux-providers/src/jina_ai.rs b/aimux-providers/src/jina_ai.rs index cea88fbb9..51ab3dd7e 100644 --- a/aimux-providers/src/jina_ai.rs +++ b/aimux-providers/src/jina_ai.rs @@ -19,6 +19,7 @@ use aimux_core::reranking_model::{ RerankingCallOptions, RerankingDocuments, RerankingModel, RerankingRank, RerankingResponse, RerankingResult, }; +use aimux_core::shared::SharedProviderOptions; use aimux_core::types::Warning; use aimux_provider_utils::{HttpRequest, load_api_key, without_trailing_slash}; @@ -119,7 +120,7 @@ struct JinaRerankingOptions { } fn parse_jina_reranking_options( - provider_options: Option<&HashMap>, + provider_options: Option<&SharedProviderOptions>, ) -> JinaRerankingOptions { let mut opts = JinaRerankingOptions::default(); if let Some(po) = provider_options diff --git a/aimux-providers/src/linkup.rs b/aimux-providers/src/linkup.rs index 2cb7ab4a9..697931187 100644 --- a/aimux-providers/src/linkup.rs +++ b/aimux-providers/src/linkup.rs @@ -19,6 +19,7 @@ use aimux_core::provider::Provider; use aimux_core::search_model::{ SearchCallOptions, SearchModel, SearchResponse, SearchResult, SearchResultItem, }; +use aimux_core::shared::SharedProviderOptions; use aimux_provider_utils::{HttpRequest, load_api_key, without_trailing_slash}; /// Fixed model ID for the Linkup search model. @@ -34,7 +35,7 @@ struct LinkupOptions { output_type: Option, } -fn parse_linkup_options(provider_options: Option<&HashMap>) -> LinkupOptions { +fn parse_linkup_options(provider_options: Option<&SharedProviderOptions>) -> LinkupOptions { let mut opts = LinkupOptions::default(); if let Some(po) = provider_options && let Some(linkup) = po.get("linkup") diff --git a/aimux-providers/src/lmnt.rs b/aimux-providers/src/lmnt.rs index 2c224d840..6fe407a01 100644 --- a/aimux-providers/src/lmnt.rs +++ b/aimux-providers/src/lmnt.rs @@ -298,8 +298,7 @@ struct LMNTSpeechProviderOptions { fn parse_lmnt_provider_options( options: Option<&SharedProviderOptions>, ) -> Option { - let provider_opts = options.and_then(|opts| opts.get("lmnt"))?; - let opts = provider_opts.as_object()?; + let opts = options.and_then(|opts| opts.get("lmnt"))?; Some(LMNTSpeechProviderOptions { conversational: opts diff --git a/aimux-providers/src/luma.rs b/aimux-providers/src/luma.rs index 56d8d239f..f99e163a1 100644 --- a/aimux-providers/src/luma.rs +++ b/aimux-providers/src/luma.rs @@ -301,7 +301,7 @@ impl ImageModel for LumaImageModel { } // Forward luma provider options (excluding non-request options) - if let Some(luma) = luma_opts.and_then(|v| v.as_object()) { + if let Some(luma) = luma_opts { for (k, v) in luma { if matches!( k.as_str(), diff --git a/aimux-providers/src/mistral/convert.rs b/aimux-providers/src/mistral/convert.rs index a224a99c9..683392aa2 100644 --- a/aimux-providers/src/mistral/convert.rs +++ b/aimux-providers/src/mistral/convert.rs @@ -3,10 +3,13 @@ //! Mirrors the TS `convert-to-mistral-chat-messages.ts`, //! `mistral-prepare-tools.ts`, and `map-mistral-finish-reason.ts`. -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::error::AiMuxError; +use aimux_core::language_model_message::{ + AssistantPart, LanguageModelMessage, LanguageModelPrompt, TextPart, ToolCallPart, ToolPart, + ToolResultOutput, ToolResultPart, UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; +use aimux_core::shared::{FileBytes, FileData}; use aimux_core::tool::{FunctionTool, Tool}; use aimux_core::types::{FinishReason, FinishReasonUnified}; use serde_json::{Value, json}; @@ -101,54 +104,68 @@ pub fn prepare_tools( /// message if it is an assistant message (continuation mode). /// - Tool messages include `tool_call_id` (no `name` — the Rust data model /// does not carry the tool name on `ToolResult` parts). -#[must_use] -pub fn convert_prompt_to_mistral_messages(prompt: &LanguageModelPrompt) -> Vec { +/// +/// # Errors +/// Returns an error for unsupported text file data. +pub fn convert_prompt_to_mistral_messages( + prompt: &LanguageModelPrompt, +) -> Result, AiMuxError> { let mut result = Vec::new(); let last_idx = prompt.len().saturating_sub(1); for (i, msg) in prompt.iter().enumerate() { let is_last = i == last_idx; - for value in convert_message_to_mistral(msg, is_last) { + for value in convert_message_to_mistral(msg, is_last)? { result.push(value); } } - result + Ok(result) } -fn convert_message_to_mistral(msg: &LanguageModelPromptMessage, is_last: bool) -> Vec { - match msg.role { - Role::System => { - let text = join_text_parts(&msg.content); - vec![json!({ "role": "system", "content": text })] +fn convert_message_to_mistral( + msg: &LanguageModelMessage, + is_last: bool, +) -> Result, AiMuxError> { + Ok(match msg { + LanguageModelMessage::System { content, .. } => { + vec![json!({ "role": "system", "content": content })] } - Role::User => { - let parts: Vec = msg.content.iter().map(convert_part_to_mistral).collect(); + LanguageModelMessage::User { content, .. } => { + let parts: Vec = content + .iter() + .map(convert_part_to_mistral) + .collect::>()?; vec![json!({ "role": "user", "content": parts })] } - Role::Assistant => { - let text = join_text_parts(&msg.content); - let has_tool_calls = msg - .content + LanguageModelMessage::Assistant { content, .. } => { + let text = join_text_parts(content); + let has_reasoning = content.iter().any(|part| matches!(part, AssistantPart::Reasoning(_))); + let mut content_parts = Vec::new(); + for part in content { + match part { + AssistantPart::Text(part) => content_parts.push(json!({ "type": "text", "text": part.text })), + AssistantPart::Reasoning(part) => content_parts.push(json!({ "type": "thinking", "thinking": [{ "type": "text", "text": part.text }], "closed": true })), + AssistantPart::ToolCall(_) => {}, + _ => return Err(AiMuxError::UnsupportedFunctionality("assistant content part".to_string())), + } + } + let has_tool_calls = content .iter() - .any(|p| matches!(p, ContentPart::ToolCall { .. })); + .any(|p| matches!(p, AssistantPart::ToolCall(_))); let mut msg_json = json!({ "role": "assistant", "content": text }); + if has_reasoning { msg_json["content"] = json!(content_parts); } if has_tool_calls { - let tool_calls: Vec = msg - .content + let tool_calls: Vec = content .iter() .filter_map(|p| match p { - ContentPart::ToolCall { + AssistantPart::ToolCall(ToolCallPart { tool_call_id, tool_name, input, .. - } => { - let arguments = if input.is_null() { - "{}".to_string() - } else { - input.to_string() - }; + }) => { + let arguments = input.to_string(); Some(json!({ "id": tool_call_id, "type": "function", @@ -169,120 +186,127 @@ fn convert_message_to_mistral(msg: &LanguageModelPromptMessage, is_last: bool) - } vec![msg_json] } - Role::Tool => msg - .content + LanguageModelMessage::Tool { content, .. } => content .iter() - .filter_map(|part| match part { - ContentPart::ToolResult { - tool_call_id, - result, - .. - } => { - let content = tool_result_to_content(result); - Some(json!({ - "role": "tool", - "tool_call_id": tool_call_id, - "content": content, - })) - } - _ => None, + .filter_map(|part| { + let ToolPart::ToolResult(ToolResultPart { tool_call_id, tool_name, output, .. }) = part else { return None; }; + Some(json!({ "role": "tool", "name": tool_name, "tool_call_id": tool_call_id, "content": tool_result_to_content(output) })) }) .collect(), - } + }) } -fn join_text_parts(content: &[ContentPart]) -> String { +fn join_text_parts(content: &[AssistantPart]) -> String { content .iter() .filter_map(|p| match p { - ContentPart::Text { text, .. } => Some(text.as_str()), + AssistantPart::Text(TextPart { text, .. }) => Some(text.as_str()), _ => None, }) .collect::>() .join("") } -fn tool_result_to_content(output: &Value) -> Value { - match output { - Value::String(s) => Value::String(s.clone()), - other => Value::String(other.to_string()), - } +fn tool_result_to_content(output: &ToolResultOutput) -> Value { + Value::String(match output { + ToolResultOutput::Text { value, .. } | ToolResultOutput::ErrorText { value, .. } => { + value.clone() + } + ToolResultOutput::ExecutionDenied { reason, .. } => reason + .clone() + .unwrap_or_else(|| "Tool call execution denied.".to_string()), + ToolResultOutput::Json { value, .. } | ToolResultOutput::ErrorJson { value, .. } => { + value.to_string() + } + ToolResultOutput::Content { value } => { + crate::openai::convert::tool_result_content_value(value).to_string() + } + }) } -fn convert_part_to_mistral(part: &ContentPart) -> Value { - match part { - ContentPart::Text { text, .. } => json!({ "type": "text", "text": text }), - ContentPart::Image { - image, media_type, .. - } => { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(image); - json!({ - "type": "image_url", - "image_url": format!("data:{};base64,{}", media_type, b64), - }) - } - ContentPart::File { - data, media_type, .. - } => { +fn convert_part_to_mistral(part: &UserPart) -> Result { + Ok(match part { + UserPart::Text(TextPart { text, .. }) => json!({ "type": "text", "text": text }), + UserPart::File(file) => { use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(data); - let top_level = media_type.split('/').next().unwrap_or(""); - if top_level == "image" { - json!({ - "type": "image_url", - "image_url": format!("data:{};base64,{}", media_type, b64), - }) + let is_image = file.media_type.split('/').next() == Some("image"); + let url = match &file.data { + FileData::Data { data } => { + let b64 = match data { + FileBytes::Binary(bytes) => { + base64::engine::general_purpose::STANDARD.encode(bytes) + } + FileBytes::Base64(data) => data.clone(), + }; + let media_type = crate::google::convert::tool_file_media_type(file)?; + if !is_image && media_type != "application/pdf" { + return Err(AiMuxError::UnsupportedFunctionality( + "Only images and PDF file parts are supported".to_string(), + )); + } + format!("data:{media_type};base64,{b64}") + } + FileData::Url { url, .. } => { + if !is_image && file.media_type != "application/pdf" { + return Err(AiMuxError::UnsupportedFunctionality( + "Only images and PDF file parts are supported".to_string(), + )); + } + url.clone() + } + FileData::Reference { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "file parts with provider references".to_string(), + )); + } + FileData::Text { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "text file parts".to_string(), + )); + } + }; + if is_image { + json!({ "type": "image_url", "image_url": url }) } else { - json!({ - "type": "document_url", - "document_url": format!("data:{};base64,{}", media_type, b64), - }) + json!({ "type": "document_url", "document_url": url }) } } - // These variants are handled by `convert_message_to_mistral` for - // assistant/tool roles. - ContentPart::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => { - json!({ - "type": "tool_call", - "id": tool_call_id, - "function": { - "name": tool_name, - "arguments": input.to_string(), - } - }) - } - ContentPart::ToolResult { - tool_call_id, - result, - .. - } => { - json!({ - "type": "tool_result", - "tool_call_id": tool_call_id, - "content": result, - }) - } - - // Variants not yet modelled for Mistral; no test exercises these paths. - ContentPart::FileBase64 { .. } - | ContentPart::FileUrl { .. } - | ContentPart::FileReference { .. } - | ContentPart::Reasoning { .. } => Value::Null, - } + }) } // ── Request body ──────────────────────────────────────────────────────────── /// Convert `CallOptions` to a Mistral request body. -#[must_use] -pub fn build_request_body(model_id: &str, options: &CallOptions, stream: bool) -> Value { - let messages = convert_prompt_to_mistral_messages(&options.prompt); +/// +/// # Errors +/// Returns an error for unsupported text file data. +pub fn build_request_body( + model_id: &str, + options: &CallOptions, + stream: bool, +) -> Result { + let provider_options = options + .provider_options + .as_ref() + .and_then(|namespaces| namespaces.get("mistral")) + .map(|namespace| { + crate::openai::convert::parse_option_fields(namespace, "mistral", |key, value| { + let valid = match key { + "safePrompt" | "structuredOutputs" | "strictJsonSchema" + | "parallelToolCalls" => value.is_boolean(), + "documentImageLimit" | "documentPageLimit" => value.is_number(), + "promptCacheKey" => value.is_string(), + "reasoningEffort" => value + .as_str() + .is_some_and(|value| matches!(value, "high" | "none")), + _ => return None, + }; + Some(if valid { Ok(value.clone()) } else { Err(()) }) + }) + }) + .transpose()? + .unwrap_or_default(); + let messages = convert_prompt_to_mistral_messages(&options.prompt)?; let mut body = json!({ "model": model_id, @@ -293,6 +317,10 @@ pub fn build_request_body(model_id: &str, options: &CallOptions, stream: bool) - body["stream"] = json!(true); } + if let Some(safe_prompt) = provider_options.get("safePrompt") { + body["safe_prompt"] = safe_prompt.clone(); + } + if let Some(max_tokens) = options.max_output_tokens { body["max_tokens"] = json!(max_tokens); } @@ -364,7 +392,7 @@ pub fn build_request_body(model_id: &str, options: &CallOptions, stream: bool) - } } - body + Ok(body) } /// Parse Mistral finish reason string into `FinishReason`. diff --git a/aimux-providers/src/mistral/model.rs b/aimux-providers/src/mistral/model.rs index ddcc02018..74e713e32 100644 --- a/aimux-providers/src/mistral/model.rs +++ b/aimux-providers/src/mistral/model.rs @@ -20,7 +20,8 @@ use serde_json::Value; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, StreamResult}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; @@ -92,7 +93,7 @@ fn convert_usage(usage: &UsageResponse) -> Usage { let no_cache = prompt_tokens - cache_read; Usage { - input_tokens: aimux_core::types::TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(prompt_tokens), no_cache: Some(no_cache), cache_read: if cache_read > 0 { @@ -101,14 +102,15 @@ fn convert_usage(usage: &UsageResponse) -> Usage { None }, cache_write: None, - ..Default::default() }, - output_tokens: aimux_core::types::TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(completion_tokens), ..Default::default() }, // RFC-0015 P0-3: keep the raw provider usage payload. - raw: Some(serde_json::to_value(usage).unwrap_or(serde_json::Value::Null)), + raw: serde_json::to_value(usage) + .ok() + .and_then(|value| value.as_object().cloned()), } } @@ -223,7 +225,7 @@ impl LanguageModel for MistralModel { } async fn do_generate(&self, options: &CallOptions) -> Result { - let body = build_request_body(&self.model_id, options, false); + let body = build_request_body(&self.model_id, options, false)?; let headers = self.build_headers(options.headers.as_ref()); let resp = aimux_provider_utils::post_json_to_api( HttpRequest::new( @@ -240,6 +242,7 @@ impl LanguageModel for MistralModel { ) .await?; + let response_body = resp.raw_value; let response_headers = resp.response_headers; let data: ChatCompletionResponse = resp.value; @@ -256,10 +259,10 @@ impl LanguageModel for MistralModel { if let Some(r) = extract_reasoning_content(&choice.message.content) && !r.is_empty() { - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text: r, provider_metadata: None, - }); + })); } // Content can be a string (legacy) or an array of typed parts. @@ -304,21 +307,24 @@ impl LanguageModel for MistralModel { usage, warnings: Vec::new(), provider_metadata: None, - response: ResponseMetadata { - id: data.id, - timestamp: data - .created - .and_then(|secs| chrono::DateTime::from_timestamp(secs as i64, 0)) - .map(|dt| dt.to_rfc3339()), - model_id: data.model, - }, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: response_body, + headers: Some(response_headers), + ..aimux_core::result::GenerateResponse::from(ResponseMetadata { + id: data.id, + timestamp: data + .created + .and_then(|secs| chrono::DateTime::from_timestamp(secs as i64, 0)) + .map(|dt| dt.to_rfc3339()), + model_id: data.model, + }) + }), }) } async fn do_stream(&self, options: &CallOptions) -> Result { - let body = build_request_body(&self.model_id, options, true); + let body = build_request_body(&self.model_id, options, true)?; let headers = self.build_headers(options.headers.as_ref()); let resp = aimux_provider_utils::post_json_to_api( HttpRequest::new( @@ -408,7 +414,7 @@ impl LanguageModel for MistralModel { && (chunk.id.is_some() || chunk.model.is_some()) { response_metadata_emitted = true; - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: chunk.id.clone(), timestamp: chunk .created @@ -417,7 +423,7 @@ impl LanguageModel for MistralModel { }) .map(|dt| dt.to_rfc3339()), model_id: chunk.model.clone(), - }); + })); } // Update usage. @@ -597,14 +603,16 @@ impl LanguageModel for MistralModel { } else { final_usage }, - provider_metadata: Some(serde_json::json!({ "mistral": {} })), + provider_metadata: Some(provider_namespace("mistral", serde_json::json!({})).expect("provider metadata must be an object")), }); }; Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } diff --git a/aimux-providers/src/open_responses.rs b/aimux-providers/src/open_responses.rs index 27cc52115..c684cc6eb 100644 --- a/aimux-providers/src/open_responses.rs +++ b/aimux-providers/src/open_responses.rs @@ -14,15 +14,17 @@ use async_trait::async_trait; use futures::StreamExt; use serde_json::{Map, Value, json}; -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::error::ApiCallError; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::LanguageModelPrompt; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, TextPart, ToolCallPart, + ToolPart, ToolResultContent, ToolResultOutput, ToolResultPart, UserPart, +}; use aimux_core::options::{CallOptions, ToolChoice}; use aimux_core::provider::Provider; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, StreamResult}; +use aimux_core::shared::{FileBytes, FileData}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::Tool; use aimux_core::types::{ @@ -312,6 +314,7 @@ impl LanguageModel for OpenResponsesModel { async fn do_generate(&self, options: &CallOptions) -> Result { let headers = self.build_headers(options.headers.as_ref()); + validate_tool_output_media(&options.prompt)?; let (body, warnings) = build_request_body(&self.model_id, options, &self.config.provider_options_name); @@ -327,6 +330,7 @@ impl LanguageModel for OpenResponsesModel { ) .await?; + let response_body = resp.raw_value; let response_headers = resp.response_headers; let raw: Value = resp.value; @@ -359,10 +363,10 @@ impl LanguageModel for OpenResponsesModel { { for cp in content_parts { if let Some(text) = cp.get("text").and_then(|t| t.as_str()) { - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text: text.to_string(), provider_metadata: None, - }); + })); } } } @@ -443,14 +447,18 @@ impl LanguageModel for OpenResponsesModel { usage, warnings, provider_metadata: None, - response, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: response_body, + headers: Some(response_headers), + ..aimux_core::result::GenerateResponse::from(response) + }), }) } async fn do_stream(&self, options: &CallOptions) -> Result { let headers = self.build_headers(options.headers.as_ref()); + validate_tool_output_media(&options.prompt)?; let (body, warnings) = build_request_body(&self.model_id, options, &self.config.provider_options_name); @@ -820,8 +828,10 @@ impl LanguageModel for OpenResponsesModel { Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } @@ -897,7 +907,8 @@ fn build_request_body( } // Convert prompt to input + instructions. - let (input, instructions, input_warnings) = convert_to_open_responses_input(&options.prompt); + let (input, instructions, input_warnings) = + convert_to_open_responses_input_with_namespace(&options.prompt, provider_options_name); warnings.extend(input_warnings); // Convert function tools. @@ -1057,87 +1068,152 @@ fn build_request_body( #[must_use] pub fn convert_to_open_responses_input( prompt: &LanguageModelPrompt, +) -> (Value, Option, Vec) { + convert_to_open_responses_input_with_namespace(prompt, "open-responses") +} + +fn convert_to_open_responses_input_with_namespace( + prompt: &LanguageModelPrompt, + provider_options_name: &str, ) -> (Value, Option, Vec) { let mut input: Vec = Vec::new(); let mut warnings = Vec::new(); let mut system_messages: Vec = Vec::new(); for msg in prompt { - match msg.role { - Role::System => { - for part in &msg.content { - if let ContentPart::Text { text, .. } = part { - system_messages.push(text.clone()); - } - } + match msg { + LanguageModelMessage::System { content, .. } => { + system_messages.push(content.clone()); } - Role::User => { - let user_content = convert_user_content(&msg.content, &mut warnings); + LanguageModelMessage::User { content, .. } => { + let user_content = convert_user_content(content, &mut warnings); input.push(json!({ "type": "message", "role": "user", "content": user_content, })); } - Role::Assistant => { + LanguageModelMessage::Assistant { content, .. } => { let mut assistant_content: Vec = Vec::new(); - let mut tool_calls: Vec = Vec::new(); - for part in &msg.content { + for part in content { match part { - ContentPart::Text { text, .. } => { + AssistantPart::Text(TextPart { text, .. }) => { assistant_content.push(json!({ "type": "output_text", "text": text, })); } - ContentPart::ToolCall { + AssistantPart::ToolCall(ToolCallPart { tool_call_id, tool_name, input: tool_input, .. - } => { + }) => { let arguments = match tool_input { Value::String(s) => s.clone(), other => other.to_string(), }; - tool_calls.push(json!({ + flush_assistant_content(&mut assistant_content, &mut input); + input.push(json!({ "type": "function_call", "call_id": tool_call_id, "name": tool_name, "arguments": arguments, })); } + AssistantPart::Reasoning(part) => { + flush_assistant_content(&mut assistant_content, &mut input); + let metadata = part + .provider_options + .as_ref() + .and_then(|options| options.get(provider_options_name)); + let parse_parts = |key: &str, kind: &str| { + metadata + .and_then(|data| data.get(key)) + .and_then(Value::as_array) + .filter(|parts| { + parts.iter().all(|part| { + part["type"] == kind && part["text"].is_string() + }) + }) + .map(|parts| { + json!( + parts + .iter() + .map( + |part| json!({"type":kind, "text":part["text"]}) + ) + .collect::>() + ) + }) + }; + let mut reasoning = json!({"type":"reasoning", "summary":parse_parts("reasoningSummary", "summary_text").unwrap_or_else(|| json!([]))}); + if let Some(id) = metadata + .and_then(|data| data.get("itemId")) + .and_then(Value::as_str) + { + reasoning["id"] = json!(id); + } + if let Some(content) = parse_parts("reasoningContent", "reasoning_text") + { + reasoning["content"] = content; + } else if !metadata + .is_some_and(|data| data.contains_key("reasoningContent")) + && !part.text.is_empty() + { + reasoning["content"] = + json!([{"type":"reasoning_text", "text":part.text}]); + } + if let Some(encrypted) = metadata + .and_then(|data| data.get("reasoningEncryptedContent")) + .and_then(Value::as_str) + { + reasoning["encrypted_content"] = json!(encrypted); + } + if let Some(previous) = input.last_mut().filter(|previous| { + reasoning.get("id").is_some() + && previous["type"] == "reasoning" + && previous.get("id") == reasoning.get("id") + }) { + if let Some(content) = + reasoning.get("content").and_then(Value::as_array) + { + if previous.get("content").is_none() { + previous["content"] = json!([]); + } + if let Some(previous_content) = + previous["content"].as_array_mut() + { + previous_content.extend(content.iter().cloned()); + } + } + } else { + input.push(reasoning); + } + } _ => {} } } - - if !assistant_content.is_empty() { - input.push(json!({ - "type": "message", - "role": "assistant", - "content": assistant_content, - })); - } - for tc in tool_calls { - input.push(tc); - } + flush_assistant_content(&mut assistant_content, &mut input); } - Role::Tool => { - for part in &msg.content { - if let ContentPart::ToolResult { + LanguageModelMessage::Tool { content, .. } => { + for part in content { + let ToolPart::ToolResult(ToolResultPart { tool_call_id, - result, + output, .. - } = part - { - let content_value = resolve_tool_result_output(result, &mut warnings); - input.push(json!({ - "type": "function_call_output", - "call_id": tool_call_id, - "output": content_value, - })); - } + }) = part + else { + continue; + }; + let content_value = + resolve_tool_result_output(output, provider_options_name, &mut warnings); + input.push(json!({ + "type": "function_call_output", + "call_id": tool_call_id, + "output": content_value, + })); } } } @@ -1152,183 +1228,207 @@ pub fn convert_to_open_responses_input( (json!(input), instructions, warnings) } +fn flush_assistant_content(content: &mut Vec, input: &mut Vec) { + if !content.is_empty() { + input + .push(json!({"type":"message", "role":"assistant", "content":std::mem::take(content)})); + } +} + /// Convert user message content parts to the Open Responses format. -fn convert_user_content(content: &[ContentPart], warnings: &mut Vec) -> Value { +fn convert_user_content(content: &[UserPart], warnings: &mut Vec) -> Value { + use base64::Engine; + let mut parts: Vec = Vec::new(); for part in content { match part { - ContentPart::Text { text, .. } => { + UserPart::Text(TextPart { text, .. }) => { parts.push(json!({ "type": "input_text", "text": text })); } - ContentPart::Image { - image, media_type, .. - } => { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(image); - parts.push(json!({ - "type": "input_image", - "image_url": format!("data:{};base64,{}", media_type, b64), - })); - } - ContentPart::File { + UserPart::File(FilePart { data, media_type, filename, .. - } => { - let top_level = top_level_media_type(media_type); - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(data); - if top_level == "image" { - parts.push(json!({ - "type": "input_image", - "image_url": format!("data:{};base64,{}", media_type, b64), - })); - } else { - parts.push(json!({ - "type": "input_file", - "filename": filename.as_deref().unwrap_or("data"), - "file_data": format!("data:{};base64,{}", media_type, b64), - })); + }) => { + let image = top_level_media_type(media_type) == "image"; + match data { + FileData::Data { data } => { + let b64 = match data { + FileBytes::Binary(bytes) => { + base64::engine::general_purpose::STANDARD.encode(bytes) + } + FileBytes::Base64(data) => data.clone(), + }; + let data_url = format!("data:{media_type};base64,{b64}"); + parts.push(if image { + json!({ "type": "input_image", "image_url": data_url }) + } else { + json!({ + "type": "input_file", + "filename": filename.as_deref().unwrap_or("data"), + "file_data": data_url, + }) + }); + } + FileData::Url { url, .. } => { + parts.push(if image { + json!({ "type": "input_image", "image_url": url }) + } else { + json!({ "type": "input_file", "file_url": url }) + }); + } + FileData::Reference { .. } => { + warnings.push(Warning::Other { + message: "unsupported file part with provider reference".to_string(), + }); + } + FileData::Text { .. } => { + warnings.push(Warning::Other { + message: "unsupported text file part".to_string(), + }); + } } } - ContentPart::FileBase64 { - data, - media_type, - filename, + } + } + json!(parts) +} + +fn validate_tool_output_media(prompt: &LanguageModelPrompt) -> Result<(), AiMuxError> { + use base64::Engine; + for message in prompt { + let LanguageModelMessage::Tool { content, .. } = message else { + continue; + }; + for part in content { + let ToolPart::ToolResult(ToolResultPart { + output: ToolResultOutput::Content { value }, .. - } => { - let top_level = top_level_media_type(media_type); - if top_level == "image" { - parts.push(json!({ - "type": "input_image", - "image_url": format!("data:{};base64,{}", media_type, data), - })); - } else { - parts.push(json!({ - "type": "input_file", - "filename": filename.as_deref().unwrap_or("data"), - "file_data": format!("data:{};base64,{}", media_type, data), - })); - } - } - ContentPart::FileUrl { - url, media_type, .. - } => { - let top_level = top_level_media_type(media_type); - if top_level == "image" { - parts.push(json!({ - "type": "input_image", - "image_url": url, - })); - } else { - parts.push(json!({ - "type": "input_file", - "file_url": url, - })); - } - } - ContentPart::FileReference { .. } => { - warnings.push(Warning::Other { - message: "unsupported file part with provider reference".to_string(), - }); - } - _ => { - warnings.push(Warning::Other { - message: format!("unsupported content part type: {}", part_variant_name(part)), - }); + }) = part + else { + continue; + }; + for item in value { + let ToolResultContent::File(FilePart { + data: FileData::Data { data }, + media_type, + .. + }) = item + else { + continue; + }; + let bytes = match data { + FileBytes::Binary(bytes) => std::borrow::Cow::Borrowed(bytes.as_slice()), + FileBytes::Base64(data) => std::borrow::Cow::Owned( + base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|error| { + AiMuxError::InvalidArgument(format!( + "invalid base64 file data: {error}" + )) + })?, + ), + }; + crate::anthropic::convert::resolve_full_media_type(media_type, &bytes)?; } } } - json!(parts) + Ok(()) } /// Resolve a tool-result `output` value into the Open Responses `output` /// field, mirroring the TS convert logic. -fn resolve_tool_result_output(output: &Value, warnings: &mut Vec) -> Value { - let output_type = output.get("type").and_then(|x| x.as_str()); - - match output_type { - Some("text") | Some("error-text") => { - output.get("value").cloned().unwrap_or_else(|| Value::Null) - } - Some("execution-denied") => output - .get("reason") - .and_then(|r| r.as_str()) - .map(|s| Value::String(s.to_string())) - .unwrap_or_else(|| Value::String("Tool call execution denied.".to_string())), - Some("json") | Some("error-json") => { - let v = output.get("value").unwrap_or(&Value::Null); - Value::String(v.to_string()) - } - Some("content") => { - let v = output.get("value"); - if let Some(arr) = v.and_then(|v| v.as_array()) { - let mut parts: Vec = Vec::new(); - for item in arr { - match item.get("type").and_then(|t| t.as_str()) { - Some("text") => { - if let Some(text) = item.get("text").and_then(|t| t.as_str()) { - parts.push(json!({ "type": "input_text", "text": text })); +fn resolve_tool_result_output( + output: &ToolResultOutput, + provider_options_name: &str, + warnings: &mut Vec, +) -> Value { + let ToolResultOutput::Content { value } = output else { + return crate::openai::convert::tool_result_to_content(output); + }; + let mut parts = Vec::new(); + for item in value { + match item { + ToolResultContent::Text(part) => { + parts.push(json!({"type":"input_text", "text":part.text})) + } + ToolResultContent::File(part) => { + let image = top_level_media_type(&part.media_type) == "image"; + let mut converted = match &part.data { + FileData::Data { data } => { + use base64::Engine; + let bytes = match data { + FileBytes::Binary(bytes) => bytes.clone(), + FileBytes::Base64(data) => base64::engine::general_purpose::STANDARD + .decode(data) + .unwrap_or_default(), + }; + let media_type = match crate::anthropic::convert::resolve_full_media_type( + &part.media_type, + &bytes, + ) { + Ok(media_type) => media_type, + Err(error) => { + warnings.push(Warning::Other { + message: error.to_string(), + }); + continue; } - } - Some("file") => { - if let Some(media_type) = item.get("mediaType").and_then(|m| m.as_str()) - { - let top_level = top_level_media_type(media_type); - if let Some(data) = item.get("data") { - if let Some(data_obj) = - data.get("data").and_then(|d| d.as_str()) - { - if top_level == "image" { - parts.push(json!({ - "type": "input_image", - "image_url": format!("data:{};base64,{}", media_type, data_obj), - })); - } else { - parts.push(json!({ - "type": "input_file", - "filename": item.get("filename").and_then(|f| f.as_str()).unwrap_or("data"), - "file_data": format!("data:{};base64,{}", media_type, data_obj), - })); - } - } else if let Some(url) = - data.get("url").and_then(|u| u.as_str()) - { - if top_level == "image" { - parts.push(json!({ - "type": "input_image", - "image_url": url, - })); - } else { - parts.push(json!({ - "type": "input_file", - "file_url": url, - })); - } - } - } + }; + let b64 = match data { + FileBytes::Binary(_) => { + base64::engine::general_purpose::STANDARD.encode(&bytes) } + FileBytes::Base64(data) => data.clone(), + }; + let data_url = format!("data:{media_type};base64,{b64}"); + if image { + json!({"type":"input_image", "image_url":data_url}) + } else { + json!({"type":"input_file", "filename":part.filename.as_deref().unwrap_or("data"), "file_data":data_url}) } - _ => { - warnings.push(Warning::Other { - message: format!( - "unsupported tool content part type: {}", - item.get("type") - .and_then(|t| t.as_str()) - .unwrap_or("unknown") - ), - }); + } + FileData::Url { url, .. } => { + if image { + json!({"type":"input_image", "image_url":url}) + } else { + json!({"type":"input_file", "file_url":url}) } } + data => { + warnings.push(Warning::Other { + message: format!( + "unsupported tool content part type: file with data type: {}", + if matches!(data, FileData::Reference { .. }) { + "reference" + } else { + "text" + } + ), + }); + continue; + } + }; + if image { + let detail = part + .provider_options + .as_ref() + .and_then(|options| options.get(provider_options_name)) + .and_then(|options| options.get("imageDetail")) + .and_then(Value::as_str) + .filter(|detail| matches!(*detail, "low" | "high" | "auto")) + .unwrap_or("auto"); + converted["detail"] = json!(detail); } - Value::Array(parts) - } else { - output.clone() + parts.push(converted); } + ToolResultContent::Custom { .. } => warnings.push(Warning::Other { + message: "unsupported tool content part type: custom".into(), + }), } - _ => output.clone(), } + json!(parts) } /// Extract the top-level media type (e.g. "image" from "image/png"). @@ -1336,21 +1436,6 @@ fn top_level_media_type(media_type: &str) -> &str { media_type.split('/').next().unwrap_or("") } -/// Get a human-readable name for a ContentPart variant (for warnings). -fn part_variant_name(part: &ContentPart) -> &'static str { - match part { - ContentPart::Text { .. } => "text", - ContentPart::Image { .. } => "image", - ContentPart::File { .. } => "file", - ContentPart::FileBase64 { .. } => "file-base64", - ContentPart::FileUrl { .. } => "file-url", - ContentPart::FileReference { .. } => "file-reference", - ContentPart::Reasoning { .. } => "reasoning", - ContentPart::ToolCall { .. } => "tool-call", - ContentPart::ToolResult { .. } => "tool-result", - } -} - // == Usage extraction == /// Extract `Usage` from a response body `Value`. @@ -1384,20 +1469,18 @@ fn extract_usage_from_value(usage: &Value) -> Usage { .map(|n| n as u32); Usage { - input_tokens: aimux_core::types::TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: input_tokens, no_cache: Some(input_tokens.unwrap_or(0) - cached_input_tokens.unwrap_or(0)), cache_read: cached_input_tokens, cache_write: None, - ..Default::default() }, - output_tokens: aimux_core::types::TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: output_tokens, text: Some(output_tokens.unwrap_or(0) - reasoning_tokens.unwrap_or(0)), reasoning: reasoning_tokens, - ..Default::default() }, // RFC-0015 P0-3: keep the raw provider usage payload. - raw: Some(usage.clone()), + raw: usage.as_object().cloned(), } } diff --git a/aimux-providers/src/openai/convert.rs b/aimux-providers/src/openai/convert.rs index aab1998e6..f79d608f0 100644 --- a/aimux-providers/src/openai/convert.rs +++ b/aimux-providers/src/openai/convert.rs @@ -1,10 +1,12 @@ //! Conversion between `LanguageModelPrompt` and OpenAI API format. -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, TextPart, ToolPart, + ToolResultContent, ToolResultOutput, UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; +use aimux_core::shared::{FileBytes, FileData, JsonObject, SharedProviderOptions}; use aimux_core::tool::{FunctionTool, Tool}; use aimux_core::types::{FinishReason, FinishReasonUnified, ReasoningEffort, Warning}; use serde::Serialize; @@ -15,7 +17,6 @@ use super::OpenAICompatProfile; /// `convert_common` in M10; re-exported for API compatibility). pub use super::convert_common::SystemMessageMode; use super::convert_common::{ModelCapabilities, get_model_capabilities}; -use std::collections::HashMap; // ── Model capabilities ────────────────────────────────────────────────────── // `GptVersion` / `get_gpt_version` / `get_o_series_version` / @@ -269,6 +270,15 @@ pub fn convert_prompt_to_openai_messages_with_provider( prompt: &LanguageModelPrompt, system_message_mode: SystemMessageMode, provider: &str, +) -> Result, AiMuxError> { + convert_prompt_with_warnings(prompt, system_message_mode, provider, &mut Vec::new()) +} + +fn convert_prompt_with_warnings( + prompt: &LanguageModelPrompt, + system_message_mode: SystemMessageMode, + provider: &str, + warnings: &mut Vec, ) -> Result, AiMuxError> { let mut result = Vec::new(); for msg in prompt { @@ -276,13 +286,14 @@ pub fn convert_prompt_to_openai_messages_with_provider( msg, system_message_mode, provider, + warnings, )?); } Ok(result) } /// Get the prompt cache breakpoint from provider options. -fn get_prompt_cache_breakpoint(provider_options: &Option) -> Option { +fn get_prompt_cache_breakpoint(provider_options: &Option) -> Option { provider_options .as_ref() .and_then(|po| po.get("openai")) @@ -291,7 +302,7 @@ fn get_prompt_cache_breakpoint(provider_options: &Option) -> Option) -> Option { +fn get_image_detail(provider_options: &Option) -> Option { provider_options .as_ref() .and_then(|po| po.get("openai")) @@ -332,48 +343,63 @@ fn resolve_full_media_type(media_type: &str, b64_data: &str) -> String { } /// Resolve a provider reference, throwing if the provider is not found. -fn resolve_provider_reference(reference: &Value, provider: &str) -> Result { - if let Some(val) = reference.get(provider) { - if let Some(s) = val.as_str() { - return Ok(s.to_string()); - } - return Ok(val.to_string()); - } - let available: Vec = reference - .as_object() - .map(|m| m.keys().cloned().collect()) - .unwrap_or_default(); - Err(format!( - "No provider reference found for provider '{}'. Available providers: {}", - provider, - available.join(", ") - )) +fn resolve_provider_reference( + reference: &std::collections::HashMap, + provider: &str, +) -> Result { + reference.get(provider).cloned().ok_or_else(|| { + let mut available: Vec<&str> = reference.keys().map(String::as_str).collect(); + available.sort_unstable(); + format!( + "No provider reference found for provider '{}'. Available providers: {}", + provider, + available.join(", ") + ) + }) } /// Convert a file part to the OpenAI format, handling images, audio, and PDF. fn convert_file_part_to_openai( - media_type: &str, - data_b64: Option<&str>, - url: Option<&str>, - reference: Option<&Value>, - filename: Option<&str>, - provider_options: &Option, + file: &FilePart, part_index: usize, + provider: &str, ) -> Result { - let prompt_cache_breakpoint = get_prompt_cache_breakpoint(provider_options); - - // Reference type - if let Some(ref_val) = reference { - let file_id = resolve_provider_reference(ref_val, "openai")?; - let mut part = json!({ - "type": "file", - "file": { "file_id": file_id } - }); - if let Some(bpt) = prompt_cache_breakpoint { - part["prompt_cache_breakpoint"] = bpt; + use base64::Engine; + + let FilePart { + data, + media_type, + filename, + provider_options, + } = file; + let prompt_cache_breakpoint = (provider == "openai") + .then(|| get_prompt_cache_breakpoint(provider_options)) + .flatten(); + let (data_b64, url) = match data { + FileData::Reference { reference } => { + let file_id = resolve_provider_reference(reference, provider)?; + let mut part = if provider == "deepseek" { + json!({ "type": "file", "file_id": file_id }) + } else { + json!({ "type": "file", "file": { "file_id": file_id } }) + }; + if let Some(bpt) = prompt_cache_breakpoint { + part["prompt_cache_breakpoint"] = bpt; + } + return Ok(part); } - return Ok(part); - } + FileData::Text { .. } => return Err("text file parts".to_string()), + FileData::Url { url, .. } => (None, Some(url.as_str())), + FileData::Data { data } => { + let b64 = match data { + FileBytes::Binary(bytes) => base64::engine::general_purpose::STANDARD.encode(bytes), + FileBytes::Base64(data) => data.clone(), + }; + (Some(b64), None) + } + }; + let data_b64 = data_b64.as_deref(); + let filename = filename.as_deref(); let top_level = get_top_level_media_type(media_type); @@ -459,435 +485,414 @@ fn convert_file_part_to_openai( /// Convert a single provider-facing message into one or more OpenAI messages. fn convert_message_to_openai( - msg: &LanguageModelPromptMessage, + msg: &LanguageModelMessage, system_message_mode: SystemMessageMode, provider: &str, + warnings: &mut Vec, ) -> Result, AiMuxError> { - let role = match msg.role { - Role::System => "system", - Role::User => "user", - Role::Assistant => "assistant", - Role::Tool => "tool", - }; - - // System messages: respect systemMessageMode. - if msg.role == Role::System { - match system_message_mode { - SystemMessageMode::Remove => return Ok(vec![]), - SystemMessageMode::Developer => { - return Ok(convert_system_message(msg, "developer")); - } - SystemMessageMode::System => { - return Ok(convert_system_message(msg, "system")); - } + if provider != "openai" && provider != "deepseek" { + let has_reference = match msg { + LanguageModelMessage::User { content, .. } => content.iter().any(|part| { + matches!( + part, + UserPart::File(FilePart { + data: FileData::Reference { .. }, + .. + }) + ) + }), + LanguageModelMessage::Assistant { content, .. } => content.iter().any(|part| { + matches!( + part, + AssistantPart::File(FilePart { + data: FileData::Reference { .. }, + .. + }) + ) + }), + _ => false, + }; + if has_reference { + return Err(AiMuxError::UnsupportedFunctionality( + "file parts with provider references".into(), + )); } } - - // Tool-role messages: each ToolResult part becomes its own OpenAI message. - if msg.role == Role::Tool { - let messages = msg - .content - .iter() - .filter_map(|part| match part { - ContentPart::ToolResult { - tool_call_id, - result, - .. - } => { - let content = tool_result_to_content(result); - Some(json!({ - "role": "tool", - "content": content, - "tool_call_id": tool_call_id, - })) - } - _ => None, - }) - .collect(); - return Ok(messages); - } - - // Assistant messages with tool calls - let has_tool_calls = msg - .content - .iter() - .any(|p| matches!(p, ContentPart::ToolCall { .. })); - - // Groq: assistant messages always collect text, reasoning, and tool_calls - // together (mirrors TS `convertToGroqChatMessages`). The `reasoning` field - // is added when non-empty, and `content` is "" (not null) when there is no - // text. - if msg.role == Role::Assistant && provider == "groq" { - let text: String = msg - .content - .iter() - .filter_map(|p| match p { - ContentPart::Text { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join(""); - - let reasoning: String = msg - .content - .iter() - .filter_map(|p| match p { - ContentPart::Reasoning { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join(""); - - let tool_calls_json: Vec = msg - .content - .iter() - .filter_map(|p| match p { - ContentPart::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => { - let arguments = if input.is_null() { - "{}".to_string() - } else { - input.to_string() - }; - Some(json!({ - "type": "function", - "id": tool_call_id, - "function": { - "name": tool_name, - "arguments": arguments, - } - })) + let message = match msg { + LanguageModelMessage::System { + content, + provider_options, + } => { + let role = match system_message_mode { + SystemMessageMode::Remove => return Ok(vec![]), + SystemMessageMode::Developer => "developer", + SystemMessageMode::System => "system", + }; + let content = match (provider == "openai") + .then(|| get_prompt_cache_breakpoint(provider_options)) + .flatten() + { + None => json!(content), + Some(bpt) => json!([{ + "type": "text", "text": content, "prompt_cache_breakpoint": bpt, + }]), + }; + json!({ "role": role, "content": content }) + } + LanguageModelMessage::Tool { content, .. } => { + let mut messages = Vec::new(); + for part in content { + let ToolPart::ToolResult(result) = part else { + continue; + }; + let mut content = if provider == "deepseek" { + deepseek_tool_result_content(&result.output, warnings)? + } else { + tool_result_to_content(&result.output) + }; + if provider == "openai" + && let Some(breakpoint) = tool_result_cache_breakpoint(&result.output) + .or_else(|| get_prompt_cache_breakpoint(&result.provider_options)) + { + content = json!([{"type":"text", "text":content, "prompt_cache_breakpoint":breakpoint}]); } - _ => None, - }) - .collect(); - - let mut msg_obj = json!({ - "role": "assistant", - "content": text, - }); - if !reasoning.is_empty() { - msg_obj["reasoning"] = json!(reasoning); + messages.push( + json!({"role":"tool", "content":content, "tool_call_id":result.tool_call_id}), + ); + } + return Ok(messages); } - if !tool_calls_json.is_empty() { - msg_obj["tool_calls"] = json!(tool_calls_json); + LanguageModelMessage::User { content, .. } => { + let all_plain_text = content.len() == 1 && content.iter().all(|part| { + matches!(part, UserPart::Text(text) + if provider != "openai" || get_prompt_cache_breakpoint(&text.provider_options).is_none()) + }); + let content = if all_plain_text { + json!( + content + .iter() + .filter_map(|part| match part { + UserPart::Text(text) => Some(text.text.as_str()), + UserPart::File(_) => None, + }) + .collect::() + ) + } else { + json!( + content + .iter() + .enumerate() + .map(|(index, part)| match part { + UserPart::Text(text) => Ok(convert_text_part_to_openai(text, provider)), + UserPart::File(file) => + convert_file_part_to_openai(file, index, provider) + .map_err(AiMuxError::InvalidArgument), + }) + .collect::, _>>()? + ) + }; + json!({ "role": "user", "content": content }) } - return Ok(vec![msg_obj]); - } - - if msg.role == Role::Assistant && has_tool_calls { - let text: String = msg - .content - .iter() - .filter_map(|p| match p { - ContentPart::Text { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join(""); - - // Reasoning / thinking content. DeepSeek V4 thinking mode (and other - // OpenAI-compatible reasoning models such as xAI) require prior - // assistant `reasoning_content` to be replayed on subsequent turns, - // including tool-call turns. Mirrors the Vercel AI SDK - // `openai-compatible` assistant conversion, which emits - // `reasoning_content` whenever a reasoning part is present. Groq uses - // the `reasoning` field name and is handled in its own branch above. - let reasoning: String = collect_reasoning(&msg.content); - - let tool_calls_json: Vec = msg - .content - .iter() - .filter_map(|p| match p { - ContentPart::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => { - let arguments = if input.is_null() { - "{}".to_string() - } else { - input.to_string() - }; - Some(json!({ - "type": "function", - "id": tool_call_id, - "function": { - "name": tool_name, - "arguments": arguments, - } - })) + LanguageModelMessage::Assistant { content, .. } => { + let mut text = String::new(); + let mut reasoning = String::new(); + let mut tool_calls = Vec::new(); + for part in content { + match part { + AssistantPart::Text(part) => text.push_str(&part.text), + AssistantPart::Reasoning(part) => reasoning.push_str(&part.text), + AssistantPart::ToolCall(part) => { + let arguments = if provider == "openai" && !part.input.is_object() { + "{}".to_string() + } else { + part.input.to_string() + }; + tool_calls.push(json!({ + "type": "function", "id": part.tool_call_id, + "function": { "name": part.tool_name, "arguments": arguments }, + })); + } + AssistantPart::File(_) + | AssistantPart::ToolResult(_) + | AssistantPart::ReasoningFile(_) + | AssistantPart::Custom(_) => {} } - _ => None, - }) - .collect(); - - let content = if text.is_empty() { - Value::Null - } else { - Value::String(text) - }; - - let mut msg_obj = json!({ - "role": "assistant", - "content": content, - "tool_calls": tool_calls_json, - }); - if !reasoning.is_empty() { - msg_obj["reasoning_content"] = json!(reasoning); + } + let mut message = if provider == "groq" || !tool_calls.is_empty() { + let value = if provider != "groq" && text.is_empty() { + Value::Null + } else { + json!(text) + }; + let mut message = json!({ "role": "assistant", "content": value }); + if !tool_calls.is_empty() { + message["tool_calls"] = json!(tool_calls); + } + message + } else { + let all_plain_text = content.iter().all(|part| { + matches!( + part, + AssistantPart::Text(TextPart { + provider_options: None, + .. + }) | AssistantPart::Reasoning(_) + | AssistantPart::ToolResult(_) + | AssistantPart::ReasoningFile(_) + | AssistantPart::Custom(_) + ) + }); + let value = if all_plain_text { + json!(text) + } else { + let mut parts = Vec::new(); + for part in content { + match part { + AssistantPart::Text(text) => { + parts.push(convert_text_part_to_openai(text, provider)) + } + AssistantPart::File(file) => parts.push( + convert_file_part_to_openai(file, parts.len(), provider) + .map_err(AiMuxError::InvalidArgument)?, + ), + AssistantPart::Reasoning(_) + | AssistantPart::ToolResult(_) + | AssistantPart::ReasoningFile(_) + | AssistantPart::Custom(_) + | AssistantPart::ToolCall(_) => {} + } + } + json!(parts) + }; + json!({ "role": "assistant", "content": value }) + }; + if !reasoning.is_empty() { + let field = if provider == "groq" { + "reasoning" + } else { + "reasoning_content" + }; + message[field] = json!(reasoning); + } + message } - return Ok(vec![msg_obj]); - } + }; + Ok(vec![message]) +} - // Default path (non-Groq, no tool calls). Assistant reasoning / thinking - // parts are lifted to a top-level `reasoning_content` string (DeepSeek V4 - // thinking mode and other OpenAI-compatible reasoning models require it to - // be replayed on later turns); they are never valid OpenAI content parts, - // so they are excluded from the content shape below. Non-assistant roles do - // not carry reasoning, but the filter is harmless. - let reasoning = if msg.role == Role::Assistant { - collect_reasoning(&msg.content) - } else { - String::new() +/// Serialize a tool-result value into the OpenAI tool message content string. +fn deepseek_tool_result_content( + output: &ToolResultOutput, + warnings: &mut Vec, +) -> Result { + let ToolResultOutput::Content { value } = output else { + return Ok(tool_result_to_content(output)); + }; + let is_image = |part: &FilePart| { + get_top_level_media_type(&part.media_type) == "image" + && !matches!(part.data, FileData::Text { .. }) }; - let has_reasoning = !reasoning.is_empty(); - - // Consider only non-reasoning parts for the content shape. When they are - // all plain text (without providerOptions), collapse to a string — matching - // the Vercel AI SDK `openai-compatible` assistant conversion - // (`content: toolCalls.length > 0 ? text || null : text`). - // - // Provider-executed tool results are dropped from assistant messages: the - // OpenAI chat wire format has no content part for them, and upstream's - // assistant branch handles only `text` and `tool-call` - // (convert-to-openai-chat-messages.ts:246-300). Emitting one produces a - // part type the API rejects. - let content_parts: Vec<&ContentPart> = msg - .content + if !value .iter() - .filter(|p| match p { - ContentPart::Reasoning { .. } => false, - ContentPart::ToolResult { .. } => msg.role != Role::Assistant, - _ => true, - }) - .collect(); - let all_plain_text = content_parts.iter().all(|p| { - matches!( - p, - ContentPart::Text { - provider_options: None, - .. + .any(|part| matches!(part, ToolResultContent::File(file) if is_image(file))) + { + return Ok(tool_result_to_content(output)); + } + let mut parts = Vec::new(); + for part in value { + match part { + ToolResultContent::Text(part) => parts.push(json!({"type":"text", "text":part.text})), + ToolResultContent::File(part) if is_image(part) => { + if let FileData::Reference { reference } = &part.data { + let file_id = resolve_provider_reference(reference, "deepseek") + .map_err(AiMuxError::InvalidArgument)?; + parts.push(json!({"type":"file", "file_id":file_id})); + continue; + } + let url = match &part.data { + FileData::Url { url, .. } => { + let media_type = resolve_full_media_type(&part.media_type, ""); + if !matches!( + media_type.as_str(), + "image/jpeg" | "image/jpg" | "image/png" | "image/gif" | "image/webp" + ) { + return Err(AiMuxError::UnsupportedFunctionality(format!( + "DeepSeek image media type {media_type}" + ))); + } + if url.len() > 8192 { + return Err(AiMuxError::InvalidPrompt( + "DeepSeek image URLs must not exceed 8192 characters.".into(), + )); + } + url.clone() + } + FileData::Data { data } => { + use base64::Engine; + let b64 = match data { + FileBytes::Binary(bytes) => { + base64::engine::general_purpose::STANDARD.encode(bytes) + } + FileBytes::Base64(data) => data.clone(), + }; + let media_type = resolve_full_media_type(&part.media_type, &b64); + let media_type = if media_type == "image/jpg" { + "image/jpeg" + } else { + media_type.as_str() + }; + if !matches!( + media_type, + "image/jpeg" | "image/png" | "image/gif" | "image/webp" + ) { + return Err(AiMuxError::UnsupportedFunctionality(format!( + "DeepSeek image media type {media_type}" + ))); + } + format!("data:{media_type};base64,{b64}") + } + _ => continue, + }; + let mut image_url = json!({"url":url}); + if let Some(detail) = part + .provider_options + .as_ref() + .and_then(|options| options.get("deepseek")) + .and_then(|options| options.get("imageDetail")) + { + image_url["detail"] = detail.clone(); + } + parts.push(json!({"type":"image_url", "image_url":image_url})); } - ) - }); - - let mut msg_obj = if all_plain_text { - let text: String = content_parts - .iter() - .filter_map(|p| match p { - ContentPart::Text { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join(""); - json!({ "role": role, "content": text }) - } else { - let parts: Vec = content_parts - .iter() - .enumerate() - .map(|(i, part)| convert_part_to_openai(part, i)) - .collect::, _>>()? - .into_iter() - .filter(|p| !p.is_null()) - .collect(); - json!({ "role": role, "content": parts }) - }; - if has_reasoning { - msg_obj["reasoning_content"] = json!(reasoning); + part => warnings.push(Warning::Unsupported { + feature: format!( + "tool result content part type: {}", + if matches!(part, ToolResultContent::File(_)) { + "file" + } else { + "custom" + } + ), + details: None, + }), + } } - Ok(vec![msg_obj]) + Ok(json!(parts)) } -/// Collect and concatenate the `text` of all `ContentPart::Reasoning` parts in -/// `content`, mirroring the Vercel AI SDK assistant-message conversion. Used to -/// build the OpenAI-compatible top-level `reasoning_content` / `reasoning` -/// field that thinking models (DeepSeek V4, xAI, Groq) require to be replayed -/// across turns. -fn collect_reasoning(content: &[ContentPart]) -> String { - content - .iter() - .filter_map(|p| match p { - ContentPart::Reasoning { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join("") +pub(crate) fn tool_result_to_content(output: &ToolResultOutput) -> Value { + Value::String(match output { + ToolResultOutput::Text { value, .. } | ToolResultOutput::ErrorText { value, .. } => { + value.clone() + } + ToolResultOutput::ExecutionDenied { reason, .. } => reason + .clone() + .unwrap_or_else(|| "Tool call execution denied.".to_string()), + ToolResultOutput::Json { value, .. } | ToolResultOutput::ErrorJson { value, .. } => { + value.to_string() + } + ToolResultOutput::Content { value } => tool_result_content_value(value).to_string(), + }) } -/// Convert a system message, respecting promptCacheBreakpoint. -fn convert_system_message(msg: &LanguageModelPromptMessage, role: &str) -> Vec { - // System messages in the Rust model are always a single text part. - let text = msg - .content - .iter() - .filter_map(|p| match p { - ContentPart::Text { - text, - provider_options, - } => { - let bpt = get_prompt_cache_breakpoint(provider_options); - Some((text.clone(), bpt)) - } - _ => None, - }) - .next(); - - match text { - Some((t, None)) => vec![json!({ "role": role, "content": t })], - Some((t, Some(bpt))) => vec![json!({ - "role": role, - "content": [{ - "type": "text", - "text": t, - "prompt_cache_breakpoint": bpt, - }] - })], - None => vec![json!({ "role": role, "content": "" })], - } +pub(crate) fn tool_result_content_value(content: &[ToolResultContent]) -> Value { + json!( + content + .iter() + .map(|part| { + let (mut item, provider_options) = match part { + ToolResultContent::Text(part) => ( + json!({"type":"text", "text":part.text}), + &part.provider_options, + ), + ToolResultContent::File(part) => { + let data = match &part.data { + FileData::Data { data } => { + let data = match data { + FileBytes::Base64(data) => json!(data), + FileBytes::Binary(bytes) => Value::Object( + bytes + .iter() + .enumerate() + .map(|(i, byte)| (i.to_string(), json!(byte))) + .collect(), + ), + }; + json!({"type":"data", "data":data}) + } + FileData::Url { url, original_url } => { + let mut data = json!({"type":"url", "url":url}); + if let Some(original_url) = original_url { + data["originalUrl"] = json!(original_url); + } + data + } + FileData::Reference { reference } => { + json!({"type":"reference", "reference":reference}) + } + FileData::Text { text } => json!({"type":"text", "text":text}), + }; + let mut item = + json!({"type":"file", "data":data, "mediaType":part.media_type}); + if let Some(filename) = &part.filename { + item["filename"] = json!(filename); + } + (item, &part.provider_options) + } + ToolResultContent::Custom { provider_options } => { + (json!({"type":"custom"}), provider_options) + } + }; + if let Some(provider_options) = provider_options { + item["providerOptions"] = json!(provider_options); + } + item + }) + .collect::>() + ) } -/// Serialize a tool-result `output` value into the OpenAI tool message -/// `content` string. -fn tool_result_to_content(output: &Value) -> Value { +pub(crate) fn tool_result_cache_breakpoint(output: &ToolResultOutput) -> Option { match output { - Value::String(s) => Value::String(s.clone()), - other => Value::String(other.to_string()), - } -} - -/// Convert a content part to the OpenAI format. -fn convert_part_to_openai(part: &ContentPart, index: usize) -> Result { - match part { - ContentPart::Text { - text, - provider_options, - } => { - let bpt = get_prompt_cache_breakpoint(provider_options); - let mut p = json!({ "type": "text", "text": text }); - if let Some(b) = bpt { - p["prompt_cache_breakpoint"] = b; - } - Ok(p) + ToolResultOutput::Text { + provider_options, .. } - ContentPart::Image { - image, - media_type, - provider_options, - } => { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(image); - convert_file_part_to_openai( - media_type, - Some(&b64), - None, - None, - None, - provider_options, - index, - ) - .map_err(AiMuxError::InvalidArgument) + | ToolResultOutput::Json { + provider_options, .. } - ContentPart::File { - data, - media_type, - filename, - provider_options, - } => { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(data); - convert_file_part_to_openai( - media_type, - Some(&b64), - None, - None, - filename.as_deref(), - provider_options, - index, - ) - .map_err(AiMuxError::InvalidArgument) + | ToolResultOutput::ErrorText { + provider_options, .. } - ContentPart::FileBase64 { - data, - media_type, - filename, - provider_options, - } => convert_file_part_to_openai( - media_type, - Some(data), - None, - None, - filename.as_deref(), - provider_options, - index, - ) - .map_err(AiMuxError::InvalidArgument), - ContentPart::FileUrl { - url, - media_type, - provider_options, - } => convert_file_part_to_openai( - media_type, - None, - Some(url), - None, - None, - provider_options, - index, - ) - .map_err(AiMuxError::InvalidArgument), - ContentPart::FileReference { - media_type, - reference, - filename, - provider_options, - } => convert_file_part_to_openai( - media_type, - None, - None, - Some(reference), - filename.as_deref(), - provider_options, - index, - ) - .map_err(AiMuxError::InvalidArgument), - ContentPart::Reasoning { .. } => Ok(Value::Null), - // These variants are handled by `convert_message_to_openai` for - // assistant/tool roles; kept here as a defensive fallback. - ContentPart::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => Ok(json!({ - "type": "tool_call", - "id": tool_call_id, - "function": { - "name": tool_name, - "arguments": input.to_string(), + | ToolResultOutput::ErrorJson { + provider_options, .. + } + | ToolResultOutput::ExecutionDenied { + provider_options, .. + } => get_prompt_cache_breakpoint(provider_options), + ToolResultOutput::Content { value } => value.iter().find_map(|part| match part { + aimux_core::language_model_message::ToolResultContent::Text(part) => { + get_prompt_cache_breakpoint(&part.provider_options) + } + aimux_core::language_model_message::ToolResultContent::File(part) => { + get_prompt_cache_breakpoint(&part.provider_options) } - })), - // Tool results reach the wire as a separate `role: "tool"` message - // (built by `convert_message_to_openai`), never as a content part — - // there is no such part type in the OpenAI chat format. - ContentPart::ToolResult { .. } => Ok(Value::Null), + aimux_core::language_model_message::ToolResultContent::Custom { provider_options } => { + get_prompt_cache_breakpoint(provider_options) + } + }), } } +fn convert_text_part_to_openai(part: &TextPart, provider: &str) -> Value { + let mut value = json!({ "type": "text", "text": part.text }); + if provider == "openai" + && let Some(bpt) = get_prompt_cache_breakpoint(&part.provider_options) + { + value["prompt_cache_breakpoint"] = bpt; + } + value +} + // ── Request body ──────────────────────────────────────────────────────────── /// Result of building a request body, including warnings. @@ -897,15 +902,210 @@ pub struct RequestBodyResult { pub warnings: Vec, } +/// Parse declared namespace fields, discarding unknown keys like a default z.object. +/// +/// # Errors +/// Returns a parse error when a declared field fails its schema. +pub(crate) fn parse_option_fields( + options: &JsonObject, + provider: &str, + parse: impl Fn(&str, &Value) -> Option>, +) -> Result { + let mut parsed = JsonObject::new(); + for (key, value) in options { + if let Some(result) = parse(key, value) { + parsed.insert( + key.clone(), + result.map_err(|()| { + AiMuxError::InvalidArgument(format!("invalid {provider} provider options")) + })?, + ); + } + } + Ok(parsed) +} + +fn parse_chat_provider_options( + options: &Option, + provider: &str, +) -> Result, AiMuxError> { + let provider = if provider == "azure" { + "openai" + } else { + provider + }; + if !matches!(provider, "openai" | "groq" | "deepseek") { + return Ok(options.clone()); + } + let Some(namespace) = options.as_ref().and_then(|options| options.get(provider)) else { + return Ok(None); + }; + let parsed = parse_option_fields(namespace, provider, |key, value| { + let one_of = |values: &[&str]| value.as_str().is_some_and(|s| values.contains(&s)); + let valid = match (provider, key) { + ("openai", "logitBias") => { + return Some(value.as_object().ok_or(()).and_then(|object| { + let mut parsed = JsonObject::new(); + for (key, value) in object { + if !value.is_number() { + return Err(()); + } + let key = key.trim_matches(|c| { + matches!(c, + '\u{0009}'..='\u{000d}' | '\u{0020}' | '\u{00a0}' | '\u{1680}' + | '\u{2000}'..='\u{200a}' | '\u{2028}' | '\u{2029}' | '\u{202f}' + | '\u{205f}' | '\u{3000}' | '\u{feff}' + ) + }); + let number = if key.is_empty() { + 0.0 + } else if let Some((digits, radix)) = key + .strip_prefix("0x") + .or_else(|| key.strip_prefix("0X")) + .map(|s| (s, 16)) + .or_else(|| { + key.strip_prefix("0o") + .or_else(|| key.strip_prefix("0O")) + .map(|s| (s, 8)) + }) + .or_else(|| { + key.strip_prefix("0b") + .or_else(|| key.strip_prefix("0B")) + .map(|s| (s, 2)) + }) + { + if digits.is_empty() { + return Err(()); + } + let bits_per_digit = match radix { + 16 => 4, + 8 => 3, + _ => 1, + }; + let mut significant_bits = 0usize; + let mut mantissa = 0u64; + let mut guard = false; + let mut sticky = false; + for digit in digits.chars() { + let digit = digit.to_digit(radix).ok_or(())?; + for shift in (0..bits_per_digit).rev() { + let bit = (digit >> shift) & 1; + if significant_bits == 0 && bit == 0 { + continue; + } + if significant_bits < 53 { + mantissa = (mantissa << 1) | u64::from(bit); + } else if significant_bits == 53 { + guard = bit != 0; + } else { + sticky |= bit != 0; + } + significant_bits += 1; + } + } + if guard && (sticky || mantissa & 1 != 0) { + mantissa += 1; + } + let exponent = significant_bits.saturating_sub(53); + if exponent > 1023 { + f64::INFINITY + } else { + mantissa as f64 * 2.0f64.powi(exponent as i32) + } + } else { + key.parse::().map_err(|_| ())? + }; + if !number.is_finite() { + return Err(()); + } + let key = if number == 0.0 { + "0".to_string() + } else if number.abs() >= 1e21 || number.abs() < 1e-6 { + let scientific = format!("{number:e}"); + let (mantissa, exponent) = scientific.split_once('e').ok_or(())?; + let exponent = exponent.parse::().map_err(|_| ())?; + format!("{mantissa}e{exponent:+}") + } else { + number.to_string() + }; + parsed.insert(key, value.clone()); + } + Ok(Value::Object(parsed)) + })); + } + ("openai", "logprobs") => value.is_boolean() || value.is_number(), + ("openai", "maxCompletionTokens") => value.is_number(), + ("openai", "metadata") => value.as_object().is_some_and(|object| { + object.iter().all(|(key, value)| { + key.encode_utf16().count() <= 64 + && value + .as_str() + .is_some_and(|s| s.encode_utf16().count() <= 512) + }) + }), + ("openai", "prediction") => value.is_object(), + ("openai", "reasoningEffort") => { + one_of(&["none", "minimal", "low", "medium", "high", "xhigh", "max"]) + } + ("openai", "serviceTier") => { + one_of(&["auto", "flex", "priority", "fast", "ultrafast", "default"]) + } + ("openai", "textVerbosity") => one_of(&["low", "medium", "high"]), + ("openai", "promptCacheRetention") => one_of(&["in_memory", "24h"]), + ("openai", "systemMessageMode") => one_of(&["system", "developer", "remove"]), + ("openai", "user" | "promptCacheKey" | "safetyIdentifier") | ("groq", "user") => { + value.is_string() + } + ("openai", "parallelToolCalls" | "store" | "strictJsonSchema" | "forceReasoning") + | ("groq", "parallelToolCalls" | "structuredOutputs" | "strictJsonSchema") + | ("deepseek", "logprobs" | "strictJsonSchema") => value.is_boolean(), + ("groq", "reasoningFormat") => one_of(&["parsed", "raw", "hidden"]), + ("groq", "reasoningEffort") => one_of(&["none", "default", "low", "medium", "high"]), + ("groq", "serviceTier") => one_of(&["on_demand", "performance", "flex", "auto"]), + ("deepseek", "reasoningEffort") => one_of(&["low", "medium", "high", "xhigh", "max"]), + ("deepseek", "topLogprobs") => value + .as_f64() + .is_some_and(|n| n.fract() == 0.0 && (0.0..=20.0).contains(&n)), + ("deepseek", "userId") => value.as_str().is_some_and(|s| { + !s.is_empty() + && s.len() <= 512 + && s.bytes() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-')) + }), + ("openai", "promptCacheOptions") | ("deepseek", "thinking") => { + return Some(value.as_object().ok_or(()).and_then(|object| { + parse_option_fields(object, provider, |key, value| { + let valid = match (provider, key) { + ("openai", "mode") => value + .as_str() + .is_some_and(|s| matches!(s, "implicit" | "explicit")), + ("openai", "ttl") => value.as_str() == Some("30m"), + ("deepseek", "type") => value + .as_str() + .is_some_and(|s| matches!(s, "adaptive" | "enabled" | "disabled")), + _ => return None, + }; + Some(if valid { Ok(value.clone()) } else { Err(()) }) + }) + .map(Value::Object) + .map_err(|_| ()) + })); + } + _ => return None, + }; + Some(if valid { Ok(value.clone()) } else { Err(()) }) + })?; + Ok(Some(std::collections::HashMap::from([( + provider.to_string(), + parsed, + )]))) +} + /// Get a value from provider_options.openai.. -fn openai_option( - options: &Option>, - key: &str, -) -> Option { - use std::collections::HashMap; +fn openai_option(options: &Option, key: &str) -> Option { options .as_ref() - .and_then(|m: &HashMap| m.get("openai")) + .and_then(|m| m.get("openai")) .and_then(|o| o.get(key)) .cloned() } @@ -940,21 +1140,18 @@ pub fn build_request_body( /// Conversion errors propagate to the caller (fail-fast, issue H2): the old /// behaviour of silently returning `body: null` sent empty requests upstream /// and made conversion failures invisible. -/// Look up a key from the provider-specific options (groq → "groq" then -/// "openai"; otherwise "openai"). +/// Look up a key from the selected vendor namespace. fn provider_option( - provider_opts: &Option>, + provider_opts: &Option, provider: &str, key: &str, ) -> Option { - if provider == "groq" - && let Some(v) = provider_opts + if matches!(provider, "groq" | "deepseek") { + return provider_opts .as_ref() - .and_then(|m| m.get("groq")) + .and_then(|m| m.get(provider)) .and_then(|o| o.get(key)) - .cloned() - { - return Some(v); + .cloned(); } openai_option(provider_opts, key) } @@ -963,7 +1160,7 @@ fn provider_option( /// vendor normalization). `providerOptions.reasoningEffort` wins over top-level /// `reasoning`; custom top-level levels map verbatim to `reasoning_effort`. fn resolve_reasoning_effort( - provider_opts: &Option>, + provider_opts: &Option, provider: &str, reasoning: &Option, ) -> Option { @@ -983,7 +1180,7 @@ fn resolve_reasoning_effort( } fn resolve_is_reasoning_model( - provider_opts: &Option>, + provider_opts: &Option, provider: &str, caps: &ModelCapabilities, ) -> bool { @@ -993,7 +1190,7 @@ fn resolve_is_reasoning_model( } fn resolve_system_message_mode( - provider_opts: &Option>, + provider_opts: &Option, provider: &str, is_reasoning_model: bool, caps: &ModelCapabilities, @@ -1021,13 +1218,12 @@ fn resolve_system_message_mode( fn apply_max_tokens( body: &mut Value, options: &CallOptions, - provider_opts: &Option>, + provider_opts: &Option, provider: &str, profile: &OpenAICompatProfile, is_reasoning_model: bool, ) { - let max_completion_tokens_opt = provider_option(provider_opts, provider, "maxCompletionTokens") - .and_then(|v| v.as_u64().map(|n| n as u32)); + let max_completion_tokens_opt = provider_option(provider_opts, provider, "maxCompletionTokens"); let use_mct_key = match profile.max_tokens_key { Some("max_tokens") => false, @@ -1157,7 +1353,7 @@ fn insert_sampling_params(body: &mut Value, params: &SamplingParams, options: &C fn apply_response_format( body: &mut Value, options: &CallOptions, - provider_opts: &Option>, + provider_opts: &Option, provider: &str, profile: &OpenAICompatProfile, warnings: &mut Vec, @@ -1199,7 +1395,12 @@ fn apply_response_format( .unwrap_or(true), ) } else { - (true, true) + ( + true, + provider_option(provider_opts, provider, "strictJsonSchema") + .and_then(|v| v.as_bool()) + .unwrap_or(true), + ) }; if schema.is_some() && structured_outputs { @@ -1237,7 +1438,7 @@ fn apply_response_format( /// field (plus Groq's `reasoning_format`). fn apply_provider_option_passthrough( body: &mut Value, - provider_opts: &Option>, + provider_opts: &Option, provider: &str, ) { let mut set = |key: &str, body_key: &str| { @@ -1256,12 +1457,32 @@ fn apply_provider_option_passthrough( set("promptCacheRetention", "prompt_cache_retention"); set("promptCacheOptions", "prompt_cache_options"); set("safetyIdentifier", "safety_identifier"); - // M3 (RFC-0016): logprobs request support. Previously `logprobs` / - // `topLogprobs` were silently dropped by the provider_options whitelist — - // the only option that "quietly did nothing". Pass-through as-is (OpenAI - // expects `logprobs: bool` and `top_logprobs: int`). - set("logprobs", "logprobs"); - set("topLogprobs", "top_logprobs"); + if provider == "deepseek" { + set("userId", "user_id"); + } + if provider != "openai" { + set("topLogprobs", "top_logprobs"); + } + if let Some(logprobs) = provider_option(provider_opts, provider, "logprobs") { + if provider == "openai" { + if logprobs == json!(true) || logprobs.is_number() { + body["logprobs"] = json!(true); + body["top_logprobs"] = if logprobs.is_number() { + logprobs + } else { + json!(0) + }; + } + } else if provider == "deepseek" { + if logprobs == json!(true) || body.get("top_logprobs").is_some() { + body["logprobs"] = json!(true); + } + } else { + body["logprobs"] = logprobs; + } + } else if provider == "deepseek" && body.get("top_logprobs").is_some() { + body["logprobs"] = json!(true); + } // Groq: reasoning_format provider option if provider == "groq" && let Some(val) = provider_option(provider_opts, provider, "reasoningFormat") @@ -1274,7 +1495,7 @@ fn apply_provider_option_passthrough( /// without validation). fn apply_service_tier( body: &mut Value, - provider_opts: &Option>, + provider_opts: &Option, provider: &str, caps: &ModelCapabilities, warnings: &mut Vec, @@ -1405,7 +1626,8 @@ pub fn build_request_body_with_warnings( ) -> Result { let mut warnings: Vec = Vec::new(); let caps = get_model_capabilities(model_id); - let provider_opts = &options.provider_options; + let parsed_provider_opts = parse_chat_provider_options(&options.provider_options, provider)?; + let provider_opts = &parsed_provider_opts; let resolved_reasoning_effort = resolve_reasoning_effort(provider_opts, provider, &options.reasoning); @@ -1422,10 +1644,11 @@ pub fn build_request_body_with_warnings( }); } - let messages = convert_prompt_to_openai_messages_with_provider( + let messages = convert_prompt_with_warnings( &options.prompt, system_message_mode, provider, + &mut warnings, )?; let mut body = json!({ diff --git a/aimux-providers/src/openai/decision.rs b/aimux-providers/src/openai/decision.rs index dec090216..33eece648 100644 --- a/aimux-providers/src/openai/decision.rs +++ b/aimux-providers/src/openai/decision.rs @@ -4,7 +4,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use aimux_core::AiMuxError; use aimux_core::decision_model::*; use aimux_core::recording::ProviderRecord; -use aimux_core::types::{TokenUsage, Usage}; +use aimux_core::types::{InputTokenUsage, OutputTokenUsage, Usage}; use async_trait::async_trait; use serde::Deserialize; use serde_json::{Value, json}; @@ -182,9 +182,10 @@ fn request_body(model: &str, options: &DecisionCallOptions) -> Result 128 { return Err(AiMuxError::InvalidArgument( @@ -349,7 +350,7 @@ fn convert_response( let written = count(&raw["input_tokens_details"]["cache_write_tokens"]); let reasoning = count(&raw["output_tokens_details"]["reasoning_tokens"]); Usage { - input_tokens: TokenUsage { + input_tokens: InputTokenUsage { total: input, no_cache: input .zip(cached) @@ -357,17 +358,15 @@ fn convert_response( .and_then(|((total, read), write)| total.checked_sub(read)?.checked_sub(write)), cache_read: cached, cache_write: written, - ..Default::default() }, - output_tokens: TokenUsage { + output_tokens: OutputTokenUsage { total: output, text: output .zip(reasoning) .and_then(|(total, reasoning)| total.checked_sub(reasoning)), reasoning, - ..Default::default() }, - raw: Some(raw), + raw: raw.as_object().cloned(), } }); let result = DecisionResult { @@ -379,7 +378,7 @@ fn convert_response( probability_source: DecisionProbabilitySource::Native, usage, latency_ms: None, - provider_metadata: Some(HashMap::from([("openai".into(), raw.clone())])), + provider_metadata: Some(crate::decision_support::response_metadata("openai", &raw)?), response: Some(DecisionResponse { headers: Some(headers), body: Some(raw), diff --git a/aimux-providers/src/openai/files.rs b/aimux-providers/src/openai/files.rs index 2574cd76c..ba9e875df 100644 --- a/aimux-providers/src/openai/files.rs +++ b/aimux-providers/src/openai/files.rs @@ -8,11 +8,11 @@ use std::collections::HashMap; use async_trait::async_trait; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::json; use aimux_core::error::AiMuxError; use aimux_core::files_model::{Files, UploadFileCallOptions, UploadFileData, UploadFileResult}; -use aimux_core::shared::FileBytes; +use aimux_core::shared::{FileBytes, SharedProviderOptions}; use aimux_provider_utils::{HttpBody, HttpRequest}; @@ -31,7 +31,7 @@ struct OpenAIFilesOptions { /// Parse provider options for the OpenAI files provider. fn parse_openai_files_options( - provider_options: Option<&HashMap>, + provider_options: Option<&SharedProviderOptions>, ) -> OpenAIFilesOptions { let mut opts = OpenAIFilesOptions::default(); if let Some(po) = provider_options @@ -221,9 +221,7 @@ impl Files for OpenAIFiles { provider_reference: provider_ref, media_type: Some(options.media_type.clone()), filename: result_filename, - provider_metadata: Some( - std::iter::once(("openai".to_string(), Value::Object(metadata))).collect(), - ), + provider_metadata: Some(HashMap::from([("openai".to_string(), metadata)])), warnings: Vec::new(), }) } diff --git a/aimux-providers/src/openai/image.rs b/aimux-providers/src/openai/image.rs index ec157c4c5..df80a6133 100644 --- a/aimux-providers/src/openai/image.rs +++ b/aimux-providers/src/openai/image.rs @@ -18,7 +18,7 @@ use aimux_core::image_model::{ ImageCallOptions, ImageFile, ImageFileData, ImageModel, ImageOutputs, ImageResponse, ImageResult, ImageUsage, }; -use aimux_core::shared::{SharedProviderMetadata, Warning}; +use aimux_core::shared::{SharedProviderMetadata, SharedProviderOptions, Warning}; use aimux_provider_utils::{HttpBody, HttpRequest}; @@ -231,7 +231,7 @@ struct GenerationOptions { /// Parse generation provider options from the `"openai"` key. fn parse_generation_provider_options( - provider_options: &HashMap, + provider_options: &SharedProviderOptions, ) -> GenerationOptions { let openai = provider_options.get("openai"); GenerationOptions { @@ -256,7 +256,7 @@ struct EditOptions { } /// Parse edit provider options from the `"openai"` key. -fn parse_edit_provider_options(provider_options: &HashMap) -> EditOptions { +fn parse_edit_provider_options(provider_options: &SharedProviderOptions) -> EditOptions { let openai = provider_options.get("openai"); EditOptions { quality: openai.and_then(|o| o.get("quality")).cloned(), @@ -600,6 +600,6 @@ fn extract_provider_metadata(response: &Value) -> SharedProviderMetadata { let mut openai_meta = Map::new(); openai_meta.insert("images".to_string(), json!(images)); - metadata.insert("openai".to_string(), Value::Object(openai_meta)); + metadata.insert("openai".to_string(), openai_meta); metadata } diff --git a/aimux-providers/src/openai/model.rs b/aimux-providers/src/openai/model.rs index 87af24559..48f24c62f 100644 --- a/aimux-providers/src/openai/model.rs +++ b/aimux-providers/src/openai/model.rs @@ -17,7 +17,8 @@ use serde_json::{Value, json}; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source, StreamResult}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; @@ -157,23 +158,21 @@ fn convert_usage(usage: &UsageResponse, usage_raw: Option<&Value>) -> Usage { let text_tokens = completion_tokens.saturating_sub(reasoning_tokens); Usage { - input_tokens: aimux_core::types::TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(prompt_tokens), no_cache: Some(no_cache), cache_read: Some(cached), cache_write, - ..Default::default() }, - output_tokens: aimux_core::types::TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(completion_tokens), text: Some(text_tokens), reasoning: Some(reasoning_tokens), - ..Default::default() }, // M10 (RFC-0016): keep the provider's original usage JSON verbatim — // vendor-specific fields (e.g. Moonshot `cached_tokens`, DeepSeek // `prompt_cache_hit_tokens`) are otherwise lost for audit/billing. - raw: usage_raw.cloned(), + raw: usage_raw.and_then(|value| value.as_object().cloned()), } } @@ -310,10 +309,10 @@ pub async fn execute_generate( if let Some(reasoning) = reasoning_text && !reasoning.is_empty() { - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text: reasoning, provider_metadata: None, - }); + })); } if let Some(tool_calls) = choice.message.tool_calls { for tc in tool_calls { @@ -334,19 +333,19 @@ pub async fn execute_generate( if ann.get("type").and_then(|v| v.as_str()) == Some("url_citation") && let Some(uc) = ann.get("url_citation") { - content.push(GenerateContent::Source { + content.push(GenerateContent::Source(Source::Url { id: format!("annotation-{i}"), - source_type: "url".to_string(), url: uc .get("url") .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string), + .unwrap_or_default() + .to_string(), title: uc .get("title") .and_then(|v| v.as_str()) .map(std::string::ToString::to_string), provider_metadata: None, - }); + })); } } } @@ -378,7 +377,8 @@ pub async fn execute_generate( pm_openai["rejectedPredictionTokens"] = json!(rpt); } } - let provider_metadata = Some(serde_json::json!({ "openai": pm_openai })); + let provider_metadata = + Some(provider_namespace("openai", pm_openai).expect("provider metadata must be an object")); Ok(GenerateResult { content, @@ -386,16 +386,19 @@ pub async fn execute_generate( usage, warnings: request_result.warnings, provider_metadata, - response: ResponseMetadata { - id: Some(data.id), - timestamp: data - .created - .and_then(|secs| chrono::DateTime::from_timestamp(secs as i64, 0)) - .map(|dt| dt.to_rfc3339()), - model_id: Some(data.model), - }, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: Some(response_value), + headers: Some(response_headers), + ..aimux_core::result::GenerateResponse::from(ResponseMetadata { + id: Some(data.id), + timestamp: data + .created + .and_then(|secs| chrono::DateTime::from_timestamp(secs as i64, 0)) + .map(|dt| dt.to_rfc3339()), + model_id: Some(data.model), + }) + }), }) } @@ -553,14 +556,14 @@ pub async fn execute_stream( && (chunk.id.is_some() || chunk.model.is_some()) { response_metadata_emitted = true; - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: chunk.id.clone(), timestamp: chunk .created .and_then(|secs| chrono::DateTime::from_timestamp(secs as i64, 0)) .map(|dt| dt.to_rfc3339()), model_id: chunk.model.clone(), - }); + })); } // Update usage based on profile.stream_usage_key. @@ -663,19 +666,19 @@ pub async fn execute_stream( == Some("url_citation") && let Some(uc) = ann.get("url_citation") { - yield Ok(StreamPart::Source { + yield Ok(StreamPart::Source(Source::Url { id: format!("annotation-{i}"), - source_type: "url".to_string(), url: uc .get("url") .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string), + .unwrap_or_default() + .to_string(), title: uc .get("title") .and_then(|v| v.as_str()) .map(std::string::ToString::to_string), provider_metadata: None, - }); + })); } } } @@ -752,7 +755,7 @@ pub async fn execute_stream( pm_openai["rejectedPredictionTokens"] = json!(rpt); } } - let provider_metadata = serde_json::json!({ "openai": pm_openai }); + let provider_metadata = provider_namespace("openai", pm_openai).expect("provider metadata must be an object"); // Final part: Finish. yield Ok(StreamPart::Finish { @@ -778,8 +781,10 @@ pub async fn execute_stream( Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } diff --git a/aimux-providers/src/openai/responses/convert.rs b/aimux-providers/src/openai/responses/convert.rs index 4f0aad772..2ed3a40dc 100644 --- a/aimux-providers/src/openai/responses/convert.rs +++ b/aimux-providers/src/openai/responses/convert.rs @@ -10,14 +10,15 @@ //! - `convert-openai-responses-usage.ts` -> [`convert_responses_usage`] //! - `map-openai-responses-finish-reason.ts` -> [`map_responses_finish_reason`] -use std::collections::HashMap; - use serde_json::{Value, json}; -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::LanguageModelPrompt; -use aimux_core::message::Role; +use aimux_core::error::AiMuxError; +use aimux_core::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, + ToolCallPart, ToolPart, ToolResultContent, ToolResultOutput, ToolResultPart, UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; +use aimux_core::shared::{FileBytes, FileData, SharedProviderOptions}; use aimux_core::tool::{FunctionTool, Tool}; use aimux_core::types::{FinishReason, FinishReasonUnified, ReasoningEffort, Usage, Warning}; @@ -40,7 +41,7 @@ pub use crate::openai::convert_common::SystemMessageMode as ResponsesSystemMessa // -- Provider options helper ------------------------------------------------- /// Get a value from `provider_options.openai.`. -fn openai_option(options: &Option>, key: &str) -> Option { +fn openai_option(options: &Option, key: &str) -> Option { options .as_ref() .and_then(|m| m.get("openai")) @@ -70,28 +71,33 @@ pub struct ResponsesInputResult { /// `has_previous_response_id` is true, assistant reasoning/function-call items /// that already carry an `itemId` are skipped (they live in the previous /// response chain). +/// +/// # Errors +/// Returns an unsupported-functionality error for text file parts. pub fn convert_to_responses_input( prompt: &LanguageModelPrompt, system_message_mode: SystemMessageMode, store: bool, has_previous_response_id: bool, -) -> ResponsesInputResult { + has_conversation: bool, +) -> Result { let mut input: Vec = Vec::new(); let mut warnings: Vec = Vec::new(); + let mut processed_approval_ids = std::collections::HashSet::new(); for msg in prompt { - match msg.role { - Role::System => match system_message_mode { + match msg { + LanguageModelMessage::System { content, .. } => match system_message_mode { SystemMessageMode::System => { input.push(json!({ "role": "system", - "content": join_text_parts(&msg.content), + "content": content, })); } SystemMessageMode::Developer => { input.push(json!({ "role": "developer", - "content": join_text_parts(&msg.content), + "content": content, })); } SystemMessageMode::Remove => { @@ -100,19 +106,22 @@ pub fn convert_to_responses_input( }); } }, - Role::User => { - let content: Vec = msg.content.iter().map(convert_user_part).collect(); + LanguageModelMessage::User { content, .. } => { + let content: Vec = content + .iter() + .map(convert_user_part) + .collect::>()?; input.push(json!({ "role": "user", "content": content })); } - Role::Assistant => { - for part in &msg.content { + LanguageModelMessage::Assistant { content, .. } => { + for part in content { match part { - ContentPart::Text { + AssistantPart::Text(TextPart { text, provider_options, - } => { + }) => { let id = item_id(provider_options); - if has_previous_response_id && id.is_some() { + if (has_previous_response_id || has_conversation) && id.is_some() { continue; } if store && let Some(ref id) = id { @@ -132,15 +141,15 @@ pub fn convert_to_responses_input( } input.push(item); } - ContentPart::ToolCall { + AssistantPart::ToolCall(ToolCallPart { tool_call_id, tool_name, input: tool_input, provider_options, .. - } => { + }) => { let id = item_id(provider_options); - if has_previous_response_id && id.is_some() { + if (has_previous_response_id || has_conversation) && id.is_some() { continue; } let namespace = namespace_from_provider_options(provider_options); @@ -155,13 +164,15 @@ pub fn convert_to_responses_input( } input.push(item); } - ContentPart::Reasoning { + AssistantPart::Reasoning(ReasoningPart { text, provider_options, .. - } => { + }) => { let reasoning_id = openai_sub_option(provider_options, "itemId"); - if has_previous_response_id && reasoning_id.is_some() { + if (has_previous_response_id || has_conversation) + && reasoning_id.is_some() + { continue; } if let Some(ref rid) = reasoning_id { @@ -210,28 +221,84 @@ pub fn convert_to_responses_input( } } } + AssistantPart::ToolResult(part) => { + if has_conversation + || matches!(part.output, ToolResultOutput::ExecutionDenied { .. }) + || matches!(&part.output, ToolResultOutput::Json { value, .. } if value.get("type").and_then(Value::as_str) == Some("execution-denied")) + { + continue; + } + if store { + let id = item_id(&part.provider_options) + .unwrap_or_else(|| part.tool_call_id.clone()); + input.push(json!({"type":"item_reference", "id":id})); + } else { + warnings.push(Warning::Other { + message:format!("Results for OpenAI tool {} are not sent to the API when store is false", part.tool_name), + }); + } + } + AssistantPart::Custom(part) if part.kind == "openai.compaction" => { + let id = item_id(&part.provider_options); + if has_conversation && id.is_some() { + continue; + } + if let Some(id) = id { + if store { + input.push(json!({"type":"item_reference", "id":id})); + } else { + input.push(json!({"type":"compaction", "id":id, "encrypted_content":openai_sub_option(&part.provider_options, "encryptedContent")})); + } + } + } _ => {} } } } - Role::Tool => { - for part in &msg.content { - if let ContentPart::ToolResult { + LanguageModelMessage::Tool { content, .. } => { + for part in content { + if let ToolPart::ToolApprovalResponse(approval) = part { + if !processed_approval_ids.insert(&approval.approval_id) { + continue; + } + if store && !has_conversation && !has_previous_response_id { + input.push(json!({"type":"item_reference", "id":approval.approval_id})); + } + input.push(json!({"type":"mcp_approval_response", "approval_request_id":approval.approval_id, "approve":approval.approved})); + continue; + } + let ToolPart::ToolResult(ToolResultPart { tool_call_id, - result, + output, + provider_options, .. - } = part + }) = part + else { + continue; + }; + if let ToolResultOutput::ExecutionDenied { + provider_options, .. + } = output + && openai_sub_option(provider_options, "approvalId") + .and_then(|value| value.as_str().map(|id| !id.is_empty())) + == Some(true) { - let content_value = match result { - Value::String(s) => Value::String(s.clone()), - other => Value::String(other.to_string()), - }; - input.push(json!({ - "type": "function_call_output", - "call_id": tool_call_id, - "output": content_value, - })); + continue; } + let mut content_value = convert_tool_result_output(output, &mut warnings)?; + if !matches!(output, ToolResultOutput::Content { .. }) + && let Some(breakpoint) = + crate::openai::convert::tool_result_cache_breakpoint(output).or_else( + || openai_sub_option(provider_options, "promptCacheBreakpoint"), + ) + { + content_value = json!([{"type":"input_text", "text":content_value, "prompt_cache_breakpoint":breakpoint}]); + } + input.push(json!({ + "type": "function_call_output", + "call_id": tool_call_id, + "output": content_value, + })); } } } @@ -255,98 +322,126 @@ pub fn convert_to_responses_input( }); } - ResponsesInputResult { input, warnings } -} - -/// Join all text parts of a message into a single string (for system messages). -fn join_text_parts(parts: &[ContentPart]) -> String { - parts - .iter() - .filter_map(|p| match p { - ContentPart::Text { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join("") + Ok(ResponsesInputResult { input, warnings }) } /// Convert a single user-message content part into the Responses input shape. -fn convert_user_part(part: &ContentPart) -> Value { - match part { - ContentPart::Text { text, .. } => json!({ "type": "input_text", "text": text }), - ContentPart::Image { - image, - media_type, - provider_options, - } => { - let b64 = { - use base64::Engine; - base64::engine::general_purpose::STANDARD.encode(image) - }; - let detail = openai_sub_option(provider_options, "imageDetail"); - let mut img = json!({ - "type": "input_image", - "image_url": format!("data:{};base64,{}", media_type, b64), - }); - if let Some(d) = detail { - img["detail"] = d; - } - img - } - ContentPart::File { +fn convert_user_part(part: &UserPart) -> Result { + Ok(match part { + UserPart::Text(TextPart { text, .. }) => json!({ "type": "input_text", "text": text }), + UserPart::File(FilePart { data, media_type, filename, - .. - } => { - let b64 = { - use base64::Engine; - base64::engine::general_purpose::STANDARD.encode(data) + provider_options, + }) => { + let is_image = media_type.split('/').next() == Some("image"); + let mut item = match data { + FileData::Data { data } => { + let b64 = match data { + FileBytes::Binary(bytes) => { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(bytes) + } + FileBytes::Base64(data) => data.clone(), + }; + let data_url = format!("data:{media_type};base64,{b64}"); + if is_image { + json!({ "type": "input_image", "image_url": data_url }) + } else { + json!({ + "type": "input_file", + "filename": filename.as_deref().unwrap_or("part.pdf"), + "file_data": data_url, + }) + } + } + FileData::Url { url, .. } => { + if is_image { + json!({ "type": "input_image", "image_url": url }) + } else { + json!({ "type": "input_file", "file_url": url }) + } + } + FileData::Reference { reference } => { + let file_id = reference.get("openai").ok_or_else(|| { + AiMuxError::InvalidArgument( + "missing file reference for provider openai".into(), + ) + })?; + json!({ "type": if is_image { "input_image" } else { "input_file" }, "file_id": file_id }) + } + FileData::Text { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "text file parts".into(), + )); + } }; - let fname = filename.clone().unwrap_or_else(|| "part.pdf".to_string()); - json!({ - "type": "input_file", - "filename": fname, - "file_data": format!("data:{};base64,{}", media_type, b64), - }) - } - ContentPart::FileBase64 { - data, - media_type, - filename, - .. - } => { - let fname = filename.clone().unwrap_or_else(|| "part.pdf".to_string()); - json!({ - "type": "input_file", - "filename": fname, - "file_data": format!("data:{};base64,{}", media_type, data), - }) + if item["type"] == "input_image" + && let Some(detail) = openai_sub_option(provider_options, "imageDetail") + { + item["detail"] = detail; + } + item } - ContentPart::FileUrl { - url, media_type, .. - } => { - if media_type.starts_with("image") { - json!({ "type": "input_image", "image_url": url }) - } else { - json!({ "type": "input_file", "file_url": url }) + }) +} + +fn convert_tool_result_output( + output: &ToolResultOutput, + warnings: &mut Vec, +) -> Result { + let ToolResultOutput::Content { value } = output else { + return Ok(crate::openai::convert::tool_result_to_content(output)); + }; + let mut parts = Vec::new(); + for item in value { + let (mut converted, provider_options) = match item { + ToolResultContent::Text(part) => ( + json!({"type":"input_text", "text":part.text}), + &part.provider_options, + ), + ToolResultContent::File(part) => { + if matches!(part.data, FileData::Text { .. }) { + warnings.push(Warning::Other { + message: "unsupported tool content part type: file with data type: text" + .into(), + }); + continue; + } + let mut converted = convert_user_part(&UserPart::File(part.clone()))?; + if converted["type"] == "input_file" && converted.get("file_data").is_some() { + converted["filename"] = json!(part.filename.as_deref().unwrap_or("data")); + } + if converted["type"] == "input_image" + && let Some(detail) = openai_sub_option(&part.provider_options, "imageDetail") + { + converted["detail"] = detail; + } + (converted, &part.provider_options) + } + ToolResultContent::Custom { .. } => { + warnings.push(Warning::Other { + message: "unsupported tool content part type: custom".into(), + }); + continue; } + }; + if let Some(breakpoint) = openai_sub_option(provider_options, "promptCacheBreakpoint") { + converted["prompt_cache_breakpoint"] = breakpoint; } - _ => json!({ "type": "input_text", "text": "" }), + parts.push(converted); } + Ok(json!(parts)) } -/// Serialize tool-call arguments: null/empty -> `"{}"`, objects -> JSON string. +/// Serialize tool-call arguments as JSON, mirroring JSON.stringify. fn serialize_arguments(input: &Value) -> String { - match input { - Value::Null => "{}".to_string(), - Value::String(s) if s.is_empty() => "{}".to_string(), - other => other.to_string(), - } + input.to_string() } /// Read the `itemId` from a content part's `providerOptions.openai.itemId`. -fn item_id(provider_options: &Option) -> Option { +fn item_id(provider_options: &Option) -> Option { provider_options .as_ref() .and_then(|v| v.get("openai")) @@ -356,7 +451,7 @@ fn item_id(provider_options: &Option) -> Option { } /// Read the `phase` from a content part's `providerOptions.openai.phase`. -fn phase_from_provider_options(provider_options: &Option) -> Option { +fn phase_from_provider_options(provider_options: &Option) -> Option { provider_options .as_ref() .and_then(|v| v.get("openai")) @@ -366,7 +461,9 @@ fn phase_from_provider_options(provider_options: &Option) -> Option) -> Option { +fn namespace_from_provider_options( + provider_options: &Option, +) -> Option { provider_options .as_ref() .and_then(|v| v.get("openai")) @@ -376,7 +473,7 @@ fn namespace_from_provider_options(provider_options: &Option) -> Option` on a content part. -fn openai_sub_option(provider_options: &Option, key: &str) -> Option { +fn openai_sub_option(provider_options: &Option, key: &str) -> Option { provider_options .as_ref() .and_then(|v| v.get("openai")) @@ -513,7 +610,7 @@ fn push_unsupported_call_option_warnings(options: &CallOptions, warnings: &mut V /// wins over top-level `reasoning`), `reasoningSummary` (defaults to "detailed" /// when an effort other than "none" applies), and whether the model reasons. fn resolve_responses_reasoning( - provider_opts: &Option>, + provider_opts: &Option, options: &CallOptions, caps: &ModelCapabilities, ) -> (Option, Option, bool) { @@ -562,7 +659,7 @@ fn resolve_responses_reasoning( /// Warn when `conversation` and `previousResponseId` are both set. fn warn_conversation_conflict( - provider_opts: &Option>, + provider_opts: &Option, warnings: &mut Vec, ) { let has_conversation = openai_option(provider_opts, "conversation").is_some(); @@ -578,7 +675,7 @@ fn warn_conversation_conflict( } fn resolve_responses_system_message_mode( - provider_opts: &Option>, + provider_opts: &Option, is_reasoning_model: bool, caps: &ModelCapabilities, ) -> SystemMessageMode { @@ -600,7 +697,7 @@ fn resolve_responses_system_message_mode( fn apply_responses_sampling( body: &mut Value, options: &CallOptions, - provider_opts: &Option>, + provider_opts: &Option, caps: &ModelCapabilities, is_reasoning_model: bool, resolved_reasoning_effort: &Option, @@ -656,7 +753,7 @@ fn apply_responses_sampling( fn apply_responses_text_format( body: &mut Value, options: &CallOptions, - provider_opts: &Option>, + provider_opts: &Option, ) { if let Some(ref rf) = options.response_format { match rf { @@ -705,7 +802,7 @@ fn apply_responses_text_format( /// The computed `include` list (store=false on reasoning models adds /// `reasoning.encrypted_content`). fn resolve_responses_include( - provider_opts: &Option>, + provider_opts: &Option, is_reasoning_model: bool, ) -> Option> { let mut include: Option> = @@ -736,7 +833,7 @@ fn resolve_responses_include( /// set). fn apply_responses_provider_options( body: &mut Value, - provider_opts: &Option>, + provider_opts: &Option, ) { let mut set = |key: &str, body_key: &str| { if let Some(v) = openai_option(provider_opts, key) { @@ -751,16 +848,23 @@ fn apply_responses_provider_options( set("user", "user"); set("instructions", "instructions"); set("promptCacheKey", "prompt_cache_key"); - set("promptCacheOptions", "prompt_cache_options"); set("promptCacheRetention", "prompt_cache_retention"); set("safetyIdentifier", "safety_identifier"); set("truncation", "truncation"); + if let Some(Value::Object(options)) = openai_option(provider_opts, "promptCacheOptions") { + body["prompt_cache_options"] = Value::Object( + options + .into_iter() + .filter(|(key, _)| matches!(key.as_str(), "mode" | "ttl")) + .collect(), + ); + } } /// `service_tier` with model-capability validation. fn apply_responses_service_tier( body: &mut Value, - provider_opts: &Option>, + provider_opts: &Option, caps: &ModelCapabilities, warnings: &mut Vec, ) { @@ -793,7 +897,7 @@ fn apply_responses_service_tier( /// `reasoning` block for reasoning models. fn apply_responses_reasoning_block( body: &mut Value, - provider_opts: &Option>, + provider_opts: &Option, is_reasoning_model: bool, resolved_reasoning_effort: &Option, resolved_reasoning_summary: &Option, @@ -830,12 +934,14 @@ fn apply_responses_reasoning_block( /// /// Splits the original ~380-line function into focused helpers (issue M11); /// behavior is unchanged. -#[must_use] +/// +/// # Errors +/// Returns an unsupported-functionality error for text file parts. pub fn build_responses_request_body( model_id: &str, options: &CallOptions, stream: bool, -) -> ResponsesRequestBodyResult { +) -> Result { let mut warnings: Vec = Vec::new(); let caps = get_model_capabilities(model_id); let provider_opts = &options.provider_options; @@ -864,7 +970,8 @@ pub fn build_responses_request_body( system_message_mode, store_bool, has_previous_response_id, - ); + openai_option(provider_opts, "conversation").is_some(), + )?; warnings.extend(input_result.warnings); // -- Base body -- @@ -933,7 +1040,7 @@ pub fn build_responses_request_body( warnings.push(tw); } - ResponsesRequestBodyResult { body, warnings } + Ok(ResponsesRequestBodyResult { body, warnings }) } // -- Usage conversion -------------------------------------------------------- @@ -957,7 +1064,7 @@ pub fn build_responses_request_body( pub fn convert_responses_usage(usage: Option<&ResponsesUsage>, raw: Option) -> Usage { let Some(usage) = usage else { return Usage { - raw, + raw: raw.and_then(|value| value.as_object().cloned()), ..Default::default() }; }; @@ -984,20 +1091,18 @@ pub fn convert_responses_usage(usage: Option<&ResponsesUsage>, raw: Option Result { let headers = self.build_headers(options.headers.as_ref()); - let request_result = build_responses_request_body(&self.model_id, options, false); + let request_result = build_responses_request_body(&self.model_id, options, false)?; let body = request_result.body; let provider_key = self.provider_options_name().to_string(); @@ -155,7 +155,7 @@ impl LanguageModel for OpenAIResponsesModel { async fn do_stream(&self, options: &CallOptions) -> Result { let headers = self.build_headers(options.headers.as_ref()); - let request_result = build_responses_request_body(&self.model_id, options, true); + let request_result = build_responses_request_body(&self.model_id, options, true)?; let body = request_result.body; let warnings = request_result.warnings; let provider_key = self.provider_options_name().to_string(); @@ -199,8 +199,10 @@ impl LanguageModel for OpenAIResponsesModel { Ok(StreamResult { stream, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } diff --git a/aimux-providers/src/openai/responses/responses_convert.rs b/aimux-providers/src/openai/responses/responses_convert.rs index cbd5dcb17..e593b11ad 100644 --- a/aimux-providers/src/openai/responses/responses_convert.rs +++ b/aimux-providers/src/openai/responses/responses_convert.rs @@ -22,9 +22,12 @@ use serde_json::{Value, json}; use aimux_core::error::AiMuxError; use aimux_core::error::ApiCallError; -use aimux_core::result::{GenerateContent, GenerateResult}; +use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage, Warning}; +use aimux_core::types::{ + FinishReason, FinishReasonUnified, ProviderMetadata, ResponseMetadata, Usage, Warning, +}; use super::convert::{convert_responses_usage, map_responses_finish_reason, parse_usage}; use super::types::ResponsesUsage; @@ -140,34 +143,22 @@ pub fn build_responses_generate_result( if !text.is_empty() { content.push(GenerateContent::Text { text, - provider_metadata: Some(json!({ - (provider_key.clone()): { + provider_metadata: Some(provider_namespace( + &provider_key, + json!({ "itemId": part.get("id").cloned().unwrap_or(Value::Null), - } - })), + }), + ).expect("provider metadata must be an object")), }); } } - // Annotations (url_citation → Source). if let Some(annotations) = cp.get("annotations").and_then(|v| v.as_array()) { - for (i, ann) in annotations.iter().enumerate() { - if ann.get("type").and_then(|v| v.as_str()) == Some("url_citation") - { - content.push(GenerateContent::Source { - id: format!("annotation-{i}"), - source_type: "url".to_string(), - url: ann - .get("url") - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string), - title: ann - .get("title") - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string), - provider_metadata: None, - }); - } + for ann in annotations { + content.push(GenerateContent::Source(annotation_source( + ann, + &provider_key, + )?)); } } } @@ -197,11 +188,15 @@ pub fn build_responses_generate_result( input, provider_executed: None, dynamic: None, - provider_metadata: Some(json!({ - (provider_key.clone()): { - "itemId": part.get("id").cloned().unwrap_or(Value::Null), - } - })), + provider_metadata: Some( + provider_namespace( + &provider_key, + json!({ + "itemId": part.get("id").cloned().unwrap_or(Value::Null), + }), + ) + .expect("provider metadata must be an object"), + ), })); } Some("custom_tool_call") => { @@ -225,27 +220,32 @@ pub fn build_responses_generate_result( input, provider_executed: None, dynamic: None, - provider_metadata: Some(json!({ - (provider_key.clone()): { - "itemId": part.get("id").cloned().unwrap_or(Value::Null), - } - })), + provider_metadata: Some( + provider_namespace( + &provider_key, + json!({ + "itemId": part.get("id").cloned().unwrap_or(Value::Null), + }), + ) + .expect("provider metadata must be an object"), + ), })); } Some("reasoning") => { let summary = part.get("summary").and_then(|v| v.as_array()); let parts: Vec<&Value> = summary.map(|s| s.iter().collect()).unwrap_or_default(); - let reasoning_metadata = Some(json!({ - (provider_key.clone()): { + let reasoning_metadata = Some(provider_namespace( + &provider_key, + json!({ "itemId": part.get("id").cloned().unwrap_or(Value::Null), "reasoningEncryptedContent": part.get("encrypted_content").cloned().unwrap_or(Value::Null), - } - })); + }), + ).expect("provider metadata must be an object")); if parts.is_empty() { - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text: String::new(), provider_metadata: reasoning_metadata.clone(), - }); + })); } else { for sp in parts { let text = sp @@ -253,13 +253,58 @@ pub fn build_responses_generate_result( .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text, provider_metadata: reasoning_metadata.clone(), - }); + })); } } } + Some("compaction") => { + content.push(GenerateContent::Custom { + kind: "openai.compaction".to_string(), + provider_metadata: Some( + provider_namespace( + &provider_key, + json!({ + "type": "compaction", "itemId": part.get("id"), + "encryptedContent": part.get("encrypted_content"), + }), + ) + .expect("provider metadata must be an object"), + ), + }); + } + Some("mcp_approval_request") => { + let tool_call_id = generate_source_id(); + content.push(GenerateContent::ToolCall(RawToolCall { + tool_call_id: tool_call_id.clone(), + tool_name: format!( + "mcp.{}", + part.get("name").and_then(Value::as_str).unwrap_or("") + ), + input: part + .get("arguments") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + provider_executed: Some(true), + dynamic: Some(true), + provider_metadata: None, + })); + content.push(GenerateContent::ToolApprovalRequest( + aimux_core::result::RawToolApprovalRequest { + approval_id: part + .get("approval_request_id") + .or_else(|| part.get("id")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + tool_call_id, + provider_metadata: None, + }, + )); + } _ => {} } } @@ -286,7 +331,8 @@ pub fn build_responses_generate_result( if let Some(st) = data.get("service_tier").and_then(|v| v.as_str()) { pm["serviceTier"] = json!(st); } - let provider_metadata = Some(json!({ provider_key: pm })); + let provider_metadata = + Some(provider_namespace(&provider_key, pm).expect("provider metadata must be an object")); let response_id = data .get("id") @@ -308,13 +354,16 @@ pub fn build_responses_generate_result( usage, warnings: request_warnings, provider_metadata, - response: ResponseMetadata { - id: response_id, - timestamp, - model_id: model, - }, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: Some(data.clone()), + headers: Some(response_headers), + ..aimux_core::result::GenerateResponse::from(ResponseMetadata { + id: response_id, + timestamp, + model_id: model, + }) + }), }) } @@ -348,15 +397,82 @@ fn reasoning_stream_metadata( provider_key: &str, item_id: &str, encrypted_content: Option<&str>, -) -> Value { +) -> ProviderMetadata { let mut inner = json!({ "itemId": item_id }); if let Some(enc) = encrypted_content { inner["reasoningEncryptedContent"] = json!(enc); } - json!({ (provider_key): inner }) + provider_namespace(provider_key, inner).expect("provider metadata must be an object") } -/// Generate a unique source ID for streaming annotation sources. +fn annotation_source(annotation: &Value, provider_key: &str) -> Result { + let invalid = |field: &str| { + AiMuxError::InvalidResponseData(format!("Invalid Responses annotation field: {field}")) + }; + let string = |field| { + annotation + .get(field) + .and_then(Value::as_str) + .ok_or_else(|| invalid(field)) + }; + let number = |field| { + annotation + .get(field) + .filter(|value| value.is_number()) + .cloned() + .ok_or_else(|| invalid(field)) + }; + let annotation_type = string("type")?; + if annotation_type == "url_citation" { + number("start_index")?; + number("end_index")?; + return Ok(Source::Url { + id: generate_source_id(), + url: string("url")?.to_string(), + title: Some(string("title")?.to_string()), + provider_metadata: None, + }); + } + let file_id = string("file_id")?; + let (media_type, filename, metadata) = match annotation_type { + "file_citation" | "file_path" => ( + if annotation_type == "file_path" { + "application/octet-stream" + } else { + "text/plain" + }, + if annotation_type == "file_path" { + file_id + } else { + string("filename")? + }, + json!({ "type": annotation_type, "fileId": file_id, "index": number("index")? }), + ), + "container_file_citation" => { + number("start_index")?; + number("end_index")?; + ( + "text/plain", + string("filename")?, + json!({ + "type": annotation_type, + "fileId": file_id, + "containerId": string("container_id")?, + }), + ) + } + _ => return Err(invalid("type")), + }; + Ok(Source::Document { + id: generate_source_id(), + media_type: media_type.to_string(), + title: filename.to_string(), + filename: Some(filename.to_string()), + provider_metadata: Some(provider_namespace(provider_key, metadata)?), + }) +} + +/// Generate a unique source ID for annotation sources. /// /// Uses a process-wide atomic counter — consistent with the xAI Responses /// provider (`aimux-providers/src/xai/responses/mod.rs`). Upstream TS uses @@ -496,11 +612,11 @@ where chrono::DateTime::from_timestamp(secs as i64, 0) }) .map(|dt| dt.to_rfc3339()); - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: response_id.clone(), timestamp, model_id, - }); + })); } } @@ -516,6 +632,8 @@ where .and_then(|v| v.as_str()) .unwrap_or(""); match item_type { + + "message" => { let id = item .get("id") @@ -762,6 +880,30 @@ where .and_then(|v| v.as_str()) .unwrap_or(""); match item_type { + "mcp_approval_request" => { + let tool_call_id = generate_source_id(); + yield Ok(StreamPart::ToolCall(RawToolCall { + tool_call_id: tool_call_id.clone(), + tool_name: format!("mcp.{}", item.get("name").and_then(Value::as_str).unwrap_or("")), + input: item.get("arguments").and_then(Value::as_str).unwrap_or("").to_string(), + provider_executed: Some(true), dynamic: Some(true), + provider_metadata: None, + })); + yield Ok(StreamPart::ToolApprovalRequest(aimux_core::result::RawToolApprovalRequest { + approval_id: item.get("approval_request_id").or_else(|| item.get("id")).and_then(Value::as_str).unwrap_or("").to_string(), + tool_call_id, provider_metadata: None, + })); + } + "compaction" => { + yield Ok(StreamPart::Custom { + kind: "openai.compaction".to_string(), + provider_metadata: Some(provider_namespace(&provider_key, json!({ + "type": "compaction", "itemId": item.get("id"), + "encryptedContent": item.get("encrypted_content"), + })).expect("provider metadata must be an object")), + }); + } + "message" => { let id = item .get("id") @@ -877,23 +1019,15 @@ where // ── response.output_text.annotation.added → Source ───────── "response.output_text.annotation.added" => { - if let Some(ann) = parsed.get("annotation") - && ann.get("type").and_then(|v| v.as_str()) - == Some("url_citation") - { - yield Ok(StreamPart::Source { - id: generate_source_id(), - source_type: "url".to_string(), - url: ann - .get("url") - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string), - title: ann - .get("title") - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string), - provider_metadata: None, - }); + match annotation_source( + parsed.get("annotation").unwrap_or(&Value::Null), + &provider_key, + ) { + Ok(source) => yield Ok(StreamPart::Source(source)), + Err(error) => { + stream_errored = true; + yield Ok(StreamPart::Error { error }); + } } } @@ -1048,7 +1182,7 @@ where if let Some(ctx) = final_reasoning_context { pm["reasoningContext"] = ctx; } - let provider_metadata = Some(json!({ provider_key: pm })); + let provider_metadata = Some(provider_namespace(&provider_key, pm).expect("provider metadata must be an object")); yield Ok(StreamPart::Finish { finish_reason: if stream_errored { diff --git a/aimux-providers/src/openai/transcription.rs b/aimux-providers/src/openai/transcription.rs index b0ca5777c..cf96ceea7 100644 --- a/aimux-providers/src/openai/transcription.rs +++ b/aimux-providers/src/openai/transcription.rs @@ -24,7 +24,7 @@ use serde::Deserialize; use serde_json::Value; use aimux_core::error::AiMuxError; -use aimux_core::shared::Warning; +use aimux_core::shared::{SharedProviderOptions, Warning}; use aimux_core::transcription_model::{ AudioInput, TranscriptionCallOptions, TranscriptionModel, TranscriptionRequest, TranscriptionResponse, TranscriptionResult, TranscriptionSegment, @@ -120,7 +120,7 @@ struct OpenAITranscriptionOptions { /// Parse provider options from the shared options map. fn parse_openai_options( - provider_options: Option<&HashMap>, + provider_options: Option<&SharedProviderOptions>, ) -> OpenAITranscriptionOptions { let mut opts = OpenAITranscriptionOptions::default(); if let Some(po) = provider_options diff --git a/aimux-providers/src/prodia.rs b/aimux-providers/src/prodia.rs index fd0cba856..5ef3262bb 100644 --- a/aimux-providers/src/prodia.rs +++ b/aimux-providers/src/prodia.rs @@ -310,7 +310,7 @@ impl ImageModel for ProdiaImageModel { 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)); + metadata.insert("prodia".into(), prodia_meta); Ok(ImageResult { images: ImageOutputs::Binary(vec![image_bytes]), diff --git a/aimux-providers/src/recraft.rs b/aimux-providers/src/recraft.rs index ab208cbe0..0196615c2 100644 --- a/aimux-providers/src/recraft.rs +++ b/aimux-providers/src/recraft.rs @@ -20,7 +20,7 @@ use aimux_core::image_model::{ }; use aimux_core::provider::Provider; use aimux_core::retry; -use aimux_core::shared::Warning; +use aimux_core::shared::{SharedProviderOptions, Warning}; use aimux_provider_utils::{HttpRequest, load_api_key, without_trailing_slash}; fn recraft_failed_response_handler() -> aimux_provider_utils::ResponseHandler { @@ -161,7 +161,7 @@ struct RecraftOptions { /// /// Unknown fields are ignored (safe degradation); values are passed through as /// raw JSON so that unrecognised-but-valid enum values do not cause errors. -fn parse_recraft_options(provider_options: &HashMap) -> RecraftOptions { +fn parse_recraft_options(provider_options: &SharedProviderOptions) -> RecraftOptions { let recraft = provider_options.get("recraft"); RecraftOptions { style: recraft.and_then(|o| o.get("style")).cloned(), diff --git a/aimux-providers/src/replay.rs b/aimux-providers/src/replay.rs index e7548a76c..ccfa4e552 100644 --- a/aimux-providers/src/replay.rs +++ b/aimux-providers/src/replay.rs @@ -478,14 +478,7 @@ mod tests { let ring = aimux_core::recording::RingRecorder::with_capacity(8); let options = aimux_core::generate::GenerateTextOptions::default().into_call_options(vec![ - aimux_core::language_model_message::LanguageModelPromptMessage { - role: aimux_core::message::Role::User, - content: vec![aimux_core::content::ContentPart::Text { - text: "ping".into(), - provider_options: None, - }], - provider_options: None, - }, + aimux_core::language_model_message::LanguageModelMessage::user_text("ping"), ]); ring.record_input("c1", &options, "openai", "gpt-4o"); ring.record_provider("c1", &snap); diff --git a/aimux-providers/src/replicate.rs b/aimux-providers/src/replicate.rs index 09eb8b219..b0dca270d 100644 --- a/aimux-providers/src/replicate.rs +++ b/aimux-providers/src/replicate.rs @@ -266,7 +266,7 @@ impl ImageModel for ReplicateImageModel { } // Forward replicate provider options (excluding maxWaitTimeInSeconds) - if let Some(ro) = replicate_opts.and_then(|v| v.as_object()) { + if let Some(ro) = replicate_opts { for (k, v) in ro { if k != "maxWaitTimeInSeconds" { input.insert(k.clone(), v.clone()); diff --git a/aimux-providers/src/runtime_decision.rs b/aimux-providers/src/runtime_decision.rs index 0322e5546..dc0441a25 100644 --- a/aimux-providers/src/runtime_decision.rs +++ b/aimux-providers/src/runtime_decision.rs @@ -4,7 +4,7 @@ use std::collections::{BTreeMap, HashMap}; use aimux_core::AiMuxError; use aimux_core::decision_model::*; use aimux_core::recording::ProviderRecord; -use aimux_core::types::{TokenUsage, Usage}; +use aimux_core::types::{InputTokenUsage, OutputTokenUsage, Usage}; use async_trait::async_trait; use serde_json::{Map, Value, json}; @@ -326,9 +326,12 @@ impl RuntimeDecisionModel { // Cloudflare's usage belongs to the nested result; keep the full envelope as raw. if let Some(usage) = &mut result.usage { usage.raw = if raw.get("result").is_some() { - raw["result"].get("usage").cloned() + raw["result"] + .get("usage") + .and_then(Value::as_object) + .cloned() } else { - raw.get("usage").cloned() + raw.get("usage").and_then(Value::as_object).cloned() }; } result.latency_ms = raw.get("latency_ms").and_then(Value::as_f64); @@ -499,21 +502,21 @@ pub(crate) fn result_from_distributions( .get("usage") .filter(|v| !v.is_null()) .map(|value| Usage { - input_tokens: TokenUsage { + input_tokens: InputTokenUsage { total: value .get("prompt_tokens") .and_then(Value::as_u64) .and_then(|n| n.try_into().ok()), ..Default::default() }, - output_tokens: TokenUsage { + output_tokens: OutputTokenUsage { total: value .get("completion_tokens") .and_then(Value::as_u64) .and_then(|n| n.try_into().ok()), ..Default::default() }, - raw: Some(value.clone()), + raw: value.as_object().cloned(), }); let result = DecisionResult { rounding: DecisionRounding::default(), @@ -524,7 +527,7 @@ pub(crate) fn result_from_distributions( probability_source: DecisionProbabilitySource::LogitScoring, usage, latency_ms: None, - provider_metadata: Some(HashMap::from([(provider.into(), raw.clone())])), + provider_metadata: Some(support::response_metadata(provider, &raw)?), response: Some(DecisionResponse { headers: Some(headers), body: Some(raw), diff --git a/aimux-providers/src/stability.rs b/aimux-providers/src/stability.rs index d727cba94..a5aa918f1 100644 --- a/aimux-providers/src/stability.rs +++ b/aimux-providers/src/stability.rs @@ -303,7 +303,7 @@ impl ImageModel for StabilityImageModel { form.text("output_format", output_format)?; // Forward remaining stability provider options as scalar form fields. - if let Some(stab) = stability_opts.and_then(|v| v.as_object()) { + if let Some(stab) = stability_opts { for (k, v) in stab { if matches!( k.as_str(), diff --git a/aimux-providers/src/systemone.rs b/aimux-providers/src/systemone.rs index 7aeb5d256..b5bb7d511 100644 --- a/aimux-providers/src/systemone.rs +++ b/aimux-providers/src/systemone.rs @@ -1,7 +1,7 @@ //! Shared SystemOne wire codec. Provider policy stays in each adapter. use aimux_core::AiMuxError; use aimux_core::decision_model::*; -use aimux_core::types::{TokenUsage, Usage}; +use aimux_core::types::{InputTokenUsage, OutputTokenUsage, Usage}; use serde::Deserialize; use serde_json::{Value, json}; use std::collections::{BTreeMap, HashMap}; @@ -166,15 +166,15 @@ pub(crate) fn convert_response( answers.insert(id, answer); } let usage = Some(Usage { - input_tokens: TokenUsage { + input_tokens: InputTokenUsage { total: data.usage.input_tokens, ..Default::default() }, - output_tokens: TokenUsage { + output_tokens: OutputTokenUsage { total: data.usage.output_tokens, ..Default::default() }, - raw: raw.get("usage").cloned(), + raw: raw.get("usage").and_then(Value::as_object).cloned(), }); Ok(DecisionResult { rounding: profile.rounding, @@ -185,7 +185,7 @@ pub(crate) fn convert_response( probability_source: profile.probability_source, usage, latency_ms: None, - provider_metadata: Some(HashMap::from([(provider.into(), raw.clone())])), + provider_metadata: Some(crate::decision_support::response_metadata(provider, &raw)?), response: Some(DecisionResponse { headers: Some(headers), body: Some(raw), diff --git a/aimux-providers/src/vertex/anthropic_model.rs b/aimux-providers/src/vertex/anthropic_model.rs index 4856cf9dd..68d84b36c 100644 --- a/aimux-providers/src/vertex/anthropic_model.rs +++ b/aimux-providers/src/vertex/anthropic_model.rs @@ -25,8 +25,11 @@ use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; use aimux_core::result::{GenerateResult, StreamResult}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; +use aimux_core::types::{ + FinishReason, FinishReasonUnified, ProviderMetadata, ResponseMetadata, Usage, +}; use aimux_provider_utils::{HttpRequest, RetryConfig}; @@ -196,11 +199,13 @@ impl LanguageModel for VertexAnthropicModel { ) .await?; + let response_body = resp.raw_value; let data: AnthropicResponse = resp.value; let content = crate::anthropic::stream::parse_anthropic_content( &data.content, &ToolNameMapping::new(options.tools.as_deref()), + &crate::anthropic::stream::generate_id, ); let finish_reason = data @@ -221,13 +226,16 @@ impl LanguageModel for VertexAnthropicModel { usage, warnings: req.warnings, provider_metadata: None, - response: ResponseMetadata { - id: Some(data.id), - timestamp: None, - model_id: Some(data.model), - }, - request_body: Some(body), - response_headers: None, + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: response_body, + headers: None, + ..aimux_core::result::GenerateResponse::from(ResponseMetadata { + id: Some(data.id), + timestamp: None, + model_id: Some(data.model), + }) + }), }) } @@ -295,11 +303,11 @@ impl LanguageModel for VertexAnthropicModel { crate::anthropic::usage::usage_from_anthropic(usage); } if !response_meta_emitted { - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: Some(message.id.clone()), timestamp: None, model_id: Some(message.model.clone()), - }); + })); response_meta_emitted = true; } } @@ -393,20 +401,18 @@ impl LanguageModel for VertexAnthropicModel { input: input.to_string(), provider_executed: Some(true), dynamic: Some(true), - provider_metadata: Some(json!({ - "anthropic": { - "type": "mcp-tool-use", - "serverName": server_name, - } - })), + provider_metadata: Some(provider_namespace("anthropic", json!({ + "type": "mcp-tool-use", + "serverName": server_name, + })).expect("provider metadata must be an object")), })); } ContentBlock::RedactedThinking { data } => { yield Ok(StreamPart::ReasoningStart { id: index.to_string(), - provider_metadata: Some(json!({ - "anthropic": { "redactedData": data } - })), + provider_metadata: Some(provider_namespace("anthropic", json!({ + "redactedData": data + })).expect("provider metadata must be an object")), }); blocks.insert(index, BlockState::Thinking { started: true }); } @@ -416,6 +422,7 @@ impl LanguageModel for VertexAnthropicModel { &tool_names, &mcp_tool_calls, &server_tool_calls, + &crate::anthropic::stream::generate_id, ) { yield Ok(part); } @@ -604,8 +611,10 @@ impl LanguageModel for VertexAnthropicModel { Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } @@ -623,7 +632,7 @@ enum BlockState { dynamic: Option, provider_tool_name: Option, provider_tool_input_type: Option, - provider_metadata: Option, + provider_metadata: Option, first_delta: bool, }, Thinking { diff --git a/aimux-providers/src/vertex/image.rs b/aimux-providers/src/vertex/image.rs index f93b59245..f4dc4a1b6 100644 --- a/aimux-providers/src/vertex/image.rs +++ b/aimux-providers/src/vertex/image.rs @@ -19,7 +19,7 @@ use aimux_core::image_model::{ ImageCallOptions, ImageFile, ImageFileData, ImageModel, ImageOutputs, ImageResponse, ImageResult, ImageUsage, }; -use aimux_core::shared::Warning; +use aimux_core::shared::{Warning, provider_namespace}; use aimux_provider_utils::HttpRequest; @@ -119,16 +119,6 @@ impl VertexImageModel { .and_then(|v| v.as_str()); let mask_dilation = edit_opts.and_then(|e| e.get("maskDilation")); - // Build other options (excluding "edit") - let mut other_options = Map::new(); - if let Some(obj) = gv_opts.and_then(|v| v.as_object()) { - for (k, v) in obj { - if k != "edit" { - other_options.insert(k.clone(), v.clone()); - } - } - } - let is_edit_mode = options.files.as_ref().is_some_and(|f| !f.is_empty()); let mut parameters = Map::new(); @@ -178,10 +168,6 @@ impl VertexImageModel { } } - for (k, v) in &other_options { - parameters.insert(k.clone(), v.clone()); - } - let body = if is_edit_mode { json!({ "instances": [{ "prompt": options.prompt, "referenceImages": reference_images }], @@ -235,9 +221,11 @@ impl VertexImageModel { .unwrap_or_default(); let payload = json!({ "images": image_metas }); - let mut metadata = HashMap::new(); - metadata.insert("googleVertex".into(), payload.clone()); - metadata.insert("vertex".into(), payload); + let mut metadata = provider_namespace("googleVertex", payload.clone()) + .expect("provider metadata must be an object"); + metadata.extend( + provider_namespace("vertex", payload).expect("provider metadata must be an object"), + ); Ok(ImageResult { images: ImageOutputs::Base64(images), @@ -313,17 +301,19 @@ impl VertexImageModel { gc.insert("seed".into(), json!(seed)); } - // Passthrough provider options + // Only declared generation-config options survive the upstream language schema. let gv_opts = options .provider_options .get("googleVertex") .or_else(|| options.provider_options.get("vertex")); - if let Some(obj) = gv_opts.and_then(|v| v.as_object()) { - for (k, v) in obj { - if matches!(k.as_str(), "responseModalities" | "imageConfig") { - continue; + if let Some(obj) = gv_opts { + for key in ["audioTimestamp", "thinkingConfig", "mediaResolution"] { + if let Some(value) = obj.get(key) { + gc.insert( + key.to_string(), + crate::google::convert::image_generation_option(key, value), + ); } - gc.insert(k.clone(), v.clone()); } } @@ -382,9 +372,11 @@ impl VertexImageModel { }); let payload = json!({ "images": images.iter().map(|_| json!({})).collect::>() }); - let mut metadata = HashMap::new(); - metadata.insert("googleVertex".into(), payload.clone()); - metadata.insert("vertex".into(), payload); + let mut metadata = provider_namespace("googleVertex", payload.clone()) + .expect("provider metadata must be an object"); + metadata.extend( + provider_namespace("vertex", payload).expect("provider metadata must be an object"), + ); Ok(ImageResult { images: ImageOutputs::Base64(images), diff --git a/aimux-providers/src/vertex/model.rs b/aimux-providers/src/vertex/model.rs index 4b2366946..20a7163ed 100644 --- a/aimux-providers/src/vertex/model.rs +++ b/aimux-providers/src/vertex/model.rs @@ -15,9 +15,15 @@ use serde_json::{Value, json}; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{ + GenerateContent, GenerateResult, GeneratedFile, GeneratedFileData, ReasoningOutput, Source, + StreamResult, +}; +use aimux_core::shared::{FileBytes, provider_namespace}; use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; +use aimux_core::types::{ + FinishReason, FinishReasonUnified, ProviderMetadata, ResponseMetadata, Usage, +}; use aimux_provider_utils::{HttpRequest, RetryConfig}; @@ -155,8 +161,74 @@ impl LanguageModel for VertexModel { } async fn do_generate(&self, options: &CallOptions) -> Result { + if options + .prompt + .iter() + .filter_map(|message| match message { + aimux_core::language_model_message::LanguageModelMessage::Assistant { + content, + .. + } => Some(content), + _ => None, + }) + .flatten() + .any(|part| { + matches!( + part, + aimux_core::language_model_message::AssistantPart::ReasoningFile( + aimux_core::language_model_message::ReasoningFilePart { + data: aimux_core::shared::GeneratedFileData::Url { .. }, + .. + } + ) | aimux_core::language_model_message::AssistantPart::File( + aimux_core::language_model_message::FilePart { + data: aimux_core::shared::FileData::Url { .. }, + .. + } + ) + ) + }) + { + return Err(AiMuxError::UnsupportedFunctionality( + "File data URLs in assistant messages are not supported".to_string(), + )); + } + if options.prompt.iter().any(|message| match message { + aimux_core::language_model_message::LanguageModelMessage::Assistant { + content, .. + } => content.iter().any(|part| { + matches!( + part, + aimux_core::language_model_message::AssistantPart::File( + aimux_core::language_model_message::FilePart { + data: aimux_core::shared::FileData::Reference { .. }, + .. + } + ) + ) + }), + aimux_core::language_model_message::LanguageModelMessage::User { content, .. } => { + content.iter().any(|part| { + matches!( + part, + aimux_core::language_model_message::UserPart::File( + aimux_core::language_model_message::FilePart { + data: aimux_core::shared::FileData::Reference { .. }, + .. + } + ) + ) + }) + } + _ => false, + }) { + return Err(AiMuxError::UnsupportedFunctionality( + "file parts with provider references".to_string(), + )); + } + crate::google::convert::validate_tool_result_files(&options.prompt)?; let code_execution_tool_name = code_execution_tool_name(options.tools.as_deref()); - let body = build_vertex_request_body(&self.model_id, options); + let body = build_vertex_request_body(&self.model_id, options)?; let headers = self.build_headers(options.headers.as_ref()); let resp = aimux_provider_utils::post_json_to_api( HttpRequest { @@ -174,6 +246,7 @@ impl LanguageModel for VertexModel { ) .await?; + let response_body = resp.raw_value; let response_headers = resp.response_headers; let data: GenerateContentResponse = resp.value; @@ -215,21 +288,90 @@ impl LanguageModel for VertexModel { usage, warnings: Vec::new(), provider_metadata, - response: ResponseMetadata { - id: data.response_id, - // Gemini responses carry no timestamp field; use the response - // `Date` header (RFC1123) like Bedrock does. - timestamp: response_headers.get("date").cloned(), - model_id: data.model_version, - }, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: response_body, + headers: Some(response_headers.clone()), + ..aimux_core::result::GenerateResponse::from(ResponseMetadata { + id: data.response_id, + // Gemini responses carry no timestamp field; use the response + // `Date` header (RFC1123) like Bedrock does. + timestamp: response_headers.get("date").cloned(), + model_id: data.model_version, + }) + }), }) } async fn do_stream(&self, options: &CallOptions) -> Result { + if options + .prompt + .iter() + .filter_map(|message| match message { + aimux_core::language_model_message::LanguageModelMessage::Assistant { + content, + .. + } => Some(content), + _ => None, + }) + .flatten() + .any(|part| { + matches!( + part, + aimux_core::language_model_message::AssistantPart::ReasoningFile( + aimux_core::language_model_message::ReasoningFilePart { + data: aimux_core::shared::GeneratedFileData::Url { .. }, + .. + } + ) | aimux_core::language_model_message::AssistantPart::File( + aimux_core::language_model_message::FilePart { + data: aimux_core::shared::FileData::Url { .. }, + .. + } + ) + ) + }) + { + return Err(AiMuxError::UnsupportedFunctionality( + "File data URLs in assistant messages are not supported".to_string(), + )); + } + if options.prompt.iter().any(|message| match message { + aimux_core::language_model_message::LanguageModelMessage::Assistant { + content, .. + } => content.iter().any(|part| { + matches!( + part, + aimux_core::language_model_message::AssistantPart::File( + aimux_core::language_model_message::FilePart { + data: aimux_core::shared::FileData::Reference { .. }, + .. + } + ) + ) + }), + aimux_core::language_model_message::LanguageModelMessage::User { content, .. } => { + content.iter().any(|part| { + matches!( + part, + aimux_core::language_model_message::UserPart::File( + aimux_core::language_model_message::FilePart { + data: aimux_core::shared::FileData::Reference { .. }, + .. + } + ) + ) + }) + } + _ => false, + }) { + return Err(AiMuxError::UnsupportedFunctionality( + "file parts with provider references".to_string(), + )); + } + crate::google::convert::validate_tool_result_files(&options.prompt)?; let code_execution_tool_name = code_execution_tool_name(options.tools.as_deref()); - let body = build_vertex_request_body(&self.model_id, options); + let body = build_vertex_request_body(&self.model_id, options)?; let headers = self.build_headers(options.headers.as_ref()); let endpoint = self.stream_endpoint(); let resp = aimux_provider_utils::post_json_to_api( @@ -274,6 +416,7 @@ impl LanguageModel for VertexModel { let mut sse_stream = futures::stream::iter(first_event.into_iter()).chain(sse_stream); let mut text_id: Option = None; + let mut reasoning_id: Option = None; let mut block_counter = 0usize; let mut final_usage: Usage = Usage::default(); let mut final_finish_reason: Option = None; @@ -311,11 +454,11 @@ impl LanguageModel for VertexModel { if !response_metadata_emitted && let Some(id) = &chunk.response_id { response_metadata_emitted = true; - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: Some(id.clone()), timestamp: response_timestamp.clone(), model_id: chunk.model_version.clone(), - }); + })); } if let Some(usage) = &chunk.usage_metadata { @@ -346,21 +489,19 @@ impl LanguageModel for VertexModel { let chunk_sources = extract_sources(candidate.grounding_metadata.as_ref(), &mut source_id); for src in chunk_sources { - if let GenerateContent::Source { - url: Some(url), - source_type, + if let GenerateContent::Source(Source::Url { + url, id, title, provider_metadata: None, - } = src + }) = src && emitted_source_urls.insert(url.clone()) { - yield Ok(StreamPart::Source { + yield Ok(StreamPart::Source(Source::Url { id, - source_type, - url: Some(url), + url, title, provider_metadata: None, - }); + })); } } @@ -368,30 +509,62 @@ impl LanguageModel for VertexModel { candidate.content.as_ref().and_then(|c| c.parts.as_ref()) { for part in parts { + let thought_signature_metadata = part + .get("thoughtSignature") + .and_then(|v| v.as_str()) + .filter(|signature| !signature.is_empty()) + .map(|signature| { + vertex_provider_metadata(json!({ + "thoughtSignature": signature, + })) + }); if let Some(text) = part.get("text").and_then(|v| v.as_str()) { - if !text.is_empty() { - let text_metadata = part - .get("thoughtSignature") - .and_then(|v| v.as_str()) - .map(|signature| { - vertex_provider_metadata(json!({ - "thoughtSignature": signature, - })) + if text.is_empty() { + if let (Some(metadata), Some(id)) = (&thought_signature_metadata, &text_id) { + yield Ok(StreamPart::TextDelta { + id: id.clone(), + delta: String::new(), + provider_metadata: Some(metadata.clone()), }); + } + } else if part.get("thought").and_then(Value::as_bool) == Some(true) { + if let Some(id) = text_id.take() { + yield Ok(StreamPart::TextEnd { id, provider_metadata: None }); + } + if reasoning_id.is_none() { + let id = format!("{block_counter}"); + block_counter += 1; + reasoning_id = Some(id.clone()); + yield Ok(StreamPart::ReasoningStart { + id, + provider_metadata: thought_signature_metadata.clone(), + }); + } + if let Some(id) = &reasoning_id { + yield Ok(StreamPart::ReasoningDelta { + id: id.clone(), + delta: text.to_string(), + provider_metadata: thought_signature_metadata, + }); + } + } else { + if let Some(id) = reasoning_id.take() { + yield Ok(StreamPart::ReasoningEnd { id, provider_metadata: None }); + } if text_id.is_none() { let id = format!("{block_counter}"); block_counter += 1; text_id = Some(id.clone()); yield Ok(StreamPart::TextStart { id, - provider_metadata: text_metadata.clone(), + provider_metadata: thought_signature_metadata.clone(), }); } if let Some(id) = &text_id { yield Ok(StreamPart::TextDelta { id: id.clone(), delta: text.to_string(), - provider_metadata: text_metadata, + provider_metadata: thought_signature_metadata, }); } } @@ -444,6 +617,28 @@ impl LanguageModel for VertexModel { provider_metadata: tool_metadata, })); has_tool_calls = true; + } else if let Some(inline) = part.get("inlineData") { + if let Some(id) = text_id.take() { + yield Ok(StreamPart::TextEnd { id, provider_metadata: None }); + } + if let Some(id) = reasoning_id.take() { + yield Ok(StreamPart::ReasoningEnd { id, provider_metadata: None }); + } + if let (Some(data), Some(media_type)) = ( + inline.get("data").and_then(Value::as_str), + inline.get("mimeType").and_then(Value::as_str), + ) { + let file = GeneratedFile { + data: GeneratedFileData::Data { data: FileBytes::Base64(data.to_string()) }, + media_type: media_type.to_string(), + provider_metadata: thought_signature_metadata, + }; + yield Ok(if part.get("thought").and_then(Value::as_bool) == Some(true) { + StreamPart::ReasoningFile(file) + } else { + StreamPart::File(file) + }); + } } else if let Some(ec) = part.get("executableCode") { // Provider-executed code execution. let has_code = ec @@ -569,6 +764,9 @@ impl LanguageModel for VertexModel { if let Some(id) = text_id.take() { yield Ok(StreamPart::TextEnd { id, provider_metadata: None}); } + if let Some(id) = reasoning_id.take() { + yield Ok(StreamPart::ReasoningEnd { id, provider_metadata: None }); + } // Snapshot the finishReason-chunk metadata. if let Some(sr) = &candidate.safety_ratings { last_safety_ratings = @@ -607,6 +805,10 @@ impl LanguageModel for VertexModel { yield Ok(StreamPart::TextEnd { id, provider_metadata: None}); } + if let Some(id) = reasoning_id.take() { + yield Ok(StreamPart::ReasoningEnd { id, provider_metadata: None }); + } + let provider_metadata = Some(vertex_provider_metadata(json!({ "promptFeedback": last_prompt_feedback, "groundingMetadata": last_grounding_metadata, @@ -635,26 +837,30 @@ impl LanguageModel for VertexModel { Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } // ── Helpers ────────────────────────────────────────────────────────────────── -fn vertex_provider_metadata(payload: Value) -> Value { - json!({ - "googleVertex": payload.clone(), - "vertex": payload, - }) +fn vertex_provider_metadata(payload: Value) -> ProviderMetadata { + let mut metadata = provider_namespace("googleVertex", payload.clone()) + .expect("provider metadata must be an object"); + metadata.extend( + provider_namespace("vertex", payload).expect("provider metadata must be an object"), + ); + metadata } fn vertex_server_tool_metadata( tool_call_id: &str, server_tool_type: &str, thought_signature: Option<&str>, -) -> Value { +) -> ProviderMetadata { let mut payload = json!({ "serverToolCallId": tool_call_id, "serverToolType": server_tool_type, @@ -724,13 +930,46 @@ fn extract_content_from_candidate( })); } } else if let Some(text) = part.get("text").and_then(|v| v.as_str()) { - if !text.is_empty() { - let provider_metadata = part - .get("thoughtSignature") - .and_then(|v| v.as_str()) - .map(|signature| { - vertex_provider_metadata(json!({ "thoughtSignature": signature })) - }); + let provider_metadata = part + .get("thoughtSignature") + .and_then(Value::as_str) + .filter(|signature| !signature.is_empty()) + .map(|signature| { + vertex_provider_metadata(json!({ "thoughtSignature": signature })) + }); + if text.is_empty() { + if let ( + Some(metadata), + Some( + GenerateContent::Text { + provider_metadata, .. + } + | GenerateContent::Reasoning(ReasoningOutput { + provider_metadata, .. + }) + | GenerateContent::File(GeneratedFile { + provider_metadata, .. + }) + | GenerateContent::ReasoningFile(GeneratedFile { + provider_metadata, .. + }) + | GenerateContent::ToolCall(RawToolCall { + provider_metadata, .. + }) + | GenerateContent::ToolResult(ToolResult { + provider_metadata, .. + }), + ), + ) = (provider_metadata, content.last_mut()) + { + *provider_metadata = Some(metadata); + } + } else if part.get("thought").and_then(Value::as_bool) == Some(true) { + content.push(GenerateContent::Reasoning(ReasoningOutput { + text: text.to_string(), + provider_metadata, + })); + } else { content.push(GenerateContent::Text { text: text.to_string(), provider_metadata, @@ -764,6 +1003,32 @@ fn extract_content_from_candidate( provider_metadata, })); has_tool_calls = true; + } else if let Some(inline) = part.get("inlineData") { + if let (Some(data), Some(media_type)) = ( + inline.get("data").and_then(Value::as_str), + inline.get("mimeType").and_then(Value::as_str), + ) { + let file = GeneratedFile { + data: GeneratedFileData::Data { + data: FileBytes::Base64(data.to_string()), + }, + media_type: media_type.to_string(), + provider_metadata: part + .get("thoughtSignature") + .and_then(Value::as_str) + .filter(|signature| !signature.is_empty()) + .map(|signature| { + vertex_provider_metadata(json!({ "thoughtSignature": signature })) + }), + }; + content.push( + if part.get("thought").and_then(Value::as_bool) == Some(true) { + GenerateContent::ReasoningFile(file) + } else { + GenerateContent::File(file) + }, + ); + } } else if let Some(tc) = part.get("toolCall") { let tool_type = tc.get("toolType").and_then(|v| v.as_str()).unwrap_or(""); let id = tc diff --git a/aimux-providers/src/voyage/reranking.rs b/aimux-providers/src/voyage/reranking.rs index 013692869..8fffe0b86 100644 --- a/aimux-providers/src/voyage/reranking.rs +++ b/aimux-providers/src/voyage/reranking.rs @@ -14,6 +14,7 @@ use aimux_core::reranking_model::{ RerankingCallOptions, RerankingDocuments, RerankingModel, RerankingRank, RerankingResponse, RerankingResult, }; +use aimux_core::shared::SharedProviderOptions; use aimux_core::types::Warning; use aimux_provider_utils::HttpRequest; @@ -28,7 +29,7 @@ struct VoyageRerankingOptions { } fn parse_voyage_reranking_options( - provider_options: Option<&HashMap>, + provider_options: Option<&SharedProviderOptions>, ) -> VoyageRerankingOptions { let mut opts = VoyageRerankingOptions::default(); if let Some(po) = provider_options diff --git a/aimux-providers/src/xai/convert.rs b/aimux-providers/src/xai/convert.rs index bfd304ed9..3434df4bc 100644 --- a/aimux-providers/src/xai/convert.rs +++ b/aimux-providers/src/xai/convert.rs @@ -5,13 +5,13 @@ //! `supports-reasoning-effort.ts`, `map-xai-finish-reason.ts`, and //! `remove-additional-properties.ts`. -use std::collections::HashMap; - -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; -use aimux_core::language_model_message::LanguageModelPrompt; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, LanguageModelMessage, LanguageModelPrompt, TextPart, ToolCallPart, ToolPart, + ToolResultPart, UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; +use aimux_core::shared::{FileBytes, FileData, SharedProviderOptions}; use aimux_core::tool::Tool; use aimux_core::types::{FinishReason, FinishReasonUnified, ReasoningEffort, Warning}; @@ -97,21 +97,21 @@ pub fn convert_xai_usage(usage: &XaiUsageResponse) -> aimux_core::types::Usage { let output_total = completion_tokens + reasoning_tokens; aimux_core::types::Usage { - input_tokens: aimux_core::types::TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(input_total), no_cache: Some(input_no_cache), cache_read: Some(cache_read), cache_write: None, - ..Default::default() }, - output_tokens: aimux_core::types::TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(output_total), text: Some(completion_tokens), reasoning: Some(reasoning_tokens), - ..Default::default() }, // RFC-0015 P0-3: keep the raw provider usage payload. - raw: Some(serde_json::to_value(usage).unwrap_or(serde_json::Value::Null)), + raw: serde_json::to_value(usage) + .ok() + .and_then(|value| value.as_object().cloned()), } } @@ -206,7 +206,7 @@ pub fn remove_additional_properties_false(value: &Value) -> Value { // ── Provider options helpers ───────────────────────────────────────────────── -fn xai_option(options: &Option>, key: &str) -> Option { +fn xai_option(options: &Option, key: &str) -> Option { options .as_ref() .and_then(|m| m.get("xai")) @@ -214,7 +214,7 @@ fn xai_option(options: &Option>, key: &str) -> Option) -> Option { +fn get_image_detail(provider_options: &Option) -> Option { provider_options .as_ref() .and_then(|po| po.get("xai")) @@ -259,17 +259,14 @@ pub fn resolve_full_media_type(media_type: &str, b64_data: &str) -> String { /// /// Returns a `String` listing the available providers when the requested key /// is absent. -pub fn resolve_provider_reference(reference: &Value, provider: &str) -> Result { - if let Some(val) = reference.get(provider) { - if let Some(s) = val.as_str() { - return Ok(s.to_string()); - } - return Ok(val.to_string()); - } - let available: Vec = reference - .as_object() - .map(|m| m.keys().cloned().collect()) - .unwrap_or_default(); +pub fn resolve_provider_reference( + reference: &std::collections::HashMap, + provider: &str, +) -> Result { + if let Some(value) = reference.get(provider) { + return Ok(value.clone()); + } + let available: Vec = reference.keys().cloned().collect(); Err(format!( "No provider reference found for provider '{}'. Available providers: {}", provider, @@ -292,49 +289,36 @@ pub fn convert_to_xai_messages( let warnings: Vec = Vec::new(); for msg in prompt { - match msg.role { - Role::System => { - let content: String = msg - .content - .iter() - .filter_map(|p| match p { - ContentPart::Text { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join(""); + match msg { + LanguageModelMessage::System { content, .. } => { messages.push(json!({ "role": "system", "content": content })); } - Role::User => { - if msg.content.len() == 1 - && let ContentPart::Text { text, .. } = &msg.content[0] + LanguageModelMessage::User { content, .. } => { + if content.len() == 1 + && let UserPart::Text(TextPart { text, .. }) = &content[0] { messages.push(json!({ "role": "user", "content": text })); continue; } let mut user_content = Vec::new(); - for (i, part) in msg.content.iter().enumerate() { + for (i, part) in content.iter().enumerate() { user_content.push(convert_user_part(part, i)?); } messages.push(json!({ "role": "user", "content": user_content })); } - Role::Assistant => { + LanguageModelMessage::Assistant { content, .. } => { let mut text = String::new(); let mut tool_calls: Vec = Vec::new(); - for part in &msg.content { + for part in content { match part { - ContentPart::Text { text: t, .. } => text.push_str(t), - ContentPart::ToolCall { + AssistantPart::Text(TextPart { text: t, .. }) => text.push_str(t), + AssistantPart::ToolCall(ToolCallPart { tool_call_id, tool_name, input, .. - } => { - let arguments = if input.is_null() { - "{}".to_string() - } else { - input.to_string() - }; + }) => { + let arguments = input.to_string(); tool_calls.push(json!({ "id": tool_call_id, "type": "function", "function": { "name": tool_name, "arguments": arguments } })); } _ => {} @@ -346,20 +330,20 @@ pub fn convert_to_xai_messages( } messages.push(msg_obj); } - Role::Tool => { - for part in &msg.content { - if let ContentPart::ToolResult { + LanguageModelMessage::Tool { content, .. } => { + for part in content { + let ToolPart::ToolResult(ToolResultPart { tool_call_id, - result, + output, .. - } = part - { - let content = match result { - Value::String(s) => s.clone(), - other => other.to_string(), - }; - messages.push(json!({ "role": "tool", "tool_call_id": tool_call_id, "content": content })); - } + }) = part + else { + continue; + }; + let content = crate::openai::convert::tool_result_to_content(output); + messages.push( + json!({ "role": "tool", "tool_call_id": tool_call_id, "content": content }), + ); } } } @@ -367,52 +351,32 @@ pub fn convert_to_xai_messages( Ok((messages, warnings)) } -fn convert_user_part(part: &ContentPart, _index: usize) -> Result { +fn convert_user_part(part: &UserPart, _index: usize) -> Result { match part { - ContentPart::Text { text, .. } => Ok(json!({ "type": "text", "text": text })), - ContentPart::Image { - image, - media_type, - provider_options, - } => { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(image); - convert_image_part(media_type, Some(&b64), None, provider_options) - } - ContentPart::File { - data, - media_type, - provider_options, - .. - } => { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(data); - convert_image_part(media_type, Some(&b64), None, provider_options) - } - ContentPart::FileBase64 { - data, - media_type, - provider_options, - .. - } => convert_image_part(media_type, Some(data), None, provider_options), - ContentPart::FileUrl { - url, - media_type, - provider_options, - } => convert_image_part(media_type, None, Some(url), provider_options), - ContentPart::FileReference { - media_type, - reference, - provider_options, - .. - } => { - let _ = media_type; - let _ = provider_options; - let file_id = resolve_provider_reference(reference, "xai") - .map_err(AiMuxError::InvalidArgument)?; - Ok(json!({ "type": "file", "file": { "file_id": file_id } })) - } - _ => Ok(Value::Null), + UserPart::Text(TextPart { text, .. }) => Ok(json!({ "type": "text", "text": text })), + UserPart::File(file) => match &file.data { + FileData::Data { + data: FileBytes::Binary(bytes), + } => { + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); + convert_image_part(&file.media_type, Some(&b64), None, &file.provider_options) + } + FileData::Data { + data: FileBytes::Base64(data), + } => convert_image_part(&file.media_type, Some(data), None, &file.provider_options), + FileData::Url { url, .. } => { + convert_image_part(&file.media_type, None, Some(url), &file.provider_options) + } + FileData::Reference { reference } => { + let file_id = resolve_provider_reference(reference, "xai") + .map_err(AiMuxError::InvalidArgument)?; + Ok(json!({ "type": "file", "file": { "file_id": file_id } })) + } + FileData::Text { .. } => Err(AiMuxError::UnsupportedFunctionality( + "text file parts".into(), + )), + }, } } @@ -420,7 +384,7 @@ fn convert_image_part( media_type: &str, b64_data: Option<&str>, url: Option<&str>, - provider_options: &Option, + provider_options: &Option, ) -> Result { let top_level = get_top_level_media_type(media_type); if top_level != "image" { diff --git a/aimux-providers/src/xai/model.rs b/aimux-providers/src/xai/model.rs index a2948521d..234bd6259 100644 --- a/aimux-providers/src/xai/model.rs +++ b/aimux-providers/src/xai/model.rs @@ -17,7 +17,7 @@ use serde_json::Value; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; @@ -122,9 +122,9 @@ impl LanguageModel for XaiModel { .await?; // Capture response headers. + let response_body = resp.raw_value; let response_headers = resp.response_headers; - let _raw_value = resp.raw_value.unwrap_or(Value::Null); let data = resp.value; let choice = data @@ -162,10 +162,10 @@ impl LanguageModel for XaiModel { if let Some(reasoning) = choice.message.reasoning_content && !reasoning.is_empty() { - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text: reasoning, provider_metadata: None, - }); + })); } // Extract tool calls @@ -186,13 +186,12 @@ impl LanguageModel for XaiModel { // Extract citations if let Some(citations) = &data.citations { for url in citations { - content.push(GenerateContent::Source { + content.push(GenerateContent::Source(Source::Url { id: generate_source_id(), - source_type: "url".to_string(), - url: Some(url.clone()), + url: url.clone(), title: None, provider_metadata: None, - }); + })); } } @@ -206,18 +205,16 @@ impl LanguageModel for XaiModel { }); let usage = data.usage.as_ref().map(convert_xai_usage).unwrap_or(Usage { - input_tokens: aimux_core::types::TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(0), no_cache: Some(0), cache_read: Some(0), cache_write: Some(0), - ..Default::default() }, - output_tokens: aimux_core::types::TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(0), text: Some(0), reasoning: Some(0), - ..Default::default() }, raw: None, }); @@ -232,13 +229,16 @@ impl LanguageModel for XaiModel { usage, warnings: request_result.warnings, provider_metadata: None, - response: ResponseMetadata { - id: data.id, - timestamp, - model_id: data.model, - }, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: response_body, + headers: Some(response_headers), + ..aimux_core::result::GenerateResponse::from(ResponseMetadata { + id: data.id, + timestamp, + model_id: data.model, + }) + }), }) } @@ -339,23 +339,22 @@ impl LanguageModel for XaiModel { chrono::DateTime::from_timestamp(c as i64, 0) .map(|dt| dt.to_rfc3339()) }); - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: chunk.id.clone(), timestamp, model_id: chunk.model.clone(), - }); + })); } // Emit citations as sources. if let Some(citations) = &chunk.citations { for url in citations { - yield Ok(StreamPart::Source { + yield Ok(StreamPart::Source(Source::Url { id: generate_source_id(), - source_type: "url".to_string(), - url: Some(url.clone()), + url: url.clone(), title: None, provider_metadata: None, - }); + })); } } @@ -573,18 +572,16 @@ impl LanguageModel for XaiModel { raw: None, }), usage: final_usage.unwrap_or(Usage { - input_tokens: aimux_core::types::TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(0), no_cache: Some(0), cache_read: Some(0), cache_write: Some(0), - ..Default::default() }, - output_tokens: aimux_core::types::TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(0), text: Some(0), reasoning: Some(0), - ..Default::default() }, raw: None, }), @@ -594,8 +591,10 @@ impl LanguageModel for XaiModel { Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } diff --git a/aimux-providers/src/xai/responses/convert.rs b/aimux-providers/src/xai/responses/convert.rs index 48c2a54b2..0855f9ce2 100644 --- a/aimux-providers/src/xai/responses/convert.rs +++ b/aimux-providers/src/xai/responses/convert.rs @@ -6,11 +6,13 @@ //! - `map-xai-responses-finish-reason.ts` //! - `xai-responses-prepare-tools.ts` -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; -use aimux_core::language_model_message::LanguageModelPrompt; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, + ToolCallPart, ToolPart, ToolResultContent, ToolResultOutput, ToolResultPart, UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; +use aimux_core::shared::{FileBytes, FileData, SharedProviderOptions}; use aimux_core::tool::Tool; use aimux_core::types::{FinishReasonUnified, ReasoningEffort, Warning}; @@ -64,21 +66,21 @@ pub fn convert_xai_responses_usage(usage: &XaiResponsesUsage) -> aimux_core::typ }; aimux_core::types::Usage { - input_tokens: aimux_core::types::TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(input_total as u32), no_cache: Some(input_no_cache as u32), cache_read: Some(cache_read as u32), cache_write: None, - ..Default::default() }, - output_tokens: aimux_core::types::TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(usage.output_tokens as u32), text: Some((usage.output_tokens - reasoning) as u32), reasoning: Some(reasoning as u32), - ..Default::default() }, // RFC-0015 P0-3: keep the raw provider usage payload. - raw: Some(serde_json::to_value(usage).unwrap_or(serde_json::Value::Null)), + raw: serde_json::to_value(usage) + .ok() + .and_then(|value| value.as_object().cloned()), } } @@ -271,10 +273,7 @@ fn prepare_provider_tool( // ── Provider options helpers ───────────────────────────────────────────────── -fn xai_option( - options: &Option>, - key: &str, -) -> Option { +fn xai_option(options: &Option, key: &str) -> Option { options .as_ref() .and_then(|m| m.get("xai")) @@ -297,114 +296,85 @@ pub fn convert_to_xai_responses_input( let mut warnings: Vec = Vec::new(); for msg in prompt { - match msg.role { - Role::System => { - let content: String = msg - .content - .iter() - .filter_map(|p| match p { - ContentPart::Text { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join(""); + match msg { + LanguageModelMessage::System { content, .. } => { input.push(json!({ "role": "system", "content": content })); } - Role::User => { + LanguageModelMessage::User { content, .. } => { let mut content_parts = Vec::new(); - for part in &msg.content { + for part in content { match part { - ContentPart::Text { text, .. } => { + UserPart::Text(TextPart { text, .. }) => { content_parts.push(json!({ "type": "input_text", "text": text })); } - ContentPart::Image { - image, - media_type, - provider_options, - } => { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(image); - content_parts.push(convert_image_part( - media_type, - Some(&b64), - None, - provider_options, - )); - } - ContentPart::File { - data, - media_type, - provider_options, - .. - } => { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(data); - content_parts.push(convert_image_part( - media_type, - Some(&b64), - None, - provider_options, - )); - } - ContentPart::FileBase64 { - data, - media_type, - provider_options, - .. - } => { - content_parts.push(convert_image_part( - media_type, - Some(data), - None, - provider_options, - )); - } - ContentPart::FileUrl { - url, - media_type, - provider_options, - } => { - let top_level = media_type.split('/').next().unwrap_or(""); - if top_level == "image" { - content_parts.push(convert_image_part( - media_type, - None, - Some(url), - provider_options, - )); - } else { - content_parts - .push(json!({ "type": "input_file", "file_url": url })); + UserPart::File(file) => { + if matches!(file.data, FileData::Data { .. }) + && file.media_type.split('/').next() != Some("image") + { + return Err(AiMuxError::UnsupportedFunctionality(format!( + "file part media type {} as inline data (xAI Responses requires a URL or a Files API reference for non-image files)", + file.media_type + ))); } - } - ContentPart::FileReference { - media_type, - reference, - provider_options, - .. - } => { - let _ = (media_type, provider_options); - let file_id = resolve_provider_reference(reference, "xai") - .map_err(AiMuxError::InvalidArgument)?; - content_parts.push(json!({ "type": "input_file", "file_id": file_id })); - } - _ => { - warnings.push(Warning::Other { - message: "xAI Responses API does not support this content type in user messages".to_string(), + content_parts.push(match &file.data { + FileData::Data { + data: FileBytes::Binary(bytes), + } => { + use base64::Engine; + let b64 = + base64::engine::general_purpose::STANDARD.encode(bytes); + convert_image_part( + &file.media_type, + Some(&b64), + None, + &file.provider_options, + ) + } + FileData::Data { + data: FileBytes::Base64(data), + } => convert_image_part( + &file.media_type, + Some(data), + None, + &file.provider_options, + ), + FileData::Url { url, .. } + if file.media_type.split('/').next() == Some("image") => + { + convert_image_part( + &file.media_type, + None, + Some(url), + &file.provider_options, + ) + } + FileData::Url { url, .. } => { + json!({ "type": "input_file", "file_url": url }) + } + FileData::Reference { reference } => { + let file_id = resolve_provider_reference(reference, "xai") + .map_err(AiMuxError::InvalidArgument)?; + json!({ "type": "input_file", "file_id": file_id }) + } + FileData::Text { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "text file parts".into(), + )); + } }); } } } input.push(json!({ "role": "user", "content": content_parts })); } - Role::Assistant => { - for part in &msg.content { + LanguageModelMessage::Assistant { content, .. } => { + for part in content { match part { - ContentPart::Text { + AssistantPart::Text(TextPart { text, provider_options, .. - } => { + }) => { let id = provider_options .as_ref() .and_then(|po| po.get("xai")) @@ -417,14 +387,14 @@ pub fn convert_to_xai_responses_input( } input.push(msg_obj); } - ContentPart::ToolCall { + AssistantPart::ToolCall(ToolCallPart { tool_call_id, tool_name, input: tool_input, provider_executed, provider_options, .. - } => { + }) => { // Skip provider-executed tool calls. // The standardized field is authoritative. Fall back // to the legacy provider option only when it is absent. @@ -446,11 +416,7 @@ pub fn convert_to_xai_responses_input( .and_then(|v| v.as_str()) .map(std::string::ToString::to_string); let item_id = id.unwrap_or_else(|| tool_call_id.clone()); - let arguments = if tool_input.is_null() { - "{}".to_string() - } else { - tool_input.to_string() - }; + let arguments = tool_input.to_string(); input.push(json!({ "type": "function_call", "id": item_id, @@ -460,12 +426,12 @@ pub fn convert_to_xai_responses_input( "status": "completed" })); } - ContentPart::ToolResult { .. } => {} - ContentPart::Reasoning { + AssistantPart::ToolResult(_) => {} + AssistantPart::Reasoning(ReasoningPart { text, provider_options, .. - } => { + }) => { let item_id = provider_options .as_ref() .and_then(|po| po.get("xai")) @@ -508,37 +474,79 @@ pub fn convert_to_xai_responses_input( } } } - Role::Tool => { - for part in &msg.content { - if let ContentPart::ToolResult { + LanguageModelMessage::Tool { content, .. } => { + for part in content { + let ToolPart::ToolResult(ToolResultPart { tool_call_id, - result, + output, .. - } = part + }) = part + else { + continue; + }; + let output_value = convert_tool_result_output(output); + input.push(json!({ + "type": "function_call_output", + "call_id": tool_call_id, + "output": output_value + })); + } + } + } + } + + Ok((input, warnings)) +} + +fn convert_tool_result_output(output: &ToolResultOutput) -> Value { + match output { + ToolResultOutput::ExecutionDenied { reason, .. } => { + json!(reason.as_deref().unwrap_or("tool execution denied")) + } + ToolResultOutput::Content { value } => { + let mut parts = Vec::new(); + for item in value { + match item { + ToolResultContent::Text(part) => { + parts.push(json!({"type":"input_text", "text":part.text})) + } + ToolResultContent::File(part) + if part.media_type.split('/').next() == Some("image") => { - let output_value = match result { - Value::String(s) => s.clone(), - other => other.to_string(), + let url = match &part.data { + FileData::Url { url, .. } => url.clone(), + FileData::Data { data } => { + use base64::Engine; + let b64 = match data { + FileBytes::Binary(bytes) => { + base64::engine::general_purpose::STANDARD.encode(bytes) + } + FileBytes::Base64(data) => data.clone(), + }; + format!( + "data:{};base64,{}", + resolve_full_media_type(&part.media_type, &b64), + b64 + ) + } + _ => continue, }; - input.push(json!({ - "type": "function_call_output", - "call_id": tool_call_id, - "output": output_value - })); + parts.push(json!({"type":"input_image", "image_url":url})); } + _ => {} } } + json!(parts) } + _ => crate::openai::convert::tool_result_to_content(output), } - - Ok((input, warnings)) } fn convert_image_part( media_type: &str, b64_data: Option<&str>, url: Option<&str>, - provider_options: &Option, + provider_options: &Option, ) -> Value { let image_url = if let Some(url_str) = url { url_str.to_string() @@ -587,7 +595,61 @@ pub fn build_responses_request_body( stream: bool, ) -> Result { let mut warnings: Vec = Vec::new(); - let xai_opts = &options.provider_options; + let parsed_options = options + .provider_options + .as_ref() + .map(|namespaces| { + let mut namespaces = namespaces.clone(); + if let Some(raw) = namespaces.get("xai") { + let parsed = + crate::openai::convert::parse_option_fields(raw, "xai", |key, value| { + let valid = match key { + "reasoningEffort" => value.as_str().is_some_and(|s| { + matches!(s, "none" | "low" | "medium" | "high" | "xhigh") + }), + "reasoningSummary" => value + .as_str() + .is_some_and(|s| matches!(s, "auto" | "concise" | "detailed")), + "logprobs" | "parallelToolCalls" | "store" => value.is_boolean(), + "topLogprobs" => value + .as_f64() + .is_some_and(|n| n.fract() == 0.0 && (0.0..=8.0).contains(&n)), + "minP" => value.as_f64().is_some_and(|n| (0.0..=1.0).contains(&n)), + "maxTurns" => value.as_f64().is_some_and(|n| { + n.fract() == 0.0 && n.abs() <= 9_007_199_254_740_991.0 + }), + "promptCacheKey" | "safetyIdentifier" | "previousResponseId" + | "user" => value.is_string(), + "serviceTier" => value + .as_str() + .is_some_and(|s| matches!(s, "default" | "priority")), + "include" => { + value.is_null() + || value.as_array().is_some_and(|items| { + items.iter().all(|item| { + item.as_str().is_some_and(|s| { + matches!( + s, + "file_search_call.results" + | "web_search_call.action.sources" + | "code_interpreter_call.outputs" + | "reasoning.encrypted_content" + | "no_inline_citations" + ) + }) + }) + }) + } + _ => return None, + }; + Some(if valid { Ok(value.clone()) } else { Err(()) }) + })?; + namespaces.insert("xai".to_string(), parsed); + } + Ok::<_, AiMuxError>(namespaces) + }) + .transpose()?; + let xai_opts = &parsed_options; if options.stop_sequences.is_some() { warnings.push(Warning::Unsupported { diff --git a/aimux-providers/src/xai/responses/mod.rs b/aimux-providers/src/xai/responses/mod.rs index 1076ff719..2f33c978b 100644 --- a/aimux-providers/src/xai/responses/mod.rs +++ b/aimux-providers/src/xai/responses/mod.rs @@ -27,7 +27,8 @@ use serde_json::{Value, json}; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source, StreamResult}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; @@ -120,7 +121,7 @@ impl LanguageModel for XaiResponsesModel { let response_headers = resp.response_headers; - let _raw_value = resp.raw_value.unwrap_or(Value::Null); + let raw_value = resp.raw_value.unwrap_or(Value::Null); let data = resp.value; let mut content: Vec = Vec::new(); @@ -228,13 +229,12 @@ impl LanguageModel for XaiResponsesModel { .and_then(|v| v.as_str()) .unwrap_or(url) .to_string(); - content.push(GenerateContent::Source { + content.push(GenerateContent::Source(Source::Url { id: generate_source_id(), - source_type: "url".to_string(), - url: Some(url.to_string()), + url: url.to_string(), title: Some(title), provider_metadata: None, - }); + })); } } } @@ -300,10 +300,13 @@ impl LanguageModel for XaiResponsesModel { if let Some(id) = item_id { meta["itemId"] = json!(id); } - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text: reasoning_text, - provider_metadata: Some(json!({ "xai": meta })), - }); + provider_metadata: Some( + provider_namespace("xai", meta) + .expect("provider metadata must be an object"), + ), + })); } } _ => {} @@ -324,9 +327,10 @@ impl LanguageModel for XaiResponsesModel { let (usage, provider_metadata) = if let Some(u) = &data.usage { let meta = if u.cost_in_usd_ticks.is_some() { - Some(json!({ - "xai": { "costInUsdTicks": u.cost_in_usd_ticks } - })) + Some( + provider_namespace("xai", json!({ "costInUsdTicks": u.cost_in_usd_ticks })) + .expect("provider metadata must be an object"), + ) } else { None }; @@ -345,13 +349,16 @@ impl LanguageModel for XaiResponsesModel { usage, warnings: request_result.warnings, provider_metadata, - response: ResponseMetadata { - id: data.id, - timestamp, - model_id: data.model, - }, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: Some(raw_value), + headers: Some(response_headers), + ..aimux_core::result::GenerateResponse::from(ResponseMetadata { + id: data.id, + timestamp, + model_id: data.model, + }) + }), }) } @@ -430,7 +437,7 @@ impl LanguageModel for XaiResponsesModel { let timestamp = created_at.and_then(|c| { chrono::DateTime::from_timestamp(c as i64, 0).map(|dt| dt.to_rfc3339()) }); - yield Ok(StreamPart::ResponseMetadata { id, timestamp, model_id: model }); + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id, timestamp, model_id: model })); } continue; } @@ -443,7 +450,7 @@ impl LanguageModel for XaiResponsesModel { active_reasoning.insert(item_id.to_string(), ()); yield Ok(StreamPart::ReasoningStart { id: block_id, - provider_metadata: Some(json!({ "xai": { "itemId": item_id } })), + provider_metadata: Some(provider_namespace("xai", json!({ "itemId": item_id })).expect("provider metadata must be an object")), }); } continue; @@ -457,7 +464,7 @@ impl LanguageModel for XaiResponsesModel { yield Ok(StreamPart::ReasoningDelta { id: block_id, delta: delta.to_string(), - provider_metadata: Some(json!({ "xai": { "itemId": item_id } })), + provider_metadata: Some(provider_namespace("xai", json!({ "itemId": item_id })).expect("provider metadata must be an object")), }); continue; } @@ -476,13 +483,13 @@ impl LanguageModel for XaiResponsesModel { active_reasoning.insert(item_id.to_string(), ()); yield Ok(StreamPart::ReasoningStart { id: block_id.clone(), - provider_metadata: Some(json!({ "xai": { "itemId": item_id } })), + provider_metadata: Some(provider_namespace("xai", json!({ "itemId": item_id })).expect("provider metadata must be an object")), }); } yield Ok(StreamPart::ReasoningDelta { id: block_id, delta: delta.to_string(), - provider_metadata: Some(json!({ "xai": { "itemId": item_id } })), + provider_metadata: Some(provider_namespace("xai", json!({ "itemId": item_id })).expect("provider metadata must be an object")), }); continue; } @@ -516,13 +523,12 @@ impl LanguageModel for XaiResponsesModel { if ann.get("type").and_then(|v| v.as_str()) == Some("url_citation") { let url = ann.get("url").and_then(|v| v.as_str()).unwrap_or(""); let title = ann.get("title").and_then(|v| v.as_str()).unwrap_or(url).to_string(); - yield Ok(StreamPart::Source { + yield Ok(StreamPart::Source(Source::Url { id: generate_source_id(), - source_type: "url".to_string(), - url: Some(url.to_string()), + url: url.to_string(), title: Some(title), provider_metadata: None, - }); + })); } } } @@ -535,13 +541,12 @@ impl LanguageModel for XaiResponsesModel { if annotation.get("type").and_then(|v| v.as_str()) == Some("url_citation") { let url = annotation.get("url").and_then(|v| v.as_str()).unwrap_or(""); let title = annotation.get("title").and_then(|v| v.as_str()).unwrap_or(url).to_string(); - yield Ok(StreamPart::Source { + yield Ok(StreamPart::Source(Source::Url { id: generate_source_id(), - source_type: "url".to_string(), - url: Some(url.to_string()), + url: url.to_string(), title: Some(title), provider_metadata: None, - }); + })); } continue; } @@ -667,7 +672,7 @@ impl LanguageModel for XaiResponsesModel { active_reasoning.insert(part_id.to_string(), ()); yield Ok(StreamPart::ReasoningStart { id: block_id.clone(), - provider_metadata: Some(json!({ "xai": { "itemId": part_id } })), + provider_metadata: Some(provider_namespace("xai", json!({ "itemId": part_id })).expect("provider metadata must be an object")), }); } @@ -677,7 +682,7 @@ impl LanguageModel for XaiResponsesModel { } yield Ok(StreamPart::ReasoningEnd { id: block_id, - provider_metadata: Some(json!({ "xai": meta })), + provider_metadata: Some(provider_namespace("xai", meta).expect("provider metadata must be an object")), }); active_reasoning.remove(part_id); } @@ -829,13 +834,12 @@ impl LanguageModel for XaiResponsesModel { if ann.get("type").and_then(|v| v.as_str()) == Some("url_citation") { let url = ann.get("url").and_then(|v| v.as_str()).unwrap_or(""); let title = ann.get("title").and_then(|v| v.as_str()).unwrap_or(url).to_string(); - yield Ok(StreamPart::Source { + yield Ok(StreamPart::Source(Source::Url { id: generate_source_id(), - source_type: "url".to_string(), - url: Some(url.to_string()), + url: url.to_string(), title: Some(title), provider_metadata: None, - }); + })); } } } @@ -901,23 +905,21 @@ impl LanguageModel for XaiResponsesModel { } // Final part: Finish. - let provider_meta = cost_in_usd_ticks.map(|cost| json!({ "xai": { "costInUsdTicks": cost } })); + let provider_meta = cost_in_usd_ticks.map(|cost| provider_namespace("xai", json!({ "costInUsdTicks": cost })).expect("provider metadata must be an object")); yield Ok(StreamPart::Finish { finish_reason: final_finish_reason, usage: final_usage.unwrap_or(Usage { - input_tokens: aimux_core::types::TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(0), no_cache: Some(0), cache_read: Some(0), cache_write: Some(0), - ..Default::default() }, - output_tokens: aimux_core::types::TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(0), text: Some(0), reasoning: Some(0), - ..Default::default() }, raw: None, }), @@ -927,8 +929,10 @@ impl LanguageModel for XaiResponsesModel { Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } diff --git a/aimux-providers/tests/alibaba_test.rs b/aimux-providers/tests/alibaba_test.rs index db2c913ff..9ff1525c6 100644 --- a/aimux-providers/tests/alibaba_test.rs +++ b/aimux-providers/tests/alibaba_test.rs @@ -31,15 +31,13 @@ use serial_test::serial; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; +use aimux_core::types::FinishReasonUnified; use aimux_providers::{ProviderOptions, provider, provider_from_env}; @@ -47,11 +45,7 @@ use aimux_providers::{ProviderOptions, provider, provider_from_env}; /// TS `TEST_PROMPT`: a single user text message "Hello". fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { @@ -285,61 +279,6 @@ async fn sends_correct_request_body() { assert_eq!(body["messages"][0]["content"], "Hello"); } -/// TS: a tool-call assistant message round-trips as `tool_calls` and a tool -/// result as a `tool` role message (shared OpenAI conversion). -#[tokio::test] -async fn converts_tool_call_and_tool_result_messages() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let model = make_provider(&server); - - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::ToolCall { - tool_call_id: "call-1".to_string(), - tool_name: "get_weather".to_string(), - input: json!({"location": "SF"}), - provider_executed: None, - provider_options: None, - }], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::ToolResult { - tool_call_id: "call-1".to_string(), - result: json!({"temp": 72}), - tool_name: None, - is_error: None, - preliminary: None, - dynamic: None, - provider_options: None, - }], - ..Default::default() - }, - ]; - let _ = model.do_generate(&default_options(prompt)).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - // Assistant tool-call message. - assert_eq!(body["messages"][0]["role"], "assistant"); - assert_eq!(body["messages"][0]["tool_calls"][0]["id"], "call-1"); - assert_eq!( - body["messages"][0]["tool_calls"][0]["function"]["name"], - "get_weather" - ); - // Tool result message. - assert_eq!(body["messages"][1]["role"], "tool"); - assert_eq!(body["messages"][1]["tool_call_id"], "call-1"); -} - // ════════════════════════════════════════════════════════════════════════════ // Usage conversion (convert-alibaba-usage.test.ts + chat model usage tests) // ════════════════════════════════════════════════════════════════════════════ @@ -436,28 +375,6 @@ async fn extracts_usage_with_reasoning_tokens() { // Reasoning (top-level reasoning �?reasoning_effort, shared OpenAI behaviour) // ════════════════════════════════════════════════════════════════════════════ -/// TS: top-level `reasoning: 'high'` is forwarded as `reasoning_effort`. -/// (Alibaba's TS maps this to `enable_thinking`; the Rust shared converter -/// maps it to `reasoning_effort` instead.) -#[tokio::test] -async fn top_level_reasoning_maps_to_reasoning_effort() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::High); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["reasoning_effort"], json!("high")); -} - // ════════════════════════════════════════════════════════════════════════════ // doGenerate / doStream / errors // ════════════════════════════════════════════════════════════════════════════ @@ -641,31 +558,3 @@ async fn status_429_maps_to_rate_limited() { "expected RateLimited, got {result:?}" ); } - -/// TS: response headers are exposed on the generate result. -#[tokio::test] -async fn exposes_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(text_completion_body()), - ) - .mount(&server) - .await; - - let model = make_provider(&server); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} diff --git a/aimux-providers/tests/amazon_bedrock_image_test.rs b/aimux-providers/tests/amazon_bedrock_image_test.rs index a93d7070d..de55218a4 100644 --- a/aimux-providers/tests/amazon_bedrock_image_test.rs +++ b/aimux-providers/tests/amazon_bedrock_image_test.rs @@ -185,21 +185,3 @@ async fn should_handle_moderated_response() { let err = result.unwrap_err().to_string(); assert!(err.contains("moderated")); } - -#[tokio::test] -async fn should_pass_negative_text() { - let server = MockServer::start().await; - mock_bedrock(&server, bedrock_response()).await; - let provider = BedrockProvider::new(config(&server)); - let model = provider.image("amazon.titan-image-generator-v1"); - let mut opts = options(PROMPT); - opts.n = 1; - opts.provider_options.insert( - "amazonBedrock".into(), - json!({ "negativeText": "ugly, blurry" }), - ); - model.do_generate(&opts).await.unwrap(); - let reqs = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&reqs[0].body).unwrap(); - assert_eq!(body["textToImageParams"]["negativeText"], "ugly, blurry"); -} diff --git a/aimux-providers/tests/anthropic_files_test.rs b/aimux-providers/tests/anthropic_files_test.rs index 4f6127f79..0dbc8f7e4 100644 --- a/aimux-providers/tests/anthropic_files_test.rs +++ b/aimux-providers/tests/anthropic_files_test.rs @@ -259,7 +259,7 @@ async fn omits_downloadable_when_null() { let anthropic = metadata.get("anthropic").expect("anthropic metadata"); assert!( anthropic.get("downloadable").is_none(), - "downloadable should be omitted when null: {anthropic}" + "downloadable should be omitted when null: {anthropic:?}" ); } diff --git a/aimux-providers/tests/aws_polly_test.rs b/aimux-providers/tests/aws_polly_test.rs index 34c553f1c..5541ea346 100644 --- a/aimux-providers/tests/aws_polly_test.rs +++ b/aimux-providers/tests/aws_polly_test.rs @@ -7,7 +7,7 @@ //! //! No network access, no real credentials. -use serde_json::{Value, json}; +use serde_json::Value; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -261,40 +261,6 @@ async fn language_maps_to_language_code() { assert_eq!(body["LanguageCode"], "es-US"); } -/// Provider options (`aws_polly` key) forward Polly-specific fields. -#[tokio::test] -async fn provider_options_forward_polly_fields() { - let server = MockServer::start().await; - mock_audio_response(&server, "mp3").await; - - let provider = AwsPollyProvider::new(test_config(&server)); - let model = provider.speech("aws_polly/standard"); - - let mut options = speech_options("Hello"); - let mut provider_options = std::collections::HashMap::new(); - provider_options.insert( - "aws_polly".to_string(), - json!({ - "engine": "neural", - "sampleRate": "22050", - "textType": "ssml", - "lexiconNames": ["lex1"], - "speechMarkTypes": ["sentence"] - }), - ); - options.provider_options = Some(provider_options); - - model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["Engine"], "neural", "engine override should apply"); - assert_eq!(body["SampleRate"], "22050"); - assert_eq!(body["TextType"], "ssml"); - assert_eq!(body["LexiconNames"], json!(["lex1"])); - assert_eq!(body["SpeechMarkTypes"], json!(["sentence"])); -} - /// The response carries timestamp, model id and headers. #[tokio::test] async fn response_carries_metadata() { diff --git a/aimux-providers/tests/baseten_test.rs b/aimux-providers/tests/baseten_test.rs index a1bb20359..7e902bf24 100644 --- a/aimux-providers/tests/baseten_test.rs +++ b/aimux-providers/tests/baseten_test.rs @@ -26,20 +26,14 @@ use serial_test::serial; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_providers::{ProviderOptions, provider, provider_from_env}; fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { diff --git a/aimux-providers/tests/bedrock_convert_test.rs b/aimux-providers/tests/bedrock_convert_test.rs index 092f5680a..76ffbce9d 100644 --- a/aimux-providers/tests/bedrock_convert_test.rs +++ b/aimux-providers/tests/bedrock_convert_test.rs @@ -7,13 +7,8 @@ //! //! Cases the Rust data model cannot express are skipped with an inline //! comment. The main categories of skips: -//! - **Message-level `providerOptions`**: `LanguageModelPromptMessage` has no -//! `provider_options` field, so message-level cache points (system / user / -//! assistant message `bedrock.cachePoint`) cannot be expressed. Part-level -//! cache points (on `ContentPart::Text` `provider_options`) ARE covered. -//! - **System-after-non-system throw / unsupported-mime throws**: the Rust -//! `convert_prompt_to_bedrock` returns `(system, messages)` (no `Result`), -//! so it cannot surface `UnsupportedFunctionalityError`. +//! - **System-after-non-system throw**: the converter currently lifts +//! all system messages into the system array. //! - **S3 URLs / provider references**: `FileUrl` / `FileReference` are not //! converted by the Rust Bedrock path. //! - **Top-level-only mediaType auto-detection from bytes**: the Rust path @@ -21,17 +16,18 @@ //! - **Mistral tool-call-id normalization (`isMistral`)**: the Rust //! `convert_prompt_to_bedrock` has no `isMistral` parameter. The //! non-Mistral (passthrough) cases ARE covered. -//! - **Redacted reasoning / foreign-provider reasoning**: `ContentPart::Reasoning` -//! carries only `signature` (no `redactedData`, no provider distinction). //! - **Provider-defined tools (web_search, anthropic provider tools)** and //! `additionalTools`/`betas`: the Rust `FunctionTool` has no `type`/`id`. //! - **`raw` echo on `Usage`**: the Rust `Usage` type has no `raw` field. use serde_json::{Value, json}; -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::LanguageModelPromptMessage; -use aimux_core::message::Role; +use aimux_core::shared::{FileBytes, FileData, SharedProviderOptions, provider_namespace}; + +use aimux_core::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, ReasoningPart, TextPart, ToolCallPart, ToolPart, + ToolResultContent, ToolResultOutput, ToolResultPart, UserPart, +}; use aimux_core::options::ToolChoice; use aimux_core::tool::FunctionTool; @@ -41,73 +37,105 @@ use aimux_providers::bedrock::convert::{ // ── prompt builders ───────────────────────────────────────────────────────── -fn msg(role: Role, content: Vec) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role, +fn user(content: Vec) -> LanguageModelMessage { + LanguageModelMessage::User { content, provider_options: None, } } -fn user(content: Vec) -> LanguageModelPromptMessage { - msg(Role::User, content) +fn assistant(content: Vec) -> LanguageModelMessage { + LanguageModelMessage::Assistant { + content, + provider_options: None, + } } -fn assistant(content: Vec) -> LanguageModelPromptMessage { - msg(Role::Assistant, content) +fn system_msg(text: &str) -> LanguageModelMessage { + LanguageModelMessage::System { + content: text.to_string(), + provider_options: None, + } +} + +fn tool_msg(content: Vec) -> LanguageModelMessage { + LanguageModelMessage::Tool { + content, + provider_options: None, + } } -fn system_msg(text: &str) -> LanguageModelPromptMessage { - msg(Role::System, vec![ContentPart::text(text)]) +fn user_text(text: &str) -> UserPart { + UserPart::Text(TextPart { + text: text.to_string(), + provider_options: None, + }) } -fn tool_msg(content: Vec) -> LanguageModelPromptMessage { - msg(Role::Tool, content) +fn assistant_text(text: &str) -> AssistantPart { + AssistantPart::Text(TextPart { + text: text.to_string(), + provider_options: None, + }) } -/// A `file` part whose inline data is an already-base64 string — mirrors the -/// TS `file` part with `data: { type: 'data', data: '' }` (the Rust -/// `ContentPart::FileBase64` holds the base64 string verbatim). +/// A file part with inline base64 data, passed through verbatim. fn file_base64( data: &str, media_type: &str, filename: Option<&str>, - provider_options: Option, -) -> ContentPart { - ContentPart::FileBase64 { - data: data.to_string(), + provider_options: Option, +) -> UserPart { + UserPart::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64(data.to_string()), + }, media_type: media_type.to_string(), filename: filename.map(std::string::ToString::to_string), provider_options, - } + }) } -fn text_with_cache(text: &str, cache_type: &str, ttl: Option<&str>) -> ContentPart { +fn text_with_cache(text: &str, cache_type: &str, ttl: Option<&str>) -> TextPart { let mut cp = serde_json::Map::new(); cp.insert("type".to_string(), json!(cache_type)); if let Some(t) = ttl { cp.insert("ttl".to_string(), json!(t)); } - ContentPart::Text { + TextPart { text: text.to_string(), - provider_options: Some(json!({ "bedrock": { "cachePoint": Value::Object(cp) } })), + provider_options: Some( + provider_namespace("bedrock", json!({ "cachePoint": Value::Object(cp) })).unwrap(), + ), } } -fn reasoning(text: &str, signature: Option<&str>) -> ContentPart { - ContentPart::Reasoning { +fn reasoning(text: &str, signature: Option<&str>) -> AssistantPart { + AssistantPart::Reasoning(ReasoningPart { text: text.to_string(), - signature: signature.map(std::string::ToString::to_string), - provider_options: None, - } + provider_options: signature.map(|signature| { + provider_namespace("amazonBedrock", json!({ "signature": signature })).unwrap() + }), + }) } -fn tool_call(id: &str, name: &str, input: Value) -> ContentPart { - ContentPart::tool_call(id.to_string(), name.to_string(), input) +fn tool_call(id: &str, name: &str, input: Value) -> AssistantPart { + AssistantPart::ToolCall(ToolCallPart { + tool_call_id: id.to_string(), + tool_name: name.to_string(), + input, + provider_executed: None, + provider_options: None, + }) } -fn tool_result(id: &str, output: Value) -> ContentPart { - ContentPart::tool_result(id.to_string(), output) +fn tool_result(id: &str, output: ToolResultOutput) -> ToolPart { + ToolPart::ToolResult(ToolResultPart { + tool_call_id: id.to_string(), + tool_name: "test".to_string(), + output, + provider_options: None, + }) } // ════════════════════════════════════════════════════════════════════════════ @@ -119,7 +147,8 @@ fn tool_result(id: &str, output: Value) -> ContentPart { /// TS: "should combine multiple leading system messages into a single system message" #[test] fn system_combine_multiple_leading() { - let (system, _) = convert_prompt_to_bedrock(&vec![system_msg("Hello"), system_msg("World")]); + let (system, _) = + convert_prompt_to_bedrock(&vec![system_msg("Hello"), system_msg("World")]).unwrap(); assert_eq!( Value::Array(system), json!([{ "text": "Hello" }, { "text": "World" }]) @@ -127,16 +156,13 @@ fn system_combine_multiple_leading() { } // SKIPPED (TS: "should throw an error if a system message is provided after a -// non-system message"): convert_prompt_to_bedrock returns (system, messages) -// with no Result, so it cannot surface UnsupportedFunctionalityError. - -// SKIPPED (TS: system message cache point, 5m, 1h — 3 cases): message-level -// providerOptions are not modelled on LanguageModelPromptMessage. +// non-system message"): the converter currently lifts all system messages +// into the system array. /// TS: "should extract the system message" #[test] fn system_extract_single() { - let (system, _) = convert_prompt_to_bedrock(&vec![system_msg("Hello")]); + let (system, _) = convert_prompt_to_bedrock(&vec![system_msg("Hello")]).unwrap(); assert_eq!(Value::Array(system), json!([{ "text": "Hello" }])); } @@ -146,9 +172,10 @@ fn system_extract_single() { #[test] fn user_convert_image_parts() { let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![ - ContentPart::text("Hello"), + user_text("Hello"), file_base64("AAECAw==", "image/png", None, None), - ])]); + ])]) + .unwrap(); assert_eq!( Value::Array(messages), json!([{ @@ -168,9 +195,10 @@ fn user_convert_image_parts() { #[test] fn user_convert_document_parts() { let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![ - ContentPart::text("Hello"), + user_text("Hello"), file_base64("AAECAw==", "application/pdf", None, None), - ])]); + ])]) + .unwrap(); assert_eq!( messages[0]["content"], json!([ @@ -184,14 +212,15 @@ fn user_convert_document_parts() { #[test] fn user_strip_file_extension() { let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![ - ContentPart::text("Hello"), + user_text("Hello"), file_base64( "AAECAw==", "application/pdf", Some("custom-filename.pdf"), None, ), - ])]); + ])]) + .unwrap(); assert_eq!( messages[0]["content"][1], json!({ "document": { "format": "pdf", "name": "custom-filename", "source": { "bytes": "AAECAw==" } } }) @@ -206,7 +235,8 @@ fn user_preserve_filename_without_extension() { "application/pdf", Some("custom-filename"), None, - )])]); + )])]) + .unwrap(); assert_eq!( messages[0]["content"][0], json!({ "document": { "format": "pdf", "name": "custom-filename", "source": { "bytes": "AAECAw==" } } }) @@ -221,9 +251,10 @@ fn user_consistent_document_names() { file_base64("AAECAw==", "application/pdf", None, None), file_base64("BAUGBw==", "application/pdf", None, None), ]), - assistant(vec![ContentPart::text("OK")]), + assistant(vec![assistant_text("OK")]), user(vec![file_base64("AAECAw==", "application/pdf", None, None)]), - ]); + ]) + .unwrap(); assert_eq!( Value::Array(messages), json!([ @@ -239,20 +270,20 @@ fn user_consistent_document_names() { ); } -// SKIPPED (TS: user message cache point, 5m, 1h — 3 cases): message-level -// providerOptions are not modelled on LanguageModelPromptMessage. +// Message-level cache-point cases remain outside this restored test subset. // SKIPPED (TS: "should throw for file parts with provider references"): -// FileReference is not converted (no Result to surface the throw). +// FileReference is not converted by the user-file path. /// TS: "should add cache point to user content part when specified" #[test] fn user_content_part_cache_point() { let (system, messages) = convert_prompt_to_bedrock(&vec![user(vec![ - ContentPart::text("Hello"), - text_with_cache("cached", "default", Some("5m")), - ContentPart::text("World"), - ])]); + user_text("Hello"), + UserPart::Text(text_with_cache("cached", "default", Some("5m"))), + user_text("World"), + ])]) + .unwrap(); assert!(system.is_empty()); assert_eq!( Value::Array(messages), @@ -274,9 +305,10 @@ fn user_content_part_cache_point() { #[test] fn assistant_trim_trailing_whitespace_last() { let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("user content")]), - assistant(vec![ContentPart::text("assistant content ")]), - ]); + user(vec![user_text("user content")]), + assistant(vec![assistant_text("assistant content ")]), + ]) + .unwrap(); assert_eq!( Value::Array(messages), json!([ @@ -290,12 +322,13 @@ fn assistant_trim_trailing_whitespace_last() { #[test] fn assistant_trim_trailing_whitespace_multi_part() { let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("user content")]), + user(vec![user_text("user content")]), assistant(vec![ - ContentPart::text("assistant "), - ContentPart::text("content "), + assistant_text("assistant "), + assistant_text("content "), ]), - ]); + ]) + .unwrap(); assert_eq!( messages[1]["content"], json!([{ "text": "assistant " }, { "text": "content" }]) @@ -306,10 +339,11 @@ fn assistant_trim_trailing_whitespace_multi_part() { #[test] fn assistant_keep_trailing_whitespace_with_further_user() { let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("user content")]), - assistant(vec![ContentPart::text("assistant content ")]), - user(vec![ContentPart::text("user content 2")]), - ]); + user(vec![user_text("user content")]), + assistant(vec![assistant_text("assistant content ")]), + user(vec![user_text("user content 2")]), + ]) + .unwrap(); assert_eq!( messages[1]["content"], json!([{ "text": "assistant content " }]) @@ -320,11 +354,12 @@ fn assistant_keep_trailing_whitespace_with_further_user() { #[test] fn assistant_combine_sequential() { let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("Hi!")]), - assistant(vec![ContentPart::text("Hello")]), - assistant(vec![ContentPart::text("World")]), - assistant(vec![ContentPart::text("!")]), - ]); + user(vec![user_text("Hi!")]), + assistant(vec![assistant_text("Hello")]), + assistant(vec![assistant_text("World")]), + assistant(vec![assistant_text("!")]), + ]) + .unwrap(); assert_eq!( Value::Array(messages), json!([ @@ -334,17 +369,17 @@ fn assistant_combine_sequential() { ); } -// SKIPPED (TS: assistant message cache point, 5m, 1h — 3 cases): message-level -// providerOptions are not modelled on LanguageModelPromptMessage. +// Message-level cache-point cases remain outside this restored test subset. /// TS: "should add cache point to assistant content part when specified" #[test] fn assistant_content_part_cache_point() { let (_, messages) = convert_prompt_to_bedrock(&vec![assistant(vec![ - ContentPart::text("Hello"), - text_with_cache("cached", "default", Some("1h")), - ContentPart::text("World"), - ])]); + assistant_text("Hello"), + AssistantPart::Text(text_with_cache("cached", "default", Some("1h"))), + assistant_text("World"), + ])]) + .unwrap(); assert_eq!( Value::Array(messages), json!([{ @@ -363,12 +398,13 @@ fn assistant_content_part_cache_point() { #[test] fn assistant_reasoning_with_signature() { let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("Explain your reasoning")]), + user(vec![user_text("Explain your reasoning")]), assistant(vec![reasoning( "This is my step-by-step reasoning process", Some("test-signature"), )]), - ]); + ]) + .unwrap(); assert_eq!( messages[1]["content"], json!([{ @@ -382,26 +418,20 @@ fn assistant_reasoning_with_signature() { ); } -// SKIPPED (TS: "should properly convert redacted-reasoning content type"): -// ContentPart::Reasoning has no redactedData field. - -// SKIPPED (TS: "should omit assistant message reasoning parts signed by a -// foreign provider"): ContentPart::Reasoning.signature carries no provider -// distinction, so foreign-provider (anthropic) signatures cannot be detected. - /// TS: "should preserve assistant message reasoning parts with amazonBedrock providerOptions" #[test] fn assistant_preserve_amazon_bedrock_reasoning() { let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("Explain your reasoning")]), + user(vec![user_text("Explain your reasoning")]), assistant(vec![ reasoning( "Bedrock-signed reasoning round-tripped to Bedrock", Some("bedrock-signature"), ), - ContentPart::text("final answer"), + assistant_text("final answer"), ]), - ]); + ]) + .unwrap(); assert_eq!( messages[1]["content"], json!([ @@ -418,12 +448,13 @@ fn assistant_preserve_amazon_bedrock_reasoning() { #[test] fn assistant_no_trim_reasoning_with_signature() { let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("Explain your reasoning")]), + user(vec![user_text("Explain your reasoning")]), assistant(vec![reasoning( "This is my reasoning with trailing space ", Some("test-signature"), )]), - ]); + ]) + .unwrap(); assert_eq!( messages[1]["content"][0]["reasoningContent"]["reasoningText"]["text"], json!("This is my reasoning with trailing space ") @@ -434,12 +465,13 @@ fn assistant_no_trim_reasoning_with_signature() { #[test] fn assistant_omit_reasoning_without_signature() { let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("Explain your reasoning")]), + user(vec![user_text("Explain your reasoning")]), assistant(vec![ reasoning("This is my reasoning with trailing space ", None), - ContentPart::text("final answer"), + assistant_text("final answer"), ]), - ]); + ]) + .unwrap(); assert_eq!(messages[1]["content"], json!([{ "text": "final answer" }])); } @@ -447,13 +479,14 @@ fn assistant_omit_reasoning_without_signature() { #[test] fn assistant_omit_multiple_reasoning_without_signatures() { let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("Explain your reasoning")]), + user(vec![user_text("Explain your reasoning")]), assistant(vec![ reasoning("First reasoning with trailing space ", None), reasoning("Second reasoning with trailing space ", None), - ContentPart::text("final answer"), + assistant_text("final answer"), ]), - ]); + ]) + .unwrap(); assert_eq!(messages[1]["content"], json!([{ "text": "final answer" }])); } @@ -461,16 +494,20 @@ fn assistant_omit_multiple_reasoning_without_signatures() { #[test] fn assistant_omit_unsigned_reasoning_preserving_tool_calls() { let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("What is the weather?")]), + user(vec![user_text("What is the weather?")]), assistant(vec![ reasoning("I should call the weather tool.", None), tool_call("call-1", "getWeather", json!({ "city": "SF" })), ]), tool_msg(vec![tool_result( "call-1", - json!({ "type": "text", "value": "Sunny, 72F" }), + ToolResultOutput::Text { + value: "Sunny, 72F".to_string(), + provider_options: None, + }, )]), - ]); + ]) + .unwrap(); assert_eq!( Value::Array(messages), json!([ @@ -489,20 +526,24 @@ fn assistant_omit_unsigned_reasoning_preserving_tool_calls() { #[test] fn assistant_preserve_reasoning_multi_turn() { let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("What is the weather?")]), + user(vec![user_text("What is the weather?")]), assistant(vec![ reasoning("Let me check the weather API.\n", Some("sig-abc123")), tool_call("call-1", "getWeather", json!({ "city": "SF" })), ]), tool_msg(vec![tool_result( "call-1", - json!({ "type": "text", "value": "Sunny, 72F" }), + ToolResultOutput::Text { + value: "Sunny, 72F".to_string(), + provider_options: None, + }, )]), assistant(vec![ reasoning("The weather is sunny and warm.\n", Some("sig-def456")), - ContentPart::text("It is sunny and 72F in SF."), + assistant_text("It is sunny and 72F in SF."), ]), - ]); + ]) + .unwrap(); assert_eq!( Value::Array(messages), json!([ @@ -530,15 +571,16 @@ fn assistant_preserve_reasoning_multi_turn() { #[test] fn assistant_mix_text_and_reasoning() { let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("Explain your reasoning")]), + user(vec![user_text("Explain your reasoning")]), assistant(vec![ - ContentPart::text("My answer is 42."), + assistant_text("My answer is 42."), reasoning( "I calculated this by analyzing the meaning of life", Some("reasoning-process"), ), ]), - ]); + ]) + .unwrap(); assert_eq!( messages[1]["content"], json!([ @@ -555,14 +597,15 @@ fn assistant_mix_text_and_reasoning() { #[test] fn assistant_filter_empty_text_blocks() { let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("Hello")]), + user(vec![user_text("Hello")]), assistant(vec![ - ContentPart::text("\n\n"), + assistant_text("\n\n"), tool_call("call-123", "test", json!({})), - ContentPart::text(" "), - ContentPart::text("actual content"), + assistant_text(" "), + assistant_text("actual content"), ]), - ]); + ]) + .unwrap(); assert_eq!( messages[1]["content"], json!([ @@ -579,7 +622,8 @@ fn assistant_wrap_non_object_tool_input() { "call-1", "cityAttractions", Value::String("{ \"city\": \"San Francisco\", }".to_string()), - )])]); + )])]) + .unwrap(); assert_eq!( messages[0]["content"], json!([{ @@ -603,7 +647,8 @@ fn assistant_strip_invalid_tool_name_chars() { json!({}), ), tool_call("call-3", "$", json!({})), - ])]); + ])]) + .unwrap(); assert_eq!( messages[0]["content"], json!([ @@ -618,15 +663,16 @@ fn assistant_strip_invalid_tool_name_chars() { #[test] fn assistant_preserve_empty_text_with_reasoning() { let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("Hello")]), + user(vec![user_text("Hello")]), assistant(vec![ reasoning("thinking...", Some("sig-1")), - ContentPart::text(""), + assistant_text(""), reasoning("more thinking...", Some("sig-2")), - ContentPart::text("response text"), + assistant_text("response text"), tool_call("call-123", "test", json!({})), ]), - ]); + ]) + .unwrap(); assert_eq!( messages[1]["content"], json!([ @@ -646,8 +692,14 @@ fn assistant_preserve_empty_text_with_reasoning() { fn tool_result_content_text() { let (_, messages) = convert_prompt_to_bedrock(&vec![tool_msg(vec![tool_result( "call-123", - json!({ "type": "content", "value": [{ "type": "text", "text": "The result is 42" }] }), - )])]); + ToolResultOutput::Content { + value: vec![ToolResultContent::Text(TextPart { + text: "The result is 42".to_string(), + provider_options: None, + })], + }, + )])]) + .unwrap(); assert_eq!( messages[0], json!({ @@ -667,12 +719,18 @@ fn tool_result_content_text() { fn tool_result_content_image() { let (_, messages) = convert_prompt_to_bedrock(&vec![tool_msg(vec![tool_result( "call-123", - json!({ "type": "content", "value": [{ - "type": "file", - "data": { "type": "data", "data": "base64data" }, - "mediaType": "image/jpeg" - }] }), - )])]); + ToolResultOutput::Content { + value: vec![ToolResultContent::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64("base64data".to_string()), + }, + media_type: "image/jpeg".to_string(), + filename: None, + provider_options: None, + })], + }, + )])]) + .unwrap(); assert_eq!( messages[0]["content"][0], json!({ @@ -692,13 +750,18 @@ fn tool_result_content_image() { fn tool_result_content_pdf() { let (_, messages) = convert_prompt_to_bedrock(&vec![tool_msg(vec![tool_result( "call-123", - json!({ "type": "content", "value": [{ - "type": "file", - "data": { "type": "data", "data": "base64data" }, - "mediaType": "application/pdf", - "filename": "tool-result.pdf" - }] }), - )])]); + ToolResultOutput::Content { + value: vec![ToolResultContent::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64("base64data".to_string()), + }, + media_type: "application/pdf".to_string(), + filename: Some("tool-result.pdf".to_string()), + provider_options: None, + })], + }, + )])]) + .unwrap(); assert_eq!( messages[0]["content"][0], json!({ @@ -712,18 +775,21 @@ fn tool_result_content_pdf() { ); } -// SKIPPED (TS: "should throw error for unsupported image format in tool result +// NOT PORTED (TS: "should throw error for unsupported image format in tool result // content" and "should throw error for unsupported mime type in tool result -// file content"): convert_prompt_to_bedrock returns no Result; unknown mimes -// fall back to a default format instead of throwing. +// file content"). /// TS: "should fallback to stringified result when content is undefined" (json output) #[test] fn tool_result_json_output() { let (_, messages) = convert_prompt_to_bedrock(&vec![tool_msg(vec![tool_result( "call-123", - json!({ "type": "json", "value": { "value": 42 } }), - )])]); + ToolResultOutput::Json { + value: json!({ "value": 42 }), + provider_options: None, + }, + )])]) + .unwrap(); assert_eq!( messages[0]["content"][0], json!({ @@ -744,8 +810,9 @@ fn citations_enabled_for_pdf() { "AAECAw==", "application/pdf", None, - Some(json!({ "bedrock": { "citations": { "enabled": true } } })), - )])]); + Some(provider_namespace("bedrock", json!({ "citations": { "enabled": true } })).unwrap()), + )])]) + .unwrap(); assert_eq!( messages[0]["content"][0], json!({ @@ -766,8 +833,9 @@ fn citations_disabled_for_pdf() { "AAECAw==", "application/pdf", None, - Some(json!({ "bedrock": { "citations": { "enabled": false } } })), - )])]); + Some(provider_namespace("bedrock", json!({ "citations": { "enabled": false } })).unwrap()), + )])]) + .unwrap(); assert_eq!( messages[0]["content"][0], json!({ @@ -789,7 +857,8 @@ fn citations_default_for_pdf() { "application/pdf", None, None, - )])]); + )])]) + .unwrap(); assert_eq!( messages[0]["content"][0], json!({ @@ -811,15 +880,21 @@ fn citations_multiple_pdfs() { "AAECAw==", "application/pdf", None, - Some(json!({ "bedrock": { "citations": { "enabled": true } } })), + Some( + provider_namespace("bedrock", json!({ "citations": { "enabled": true } })).unwrap(), + ), ), file_base64( "BAUGBw==", "application/pdf", None, - Some(json!({ "bedrock": { "citations": { "enabled": false } } })), + Some( + provider_namespace("bedrock", json!({ "citations": { "enabled": false } })) + .unwrap(), + ), ), - ])]); + ])]) + .unwrap(); assert_eq!( messages[0]["content"], json!([ @@ -842,7 +917,8 @@ fn file_format_xlsx() { "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", None, None, - )])]); + )])]) + .unwrap(); assert!(system.is_empty()); assert_eq!( messages[0]["content"][0], @@ -858,7 +934,8 @@ fn file_format_docx() { "application/vnd.openxmlformats-officedocument.wordprocessingml.document", None, None, - )])]); + )])]) + .unwrap(); assert_eq!( messages[0]["content"][0], json!({ "document": { "format": "docx", "name": "document-1", "source": { "bytes": "base64data" } } }) @@ -877,8 +954,12 @@ fn mistral_no_normalize_when_false() { let original_id = "tooluse_bpe71yCfRu2b5i-nKGDr5g"; let (_, messages) = convert_prompt_to_bedrock(&vec![tool_msg(vec![tool_result( original_id, - json!({ "type": "text", "value": "The result is 42" }), - )])]); + ToolResultOutput::Text { + value: "The result is 42".to_string(), + provider_options: None, + }, + )])]) + .unwrap(); assert_eq!( messages[0]["content"][0]["toolResult"]["toolUseId"], json!(original_id) @@ -891,8 +972,12 @@ fn mistral_default_no_normalize() { let original_id = "tooluse_bpe71yCfRu2b5i-nKGDr5g"; let (_, messages) = convert_prompt_to_bedrock(&vec![tool_msg(vec![tool_result( original_id, - json!({ "type": "text", "value": "The result is 42" }), - )])]); + ToolResultOutput::Text { + value: "The result is 42".to_string(), + provider_options: None, + }, + )])]) + .unwrap(); assert_eq!( messages[0]["content"][0]["toolResult"]["toolUseId"], json!(original_id) @@ -909,7 +994,8 @@ fn media_type_pass_through_full_image() { "image/png", None, None, - )])]); + )])]) + .unwrap(); assert_eq!( Value::Array(messages), json!([{ @@ -931,7 +1017,8 @@ fn media_type_route_to_document_text_plain() { "text/plain", None, None, - )])]); + )])]) + .unwrap(); assert_eq!( messages[0]["content"][0], json!({ "document": { "format": "txt", "name": "document-1", "source": { "bytes": "base64data" } } }) diff --git a/aimux-providers/tests/bedrock_embedding_test.rs b/aimux-providers/tests/bedrock_embedding_test.rs index 3ccce5bd9..ba1a1f5bb 100644 --- a/aimux-providers/tests/bedrock_embedding_test.rs +++ b/aimux-providers/tests/bedrock_embedding_test.rs @@ -6,8 +6,6 @@ //! Uses Bearer token auth to avoid SigV4 signing complexity in tests (the TS //! tests use a `fakeFetchWithAuth` that injects auth headers). -use std::collections::HashMap; - use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -253,43 +251,6 @@ async fn should_support_cross_region_cohere() { assert_eq!(result.usage.unwrap().tokens, 6); } -/// TS: "should pass outputDimension for Cohere v4 embedding models" -#[tokio::test] -async fn should_pass_output_dimension_cohere_v4() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/model/cohere.embed-v4:0/invoke")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("x-amzn-bedrock-input-token-count", "6") - .set_body_json(json!({ - "embeddings": { "float": [mock_embeddings()[0]] } - })), - ) - .mount(&server) - .await; - - let provider = test_provider(server.uri()); - let model = provider.embedding_model("cohere.embed-v4:0"); - - let mut provider_options = HashMap::new(); - provider_options.insert("bedrock".to_string(), json!({"outputDimension": 256})); - let options = EmbeddingCallOptions { - values: vec![TEST_VALUES[0].to_string()], - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["output_dimension"], 256); -} - /// TS: "should send SINGLE_EMBEDDING payload for Nova embeddings" #[tokio::test] async fn should_support_nova_embeddings() { @@ -334,41 +295,3 @@ async fn should_support_nova_embeddings() { TEST_VALUES[0] ); } - -/// TS: "should pass embeddingDimension for Nova embeddings" -#[tokio::test] -async fn should_pass_nova_embedding_dimension() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path( - "/model/amazon.nova-2-multimodal-embeddings-v1:0/invoke", - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "embeddings": [ - { "embeddingType": "TEXT", "embedding": mock_embeddings()[0] } - ], - "inputTokenCount": 8 - }))) - .mount(&server) - .await; - - let provider = test_provider(server.uri()); - let model = provider.embedding_model("amazon.nova-2-multimodal-embeddings-v1:0"); - - let mut provider_options = HashMap::new(); - provider_options.insert("bedrock".to_string(), json!({"embeddingDimension": 256})); - let options = EmbeddingCallOptions { - values: vec![TEST_VALUES[0].to_string()], - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["singleEmbeddingParams"]["embeddingDimension"], 256); -} diff --git a/aimux-providers/tests/bedrock_stream_replay_test.rs b/aimux-providers/tests/bedrock_stream_replay_test.rs index 880761d37..5fe4eafa6 100644 --- a/aimux-providers/tests/bedrock_stream_replay_test.rs +++ b/aimux-providers/tests/bedrock_stream_replay_test.rs @@ -24,10 +24,8 @@ use common::replay::mount_cassettes; use futures::StreamExt; use wiremock::MockServer; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_core::stream_part::StreamPart; use aimux_providers::{BedrockProvider, BedrockProviderConfig}; @@ -38,11 +36,9 @@ use aimux_providers::{BedrockProvider, BedrockProviderConfig}; const MODEL_ID: &str = "us.anthropic.claude-sonnet-4-5-20250929-v1:0"; fn prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is the capital of France?")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text( + "What is the capital of France?", + )] } /// Decoded frames must reach the parser and come out as text plus a finish. diff --git a/aimux-providers/tests/cartesia_speech_test.rs b/aimux-providers/tests/cartesia_speech_test.rs index 18b5ae6e3..441d8ca0c 100644 --- a/aimux-providers/tests/cartesia_speech_test.rs +++ b/aimux-providers/tests/cartesia_speech_test.rs @@ -292,12 +292,13 @@ async fn should_pass_provider_specific_options() { let mut provider_options = HashMap::new(); provider_options.insert( "cartesia".to_string(), - json!({ + serde_json::from_value(json!({ "container": "raw", "encoding": "pcm_s16le", "sampleRate": 16000, "speed": 0.8, - }), + })) + .unwrap(), ); options.provider_options = Some(provider_options); @@ -330,7 +331,10 @@ async fn should_ignore_encoding_for_mp3_output() { options.voice = Some("test-voice-id".to_string()); let mut provider_options = HashMap::new(); - provider_options.insert("cartesia".to_string(), json!({ "encoding": "pcm_s16le" })); + provider_options.insert( + "cartesia".to_string(), + serde_json::from_value(json!({ "encoding": "pcm_s16le" })).unwrap(), + ); options.provider_options = Some(provider_options); let result = model.do_generate(&options).await.unwrap(); diff --git a/aimux-providers/tests/cartesia_transcription_test.rs b/aimux-providers/tests/cartesia_transcription_test.rs index 18440f426..e13cf79a1 100644 --- a/aimux-providers/tests/cartesia_transcription_test.rs +++ b/aimux-providers/tests/cartesia_transcription_test.rs @@ -207,56 +207,3 @@ async fn should_use_real_date() { assert!(result.response.timestamp.is_some()); assert_eq!(result.response.model_id, Some("best".to_string())); } - -#[tokio::test] -async fn should_warn_about_streaming_options() { - let server = MockServer::start().await; - mock_response(&server, &fixture_response(), &[]).await; - - let config = CartesiaConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CartesiaProvider::new(config); - let model = provider.transcription("best"); - - let mut opts = options(mock_audio(), "audio/wav"); - let mut po = HashMap::new(); - po.insert( - "cartesia".to_string(), - json!({"streaming": {"delay": "low"}}), - ); - opts.provider_options = Some(po); - - let result = model.do_generate(&opts).await.unwrap(); - - assert_eq!(result.warnings.len(), 1); - match &result.warnings[0] { - aimux_core::types::Warning::Unsupported { feature, .. } => { - assert_eq!(feature, "providerOptions.cartesia.streaming"); - } - w => panic!("expected Unsupported warning, got: {w:?}"), - } -} - -#[tokio::test] -async fn should_pass_language_and_timestamp_granularities() { - let server = MockServer::start().await; - mock_response(&server, &fixture_response(), &[]).await; - - let config = CartesiaConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CartesiaProvider::new(config); - let model = provider.transcription("best"); - - let mut opts = options(mock_audio(), "audio/wav"); - let mut po = HashMap::new(); - po.insert( - "cartesia".to_string(), - json!({"language": "en", "timestampGranularities": ["word"]}), - ); - opts.provider_options = Some(po); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let form = parse_multipart(&requests[0].body); - assert_eq!(form.get("language").unwrap(), "en"); - assert_eq!(form.get("timestamp_granularities[]").unwrap(), "word"); -} diff --git a/aimux-providers/tests/cerebras_test.rs b/aimux-providers/tests/cerebras_test.rs index 77188c825..b58d5ad16 100644 --- a/aimux-providers/tests/cerebras_test.rs +++ b/aimux-providers/tests/cerebras_test.rs @@ -25,20 +25,14 @@ use serial_test::serial; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_providers::{ProviderOptions, provider, provider_from_env}; fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { diff --git a/aimux-providers/tests/codex_test.rs b/aimux-providers/tests/codex_test.rs index cd957ac24..481f92096 100644 --- a/aimux-providers/tests/codex_test.rs +++ b/aimux-providers/tests/codex_test.rs @@ -13,11 +13,9 @@ use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_core::result::{GenerateContent, StreamResult}; use aimux_core::stream_part::StreamPart; @@ -27,11 +25,7 @@ use aimux_providers::{CODEX_OAUTH_TOKEN_URL, CodexConfig, CodexProvider, codex_r // helpers fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { diff --git a/aimux-providers/tests/cohere_embedding_test.rs b/aimux-providers/tests/cohere_embedding_test.rs index 67427c7c2..393bdc5c5 100644 --- a/aimux-providers/tests/cohere_embedding_test.rs +++ b/aimux-providers/tests/cohere_embedding_test.rs @@ -169,74 +169,6 @@ async fn should_pass_model_and_values() { ); } -/// TS: "should pass the input_type setting" -#[tokio::test] -async fn should_pass_input_type_setting() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/embed")) - .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) - .mount(&server) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.embedding_model("embed-english-v3.0"); - - let mut provider_options = HashMap::new(); - provider_options.insert( - "cohere".to_string(), - json!({"inputType": "search_document"}), - ); - let options = EmbeddingCallOptions { - values: test_values(), - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["input_type"], "search_document"); -} - -/// TS: "should pass the output_dimension setting" -#[tokio::test] -async fn should_pass_output_dimension_setting() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/embed")) - .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) - .mount(&server) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.embedding_model("embed-v4.0"); - - let mut provider_options = HashMap::new(); - provider_options.insert("cohere".to_string(), json!({"outputDimension": 256})); - let options = EmbeddingCallOptions { - values: test_values(), - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["output_dimension"], 256); - assert_eq!(body["model"], "embed-v4.0"); -} - /// TS: "should pass headers" #[tokio::test] async fn should_pass_headers() { diff --git a/aimux-providers/tests/conformance_test.rs b/aimux-providers/tests/conformance_test.rs index 87388af58..5fe52f920 100644 --- a/aimux-providers/tests/conformance_test.rs +++ b/aimux-providers/tests/conformance_test.rs @@ -15,10 +15,8 @@ use common::replay::mount_cassettes; use futures::StreamExt; use wiremock::MockServer; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; @@ -35,11 +33,7 @@ use aimux_providers::{ // 閳光偓閳光偓 helpers 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓 fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { @@ -73,7 +67,7 @@ fn has_tool_call(content: &[GenerateContent]) -> bool { fn has_reasoning(content: &[GenerateContent]) -> bool { content .iter() - .any(|c| matches!(c, GenerateContent::Reasoning { .. })) + .any(|c| matches!(c, GenerateContent::Reasoning(_))) } #[allow(dead_code)] diff --git a/aimux-providers/tests/data_loss_regression_test.rs b/aimux-providers/tests/data_loss_regression_test.rs index 458c2a4d4..c3bb88c49 100644 --- a/aimux-providers/tests/data_loss_regression_test.rs +++ b/aimux-providers/tests/data_loss_regression_test.rs @@ -29,18 +29,16 @@ use std::path::PathBuf; use futures::StreamExt; use serde_json::{Value, json}; -use wiremock::matchers::{method, path as path_matcher, query_param}; +use wiremock::matchers::{method, path as path_matcher}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, StreamResult}; -use aimux_core::shared::{FileBytes, FileData}; +use aimux_core::result::{GenerateContent, ReasoningOutput, Source, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::{ProviderTool, Tool}; +use aimux_core::types::ProviderMetadata; use aimux_providers::anthropic::AnthropicConfig; use aimux_providers::anthropic::model::AnthropicModel; @@ -120,11 +118,7 @@ fn cassette_json(rel: &str) -> Value { // ═══════════════════════════════════════════════════════════════════════════ fn user_prompt(text: &str) -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text(text)], - ..Default::default() - }] + vec![LanguageModelMessage::user_text(text)] } fn opts() -> CallOptions { @@ -155,26 +149,6 @@ async fn collect(result: StreamResult) -> Vec { parts } -/// The base64 payload of a `GenerateContent::File`, or panic. -fn file_base64(c: &GenerateContent) -> &str { - match c { - GenerateContent::File { - data: FileData::Data { - data: FileBytes::Base64(s), - }, - .. - } => s, - other => panic!("expected File with base64 data, got {other:?}"), - } -} - -fn files(content: &[GenerateContent]) -> Vec<&GenerateContent> { - content - .iter() - .filter(|c| matches!(c, GenerateContent::File { .. })) - .collect() -} - fn texts(content: &[GenerateContent]) -> Vec<&str> { content .iter() @@ -185,14 +159,14 @@ fn texts(content: &[GenerateContent]) -> Vec<&str> { .collect() } -fn reasonings(content: &[GenerateContent]) -> Vec<(&str, Option<&Value>)> { +fn reasonings(content: &[GenerateContent]) -> Vec<(&str, Option<&ProviderMetadata>)> { content .iter() .filter_map(|c| match c { - GenerateContent::Reasoning { + GenerateContent::Reasoning(ReasoningOutput { text, provider_metadata, - } => Some((text.as_str(), provider_metadata.as_ref())), + }) => Some((text.as_str(), provider_metadata.as_ref())), _ => None, }) .collect() @@ -205,7 +179,7 @@ type ToolCallView<'a> = ( Value, Option, Option, - Option<&'a Value>, + Option<&'a ProviderMetadata>, ); fn tool_calls(content: &[GenerateContent]) -> Vec> { @@ -246,7 +220,7 @@ type ToolResultView<'a> = ( &'a Value, Option, Option, - Option<&'a Value>, + Option<&'a ProviderMetadata>, ); fn tool_results(content: &[GenerateContent]) -> Vec> { @@ -280,26 +254,37 @@ type SourceView<'a> = ( &'a str, Option<&'a str>, Option<&'a str>, - Option<&'a Value>, + Option<&'a ProviderMetadata>, ); fn sources(content: &[GenerateContent]) -> Vec> { content .iter() .filter_map(|c| match c { - GenerateContent::Source { + GenerateContent::Source(Source::Url { id, - source_type, url, title, provider_metadata, - } => Some(( + }) => Some(( id.as_str(), - source_type.as_str(), - url.as_deref(), + "url", + Some(url.as_str()), title.as_deref(), provider_metadata.as_ref(), )), + GenerateContent::Source(Source::Document { + id, + title, + provider_metadata, + .. + }) => Some(( + id.as_str(), + "document", + None, + Some(title.as_str()), + provider_metadata.as_ref(), + )), _ => None, }) .collect() @@ -312,148 +297,10 @@ fn sources(content: &[GenerateContent]) -> Vec> { // gemini/test_google_image_and_text_output.json // ═══════════════════════════════════════════════════════════════════════════ -/// PNG magic bytes, base64-encoded: every image in the two Gemini image -/// cassettes starts with this. Asserting the prefix (rather than "non-empty") -/// proves the *actual* bytes survived rather than some placeholder. -const PNG_BASE64_PREFIX: &str = "iVBORw0KGgoAAAANSUhEUgAA"; - -/// The exact base64 length recorded in `nano_banana_image_generation_smoke`. -const NANO_BANANA_BASE64_LEN: usize = 258_820; - fn google_at(uri: &str) -> GoogleProvider { GoogleProvider::new(GoogleConfig::new("test-api-key").with_base_url(format!("{uri}/v1beta"))) } -#[tokio::test] -async fn finding_1_gemini_inline_data_surfaces_as_file() { - let c = cassette("gemini/nano_banana_image_generation_smoke.json"); - let server = MockServer::start().await; - mount(&server, &c).await; - - let result = google_at(&server.uri()) - .model("gemini-2.5-flash-image") - .do_generate(&opts()) - .await - .expect("do_generate should succeed"); - - let f = files(&result.content); - assert_eq!(f.len(), 1, "exactly one inlineData part → one File"); - match f[0] { - GenerateContent::File { media_type, .. } => { - assert_eq!(media_type, "image/png"); - } - other => panic!("expected File, got {other:?}"), - } - let b64 = file_base64(f[0]); - assert!( - b64.starts_with(PNG_BASE64_PREFIX), - "image bytes must be the recorded PNG, got prefix {:?}", - &b64[..b64.len().min(32)] - ); - assert_eq!( - b64.len(), - NANO_BANANA_BASE64_LEN, - "the whole base64 payload must survive, not a truncated head" - ); -} - -#[tokio::test] -async fn finding_1_gemini_image_and_text_output_both_survive() { - let c = cassette("gemini/test_google_image_and_text_output.json"); - let server = MockServer::start().await; - mount(&server, &c).await; - - let result = google_at(&server.uri()) - .model("gemini-2.5-flash-image") - .do_generate(&opts()) - .await - .expect("do_generate should succeed"); - - // Text part first, then the inlineData part — order matters, upstream - // walks `parts` in order. - assert_eq!(result.content.len(), 2, "one text + one file"); - assert!( - matches!(result.content[0], GenerateContent::Text { .. }), - "text part comes first" - ); - assert!( - matches!(result.content[1], GenerateContent::File { .. }), - "inlineData part comes second" - ); - - let t = texts(&result.content); - assert_eq!(t.len(), 1); - assert!( - t[0].starts_with("Once, in a hidden cenote, lived an axolotl named Pip"), - "recorded story text must survive verbatim, got {:?}", - &t[0][..t[0].len().min(60)] - ); - - let f = files(&result.content); - assert_eq!(f.len(), 1); - match f[0] { - GenerateContent::File { media_type, .. } => assert_eq!(media_type, "image/png"), - other => panic!("expected File, got {other:?}"), - } - let b64 = file_base64(f[0]); - assert!(b64.starts_with(PNG_BASE64_PREFIX)); - assert_eq!(b64.len(), 2_580_504); -} - -/// The streaming path had the same hole. The chunk is built from the cassette's -/// own `parts` array so the bytes are the recorded ones. -#[tokio::test] -async fn finding_1_gemini_inline_data_streams_as_file_part() { - let body = cassette_json("gemini/nano_banana_image_generation_smoke.json"); - let candidate = &body["candidates"][0]; - let sse = format!( - "data: {}\n\n", - json!({ "candidates": [{ "content": candidate["content"].clone(), "finishReason": "STOP" }] }) - ); - - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path_matcher( - "/v1beta/models/gemini-2.5-flash-image:streamGenerateContent", - )) - .and(query_param("alt", "sse")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse), - ) - .mount(&server) - .await; - - let parts = collect( - google_at(&server.uri()) - .model("gemini-2.5-flash-image") - .do_stream(&opts()) - .await - .expect("do_stream should succeed"), - ) - .await; - - let file_parts: Vec<_> = parts - .iter() - .filter_map(|p| match p { - StreamPart::File { - data: - FileData::Data { - data: FileBytes::Base64(b64), - }, - media_type, - .. - } => Some((b64.as_str(), media_type.as_str())), - _ => None, - }) - .collect(); - assert_eq!(file_parts.len(), 1, "expected one StreamPart::File"); - assert_eq!(file_parts[0].1, "image/png"); - assert!(file_parts[0].0.starts_with(PNG_BASE64_PREFIX)); - assert_eq!(file_parts[0].0.len(), NANO_BANANA_BASE64_LEN); -} - // ═══════════════════════════════════════════════════════════════════════════ // Finding 3 — Gemini `part.thought` never read (thinking merged into the answer) // Finding 5 — Gemini `thoughtSignature` dropped @@ -775,7 +622,7 @@ async fn finding_2_anthropic_web_search_result_mapped_and_sources_emitted() { // assertion agree with whatever the code produced, including a renamed key. // (`get("pageAge").is_some()` is no good either — a missing key and a // recorded `null` both read back as `Some(Value::Null)`.) - let by_url: BTreeMap<&str, &Value> = s + let by_url: BTreeMap<&str, &ProviderMetadata> = s .iter() .filter_map(|(_, _, url, _, m)| Some(((*url)?, (*m)?))) .collect(); @@ -791,7 +638,13 @@ async fn finding_2_anthropic_web_search_result_mapped_and_sources_emitted() { ] { assert_eq!( by_url.get(url).copied(), - Some(&json!({ "anthropic": { "pageAge": expected_page_age } })), + Some( + &aimux_core::shared::provider_namespace( + "anthropic", + json!({ "pageAge": expected_page_age }) + ) + .unwrap() + ), "source {url}: providerMetadata must hold exactly anthropic.pageAge" ); } @@ -1034,7 +887,7 @@ async fn finding_2_anthropic_mcp_tool_use_and_result_are_dynamic_and_named() { assert_eq!(provider_executed, Some(true)); assert_eq!(dynamic, Some(true)); assert_eq!( - meta.expect("mcp_tool_use metadata")["anthropic"], + serde_json::to_value(&meta.expect("mcp_tool_use metadata")["anthropic"]).unwrap(), json!({ "type": "mcp-tool-use", "serverName": "deepwiki" }) ); @@ -1050,7 +903,7 @@ async fn finding_2_anthropic_mcp_tool_use_and_result_are_dynamic_and_named() { assert_eq!(is_error, Some(false), "`is_error: false` must be preserved"); assert_eq!(rdynamic, Some(true)); assert_eq!( - rmeta.expect("mcp_tool_result metadata")["anthropic"], + serde_json::to_value(&rmeta.expect("mcp_tool_result metadata")["anthropic"]).unwrap(), json!({ "type": "mcp-tool-use", "serverName": "deepwiki" }) ); assert!( @@ -1183,10 +1036,7 @@ async fn finding_13_mistral_thinking_parts_become_reasoning_in_generate() { assert_eq!(texts(&result.content), vec!["4"]); // Ordering: reasoning precedes text (upstream contract). - assert!(matches!( - result.content[0], - GenerateContent::Reasoning { .. } - )); + assert!(matches!(result.content[0], GenerateContent::Reasoning(_))); assert!(matches!(result.content[1], GenerateContent::Text { .. })); } diff --git a/aimux-providers/tests/deepgram_transcription_test.rs b/aimux-providers/tests/deepgram_transcription_test.rs index 099948aac..4d09cb343 100644 --- a/aimux-providers/tests/deepgram_transcription_test.rs +++ b/aimux-providers/tests/deepgram_transcription_test.rs @@ -140,45 +140,6 @@ async fn should_extract_text() { assert_eq!(result.text, "galileo was an american robotic space program"); } -#[tokio::test] -async fn should_pass_detect_language_query_param() { - let server = MockServer::start().await; - mock_response(&server, &fixture_response()).await; - - let config = DeepgramConfig::new("test-api-key").with_base_url(server.uri()); - let provider = DeepgramProvider::new(config); - let model = provider.transcription("nova-3"); - - let mut opts = options(mock_audio(), "audio/wav"); - let mut po = HashMap::new(); - po.insert("deepgram".to_string(), json!({"detectLanguage": true})); - opts.provider_options = Some(po); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - assert!(requests[0].url.as_str().contains("detect_language=true")); -} - -#[tokio::test] -async fn should_return_detected_language() { - let server = MockServer::start().await; - mock_response(&server, &fixture_response()).await; - - let config = DeepgramConfig::new("test-api-key").with_base_url(server.uri()); - let provider = DeepgramProvider::new(config); - let model = provider.transcription("nova-3"); - - let mut opts = options(mock_audio(), "audio/wav"); - let mut po = HashMap::new(); - po.insert("deepgram".to_string(), json!({"detectLanguage": true})); - opts.provider_options = Some(po); - - let result = model.do_generate(&opts).await.unwrap(); - - assert_eq!(result.language, Some("en".to_string())); -} - #[tokio::test] async fn should_include_response_data() { let server = MockServer::start().await; @@ -226,37 +187,6 @@ async fn should_use_real_date() { assert_eq!(result.response.model_id, Some("nova-3".to_string())); } -#[tokio::test] -async fn should_return_language_from_inline_response() { - let server = MockServer::start().await; - mock_response( - &server, - &json!({ - "metadata": {"duration": 1.0}, - "results": { - "channels": [{ - "detected_language": "sv", - "alternatives": [{"transcript": "hej", "words": []}] - }] - } - }), - ) - .await; - - let config = DeepgramConfig::new("test-api-key").with_base_url(server.uri()); - let provider = DeepgramProvider::new(config); - let model = provider.transcription("nova-3"); - - let mut opts = options(mock_audio(), "audio/wav"); - let mut po = HashMap::new(); - po.insert("deepgram".to_string(), json!({"detectLanguage": true})); - opts.provider_options = Some(po); - - let result = model.do_generate(&opts).await.unwrap(); - - assert_eq!(result.language, Some("sv".to_string())); -} - #[tokio::test] async fn should_return_none_language_when_not_detected() { let server = MockServer::start().await; diff --git a/aimux-providers/tests/deepinfra_test.rs b/aimux-providers/tests/deepinfra_test.rs index abdbce70c..a1d6e3799 100644 --- a/aimux-providers/tests/deepinfra_test.rs +++ b/aimux-providers/tests/deepinfra_test.rs @@ -24,20 +24,14 @@ use serial_test::serial; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_providers::{ProviderOptions, provider, provider_from_env}; fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { diff --git a/aimux-providers/tests/deepseek_chat_test.rs b/aimux-providers/tests/deepseek_chat_test.rs index 79c399d9c..0ff6132c9 100644 --- a/aimux-providers/tests/deepseek_chat_test.rs +++ b/aimux-providers/tests/deepseek_chat_test.rs @@ -26,20 +26,18 @@ //! overridden with the mock server's root URI (no `/v1` suffix), so the //! resulting request path is `/chat/completions`. -use aimux_core::tool::RawToolCall; -use std::collections::HashMap; - use futures::StreamExt; use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, ResponseFormat, Tool}; +use aimux_core::language_model_message::{ + FilePart, LanguageModelMessage, LanguageModelPrompt, TextPart, UserPart, +}; +use aimux_core::options::{CallOptions, Tool}; use aimux_core::result::GenerateContent; +use aimux_core::shared::{FileBytes, FileData}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; use aimux_core::types::FinishReasonUnified; @@ -54,11 +52,7 @@ use aimux_providers::{ProviderOptions, provider}; /// The TS `TEST_PROMPT`: a single user text message "Hello". fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } /// `CallOptions` with only `prompt` set (everything else default/None). @@ -80,13 +74,6 @@ fn make_provider(server: &MockServer, model_id: &str) -> Box .expect("deepseek provider should build") } -/// Wrap a value as `providerOptions.deepseek.`. -fn deepseek_opts(value: Value) -> Option> { - let mut m = HashMap::new(); - m.insert("deepseek".to_string(), value); - Some(m) -} - /// Mount a JSON chat-completion response on `/chat/completions`. async fn mock_json(server: &MockServer, body: Value) { Mock::given(method("POST")) @@ -148,24 +135,6 @@ fn text_deltas(parts: &[StreamPart]) -> Vec { .collect() } -/// A weather function tool matching the TS test's `inputSchema`. -fn weather_tool() -> FunctionTool { - FunctionTool { - name: "weather".to_string(), - description: None, - input_schema: json!({ - "type": "object", - "properties": { "location": { "type": "string" } }, - "required": ["location"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }), - strict: None, - provider_options: None, - input_examples: None, - } -} - // ───────────────────────────────────────────────────────────────────────────── // Local fixtures // ───────────────────────────────────────────────────────────────────────────── @@ -197,114 +166,12 @@ fn text_completion_body() -> Value { }) } -/// A non-streaming tool-call completion body. Abbreviated from the -/// `deepseek-tool-call` fixture (reasoning_content omitted — reasoning is tested -/// in `deepseek_reasoning_test.rs`). -fn tool_call_completion_body() -> Value { - json!({ - "id": "7a630f5b-b7e6-4878-82f8-d77db164d42b", - "object": "chat.completion", - "created": 1764665845, - "model": "deepseek-reasoner", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "", - "tool_calls": [{ - "index": 0, - "id": "call_00_9V0vrf86Pc9aelHCJMZqnJBo", - "type": "function", - "function": { - "name": "weather", - "arguments": "{\"location\": \"San Francisco\"}" - } - }] - }, - "logprobs": null, - "finish_reason": "tool_calls" - }], - "usage": { - "prompt_tokens": 339, - "completion_tokens": 92, - "total_tokens": 431 - } - }) -} - -/// A non-streaming JSON completion body. Abbreviated from the `deepseek-json` -/// fixture (reasoning_content omitted). -fn json_completion_body() -> Value { - json!({ - "id": "f03bc170-b375-4561-9685-35182c8152c5", - "object": "chat.completion", - "created": 1764681341, - "model": "deepseek-reasoner", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "{\n \"location\": \"San Francisco\",\n \"condition\": \"cloudy\",\n \"temperature\": 7\n}" - }, - "logprobs": null, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 495, - "completion_tokens": 144, - "total_tokens": 639 - } - }) -} - // ════════════════════════════════════════════════════════════════════════════ // doGenerate — text // // Translated from `deepseek-chat-language-model.test.ts` doGenerate › text. // ════════════════════════════════════════════════════════════════════════════ -/// TS: doGenerate › text › "should send correct request body" (line ~39). -/// -/// Verifies that system + user messages, temperature, and top_p are correctly -/// serialized in the request body. -#[tokio::test] -async fn should_send_correct_text_request_body() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server, "deepseek-chat"); - - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let mut options = default_options(prompt); - options.temperature = Some(0.5); - options.top_p = Some(0.3); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["model"], json!("deepseek-chat")); - assert_eq!( - body["messages"], - json!([ - { "role": "system", "content": "You are a helpful assistant." }, - { "role": "user", "content": "Hello" } - ]) - ); - assert_eq!(body["temperature"], json!(0.5)); - assert_eq!(body["top_p"], json!(0.3)); -} - /// TS: doGenerate › text › "should extract text content" (line ~68). /// /// Verifies that the text content is extracted from the response and the @@ -342,89 +209,6 @@ async fn should_extract_text_content() { // Translated from `deepseek-chat-language-model.test.ts` doGenerate › tool call. // ════════════════════════════════════════════════════════════════════════════ -/// TS: doGenerate › tool call › "should send correct request body" (line ~318). -/// -/// Verifies that tools are correctly serialized in the request body. -/// stage2-001(RFC-0017 阶段 2):DeepSeek 特化已退役——`providerOptions.deepseek` -/// 不再翻译为请求体 `thinking` 字段,用户改用 bodyOverrides 注入(新语义透传)。 -#[tokio::test] -async fn should_send_correct_tool_call_request_body() { - let server = MockServer::start().await; - mock_json(&server, tool_call_completion_body()).await; - - let model = make_provider(&server, "deepseek-reasoner"); - - let mut options = default_options(test_prompt()); - options.tools = Some(vec![weather_tool().into()]); - // 退役后 providerOptions.deepseek.* 被忽略(不再有 apply_deepseek_override)。 - options.provider_options = deepseek_opts(json!({ "thinking": { "type": "enabled" } })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["model"], json!("deepseek-reasoner")); - assert!( - body.get("thinking").is_none(), - "stage2-001: DeepSeek 特化退役,thinking 不再由 providerOptions.deepseek 注入,改用 bodyOverrides" - ); - assert_eq!( - body["tools"], - json!([{ - "type": "function", - "function": { - "name": "weather", - "parameters": { - "type": "object", - "properties": { "location": { "type": "string" } }, - "required": ["location"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - } - }]) - ); - // The messages should contain the single "Hello" user message. - assert_eq!( - body["messages"], - json!([{ "role": "user", "content": "Hello" }]) - ); -} - -/// TS: doGenerate › tool call › "should extract tool call content" (line ~668). -/// -/// Verifies that tool calls are extracted from the response as -/// `GenerateContent::ToolCall` parts with parsed JSON input. -#[tokio::test] -async fn should_extract_tool_call_content() { - let server = MockServer::start().await; - mock_json(&server, tool_call_completion_body()).await; - - let model = make_provider(&server, "deepseek-reasoner"); - - let mut options = default_options(test_prompt()); - options.tools = Some(vec![weather_tool().into()]); - options.provider_options = deepseek_opts(json!({ "thinking": { "type": "enabled" } })); - - let result = model.do_generate(&options).await.expect("should succeed"); - - // The tool-call fixture has empty content and one tool call. - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => { - assert_eq!(tool_call_id, "call_00_9V0vrf86Pc9aelHCJMZqnJBo"); - assert_eq!(tool_name, "weather"); - assert_eq!(input, r#"{"location": "San Francisco"}"#); - } - other => panic!("expected ToolCall, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); -} - // ════════════════════════════════════════════════════════════════════════════ // doGenerate — JSON response format // @@ -436,178 +220,12 @@ async fn should_extract_tool_call_content() { // ("Return JSON." or "Return JSON that conforms to the following schema: ..."). // ════════════════════════════════════════════════════════════════════════════ -/// TS: "should send correct request body without schema" (line ~383). -/// -/// Without a schema, the request body should have -/// `response_format: { type: "json_object" }` and a system message -/// "Return JSON." prepended to the messages. -#[tokio::test] -async fn should_send_json_response_format_without_schema() { - let server = MockServer::start().await; - mock_json(&server, json_completion_body()).await; - - let model = make_provider(&server, "deepseek-reasoner"); - - let mut options = default_options(test_prompt()); - options.response_format = Some(ResponseFormat::Json { - schema: None, - name: None, - description: None, - }); - options.tools = Some(vec![weather_tool().into()]); - options.provider_options = deepseek_opts(json!({ "thinking": { "type": "enabled" } })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["response_format"], json!({ "type": "json_object" })); - // DeepSeek does not inject a "Return JSON." system message. - assert_eq!( - body["messages"][0], - json!({ "role": "user", "content": "Hello" }) - ); -} - -/// TS: "should send correct request body with schema" (line ~451). -/// -/// With a schema but without `supportsStructuredOutputs`, the request body -/// should still use `response_format: { type: "json_object" }` (NOT -/// `json_schema`) and inject a system message containing the schema. -#[tokio::test] -async fn should_send_json_response_format_with_schema() { - let server = MockServer::start().await; - mock_json(&server, json_completion_body()).await; - - let model = make_provider(&server, "deepseek-reasoner"); - - let schema = json!({ - "type": "object", - "properties": { - "elements": { - "type": "array", - "items": { - "type": "object", - "properties": { - "location": { "type": "string" }, - "temperature": { "type": "number" }, - "condition": { "type": "string" } - }, - "required": ["location", "temperature", "condition"], - "additionalProperties": false - } - } - }, - "required": ["elements"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }); - - let mut options = default_options(test_prompt()); - options.response_format = Some(ResponseFormat::Json { - schema: Some(schema.clone()), - name: None, - description: None, - }); - options.tools = Some(vec![weather_tool().into()]); - options.provider_options = deepseek_opts(json!({ "thinking": { "type": "enabled" } })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - // With a schema, deepseek uses json_schema format (via OpenAI shared converter). - assert_eq!(body["response_format"]["type"], "json_schema"); - // DeepSeek does not inject a system message. - assert_eq!( - body["messages"][0], - json!({ "role": "user", "content": "Hello" }) - ); -} - -/// TS: "should extract text content" (json response format, line ~542). -/// -/// Verifies that JSON content is extracted as a text part. -#[tokio::test] -async fn should_extract_json_response_text_content() { - let server = MockServer::start().await; - mock_json(&server, json_completion_body()).await; - - let model = make_provider(&server, "deepseek-reasoner"); - - let mut options = default_options(test_prompt()); - options.response_format = Some(ResponseFormat::Json { - schema: None, - name: None, - description: None, - }); - options.tools = Some(vec![weather_tool().into()]); - options.provider_options = deepseek_opts(json!({ "thinking": { "type": "enabled" } })); - - let result = model.do_generate(&options).await.expect("should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert!( - text.contains("San Francisco"), - "expected JSON content with 'San Francisco', got: {text}" - ), - other => panic!("expected Text, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); -} - // ════════════════════════════════════════════════════════════════════════════ // doStream — text // // Translated from `deepseek-chat-language-model.test.ts` doStream › text. // ════════════════════════════════════════════════════════════════════════════ -/// TS: doStream › text › "should send model id, settings, and input" (line ~715). -/// -/// Verifies that the stream request body includes `stream: true`, -/// `stream_options: { include_usage: true }`, and the correct messages. -#[tokio::test] -async fn should_send_correct_stream_request_body() { - let server = MockServer::start().await; - let body = sse_body(&[&sse_event( - r#"{"id":"x","object":"chat.completion.chunk","created":1,"model":"deepseek-chat","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}"#, - )]); - mock_sse(&server, body).await; - - let model = make_provider(&server, "deepseek-chat"); - - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let mut options = default_options(prompt); - options.temperature = Some(0.5); - options.top_p = Some(0.3); - - let result = model.do_stream(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["model"], json!("deepseek-chat")); - assert_eq!(body["stream"], json!(true)); - assert_eq!(body["stream_options"], json!({ "include_usage": true })); - assert_eq!( - body["messages"], - json!([ - { "role": "system", "content": "You are a helpful assistant." }, - { "role": "user", "content": "Hello" } - ]) - ); - assert_eq!(body["temperature"], json!(0.5)); - assert_eq!(body["top_p"], json!(0.3)); -} - /// TS: doStream › text › "should stream text" (line ~748). /// /// Verifies that text deltas are streamed as TextStart / TextDelta / TextEnd @@ -685,87 +303,6 @@ async fn should_stream_text() { // (reasoning streaming is tested in `deepseek_reasoning_test.rs`). // ════════════════════════════════════════════════════════════════════════════ -/// TS: doStream › tool call › "should stream tool call" (line ~780). -/// -/// Verifies that tool call deltas are streamed as ToolInputStart / -/// ToolInputDelta / ToolInputEnd / ToolCall parts. -#[tokio::test] -async fn should_stream_tool_call() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"x","object":"chat.completion.chunk","created":1,"model":"deepseek-reasoner","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"x","object":"chat.completion.chunk","created":1,"model":"deepseek-reasoner","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"weather","arguments":""}}]},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"x","object":"chat.completion.chunk","created":1,"model":"deepseek-reasoner","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"location\": \"San Francisco\"}"}}]},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"x","object":"chat.completion.chunk","created":1,"model":"deepseek-reasoner","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":20,"total_tokens":30}}"#, - ), - ]); - mock_sse(&server, body).await; - - let model = make_provider(&server, "deepseek-reasoner"); - - let mut options = default_options(test_prompt()); - options.tools = Some(vec![weather_tool().into()]); - options.provider_options = deepseek_opts(json!({ "thinking": { "type": "enabled" } })); - - let result = model - .do_stream(&options) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // Should have ToolInputStart, ToolInputDelta, ToolInputEnd, ToolCall. - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ToolInputStart { .. })), - "expected ToolInputStart" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ToolInputDelta { .. })), - "expected ToolInputDelta" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ToolInputEnd { .. })), - "expected ToolInputEnd" - ); - - let tool_calls: Vec<_> = parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => Some((tool_call_id, tool_name, input)), - _ => None, - }) - .collect(); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0].0, "call_1"); - assert_eq!(tool_calls[0].1, "weather"); - assert_eq!(tool_calls[0].2, r#"{"location": "San Francisco"}"#); - - // The last part should be Finish with ToolCalls. - match parts.last() { - Some(StreamPart::Finish { finish_reason, .. }) => { - assert_eq!(finish_reason.unified, FinishReasonUnified::ToolCalls); - } - other => panic!("expected Finish as last part, got {other:?}"), - } -} - // ════════════════════════════════════════════════════════════════════════════ // Message conversion — convert-to-deepseek-chat-messages.test.ts // @@ -806,13 +343,22 @@ async fn should_convert_user_text_part_to_string_content() { /// TS: file parts with image media type are converted to image_url (OpenAI format). #[tokio::test] async fn should_convert_image_file_parts_to_image_url() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, + let prompt = vec![LanguageModelMessage::User { content: vec![ - ContentPart::text("Hello"), - ContentPart::file_base64("AAECAw==", "image/png"), + UserPart::Text(TextPart { + text: "Hello".into(), + provider_options: None, + }), + UserPart::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64("AAECAw==".into()), + }, + media_type: "image/png".into(), + filename: None, + provider_options: None, + }), ], - ..Default::default() + provider_options: None, }]; let options = default_options(prompt); let result = build_request_body_with_warnings( @@ -844,13 +390,22 @@ async fn should_convert_image_file_parts_to_image_url() { /// NOT include any image/mediaType data in the output. #[tokio::test] async fn should_accept_top_level_only_mediatype_without_error() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, + let prompt = vec![LanguageModelMessage::User { content: vec![ - ContentPart::text("Hello"), - ContentPart::file_base64("AAECAw==", "image"), + UserPart::Text(TextPart { + text: "Hello".into(), + provider_options: None, + }), + UserPart::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64("AAECAw==".into()), + }, + media_type: "image".into(), + filename: None, + provider_options: None, + }), ], - ..Default::default() + provider_options: None, }]; let options = default_options(prompt); let result = build_request_body_with_warnings( @@ -870,127 +425,6 @@ async fn should_accept_top_level_only_mediatype_without_error() { ); } -/// TS: tool calls › "should stringify arguments to tool calls" (line ~100). -/// -/// An assistant tool-call message followed by a tool-result message should -/// produce: -/// - assistant: `{ role: "assistant", content: "", tool_calls: [...] }` -/// - tool: `{ role: "tool", content: "...", tool_call_id: "..." }` -#[tokio::test] -async fn should_stringify_arguments_to_tool_calls() { - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "quux", - "thwomp", - json!({ "foo": "bar123" }), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result("quux", json!({ "oof": "321rab" }))], - ..Default::default() - }, - ]; - let options = default_options(prompt); - let result = build_request_body_with_warnings( - "deepseek-chat", - &options, - false, - "deepseek", - &OpenAICompatProfile::deepseek(), - ) - .unwrap(); - - let messages = result.body["messages"].as_array().expect("messages array"); - assert_eq!(messages.len(), 2); - - // Assistant message with tool_calls. - assert_eq!(messages[0]["role"], json!("assistant")); - // OpenAI converter sets content to null when empty. - assert!(messages[0]["content"].is_null()); - assert_eq!( - messages[0]["tool_calls"], - json!([{ - "id": "quux", - "type": "function", - "function": { - "name": "thwomp", - "arguments": "{\"foo\":\"bar123\"}" - } - }]) - ); - - // Tool result message. - assert_eq!(messages[1]["role"], json!("tool")); - assert_eq!(messages[1]["content"], json!("{\"oof\":\"321rab\"}")); - assert_eq!(messages[1]["tool_call_id"], json!("quux")); - - assert!(result.warnings.is_empty(), "expected no warnings"); -} - -/// TS: tool calls › "should handle text output type in tool results" (line ~159). -/// -/// A tool result with a text output should produce the text value directly as -/// the tool message content. -#[tokio::test] -async fn should_handle_text_output_type_in_tool_results() { - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "call-1", - "getWeather", - json!({ "query": "weather" }), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call-1", - json!("It is sunny today"), - )], - ..Default::default() - }, - ]; - let options = default_options(prompt); - let result = build_request_body_with_warnings( - "deepseek-chat", - &options, - false, - "deepseek", - &OpenAICompatProfile::deepseek(), - ) - .unwrap(); - - let messages = result.body["messages"].as_array().expect("messages array"); - assert_eq!(messages.len(), 2); - - // Assistant message. - assert_eq!(messages[0]["role"], json!("assistant")); - // OpenAI converter sets content to null when empty. - assert!(messages[0]["content"].is_null()); - assert_eq!( - messages[0]["tool_calls"], - json!([{ - "id": "call-1", - "type": "function", - "function": { - "name": "getWeather", - "arguments": "{\"query\":\"weather\"}" - } - }]) - ); - - // Tool result message with text output. - assert_eq!(messages[1]["role"], json!("tool")); - assert_eq!(messages[1]["content"], json!("It is sunny today")); - assert_eq!(messages[1]["tool_call_id"], json!("call-1")); -} - // ════════════════════════════════════════════════════════════════════════════ // Tool preparation — deepseek-prepare-tools.test.ts // diff --git a/aimux-providers/tests/elevenlabs_realtime_stream_test.rs b/aimux-providers/tests/elevenlabs_realtime_stream_test.rs index e00f15837..e3963235a 100644 --- a/aimux-providers/tests/elevenlabs_realtime_stream_test.rs +++ b/aimux-providers/tests/elevenlabs_realtime_stream_test.rs @@ -263,75 +263,6 @@ async fn stream_realtime_happy_path() { } } -/// providerOptions: languageCode lands on the query; include_timestamps -/// switches the committed event to the words-carrying shape, which fills -/// segments (spacing entries dropped) and the finish duration. -#[tokio::test] -async fn stream_language_and_timestamps() { - let (base_url, captured) = start(|ws| { - Box::pin(async move { - ws.send(Message::Text( - r#"{"message_type":"committed_transcript_with_timestamps","text":"Hello world","language_code":"fr","words":[ - {"text":"Hello","start":0.0,"end":0.5,"type":"word"}, - {"text":" ","start":0.5,"end":0.5,"type":"spacing"}, - {"text":"world","start":0.5,"end":1.0,"type":"word"} - ]}"# - .replace('\n', ""), - )) - .await - .unwrap(); - let _ = ws.next().await; // close - }) - }) - .await; - - let model = realtime_model(&base_url, "scribe_v2_realtime"); - let mut options = stream_options(vec![AudioChunk::Binary(vec![1])], None); - let mut provider_options = std::collections::HashMap::new(); - provider_options.insert( - "elevenlabs".to_string(), - serde_json::json!({ "languageCode": "fr", "includeTimestamps": true }), - ); - options.provider_options = Some(provider_options); - let result = model.do_stream(options).await.unwrap(); - let parts = collect(result).await; - - let uri = captured.uri(); - assert!(uri.contains("language_code=fr"), "in {uri}"); - assert!(uri.contains("include_timestamps=true"), "in {uri}"); - - match &parts[parts.len() - 2] { - Ok(TranscriptionStreamPart::TranscriptFinal { - text, - start_second, - end_second, - .. - }) => { - assert_eq!(text, "Hello world"); - assert_eq!(*start_second, Some(0.0)); - assert_eq!(*end_second, Some(1.0)); - } - other => panic!("expected final with timestamps, got {other:?}"), - } - match parts.last().unwrap() { - Ok(TranscriptionStreamPart::Finish { - text, - segments, - language, - duration_in_seconds, - .. - }) => { - assert_eq!(text, "Hello world"); - assert_eq!(language.as_deref(), Some("fr")); - assert_eq!(*duration_in_seconds, Some(1.0)); - assert_eq!(segments.len(), 2, "spacing entry dropped: {segments:?}"); - assert_eq!(segments[0].text, "Hello"); - assert_eq!(segments[1].text, "world"); - } - other => panic!("expected finish, got {other:?}"), - } -} - /// An empty audio stream commits via a single empty chunk. #[tokio::test] async fn stream_empty_audio_commits_empty_chunk() { diff --git a/aimux-providers/tests/elevenlabs_speech_test.rs b/aimux-providers/tests/elevenlabs_speech_test.rs index f296c385e..690d4404b 100644 --- a/aimux-providers/tests/elevenlabs_speech_test.rs +++ b/aimux-providers/tests/elevenlabs_speech_test.rs @@ -203,13 +203,14 @@ async fn should_pass_provider_specific_options() { let mut provider_options = HashMap::new(); provider_options.insert( "elevenlabs".to_string(), - json!({ + serde_json::from_value(json!({ "voiceSettings": { "stability": 0.5, "similarityBoost": 0.75, }, "seed": 123, - }), + })) + .unwrap(), ); options.provider_options = Some(provider_options); diff --git a/aimux-providers/tests/fal_image_test.rs b/aimux-providers/tests/fal_image_test.rs index b6dfb77e0..3628ed656 100644 --- a/aimux-providers/tests/fal_image_test.rs +++ b/aimux-providers/tests/fal_image_test.rs @@ -137,7 +137,10 @@ async fn should_forward_provider_options() { opts.n = 1; opts.provider_options.insert( "fal".into(), - json!({ "numInferenceSteps": 30, "guidanceScale": 7.5, "outputFormat": "png" }), + serde_json::from_value( + json!({ "numInferenceSteps": 30, "guidanceScale": 7.5, "outputFormat": "png" }), + ) + .unwrap(), ); model.do_generate(&opts).await.unwrap(); let reqs = server.received_requests().await.unwrap(); diff --git a/aimux-providers/tests/fireworks_test.rs b/aimux-providers/tests/fireworks_test.rs index f927626f1..7261fd73c 100644 --- a/aimux-providers/tests/fireworks_test.rs +++ b/aimux-providers/tests/fireworks_test.rs @@ -28,20 +28,14 @@ use serial_test::serial; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_providers::{ProviderOptions, provider, provider_from_env}; fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { diff --git a/aimux-providers/tests/google_embedding_test.rs b/aimux-providers/tests/google_embedding_test.rs index 33854bf8f..a14328afc 100644 --- a/aimux-providers/tests/google_embedding_test.rs +++ b/aimux-providers/tests/google_embedding_test.rs @@ -136,73 +136,6 @@ async fn should_pass_model_and_values() { ); } -/// TS: "should pass the outputDimensionality setting" -#[tokio::test] -async fn should_pass_output_dimensionality() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-embedding-001:batchEmbedContents")) - .respond_with(ResponseTemplate::new(200).set_body_json(batch_response_body())) - .mount(&server) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.embedding_model("gemini-embedding-001"); - - let mut provider_options = HashMap::new(); - provider_options.insert("google".to_string(), json!({"outputDimensionality": 64})); - let options = EmbeddingCallOptions { - values: test_values(), - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["requests"][0]["outputDimensionality"], 64); -} - -/// TS: "should pass the taskType setting" -#[tokio::test] -async fn should_pass_task_type() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-embedding-001:batchEmbedContents")) - .respond_with(ResponseTemplate::new(200).set_body_json(batch_response_body())) - .mount(&server) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.embedding_model("gemini-embedding-001"); - - let mut provider_options = HashMap::new(); - provider_options.insert( - "google".to_string(), - json!({"taskType": "SEMANTIC_SIMILARITY"}), - ); - let options = EmbeddingCallOptions { - values: test_values(), - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["requests"][0]["taskType"], "SEMANTIC_SIMILARITY"); -} - /// TS: "should pass headers" #[tokio::test] async fn should_pass_headers() { diff --git a/aimux-providers/tests/google_files_test.rs b/aimux-providers/tests/google_files_test.rs index 17f6a132b..e18b5e667 100644 --- a/aimux-providers/tests/google_files_test.rs +++ b/aimux-providers/tests/google_files_test.rs @@ -8,15 +8,13 @@ //! 2. POST to the upload URL - returns `{ "file": { ... } }`. //! 3. GET `/{file.name}` (poll) - returns the file resource with updated state. -use std::collections::HashMap; - use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::files_model::{Files, UploadFileCallOptions, UploadFileData}; use aimux_core::retry::RetryConfig; -use aimux_core::shared::{FileBytes, SharedProviderOptions}; +use aimux_core::shared::{FileBytes, provider_namespace}; use aimux_providers::{GoogleConfig, GoogleProvider}; // -- helpers ----------------------------------------------------------------- @@ -153,35 +151,6 @@ async fn should_send_correct_headers_for_resumable_upload_initiation() { ); } -#[tokio::test] -async fn should_include_display_name_in_initiation_body_when_provided() { - let server = MockServer::start().await; - mount_success_mocks(&server).await; - - let provider = provider(&server); - let files = provider.files(); - - let mut po = HashMap::new(); - po.insert( - "google".to_string(), - json!({ "displayName": "my-document" }), - ); - let opts = UploadFileCallOptions { - data: UploadFileData::Data { - data: FileBytes::Binary(vec![1]), - }, - media_type: "text/plain".to_string(), - filename: None, - provider_options: Some(po), - abort_signal: None, - }; - files.upload_file(&opts).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body, json!({ "file": { "display_name": "my-document" } })); -} - #[tokio::test] async fn should_omit_display_name_from_body_when_not_provided() { let server = MockServer::start().await; @@ -490,11 +459,11 @@ async fn should_accept_valid_provider_options() { let provider = provider(&server); let files = provider.files(); - let mut po = HashMap::new(); - po.insert( - "google".to_string(), + let po = provider_namespace( + "google", json!({ "displayName": "test", "pollIntervalMs": 5000, "pollTimeoutMs": 60000 }), - ); + ) + .unwrap(); let opts = UploadFileCallOptions { data: UploadFileData::Data { data: FileBytes::Binary(vec![1]), @@ -531,11 +500,7 @@ async fn should_pass_through_unknown_properties() { let provider = provider(&server); let files = provider.files(); - let mut po: SharedProviderOptions = HashMap::new(); - po.insert( - "google".to_string(), - json!({ "customField": "custom-value" }), - ); + let po = provider_namespace("google", json!({ "customField": "custom-value" })).unwrap(); let opts = UploadFileCallOptions { data: UploadFileData::Data { data: FileBytes::Binary(vec![1]), @@ -649,3 +614,29 @@ async fn transient_upload_failure_is_retried_without_re_initiating() { ); assert_eq!(upload_attempts.load(std::sync::atomic::Ordering::SeqCst), 2); } + +// ════════════════════════════════════════════════════════════════════════════ +// get_model_path (TS: get-model-path.test.ts) +// ════════════════════════════════════════════════════════════════════════════ + +mod get_model_path_tests { + use aimux_providers::google::utils::get_model_path; + + #[test] + fn pass_through_for_models_slash() { + assert_eq!(get_model_path("models/some-model"), "models/some-model"); + } + + #[test] + fn pass_through_for_tuned_models_slash() { + assert_eq!( + get_model_path("tunedModels/some-model"), + "tunedModels/some-model" + ); + } + + #[test] + fn add_prefix_to_models_without_slash() { + assert_eq!(get_model_path("some-model"), "models/some-model"); + } +} diff --git a/aimux-providers/tests/google_image_test.rs b/aimux-providers/tests/google_image_test.rs index d68b53cbf..6292f6011 100644 --- a/aimux-providers/tests/google_image_test.rs +++ b/aimux-providers/tests/google_image_test.rs @@ -213,33 +213,6 @@ async fn sends_aspect_ratio_in_the_request() { assert_eq!(body["parameters"]["aspectRatio"], "16:9"); } -/// TS: "should combine aspectRatio and provider options" -#[tokio::test] -async fn should_combine_aspect_ratio_and_provider_options() { - let server = MockServer::start().await; - mock_imagen_response(&server, imagen_response_body()).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.image("imagen-3.0-generate-002"); - - let mut opts = options("test prompt"); - opts.n = 1; - opts.aspect_ratio = Some(AspectRatio::new(1, 1)); - opts.provider_options.insert( - "google".to_string(), - json!({ "personGeneration": "dont_allow" }), - ); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["parameters"]["aspectRatio"], "1:1"); - assert_eq!(body["parameters"]["personGeneration"], "dont_allow"); - assert_eq!(body["parameters"]["sampleCount"], 1); -} - /// TS: "should return warnings for unsupported settings" #[tokio::test] async fn should_return_warnings_for_unsupported_settings() { @@ -350,70 +323,6 @@ async fn should_use_real_date_when_no_custom_date_provider_is_specified() { ); } -/// TS: "should only pass valid provider options" -#[tokio::test] -async fn should_only_pass_valid_provider_options() { - let server = MockServer::start().await; - mock_imagen_response(&server, imagen_response_body()).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.image("imagen-3.0-generate-002"); - - let mut opts = options(PROMPT); - opts.n = 2; - opts.aspect_ratio = Some(AspectRatio::new(16, 9)); - opts.provider_options.insert( - "google".to_string(), - json!({ - "addWatermark": false, - "personGeneration": "allow_all", - "foo": "bar", - "negativePrompt": "negative prompt" - }), - ); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - // Only personGeneration should be passed (other options are not in the schema) - assert_eq!(body["parameters"]["personGeneration"], "allow_all"); - assert_eq!(body["parameters"]["aspectRatio"], "16:9"); - assert_eq!(body["parameters"]["sampleCount"], 2); - // Invalid options should not be present - assert!(body["parameters"].get("addWatermark").is_none()); - assert!(body["parameters"].get("foo").is_none()); - assert!(body["parameters"].get("negativePrompt").is_none()); -} - -/// TS: "should emit an unsupported warning and not leak into parameters" (googleSearch on Imagen) -#[tokio::test] -async fn should_emit_warning_for_google_search_on_imagen() { - let server = MockServer::start().await; - mock_imagen_response(&server, imagen_response_body()).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.image("imagen-3.0-generate-002"); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.provider_options - .insert("google".to_string(), json!({ "googleSearch": {} })); - - let result = model.do_generate(&opts).await.unwrap(); - - assert!(result.warnings.iter().any(|w| { - matches!(w, Warning::Unsupported { feature, details } if feature == "googleSearch" - && details.as_deref() == Some("Google Search grounding is only supported on Gemini image models.")) - })); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert!(body["parameters"].get("googleSearch").is_none()); -} - /// TS: "should throw error when files are provided" #[tokio::test] async fn should_throw_error_when_files_are_provided() { @@ -685,55 +594,6 @@ async fn gemini_should_not_send_tools_when_no_google_search() { assert!(body.get("tools").is_none()); } -/// TS: "should forward providerOptions.google.googleSearch as the google_search tool" -#[tokio::test] -async fn gemini_should_forward_google_search_as_tool() { - let server = MockServer::start().await; - mock_gemini_response(&server, gemini_response_body()).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.image("gemini-2.5-flash-image"); - - let mut opts = options("A beautiful sunset"); - opts.n = 1; - opts.provider_options.insert( - "google".to_string(), - json!({ "googleSearch": { "searchTypes": { "imageSearch": {} } } }), - ); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!( - body["tools"], - json!([{ "googleSearch": { "searchTypes": { "imageSearch": {} } } }]) - ); -} - -/// TS: "should not leak googleSearch into providerOptions passthrough" -#[tokio::test] -async fn gemini_should_not_leak_google_search_into_passthrough() { - let server = MockServer::start().await; - mock_gemini_response(&server, gemini_response_body()).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.image("gemini-2.5-flash-image"); - - let mut opts = options("A beautiful sunset"); - opts.n = 1; - opts.provider_options - .insert("google".to_string(), json!({ "googleSearch": {} })); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert!(body["generationConfig"].get("googleSearch").is_none()); -} - /// TS: "should include input images in request for editing" #[tokio::test] async fn gemini_should_include_input_images_for_editing() { @@ -799,58 +659,3 @@ async fn gemini_should_throw_error_when_mask_is_provided() { let err = result.unwrap_err().to_string(); assert!(err.contains("do not support mask-based image editing")); } - -/// TS: "should forward groundingMetadata from the language-model response into providerMetadata.google" -#[tokio::test] -async fn gemini_should_forward_grounding_metadata() { - let server = MockServer::start().await; - let grounding_metadata = json!({ - "webSearchQueries": ["who performs at the 2026 super bowl halftime show"], - "groundingChunks": [ - { "web": { "uri": "https://example.com/source", "title": "Example" } } - ] - }); - mock_gemini_response( - &server, - json!({ - "candidates": [{ - "content": { - "parts": [{ - "inlineData": { - "mimeType": "image/png", - "data": "base64-generated-image" - } - }], - "role": "model" - }, - "finishReason": "STOP", - "groundingMetadata": grounding_metadata - }], - "usageMetadata": { - "promptTokenCount": 10, - "candidatesTokenCount": 100, - "totalTokenCount": 110 - } - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.image("gemini-2.5-flash-image"); - - let mut opts = options("A beautiful sunset"); - opts.n = 1; - opts.provider_options - .insert("google".to_string(), json!({ "googleSearch": {} })); - - let result = model.do_generate(&opts).await.unwrap(); - - let meta = result.provider_metadata.unwrap(); - let google = meta.get("google").unwrap(); - assert_eq!(google.get("groundingMetadata"), Some(&grounding_metadata)); - // images should still be present - let images = google.get("images").unwrap().as_array().unwrap(); - assert_eq!(images.len(), 1); - assert_eq!(images[0], json!({})); -} diff --git a/aimux-providers/tests/google_pse_test.rs b/aimux-providers/tests/google_pse_test.rs index 79e01fe07..efeadf59f 100644 --- a/aimux-providers/tests/google_pse_test.rs +++ b/aimux-providers/tests/google_pse_test.rs @@ -6,8 +6,6 @@ //! //! Tests do not hit the public network and do not read real credentials. -use std::collections::HashMap; - use serde_json::{Value, json}; use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -156,33 +154,6 @@ async fn do_search_forwards_max_results_as_num() { // -- cx from provider_options ------------------------------------------------ -#[tokio::test] -async fn cx_resolved_from_provider_options_when_config_has_none() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/customsearch/v1")) - .and(query_param("cx", "options-cx")) - .respond_with(ResponseTemplate::new(200).set_body_json(search_body())) - .mount(&server) - .await; - - // Config without cx. - let config = - GooglePseConfig::new(API_KEY).with_base_url(format!("{}/customsearch/v1", server.uri())); - let provider = GooglePseProvider::new(config); - let model = provider.search_model(); - - let mut options = opts("rust language"); - let mut po = HashMap::new(); - po.insert("google_pse".to_string(), json!({ "cx": "options-cx" })); - options.provider_options = Some(po); - model.do_search(&options).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let query = requests[0].url.query().unwrap_or(""); - assert!(query.contains("cx=options-cx"), "query: {query}"); -} - #[tokio::test] async fn missing_cx_returns_invalid_argument_error() { let server = MockServer::start().await; diff --git a/aimux-providers/tests/groq_test.rs b/aimux-providers/tests/groq_test.rs index 3cc114448..a206caf91 100644 --- a/aimux-providers/tests/groq_test.rs +++ b/aimux-providers/tests/groq_test.rs @@ -24,12 +24,14 @@ use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, + ToolCallPart, ToolPart, ToolResultOutput, ToolResultPart, UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, Tool, ToolChoice}; -use aimux_core::result::GenerateContent; +use aimux_core::result::{GenerateContent, ReasoningOutput}; +use aimux_core::shared::{FileBytes, FileData}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; @@ -40,11 +42,7 @@ use aimux_providers::{ProviderOptions, provider}; /// The TS `TEST_PROMPT`: a single user text message "Hello". fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } /// `CallOptions` with only `prompt` set. @@ -236,13 +234,22 @@ mod convert_messages { let model = make_provider(&server); - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, + let prompt: LanguageModelPrompt = vec![LanguageModelMessage::User { content: vec![ - ContentPart::text("Hello"), - ContentPart::file_base64("AAECAw==", "image/png"), + UserPart::Text(TextPart { + text: "Hello".into(), + provider_options: None, + }), + UserPart::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64("AAECAw==".into()), + }, + media_type: "image/png".into(), + filename: None, + provider_options: None, + }), ], - ..Default::default() + provider_options: None, }]; model.do_generate(&default_options(prompt)).await.unwrap(); @@ -287,19 +294,27 @@ mod convert_messages { let model = make_provider(&server); let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "quux", - "thwomp", - json!({"foo":"bar123"}), - )], - ..Default::default() + LanguageModelMessage::Assistant { + content: vec![AssistantPart::ToolCall(ToolCallPart { + tool_call_id: "quux".into(), + tool_name: "thwomp".into(), + input: json!({"foo":"bar123"}), + provider_executed: None, + provider_options: None, + })], + provider_options: None, }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result("quux", json!({"oof":"321rab"}))], - ..Default::default() + LanguageModelMessage::Tool { + content: vec![ToolPart::ToolResult(ToolResultPart { + tool_call_id: "quux".into(), + tool_name: "thwomp".into(), + output: ToolResultOutput::Json { + value: json!({"oof":"321rab"}), + provider_options: None, + }, + provider_options: None, + })], + provider_options: None, }, ]; model.do_generate(&default_options(prompt)).await.unwrap(); @@ -332,13 +347,21 @@ mod convert_messages { let model = make_provider(&server); - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, + let prompt: LanguageModelPrompt = vec![LanguageModelMessage::Assistant { content: vec![ - ContentPart::reasoning("I think the tool will return the correct value."), - ContentPart::tool_call("quux", "thwomp", json!({"foo":"bar123"})), + AssistantPart::Reasoning(ReasoningPart { + text: "I think the tool will return the correct value.".into(), + provider_options: None, + }), + AssistantPart::ToolCall(ToolCallPart { + tool_call_id: "quux".into(), + tool_name: "thwomp".into(), + input: json!({"foo":"bar123"}), + provider_executed: None, + provider_options: None, + }), ], - ..Default::default() + provider_options: None, }]; model.do_generate(&default_options(prompt)).await.unwrap(); @@ -361,10 +384,12 @@ mod convert_messages { let model = make_provider(&server); - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("Hello, how can I help you?")], - ..Default::default() + let prompt: LanguageModelPrompt = vec![LanguageModelMessage::Assistant { + content: vec![AssistantPart::Text(TextPart { + text: "Hello, how can I help you?".into(), + provider_options: None, + })], + provider_options: None, }]; model.do_generate(&default_options(prompt)).await.unwrap(); @@ -384,22 +409,25 @@ mod convert_messages { let model = make_provider(&server); - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_reference( - "image/png", - json!({"groq": "file-ref-123"}), - )], - ..Default::default() + let prompt: LanguageModelPrompt = vec![LanguageModelMessage::User { + content: vec![UserPart::File(FilePart { + data: FileData::Reference { + reference: [("groq".into(), "file-ref-123".into())].into(), + }, + media_type: "image/png".into(), + filename: None, + provider_options: None, + })], + provider_options: None, }]; - // Groq references don't contain an "openai" key, so the converter - // returns an InvalidArgument error instead of panicking. + // Upstream rejects file parts with provider references outright. let result = model.do_generate(&default_options(prompt)).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!( - err.to_string().contains("No provider reference found"), - "error should mention 'No provider reference found': {err}" + err.to_string() + .contains("file parts with provider references"), + "error should mention 'file parts with provider references': {err}" ); } } @@ -947,7 +975,7 @@ mod do_generate { .unwrap(); let reasoning = result.content.iter().find_map(|c| match c { - GenerateContent::Reasoning { text, .. } => Some(text.clone()), + GenerateContent::Reasoning(ReasoningOutput { text, .. }) => Some(text.clone()), _ => None, }); assert!(reasoning.is_some()); @@ -1026,27 +1054,6 @@ mod do_generate { assert_eq!(body["reasoning_effort"], "none"); } - /// TS: "should prefer providerOptions reasoningEffort over top-level reasoning" - #[tokio::test] - async fn provider_option_reasoning_effort_preferred() { - let server = MockServer::start().await; - mock_json(&server, groq_text_body()).await; - - let model = make_provider(&server); - - let mut provider_opts = std::collections::HashMap::new(); - provider_opts.insert("groq".to_string(), json!({"reasoningEffort": "high"})); - let options = CallOptions { - reasoning: Some(ReasoningEffort::Medium), - provider_options: Some(provider_opts), - ..default_options(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let body = first_request_body(&server).await; - assert_eq!(body["reasoning_effort"], "high"); - } - /// TS: "should extract usage" #[tokio::test] async fn extracts_usage() { @@ -1185,75 +1192,6 @@ mod do_generate { assert_eq!(result.finish_reason.raw.as_deref(), Some("eos")); } - /// TS: "should pass provider options" (reasoningFormat, user, parallelToolCalls) - #[tokio::test] - async fn pass_provider_options() { - let server = MockServer::start().await; - mock_json(&server, groq_text_body()).await; - - let model = make_provider(&server); - - let mut provider_opts = std::collections::HashMap::new(); - provider_opts.insert( - "groq".to_string(), - json!({ - "reasoningFormat": "hidden", - "user": "test-user-id", - "parallelToolCalls": false - }), - ); - let options = CallOptions { - provider_options: Some(provider_opts), - ..default_options(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let body = first_request_body(&server).await; - assert_eq!(body["reasoning_format"], "hidden"); - assert_eq!(body["user"], "test-user-id"); - assert_eq!(body["parallel_tool_calls"], false); - } - - /// TS: "should pass serviceTier provider option" - #[tokio::test] - async fn pass_service_tier_flex() { - let server = MockServer::start().await; - mock_json(&server, groq_text_body()).await; - - let model = make_provider(&server); - - let mut provider_opts = std::collections::HashMap::new(); - provider_opts.insert("groq".to_string(), json!({"serviceTier": "flex"})); - let options = CallOptions { - provider_options: Some(provider_opts), - ..default_options(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let body = first_request_body(&server).await; - assert_eq!(body["service_tier"], "flex"); - } - - /// TS: "should pass performance serviceTier provider option" - #[tokio::test] - async fn pass_service_tier_performance() { - let server = MockServer::start().await; - mock_json(&server, groq_text_body()).await; - - let model = make_provider(&server); - - let mut provider_opts = std::collections::HashMap::new(); - provider_opts.insert("groq".to_string(), json!({"serviceTier": "performance"})); - let options = CallOptions { - provider_options: Some(provider_opts), - ..default_options(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let body = first_request_body(&server).await; - assert_eq!(body["service_tier"], "performance"); - } - /// TS: "should pass tools and toolChoice" #[tokio::test] async fn pass_tools_and_tool_choice() { @@ -1317,91 +1255,6 @@ mod do_generate { assert_eq!(body["response_format"]["json_schema"]["name"], "test-name"); assert_eq!(body["response_format"]["json_schema"]["strict"], true); } - - /// TS: "should pass response format as json_object when structuredOutputs - /// explicitly disabled" - #[tokio::test] - async fn response_format_json_object_when_disabled() { - let server = MockServer::start().await; - mock_json(&server, groq_text_body()).await; - - let model = make_provider(&server); - - let mut provider_opts = std::collections::HashMap::new(); - provider_opts.insert("groq".to_string(), json!({"structuredOutputs": false})); - let options = CallOptions { - provider_options: Some(provider_opts), - response_format: Some(ResponseFormat::Json { - schema: Some(json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - })), - name: Some("test-name".to_string()), - description: Some("test description".to_string()), - }), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = first_request_body(&server).await; - assert_eq!(body["response_format"]["type"], "json_object"); - - // Should have a warning about structuredOutputs - assert!(result.warnings.iter().any(|w| match w { - aimux_core::types::Warning::Unsupported { feature, .. } => feature == "responseFormat", - _ => false, - })); - } - - /// TS: "should send strict: false when strictJsonSchema is explicitly disabled" - #[tokio::test] - async fn strict_json_schema_false() { - let server = MockServer::start().await; - mock_json(&server, groq_text_body()).await; - - let model = make_provider(&server); - - let mut provider_opts = std::collections::HashMap::new(); - provider_opts.insert("groq".to_string(), json!({"strictJsonSchema": false})); - let options = CallOptions { - provider_options: Some(provider_opts), - response_format: Some(ResponseFormat::Json { - schema: Some(json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - })), - name: Some("test-name".to_string()), - description: Some("test description".to_string()), - }), - ..default_options(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let body = first_request_body(&server).await; - assert_eq!(body["response_format"]["json_schema"]["strict"], false); - } - - /// TS: "should send request body" (request.body) - #[tokio::test] - async fn request_body_string() { - let server = MockServer::start().await; - mock_json(&server, groq_text_body()).await; - - let model = make_provider(&server); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let request_body = result.request_body.expect("should have request body"); - assert_eq!(request_body["model"], "gemma2-9b-it"); - assert_eq!(request_body["messages"][0]["content"], "Hello"); - } } // ════════════════════════════════════════════════════════════════════════════ diff --git a/aimux-providers/tests/huggingface_responses_test.rs b/aimux-providers/tests/huggingface_responses_test.rs index 404e4ae21..81fda04de 100644 --- a/aimux-providers/tests/huggingface_responses_test.rs +++ b/aimux-providers/tests/huggingface_responses_test.rs @@ -17,23 +17,23 @@ //! resulting request path is `/responses`. use aimux_core::tool::{RawToolCall, ToolResult}; -use std::collections::HashMap; use futures::StreamExt; use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::generate::{GenerateTextOptions, generate_text}; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, ResponseFormat}; -use aimux_core::result::GenerateContent; +use aimux_core::language_model_message::{ + FilePart, LanguageModelMessage, LanguageModelPrompt, UserPart, +}; +use aimux_core::options::CallOptions; +use aimux_core::result::{GenerateContent, Source}; +use aimux_core::shared::{FileBytes, FileData}; use aimux_core::stream_part::StreamPart; -use aimux_core::types::FinishReasonUnified; +use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; use aimux_providers::huggingface::responses::convert_to_huggingface_responses_messages; use aimux_providers::{HuggingFaceConfig, HuggingFaceProvider}; @@ -44,11 +44,7 @@ use aimux_providers::{HuggingFaceConfig, HuggingFaceProvider}; /// The TS `TEST_PROMPT`: a single user text message "Hello". fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } /// `CallOptions` with only `prompt` set (everything else default/None). @@ -305,50 +301,6 @@ async fn should_handle_missing_usage_gracefully() { assert_eq!(result.usage.output_tokens.reasoning, None); } -/// TS: doGenerate › basic text response › "should send model id, settings, and -/// input" -#[tokio::test] -async fn should_send_model_id_settings_and_input() { - let server = MockServer::start().await; - mock_json(&server, basic_response_body()).await; - - let provider = make_provider(&server); - let model = provider.responses_model("deepseek-ai/DeepSeek-V3-0324"); - - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let mut options = default_options(prompt); - options.temperature = Some(0.5); - options.top_p = Some(0.3); - options.max_output_tokens = Some(100); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["model"], json!("deepseek-ai/DeepSeek-V3-0324")); - assert_eq!(body["temperature"], json!(0.5)); - assert_eq!(body["top_p"], json!(0.3)); - assert_eq!(body["max_output_tokens"], json!(100)); - assert_eq!(body["stream"], json!(false)); - assert_eq!( - body["input"], - json!([ - { "role": "system", "content": "You are a helpful assistant." }, - { "role": "user", "content": [{ "type": "input_text", "text": "Hello" }] } - ]) - ); -} - /// TS: doGenerate › basic text response › "should handle unsupported settings /// with warnings" #[tokio::test] @@ -452,17 +404,17 @@ async fn should_generate_text_and_sources_from_annotations() { other => panic!("expected Text at [0], got {other:?}"), } match &result.content[1] { - GenerateContent::Source { id, url, title, .. } => { + GenerateContent::Source(Source::Url { id, url, title, .. }) => { assert_eq!(id, "id-0"); - assert_eq!(url.as_deref(), Some("https://example.com/article1")); + assert_eq!(url, "https://example.com/article1"); assert_eq!(title.as_deref(), Some("AI Developments Article")); } other => panic!("expected Source at [1], got {other:?}"), } match &result.content[2] { - GenerateContent::Source { id, url, title, .. } => { + GenerateContent::Source(Source::Url { id, url, title, .. }) => { assert_eq!(id, "id-1"); - assert_eq!(url.as_deref(), Some("https://test.com/article2")); + assert_eq!(url, "https://test.com/article2"); assert_eq!(title.as_deref(), Some("Industry Trends Report")); } other => panic!("expected Source at [2], got {other:?}"), @@ -564,11 +516,11 @@ async fn should_handle_mcp_tools_with_annotations() { other => panic!("expected Text at [2], got {other:?}"), } match &result.content[3] { - GenerateContent::Source { id, .. } => assert_eq!(id, "id-0"), + GenerateContent::Source(Source::Url { id, .. }) => assert_eq!(id, "id-0"), other => panic!("expected Source at [3], got {other:?}"), } match &result.content[4] { - GenerateContent::Source { id, .. } => assert_eq!(id, "id-1"), + GenerateContent::Source(Source::Url { id, .. }) => assert_eq!(id, "id-1"), other => panic!("expected Source at [4], got {other:?}"), } @@ -633,7 +585,7 @@ async fn should_stream_text_deltas() { assert!(matches!(&parts[0], StreamPart::StreamStart { warnings } if warnings.is_empty())); match &parts[1] { - StreamPart::ResponseMetadata { id, model_id, .. } => { + StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. }) => { assert_eq!(id.as_deref(), Some("resp_test")); assert_eq!(model_id.as_deref(), Some("deepseek-ai/DeepSeek-V3-0324")); } @@ -810,33 +762,6 @@ async fn should_handle_streaming_errors() { ))); } -/// TS: doStream › "should send correct streaming request" -#[tokio::test] -async fn should_send_correct_streaming_request() { - let server = MockServer::start().await; - let chunks = sse_body(&[sse_event( - r#"{"type":"response.completed","response":{"id":"resp_test","status":"completed"},"sequence_number":1}"#, - )]); - mock_sse(&server, chunks).await; - - let provider = make_provider(&server); - let model = provider.responses_model("deepseek-ai/DeepSeek-V3-0324"); - - let mut options = default_options(test_prompt()); - options.temperature = Some(0.7); - - let result = model.do_stream(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["model"], json!("deepseek-ai/DeepSeek-V3-0324")); - assert_eq!(body["temperature"], json!(0.7)); - assert_eq!(body["stream"], json!(true)); - assert_eq!( - body["input"], - json!([{ "role": "user", "content": [{ "type": "input_text", "text": "Hello" }] }]) - ); -} - // ════════════════════════════════════════════════════════════════════════════ // Message conversion // ════════════════════════════════════════════════════════════════════════════ @@ -869,39 +794,6 @@ async fn stub_empty_response(server: &MockServer) { .await; } -/// TS: message conversion › "should convert user messages with images" -#[tokio::test] -async fn should_convert_user_messages_with_images() { - let server = MockServer::start().await; - stub_empty_response(&server).await; - - let provider = make_provider(&server); - let model = provider.responses_model("deepseek-ai/DeepSeek-V3-0324"); - - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What do you see?"), - ContentPart::file_base64("AQIDBA==", "image/jpeg"), - ], - ..Default::default() - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("ok"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["input"][0]["content"], - json!([ - { "text": "What do you see?", "type": "input_text" }, - { "image_url": "data:image/jpeg;base64,AQIDBA==", "type": "input_image" } - ]) - ); -} - /// TS: message conversion › "should throw for file parts with provider /// references" #[tokio::test] @@ -912,13 +804,16 @@ async fn should_throw_for_file_parts_with_provider_references() { let provider = make_provider(&server); let model = provider.responses_model("Qwen/Qwen2.5-VL-32B-Instruct"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_reference( - "image/jpeg", - json!({ "huggingface": "file-ref-123" }), - )], - ..Default::default() + let prompt = vec![LanguageModelMessage::User { + content: vec![UserPart::File(FilePart { + data: FileData::Reference { + reference: [("huggingface".into(), "file-ref-123".into())].into(), + }, + media_type: "image/jpeg".into(), + filename: None, + provider_options: None, + })], + provider_options: None, }]; let result = model.do_generate(&default_options(prompt)).await; @@ -932,119 +827,6 @@ async fn should_throw_for_file_parts_with_provider_references() { ); } -/// TS: message conversion › "should handle assistant messages" -#[tokio::test] -async fn should_handle_assistant_messages() { - let server = MockServer::start().await; - stub_empty_response(&server).await; - - let provider = make_provider(&server); - let model = provider.responses_model("deepseek-ai/DeepSeek-V3-0324"); - - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("Hi there!")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("How are you?")], - ..Default::default() - }, - ]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("ok"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["input"], - json!([ - { "content": [{ "text": "Hello", "type": "input_text" }], "role": "user" }, - { "content": [{ "text": "Hi there!", "type": "output_text" }], "role": "assistant" }, - { "content": [{ "text": "How are you?", "type": "input_text" }], "role": "user" } - ]) - ); -} - -/// TS: message conversion › "should warn about unsupported assistant content -/// types" — tool-call, tool-result, and reasoning in assistant messages -/// produce NO warnings (they are silently skipped). -#[tokio::test] -async fn should_not_warn_about_assistant_content_types() { - let server = MockServer::start().await; - stub_empty_response(&server).await; - - let provider = make_provider(&server); - let model = provider.responses_model("deepseek-ai/DeepSeek-V3-0324"); - - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ - ContentPart::tool_call("test", "test", json!({})), - ContentPart::tool_result("test", json!({ "type": "text", "value": "test" })), - ContentPart::reasoning("thinking..."), - ], - ..Default::default() - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("ok"); - - // TS expects no warnings (tool calls/results/reasoning are silently - // skipped, reasoning is included as output_text). - assert!( - result.warnings.is_empty(), - "expected no warnings, got: {:?}", - result.warnings - ); -} - -/// TS: message conversion › "should warn about tool messages" -#[tokio::test] -async fn should_warn_about_tool_messages() { - let server = MockServer::start().await; - stub_empty_response(&server).await; - - let provider = make_provider(&server); - let model = provider.responses_model("deepseek-ai/DeepSeek-V3-0324"); - - let prompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "test", - json!({ "type": "text", "value": "test" }), - )], - ..Default::default() - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("ok"); - - let features: Vec = result - .warnings - .iter() - .filter_map(|w| match w { - aimux_core::types::Warning::Unsupported { feature, .. } => Some(feature.clone()), - _ => None, - }) - .collect(); - - assert_eq!(features, vec!["tool messages"]); -} - // ════════════════════════════════════════════════════════════════════════════ // Tool calls // ════════════════════════════════════════════════════════════════════════════ @@ -1170,7 +952,7 @@ async fn should_stream_tool_calls() { assert!(matches!(&parts[0], StreamPart::StreamStart { .. })); match &parts[1] { - StreamPart::ResponseMetadata { id, model_id, .. } => { + StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. }) => { assert_eq!(id.as_deref(), Some("resp_tool_stream")); assert_eq!(model_id.as_deref(), Some("deepseek-ai/DeepSeek-V3-0324")); } @@ -1230,406 +1012,14 @@ async fn should_stream_tool_calls() { // Structured output // ════════════════════════════════════════════════════════════════════════════ -/// TS: structured output › "should send text.format for structured output" -#[tokio::test] -async fn should_send_text_format_for_structured_output() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "resp_structured", - "model": "moonshotai/Kimi-K2-Instruct", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "instructions": null, - "max_output_tokens": null, - "metadata": null, - "tool_choice": "auto", - "tools": [], - "temperature": 1.0, - "top_p": 1.0, - "incomplete_details": null, - "usage": null, - "output": [ - { - "id": "msg_structured", - "type": "message", - "role": "assistant", - "status": "completed", - "content": [ - { "type": "output_text", "text": "{\"name\": \"John Doe\", \"age\": 30}" } - ] - } - ], - "output_text": null - }), - ) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("moonshotai/Kimi-K2-Instruct"); - - let mut options = default_options(test_prompt()); - options.response_format = Some(ResponseFormat::Json { - schema: Some(json!({ - "type": "object", - "properties": { - "name": { "type": "string" }, - "age": { "type": "number" } - }, - "required": ["name", "age"] - })), - name: None, - description: None, - }); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["text"]["format"], - json!({ - "type": "json_schema", - "strict": false, - "name": "response", - "schema": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "age": { "type": "number" } - }, - "required": ["name", "age"] - } - }) - ); - // description should NOT be present (it was None). - assert!( - body["text"]["format"].get("description").is_none(), - "description should be absent when None" - ); -} - -/// TS: structured output › "should handle structured output with custom name -/// and description" -#[tokio::test] -async fn should_handle_structured_output_with_custom_name_and_description() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "resp_structured", - "model": "moonshotai/Kimi-K2-Instruct", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "instructions": null, - "max_output_tokens": null, - "metadata": null, - "tool_choice": "auto", - "tools": [], - "temperature": 1.0, - "top_p": 1.0, - "incomplete_details": null, - "usage": null, - "output": [], - "output_text": "{}" - }), - ) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("moonshotai/Kimi-K2-Instruct"); - - let mut options = default_options(test_prompt()); - options.response_format = Some(ResponseFormat::Json { - schema: Some(json!({ "type": "object", "properties": { "name": { "type": "string" } } })), - name: Some("person_profile".to_string()), - description: Some("A person profile with basic information".to_string()), - }); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["text"]["format"]["name"], json!("person_profile")); - assert_eq!( - body["text"]["format"]["description"], - json!("A person profile with basic information") - ); -} - // ════════════════════════════════════════════════════════════════════════════ // Reasoning // ════════════════════════════════════════════════════════════════════════════ -/// TS: reasoning › "should handle reasoning content in responses" -#[tokio::test] -async fn should_handle_reasoning_content_in_responses() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "resp_reasoning", - "model": "deepseek-ai/DeepSeek-R1", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "instructions": null, - "max_output_tokens": null, - "metadata": null, - "tool_choice": "auto", - "tools": [], - "temperature": 1.0, - "top_p": 1.0, - "incomplete_details": null, - "usage": { "input_tokens": 10, "output_tokens": 50, "total_tokens": 60 }, - "output": [ - { - "id": "reasoning_1", - "type": "reasoning", - "content": [ - { "type": "reasoning_text", "text": "Let me think about this problem step by step..." } - ] - }, - { - "id": "msg_after_reasoning", - "type": "message", - "role": "assistant", - "status": "completed", - "content": [ - { "type": "output_text", "text": "The answer is 42." } - ] - } - ], - "output_text": null - }), - ) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("deepseek-ai/DeepSeek-R1"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.content.len(), 2); - match &result.content[0] { - GenerateContent::Reasoning { - text, - provider_metadata, - } => { - assert_eq!(text, "Let me think about this problem step by step..."); - assert_eq!( - provider_metadata.as_ref(), - Some(&json!({ "huggingface": { "itemId": "reasoning_1" } })) - ); - } - other => panic!("expected Reasoning at [0], got {other:?}"), - } - match &result.content[1] { - GenerateContent::Text { text, .. } => assert_eq!(text, "The answer is 42."), - other => panic!("expected Text at [1], got {other:?}"), - } -} - -/// TS: reasoning › "should stream reasoning content" -#[tokio::test] -async fn should_stream_reasoning_content() { - let server = MockServer::start().await; - let chunks = sse_body(&[ - sse_event( - r#"{"type":"response.created","response":{"id":"resp_reasoning_stream","object":"response","created_at":1741269019,"status":"in_progress","model":"deepseek-ai/DeepSeek-R1"}}"#, - ), - sse_event( - r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"reasoning_stream","type":"reasoning"},"sequence_number":1}"#, - ), - sse_event( - r#"{"type":"response.reasoning_text.delta","item_id":"reasoning_stream","output_index":0,"content_index":0,"delta":"Thinking about","sequence_number":2}"#, - ), - sse_event( - r#"{"type":"response.reasoning_text.delta","item_id":"reasoning_stream","output_index":0,"content_index":0,"delta":" the problem...","sequence_number":3}"#, - ), - sse_event( - r#"{"type":"response.reasoning_text.done","item_id":"reasoning_stream","output_index":0,"content_index":0,"text":"Thinking about the problem...","sequence_number":4}"#, - ), - sse_event( - r#"{"type":"response.output_item.done","output_index":0,"item":{"id":"reasoning_stream","type":"reasoning","content":[{"type":"reasoning_text","text":"Thinking about the problem..."}]},"sequence_number":5}"#, - ), - sse_event( - r#"{"type":"response.output_item.added","output_index":1,"item":{"id":"msg_stream","type":"message","role":"assistant"},"sequence_number":6}"#, - ), - sse_event( - r#"{"type":"response.output_text.delta","item_id":"msg_stream","output_index":1,"content_index":0,"delta":"The solution is","sequence_number":7}"#, - ), - sse_event( - r#"{"type":"response.output_text.delta","item_id":"msg_stream","output_index":1,"content_index":0,"delta":" simple.","sequence_number":8}"#, - ), - sse_event( - r#"{"type":"response.output_item.done","output_index":1,"item":{"id":"msg_stream","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"The solution is simple."}]},"sequence_number":9}"#, - ), - sse_event( - r#"{"type":"response.completed","response":{"id":"resp_reasoning_stream","status":"completed","usage":{"input_tokens":10,"output_tokens":20,"total_tokens":30}},"sequence_number":10}"#, - ), - ]); - mock_sse(&server, chunks).await; - - let provider = make_provider(&server); - let model = provider.responses_model("deepseek-ai/DeepSeek-R1"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let parts = collect_stream(result).await; - - // Expected: stream-start, response-metadata, reasoning-start, - // reasoning-delta×2, reasoning-end, text-start, text-delta×2, text-end, - // finish - assert_eq!(parts.len(), 11); - - assert!(matches!(&parts[0], StreamPart::StreamStart { .. })); - - match &parts[1] { - StreamPart::ResponseMetadata { id, model_id, .. } => { - assert_eq!(id.as_deref(), Some("resp_reasoning_stream")); - assert_eq!(model_id.as_deref(), Some("deepseek-ai/DeepSeek-R1")); - } - other => panic!("expected ResponseMetadata, got {other:?}"), - } - - match &parts[2] { - StreamPart::ReasoningStart { - id, - provider_metadata, - } => { - assert_eq!(id, "reasoning_stream"); - assert_eq!( - provider_metadata.as_ref(), - Some(&json!({ "huggingface": { "itemId": "reasoning_stream" } })) - ); - } - other => panic!("expected ReasoningStart, got {other:?}"), - } - match &parts[3] { - StreamPart::ReasoningDelta { id, delta, .. } => { - assert_eq!(id, "reasoning_stream"); - assert_eq!(delta, "Thinking about"); - } - other => panic!("expected ReasoningDelta, got {other:?}"), - } - match &parts[4] { - StreamPart::ReasoningDelta { id, delta, .. } => { - assert_eq!(id, "reasoning_stream"); - assert_eq!(delta, " the problem..."); - } - other => panic!("expected ReasoningDelta, got {other:?}"), - } - match &parts[5] { - StreamPart::ReasoningEnd { id, .. } => assert_eq!(id, "reasoning_stream"), - other => panic!("expected ReasoningEnd, got {other:?}"), - } - match &parts[6] { - StreamPart::TextStart { id, .. } => assert_eq!(id, "msg_stream"), - other => panic!("expected TextStart, got {other:?}"), - } - match &parts[7] { - StreamPart::TextDelta { id, delta, .. } => { - assert_eq!(id, "msg_stream"); - assert_eq!(delta, "The solution is"); - } - other => panic!("expected TextDelta, got {other:?}"), - } - match &parts[8] { - StreamPart::TextDelta { id, delta, .. } => { - assert_eq!(id, "msg_stream"); - assert_eq!(delta, " simple."); - } - other => panic!("expected TextDelta, got {other:?}"), - } - match &parts[9] { - StreamPart::TextEnd { id, .. } => assert_eq!(id, "msg_stream"), - other => panic!("expected TextEnd, got {other:?}"), - } - match &parts[10] { - StreamPart::Finish { - finish_reason, - usage, - .. - } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(usage.input_tokens.total, Some(10)); - assert_eq!(usage.output_tokens.total, Some(20)); - } - other => panic!("expected Finish, got {other:?}"), - } -} - // ════════════════════════════════════════════════════════════════════════════ // Provider options // ════════════════════════════════════════════════════════════════════════════ -/// TS: provider options › "should send provider-specific options" -#[tokio::test] -async fn should_send_provider_specific_options() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "resp_provider_options", - "model": "deepseek-ai/DeepSeek-V3-0324", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "instructions": null, - "max_output_tokens": null, - "metadata": null, - "tool_choice": "auto", - "tools": [], - "temperature": 1.0, - "top_p": 1.0, - "incomplete_details": null, - "usage": null, - "output": [], - "output_text": "Test" - }), - ) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("deepseek-ai/DeepSeek-V3-0324"); - - let mut options = default_options(test_prompt()); - let mut po = HashMap::new(); - po.insert( - "huggingface".to_string(), - json!({ - "metadata": { "key": "value" }, - "instructions": "Be concise", - "strictJsonSchema": true - }), - ); - options.provider_options = Some(po); - options.response_format = Some(ResponseFormat::Json { - schema: Some(json!({ "type": "object" })), - name: None, - description: None, - }); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["metadata"], json!({ "key": "value" })); - assert_eq!(body["instructions"], json!("Be concise")); - assert_eq!(body["text"]["format"]["strict"], json!(true)); -} - // ════════════════════════════════════════════════════════════════════════════ // Tool preparation // ════════════════════════════════════════════════════════════════════════════ @@ -1643,10 +1033,16 @@ const PNG_BASE64: &str = "iVBORw0KGgo="; /// TS: "passes full image/png through unchanged for inline data" #[test] fn passes_full_image_png_through_unchanged_for_inline_data() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_base64(PNG_BASE64, "image/png")], - ..Default::default() + let prompt = vec![LanguageModelMessage::User { + content: vec![UserPart::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64(PNG_BASE64.into()), + }, + media_type: "image/png".into(), + filename: None, + provider_options: None, + })], + provider_options: None, }]; let (input, warnings) = @@ -1666,10 +1062,16 @@ fn passes_full_image_png_through_unchanged_for_inline_data() { /// TS: "detects image subtype from inline bytes for top-level 'image'" #[test] fn detects_image_subtype_from_inline_bytes_for_top_level_image() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_base64(PNG_BASE64, "image")], - ..Default::default() + let prompt = vec![LanguageModelMessage::User { + content: vec![UserPart::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64(PNG_BASE64.into()), + }, + media_type: "image".into(), + filename: None, + provider_options: None, + })], + provider_options: None, }]; let (input, warnings) = @@ -1686,36 +1088,19 @@ fn detects_image_subtype_from_inline_bytes_for_top_level_image() { ); } -/// TS: "passes through URL source for top-level-only image" -#[test] -fn passes_through_url_source_for_top_level_only_image() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_url("https://example.com/x.png", "image")], - ..Default::default() - }]; - - let (input, warnings) = - convert_to_huggingface_responses_messages(&prompt).expect("should succeed"); - - assert!(warnings.is_empty()); - let content = &input[0]["content"][0]; - assert_eq!( - content, - &json!({ - "type": "input_image", - "image_url": "https://example.com/x.png" - }) - ); -} - /// TS: "normalizes image/* wildcard via detection" #[test] fn normalizes_image_wildcard_via_detection() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_base64(PNG_BASE64, "image/*")], - ..Default::default() + let prompt = vec![LanguageModelMessage::User { + content: vec![UserPart::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64(PNG_BASE64.into()), + }, + media_type: "image/*".into(), + filename: None, + provider_options: None, + })], + provider_options: None, }]; let (input, warnings) = diff --git a/aimux-providers/tests/jev_test.rs b/aimux-providers/tests/jev_test.rs index 5abb0e729..652b861a5 100644 --- a/aimux-providers/tests/jev_test.rs +++ b/aimux-providers/tests/jev_test.rs @@ -40,7 +40,7 @@ async fn optional_usage_counts_do_not_discard_valid_answers() { actual.output_tokens.total, usage["output_tokens"].as_u64().map(|n| n as u32) ); - assert_eq!(actual.raw, Some(usage)); + assert_eq!(actual.raw.map(Value::Object), Some(usage)); } let server = MockServer::start().await; let mut response = fixture(); @@ -194,14 +194,23 @@ async fn official_contract_round_trip_preserves_types_and_raw_metadata() { assert_eq!(result.usage.as_ref().unwrap().input_tokens.total, Some(392)); assert_eq!(result.usage.as_ref().unwrap().output_tokens.total, Some(65)); assert_eq!( - result.usage.as_ref().unwrap().raw, + result + .usage + .as_ref() + .unwrap() + .raw + .clone() + .map(Value::Object), Some(fixture()["usage"].clone()) ); assert_eq!( result.response.as_ref().unwrap().body.as_ref().unwrap(), &fixture() ); - assert_eq!(result.provider_metadata.as_ref().unwrap()["jev"], fixture()); + assert_eq!( + Value::Object(result.provider_metadata.as_ref().unwrap()["jev"].clone()), + fixture() + ); assert_eq!( result.response.as_ref().unwrap().headers.as_ref().unwrap()["x-request-id"], "test-request" diff --git a/aimux-providers/tests/jina_ai_test.rs b/aimux-providers/tests/jina_ai_test.rs index 7729fd34a..bccf0b61a 100644 --- a/aimux-providers/tests/jina_ai_test.rs +++ b/aimux-providers/tests/jina_ai_test.rs @@ -6,8 +6,6 @@ //! //! Tests do not hit the public network and do not read real credentials. -use std::collections::HashMap; - use serde_json::{Value, json}; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -91,15 +89,6 @@ fn json_docs_opts(query: &str, top_n: u32) -> RerankingCallOptions { } } -fn jina_provider_options(return_documents: bool) -> HashMap { - let mut po = HashMap::new(); - po.insert( - "jina".to_string(), - json!({ "returnDocuments": return_documents }), - ); - po -} - async fn mount_rerank_mock(server: &MockServer) { Mock::given(method("POST")) .and(path("/v1/rerank")) @@ -221,23 +210,6 @@ async fn do_rerank_sends_correct_request_body() { assert!(body.get("return_documents").is_none()); } -#[tokio::test] -async fn do_rerank_forwards_return_documents_provider_option() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model(MODEL); - - let mut opts = text_docs_opts("rainy day", 2); - opts.provider_options = Some(jina_provider_options(false)); - model.do_rerank(&opts).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["return_documents"], false); -} - #[tokio::test] async fn object_documents_are_stringified_with_warning() { let server = MockServer::start().await; diff --git a/aimux-providers/tests/linkup_test.rs b/aimux-providers/tests/linkup_test.rs index 898b2178e..73f25281b 100644 --- a/aimux-providers/tests/linkup_test.rs +++ b/aimux-providers/tests/linkup_test.rs @@ -6,8 +6,6 @@ //! //! Tests do not hit the public network and do not read real credentials. -use std::collections::HashMap; - use serde_json::{Value, json}; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -62,15 +60,6 @@ fn opts(query: &str) -> SearchCallOptions { SearchCallOptions::new(query) } -fn linkup_provider_options(depth: &str, output_type: &str) -> HashMap { - let mut po = HashMap::new(); - po.insert( - "linkup".to_string(), - json!({ "depth": depth, "outputType": output_type }), - ); - po -} - async fn mount_search_mock(server: &MockServer) { Mock::given(method("POST")) .and(path("/v1/search")) @@ -196,24 +185,6 @@ async fn do_search_sends_correct_request_body() { assert_eq!(body["excludeDomains"], json!(["example.com"])); } -#[tokio::test] -async fn do_search_forwards_depth_and_output_type_provider_options() { - let server = MockServer::start().await; - mount_search_mock(&server).await; - - let provider = provider(&server); - let model = provider.search_model(); - - let mut options = opts("rust language"); - options.provider_options = Some(linkup_provider_options("deep", "sourcedAnswer")); - model.do_search(&options).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["depth"], "deep"); - assert_eq!(body["outputType"], "sourcedAnswer"); -} - // -- error mapping ----------------------------------------------------------- #[tokio::test] diff --git a/aimux-providers/tests/lmnt_speech_test.rs b/aimux-providers/tests/lmnt_speech_test.rs index 382959dab..456481a49 100644 --- a/aimux-providers/tests/lmnt_speech_test.rs +++ b/aimux-providers/tests/lmnt_speech_test.rs @@ -247,7 +247,10 @@ async fn should_handle_different_audio_formats() { let mut options = speech_options("Hello from the AI SDK!"); let mut provider_options = HashMap::new(); - provider_options.insert("lmnt".to_string(), json!({ "format": format })); + provider_options.insert( + "lmnt".to_string(), + serde_json::from_value(json!({ "format": format })).unwrap(), + ); options.provider_options = Some(provider_options); let result = model.do_generate(&options).await.unwrap(); diff --git a/aimux-providers/tests/mistral_embedding_test.rs b/aimux-providers/tests/mistral_embedding_test.rs index 7d5940758..66eed80be 100644 --- a/aimux-providers/tests/mistral_embedding_test.rs +++ b/aimux-providers/tests/mistral_embedding_test.rs @@ -10,6 +10,8 @@ use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; +use aimux_core::tool::FunctionTool; +use aimux_providers::mistral::convert::prepare_tools; use aimux_providers::{MistralConfig, MistralProvider}; const TEST_VALUES: &[&str] = &["sunny day at the beach", "rainy day in the city"]; @@ -211,3 +213,13 @@ async fn should_pass_headers() { Some("request-header-value") ); } + +/// TS: "should include strict field when set" +#[test] +fn prepare_tools_function_tool_with_strict() { + let tool = FunctionTool::new("strictTool", json!({ "type": "object", "properties": {} })) + .with_strict(true); + let result = prepare_tools(&Some(vec![tool]), None); + let tools = result.tools.expect("tools should be Some"); + assert_eq!(tools[0]["function"]["strict"], json!(true)); +} diff --git a/aimux-providers/tests/moonshotai_test.rs b/aimux-providers/tests/moonshotai_test.rs index 8b6bbb8ad..0760eb048 100644 --- a/aimux-providers/tests/moonshotai_test.rs +++ b/aimux-providers/tests/moonshotai_test.rs @@ -23,10 +23,8 @@ use serial_test::serial; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; @@ -37,11 +35,7 @@ use aimux_providers::{ProviderOptions, provider, provider_from_env}; // ── shared helpers ─────────────────────────────────────────────────────────── fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { diff --git a/aimux-providers/tests/open_responses_test.rs b/aimux-providers/tests/open_responses_test.rs index e72ffdd47..8cf842c39 100644 --- a/aimux-providers/tests/open_responses_test.rs +++ b/aimux-providers/tests/open_responses_test.rs @@ -17,12 +17,13 @@ use serde_json::{Value, json}; use wiremock::matchers::method; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, TextPart, UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, Tool}; -use aimux_core::result::GenerateContent; +use aimux_core::result::{GenerateContent, ReasoningOutput}; +use aimux_core::shared::{FileBytes, FileData}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; @@ -38,11 +39,7 @@ use aimux_providers::open_responses::{ /// The TS `TEST_PROMPT`: a single user text message "Hello". fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } /// `CallOptions` with only `prompt` set (everything else default/None). @@ -292,10 +289,9 @@ mod convert_tests { #[test] fn convert_single_system_message_to_instructions() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() + let prompt = vec![LanguageModelMessage::System { + content: "You are a helpful assistant.".into(), + provider_options: None, }]; let (input, instructions, warnings) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -309,15 +305,13 @@ mod convert_tests { #[test] fn convert_multiple_system_messages_joined_with_newlines() { let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() + LanguageModelMessage::System { + content: "You are a helpful assistant.".into(), + provider_options: None, }, - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("Always be concise.")], - ..Default::default() + LanguageModelMessage::System { + content: "Always be concise.".into(), + provider_options: None, }, ]; let (_, instructions, _) = convert_to_open_responses_input(&prompt); @@ -337,20 +331,17 @@ mod convert_tests { #[test] fn convert_system_message_with_user_and_assistant() { let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() + LanguageModelMessage::System { + content: "You are a helpful assistant.".into(), + provider_options: None, }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("Hi there!")], - ..Default::default() + LanguageModelMessage::user_text("Hello"), + LanguageModelMessage::Assistant { + content: vec![AssistantPart::Text(TextPart { + text: "Hi there!".into(), + provider_options: None, + })], + provider_options: None, }, ]; let (input, instructions, _) = convert_to_open_responses_input(&prompt); @@ -393,10 +384,16 @@ mod convert_tests { #[test] fn convert_image_file_base64_to_input_image() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_base64("ZmFrZS1kYXRh", "image/png")], - ..Default::default() + let prompt = vec![LanguageModelMessage::User { + content: vec![UserPart::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64("ZmFrZS1kYXRh".into()), + }, + media_type: "image/png".into(), + filename: None, + provider_options: None, + })], + provider_options: None, }]; let (input, _, warnings) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -410,36 +407,24 @@ mod convert_tests { assert!(warnings.is_empty()); } - #[test] - fn convert_image_file_url_to_input_image() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_url( - "https://example.com/image.png", - "image/png", - )], - ..Default::default() - }]; - let (input, _, _) = convert_to_open_responses_input(&prompt); - assert_eq!( - input, - json!([{ - "type": "message", - "role": "user", - "content": [{"type": "input_image", "image_url": "https://example.com/image.png"}] - }]) - ); - } - #[test] fn convert_pdf_file_base64_to_input_file() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, + let prompt = vec![LanguageModelMessage::User { content: vec![ - ContentPart::text("What does this PDF say?"), - ContentPart::file_base64("UERGREFUQQ==", "application/pdf"), + UserPart::Text(TextPart { + text: "What does this PDF say?".into(), + provider_options: None, + }), + UserPart::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64("UERGREFUQQ==".into()), + }, + media_type: "application/pdf".into(), + filename: None, + provider_options: None, + }), ], - ..Default::default() + provider_options: None, }]; let (input, _, warnings) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -456,39 +441,18 @@ mod convert_tests { assert!(warnings.is_empty()); } - #[test] - fn convert_pdf_file_url_to_input_file() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_url( - "https://example.com/document.pdf", - "application/pdf", - )], - ..Default::default() - }]; - let (input, _, warnings) = convert_to_open_responses_input(&prompt); - assert_eq!( - input, - json!([{ - "type": "message", - "role": "user", - "content": [{"type": "input_file", "file_url": "https://example.com/document.pdf"}] - }]) - ); - assert!(warnings.is_empty()); - } - #[test] fn convert_file_with_custom_filename() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::FileBase64 { - data: "UERGREFUQQ==".to_string(), + let prompt = vec![LanguageModelMessage::User { + content: vec![UserPart::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64("UERGREFUQQ==".to_string()), + }, media_type: "application/pdf".to_string(), filename: Some("report.pdf".to_string()), provider_options: None, - }], - ..Default::default() + })], + provider_options: None, }]; let (input, _, _) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -509,10 +473,12 @@ mod convert_tests { #[test] fn convert_assistant_text_part() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("Hello from assistant")], - ..Default::default() + let prompt = vec![LanguageModelMessage::Assistant { + content: vec![AssistantPart::Text(TextPart { + text: "Hello from assistant".into(), + provider_options: None, + })], + provider_options: None, }]; let (input, _, _) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -527,13 +493,18 @@ mod convert_tests { #[test] fn convert_assistant_multiple_text_parts() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, + let prompt = vec![LanguageModelMessage::Assistant { content: vec![ - ContentPart::text("First part"), - ContentPart::text("Second part"), + AssistantPart::Text(TextPart { + text: "First part".into(), + provider_options: None, + }), + AssistantPart::Text(TextPart { + text: "Second part".into(), + provider_options: None, + }), ], - ..Default::default() + provider_options: None, }]; let (input, _, _) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -551,278 +522,22 @@ mod convert_tests { // -- Assistant messages with tool calls -- - #[test] - fn convert_assistant_single_tool_call() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "call_123", - "get_weather", - json!({"location": "San Francisco"}), - )], - ..Default::default() - }]; - let (input, _, _) = convert_to_open_responses_input(&prompt); - assert_eq!( - input, - json!([{ - "type": "function_call", - "call_id": "call_123", - "name": "get_weather", - "arguments": "{\"location\":\"San Francisco\"}" - }]) - ); - } - - #[test] - fn convert_assistant_tool_call_string_input() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "call_124", - "get_weather", - Value::String("{\"location\":\"Berlin\"}".to_string()), - )], - ..Default::default() - }]; - let (input, _, _) = convert_to_open_responses_input(&prompt); - assert_eq!( - input, - json!([{ - "type": "function_call", - "call_id": "call_124", - "name": "get_weather", - "arguments": "{\"location\":\"Berlin\"}" - }]) - ); - } - - #[test] - fn convert_assistant_text_and_tool_call() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ - ContentPart::text("Let me check the weather for you."), - ContentPart::tool_call("call_456", "get_weather", json!({"location": "New York"})), - ], - ..Default::default() - }]; - let (input, _, _) = convert_to_open_responses_input(&prompt); - assert_eq!( - input, - json!([ - { - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "Let me check the weather for you."}] - }, - { - "type": "function_call", - "call_id": "call_456", - "name": "get_weather", - "arguments": "{\"location\":\"New York\"}" - } - ]) - ); - } - - #[test] - fn convert_assistant_multiple_tool_calls() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ - ContentPart::tool_call("call_001", "get_weather", json!({"location": "Paris"})), - ContentPart::tool_call("call_002", "get_time", json!({"timezone": "Europe/Paris"})), - ], - ..Default::default() - }]; - let (input, _, _) = convert_to_open_responses_input(&prompt); - assert_eq!( - input, - json!([ - { - "type": "function_call", - "call_id": "call_001", - "name": "get_weather", - "arguments": "{\"location\":\"Paris\"}" - }, - { - "type": "function_call", - "call_id": "call_002", - "name": "get_time", - "arguments": "{\"timezone\":\"Europe/Paris\"}" - } - ]) - ); - } - // -- Tool messages -- - #[test] - fn convert_tool_message_json_output() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call_123", - json!({"type": "json", "value": {"temperature": 72, "condition": "sunny"}}), - )], - ..Default::default() - }]; - let (input, _, _) = convert_to_open_responses_input(&prompt); - assert_eq!( - input, - json!([{ - "type": "function_call_output", - "call_id": "call_123", - "output": "{\"temperature\":72,\"condition\":\"sunny\"}" - }]) - ); - } - - #[test] - fn convert_tool_message_text_output() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call_456", - json!({"type": "text", "value": "Search results: Found 5 items"}), - )], - ..Default::default() - }]; - let (input, _, _) = convert_to_open_responses_input(&prompt); - assert_eq!( - input, - json!([{ - "type": "function_call_output", - "call_id": "call_456", - "output": "Search results: Found 5 items" - }]) - ); - } - - #[test] - fn convert_tool_message_error_text_output() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call_789", - json!({"type": "error-text", "value": "API request failed: timeout"}), - )], - ..Default::default() - }]; - let (input, _, _) = convert_to_open_responses_input(&prompt); - assert_eq!( - input, - json!([{ - "type": "function_call_output", - "call_id": "call_789", - "output": "API request failed: timeout" - }]) - ); - } - - #[test] - fn convert_tool_message_execution_denied_output() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call_denied", - json!({"type": "execution-denied", "reason": "User declined the action"}), - )], - ..Default::default() - }]; - let (input, _, _) = convert_to_open_responses_input(&prompt); - assert_eq!( - input, - json!([{ - "type": "function_call_output", - "call_id": "call_denied", - "output": "User declined the action" - }]) - ); - } - - #[test] - fn convert_tool_message_content_output_text() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call_content", - json!({ - "type": "content", - "value": [ - {"type": "text", "text": "First result"}, - {"type": "text", "text": "Second result"} - ] - }), - )], - ..Default::default() - }]; - let (input, _, _) = convert_to_open_responses_input(&prompt); - assert_eq!( - input, - json!([{ - "type": "function_call_output", - "call_id": "call_content", - "output": [ - {"type": "input_text", "text": "First result"}, - {"type": "input_text", "text": "Second result"} - ] - }]) - ); - } - - #[test] - fn convert_tool_message_multiple_results() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ - ContentPart::tool_result( - "call_001", - json!({"type": "json", "value": {"temp": 72}}), - ), - ContentPart::tool_result("call_002", json!({"type": "text", "value": "3:00 PM"})), - ], - ..Default::default() - }]; - let (input, _, _) = convert_to_open_responses_input(&prompt); - assert_eq!( - input, - json!([ - { - "type": "function_call_output", - "call_id": "call_001", - "output": "{\"temp\":72}" - }, - { - "type": "function_call_output", - "call_id": "call_002", - "output": "3:00 PM" - } - ]) - ); - } - // -- Message chains -- #[test] fn convert_user_assistant_user_chain() { let prompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is the capital of France?")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("The capital of France is Paris.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("And what about Germany?")], - ..Default::default() + LanguageModelMessage::user_text("What is the capital of France?"), + LanguageModelMessage::Assistant { + content: vec![AssistantPart::Text(TextPart { + text: "The capital of France is Paris.".into(), + provider_options: None, + })], + provider_options: None, }, + LanguageModelMessage::user_text("And what about Germany?"), ]; let (input, _, _) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -835,97 +550,22 @@ mod convert_tests { ); } - #[test] - fn convert_user_assistant_tool_tool_chain() { - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is the weather in Tokyo?")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "call_weather", - "get_weather", - json!({"location": "Tokyo"}), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call_weather", - json!({"type": "json", "value": {"temperature": 25, "condition": "cloudy"}}), - )], - ..Default::default() - }, - ]; - let (input, _, _) = convert_to_open_responses_input(&prompt); - assert_eq!( - input, - json!([ - {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "What is the weather in Tokyo?"}]}, - {"type": "function_call", "call_id": "call_weather", "name": "get_weather", "arguments": "{\"location\":\"Tokyo\"}"}, - {"type": "function_call_output", "call_id": "call_weather", "output": "{\"temperature\":25,\"condition\":\"cloudy\"}"} - ]) - ); - } - - #[test] - fn convert_tool_roundtrip_with_followup_assistant() { - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is the weather in Tokyo?")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "call_weather", - "get_weather", - Value::String("{\"location\":\"Tokyo\"}".to_string()), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call_weather", - json!({"type": "json", "value": {"temperature": 25, "condition": "cloudy"}}), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("It is 25 C and cloudy in Tokyo.")], - ..Default::default() - }, - ]; - let (input, _, _) = convert_to_open_responses_input(&prompt); - assert_eq!( - input, - json!([ - {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "What is the weather in Tokyo?"}]}, - {"type": "function_call", "call_id": "call_weather", "name": "get_weather", "arguments": "{\"location\":\"Tokyo\"}"}, - {"type": "function_call_output", "call_id": "call_weather", "output": "{\"temperature\":25,\"condition\":\"cloudy\"}"}, - {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "It is 25 C and cloudy in Tokyo."}]} - ]) - ); - } - // -- Provider reference (warning, not error in Rust) -- #[test] fn convert_file_reference_produces_warning() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_reference( - "image/png", - json!({"openResponses": "file-ref-123"}), - )], - ..Default::default() + let prompt = vec![LanguageModelMessage::User { + content: vec![UserPart::File(FilePart { + data: FileData::Reference { + reference: [("openResponses".into(), "file-ref-123".into())] + .into_iter() + .collect(), + }, + media_type: "image/png".into(), + filename: None, + provider_options: None, + })], + provider_options: None, }]; let (input, _, warnings) = convert_to_open_responses_input(&prompt); // The reference part is skipped, so user content is empty array. @@ -1044,10 +684,10 @@ mod do_generate_tests { assert_eq!(result.content.len(), 2); assert_eq!( result.content[0], - GenerateContent::Reasoning { + GenerateContent::Reasoning(ReasoningOutput { text: "reasoning content".to_string(), provider_metadata: None, - } + }) ); assert_eq!( result.content[1], @@ -1299,84 +939,6 @@ mod do_generate_tests { // -- ProviderOptions reasoning tests -- - fn lmstudio_opts(value: Value) -> Option> { - let mut m = HashMap::new(); - m.insert("lmstudio".to_string(), value); - Some(m) - } - - #[tokio::test] - async fn provider_options_reasoning_summary_detailed() { - let server = MockServer::start().await; - mock_json(&server, lmstudio_basic_json()).await; - - let model = make_model(&server, "gemma-7b-it"); - let options = CallOptions { - provider_options: lmstudio_opts(json!({"reasoningSummary": "detailed"})), - ..CallOptions::new(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let request = &server.received_requests().await.unwrap()[0]; - let body: Value = serde_json::from_slice(&request.body).unwrap(); - assert_eq!(body["reasoning"], json!({"summary": "detailed"})); - } - - #[tokio::test] - async fn provider_options_combines_effort_with_summary() { - let server = MockServer::start().await; - mock_json(&server, lmstudio_basic_json()).await; - - let model = make_model(&server, "gemma-7b-it"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::High), - provider_options: lmstudio_opts(json!({"reasoningSummary": "auto"})), - ..CallOptions::new(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let request = &server.received_requests().await.unwrap()[0]; - let body: Value = serde_json::from_slice(&request.body).unwrap(); - assert_eq!( - body["reasoning"], - json!({"effort": "high", "summary": "auto"}) - ); - } - - #[tokio::test] - async fn provider_options_reasoning_summary_concise() { - let server = MockServer::start().await; - mock_json(&server, lmstudio_basic_json()).await; - - let model = make_model(&server, "gemma-7b-it"); - let options = CallOptions { - provider_options: lmstudio_opts(json!({"reasoningSummary": "concise"})), - ..CallOptions::new(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let request = &server.received_requests().await.unwrap()[0]; - let body: Value = serde_json::from_slice(&request.body).unwrap(); - assert_eq!(body["reasoning"], json!({"summary": "concise"})); - } - - #[tokio::test] - async fn provider_options_no_reasoning_fields() { - let server = MockServer::start().await; - mock_json(&server, lmstudio_basic_json()).await; - - let model = make_model(&server, "gemma-7b-it"); - let options = CallOptions { - provider_options: lmstudio_opts(json!({})), - ..CallOptions::new(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let request = &server.received_requests().await.unwrap()[0]; - let body: Value = serde_json::from_slice(&request.body).unwrap(); - assert!(body.get("reasoning").is_none()); - } - // -- Tool call parsing tests -- // -- Tool choice tests -- @@ -1390,16 +952,11 @@ mod do_generate_tests { let model = make_model(&server, "gemma-7b-it"); let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() + LanguageModelMessage::System { + content: "You are a helpful assistant.".into(), + provider_options: None, }, + LanguageModelMessage::user_text("Hello"), ]; model.do_generate(&default_options(prompt)).await.unwrap(); @@ -1415,21 +972,15 @@ mod do_generate_tests { let model = make_model(&server, "gemma-7b-it"); let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("Always be concise.")], - ..Default::default() + LanguageModelMessage::System { + content: "You are a helpful assistant.".into(), + provider_options: None, }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() + LanguageModelMessage::System { + content: "Always be concise.".into(), + provider_options: None, }, + LanguageModelMessage::user_text("Hello"), ]; model.do_generate(&default_options(prompt)).await.unwrap(); @@ -1443,76 +994,6 @@ mod do_generate_tests { // -- Multi-turn tool conversation -- - #[tokio::test] - async fn multi_turn_tool_conversation_request_body() { - let server = MockServer::start().await; - mock_json(&server, lmstudio_basic_json()).await; - - let model = make_model(&server, "gemma-7b-it"); - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is the weather in Tokyo?")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "call_weather_123", - "get_weather", - json!({"location": "Tokyo"}), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call_weather_123", - json!({ - "type": "json", - "value": {"temperature": 22, "condition": "sunny", "humidity": 65} - }), - )], - ..Default::default() - }, - ]; - let options = CallOptions { - tools: Some(vec![Tool::from( - FunctionTool::new( - "get_weather", - json!({ - "type": "object", - "properties": {"location": {"type": "string"}}, - "required": ["location"] - }), - ) - .with_description("Get the current weather for a location"), - )]), - ..CallOptions::new(prompt) - }; - model.do_generate(&options).await.unwrap(); - - let request = &server.received_requests().await.unwrap()[0]; - let body: Value = serde_json::from_slice(&request.body).unwrap(); - assert_eq!( - body, - json!({ - "model": "gemma-7b-it", - "input": [ - {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "What is the weather in Tokyo?"}]}, - {"type": "function_call", "call_id": "call_weather_123", "name": "get_weather", "arguments": "{\"location\":\"Tokyo\"}"}, - {"type": "function_call_output", "call_id": "call_weather_123", "output": "{\"temperature\":22,\"condition\":\"sunny\",\"humidity\":65}"} - ], - "tools": [{ - "type": "function", - "name": "get_weather", - "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}, - "description": "Get the current weather for a location" - }] - }) - ); - } - // -- PDF input file -- #[tokio::test] @@ -1521,18 +1002,23 @@ mod do_generate_tests { mock_json(&server, openai_pdf_json()).await; let model = make_model(&server, "gpt-4.1-nano"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, + let prompt = vec![LanguageModelMessage::User { content: vec![ - ContentPart::text( - "What text does this PDF contain? Reply with just the text content, nothing else.", - ), - ContentPart::file_url( - "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf", - "application/pdf", - ), - ], - ..Default::default() +UserPart::Text(TextPart { +text: "What text does this PDF contain? Reply with just the text content, nothing else.".into(), +provider_options: None +}), +UserPart::File(FilePart { +data: FileData::Url { +url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf".into(), +original_url: None +}, +media_type: "application/pdf".into(), +filename: None, +provider_options: None +}) +], + provider_options: None, }]; model.do_generate(&default_options(prompt)).await.unwrap(); @@ -1560,16 +1046,23 @@ mod do_generate_tests { mock_json(&server, openai_pdf_json()).await; let model = make_model(&server, "gpt-4.1-nano"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, + let prompt = vec![LanguageModelMessage::User { content: vec![ - ContentPart::text("What text does this PDF contain?"), - ContentPart::file_url( - "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf", - "application/pdf", - ), - ], - ..Default::default() +UserPart::Text(TextPart { +text: "What text does this PDF contain?".into(), +provider_options: None +}), +UserPart::File(FilePart { +data: FileData::Url { +url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf".into(), +original_url: None +}, +media_type: "application/pdf".into(), +filename: None, +provider_options: None +}) +], + provider_options: None, }]; let result = model.do_generate(&default_options(prompt)).await.unwrap(); @@ -1589,16 +1082,23 @@ mod do_generate_tests { mock_json(&server, openai_pdf_json()).await; let model = make_model(&server, "gpt-4.1-nano"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, + let prompt = vec![LanguageModelMessage::User { content: vec![ - ContentPart::text("What text does this PDF contain?"), - ContentPart::file_url( - "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf", - "application/pdf", - ), - ], - ..Default::default() +UserPart::Text(TextPart { +text: "What text does this PDF contain?".into(), +provider_options: None +}), +UserPart::File(FilePart { +data: FileData::Url { +url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf".into(), +original_url: None +}, +media_type: "application/pdf".into(), +filename: None, +provider_options: None +}) +], + provider_options: None, }]; let result = model.do_generate(&default_options(prompt)).await.unwrap(); @@ -1866,16 +1366,23 @@ mod do_stream_tests { mock_sse(&server, body).await; let model = make_model(&server, "gpt-4.1-nano"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, + let prompt = vec![LanguageModelMessage::User { content: vec![ - ContentPart::text("What text does this PDF contain?"), - ContentPart::file_url( - "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf", - "application/pdf", - ), - ], - ..Default::default() +UserPart::Text(TextPart { +text: "What text does this PDF contain?".into(), +provider_options: None +}), +UserPart::File(FilePart { +data: FileData::Url { +url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf".into(), +original_url: None +}, +media_type: "application/pdf".into(), +filename: None, +provider_options: None +}) +], + provider_options: None, }]; let result = model.do_stream(&default_options(prompt)).await.unwrap(); let parts = collect_stream(result).await; diff --git a/aimux-providers/tests/openai_compatible_test.rs b/aimux-providers/tests/openai_compatible_test.rs index e554f4287..96830dd28 100644 --- a/aimux-providers/tests/openai_compatible_test.rs +++ b/aimux-providers/tests/openai_compatible_test.rs @@ -21,15 +21,13 @@ use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; +use aimux_core::types::FinishReasonUnified; use aimux_providers::{ ProviderOptions, bedrock_mantle::BedrockMantleConfig, bedrock_mantle::BedrockMantleProvider, @@ -60,11 +58,7 @@ use aimux_providers::{ /// The TS `TEST_PROMPT`: a single user text message "Hello". fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } /// `CallOptions` with only `prompt` set (everything else default/None). @@ -851,35 +845,6 @@ mod default_base_urls { // rate-limit mapping. // ════════════════════════════════════════════════════════════════════════════ -/// TS (deepseek): a top-level `reasoning` value maps to `reasoning_effort`. -#[tokio::test] -async fn deepseek_maps_reasoning_to_reasoning_effort() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let model = provider( - "deepseek", - Some("test-api-key".to_string()), - "deepseek-reasoner", - Some(ProviderOptions { - base_url: Some(server.uri()), - ..Default::default() - }), - ) - .expect("provider construction"); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::High); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["reasoning_effort"], json!("high")); -} - /// TS (groq): usage tokens are extracted from the response. #[tokio::test] async fn groq_extracts_usage() { @@ -951,43 +916,6 @@ async fn deepseek_rate_limit_maps_to_rate_limited() { ); } -/// TS: response headers are exposed on the generate result. -#[tokio::test] -async fn groq_exposes_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(text_completion_body()), - ) - .mount(&server) - .await; - - let model = provider( - "groq", - Some("test-api-key".to_string()), - "llama-3.3-70b-versatile", - Some(ProviderOptions { - base_url: Some(server.uri()), - ..Default::default() - }), - ) - .expect("provider construction"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - // P1 thin-wrapper providers (provider-research batch). openai_compatible_tests!(chutes, "chutes", "default"); openai_compatible_tests!(meta, "meta", "meta/muse-spark-1.1"); diff --git a/aimux-providers/tests/openai_decision_test.rs b/aimux-providers/tests/openai_decision_test.rs index a70c557ae..8e80c1498 100644 --- a/aimux-providers/tests/openai_decision_test.rs +++ b/aimux-providers/tests/openai_decision_test.rs @@ -18,6 +18,13 @@ use wiremock::{Mock, MockServer, Request, ResponseTemplate}; mod common; +fn object(value: Value) -> serde_json::Map { + value + .as_object() + .cloned() + .expect("provider options must be an object") +} + fn fixture() -> Value { serde_json::from_str(include_str!("fixtures/openai_decisions.json")).unwrap() } @@ -68,8 +75,11 @@ async fn native_wire_maps_all_questions_and_preserves_partial_refusal() { let mut request = options(); request.headers = Some(HashMap::from([("X-Per-Call".into(), "yes".into())])); request.provider_options = Some(HashMap::from([ - ("openai".into(), json!({"safety_identifier":"opaque-user"})), - ("other-provider".into(), json!({"ignored":true})), + ( + "openai".into(), + object(json!({"safety_identifier":"opaque-user"})), + ), + ("other-provider".into(), object(json!({"ignored":true}))), ])); let result = decide(model(&server).as_ref(), request).await.unwrap(); assert!(matches!( @@ -120,7 +130,7 @@ async fn response_order_and_usage_metadata_do_not_discard_answers() { assert_eq!(result.answers.len(), 4); assert_eq!( result.usage.and_then(|usage| usage.raw), - if usage.is_null() { None } else { Some(usage) } + usage.as_object().cloned() ); } } diff --git a/aimux-providers/tests/openai_embedding_test.rs b/aimux-providers/tests/openai_embedding_test.rs index 1c7bc3eae..84324fedf 100644 --- a/aimux-providers/tests/openai_embedding_test.rs +++ b/aimux-providers/tests/openai_embedding_test.rs @@ -227,46 +227,6 @@ async fn should_pass_model_and_values() { assert!(body.get("user").is_none()); } -/// TS: "should pass the dimensions setting" -#[tokio::test] -async fn should_pass_dimensions_setting() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/embeddings")) - .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) - .mount(&server) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.embedding_model("text-embedding-3-large"); - - let mut provider_options = HashMap::new(); - provider_options.insert("openai".to_string(), json!({"dimensions": 64})); - let options = EmbeddingCallOptions { - values: test_values(), - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - - assert_eq!(body["dimensions"], 64); - assert_eq!(body["model"], "text-embedding-3-large"); - assert_eq!( - body["input"], - json!(["sunny day at the beach", "rainy day in the city"]) - ); - assert_eq!(body["encoding_format"], "float"); -} - /// TS: "should pass headers" — verifies that Authorization, OpenAI-Organization, /// OpenAI-Project, config-level headers, and request-level headers are all sent. #[tokio::test] diff --git a/aimux-providers/tests/openai_image_test.rs b/aimux-providers/tests/openai_image_test.rs index a76deedda..c12a5a7af 100644 --- a/aimux-providers/tests/openai_image_test.rs +++ b/aimux-providers/tests/openai_image_test.rs @@ -11,8 +11,6 @@ //! verify that a timestamp is present and that `model_id` matches, rather //! than asserting an exact timestamp value. -use std::collections::HashMap; - use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -128,138 +126,6 @@ fn base64_file(media_type: &str, b64: &str) -> ImageFile { // doGenerate — generation // ════════════════════════════════════════════════════════════════════════════ -/// TS: "should pass the model and the settings" -#[tokio::test] -async fn should_pass_the_model_and_the_settings() { - let server = MockServer::start().await; - mock_generations_response(&server, image_response_body()).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.image("dall-e-3"); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - opts.provider_options - .insert("openai".to_string(), json!({ "style": "vivid" })); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "dall-e-3"); - assert_eq!(body["prompt"], PROMPT); - assert_eq!(body["n"], 1); - assert_eq!(body["size"], "1024x1024"); - assert_eq!(body["style"], "vivid"); - assert_eq!(body["response_format"], "b64_json"); -} - -/// TS: "should map provider options to snake_case for /images/generations" -#[tokio::test] -async fn should_map_provider_options_to_snake_case_for_generations() { - let server = MockServer::start().await; - mock_generations_response(&server, image_response_body()).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.image("gpt-image-1"); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - opts.provider_options.insert( - "openai".to_string(), - json!({ - "quality": "high", - "background": "transparent", - "moderation": "low", - "outputFormat": "webp", - "outputCompression": 80, - "user": "user-123" - }), - ); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "gpt-image-1"); - assert_eq!(body["prompt"], PROMPT); - assert_eq!(body["n"], 1); - assert_eq!(body["size"], "1024x1024"); - assert_eq!(body["quality"], "high"); - assert_eq!(body["background"], "transparent"); - assert_eq!(body["moderation"], "low"); - assert_eq!(body["output_format"], "webp"); - assert_eq!(body["output_compression"], 80); - assert_eq!(body["user"], "user-123"); - // gpt-image-1 should NOT have response_format - assert!(body.get("response_format").is_none()); -} - -/// TS: "should pass headers" -#[tokio::test] -async fn should_pass_headers() { - let server = MockServer::start().await; - mock_generations_response(&server, image_response_body()).await; - - let mut provider_headers = HashMap::new(); - provider_headers.insert( - "Custom-Provider-Header".to_string(), - "provider-header-value".to_string(), - ); - - let config = OpenAIConfig::new("test-api-key") - .with_base_url(server.uri()) - .with_org_id("test-organization") - .with_project("test-project") - .with_headers(provider_headers); - let provider = OpenAIProvider::new(config); - let model = provider.image("dall-e-3"); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - opts.provider_options - .insert("openai".to_string(), json!({ "style": "vivid" })); - let mut req_headers = HashMap::new(); - req_headers.insert( - "Custom-Request-Header".to_string(), - "request-header-value".to_string(), - ); - opts.headers = Some(req_headers); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let h = &requests[0].headers; - assert_eq!( - h.get("authorization").unwrap().to_str().unwrap(), - "Bearer test-api-key" - ); - assert_eq!( - h.get("openai-organization").unwrap().to_str().unwrap(), - "test-organization" - ); - assert_eq!( - h.get("openai-project").unwrap().to_str().unwrap(), - "test-project" - ); - assert_eq!( - h.get("custom-provider-header").unwrap().to_str().unwrap(), - "provider-header-value" - ); - assert_eq!( - h.get("custom-request-header").unwrap().to_str().unwrap(), - "request-header-value" - ); -} - /// TS: "should extract the generated images" #[tokio::test] async fn should_extract_the_generated_images() { @@ -592,41 +458,6 @@ async fn should_include_response_format_for_dall_e_3() { assert_eq!(body["response_format"], "b64_json"); } -/// TS: "should return image meta data" -#[tokio::test] -async fn should_return_image_meta_data() { - let server = MockServer::start().await; - mock_generations_response(&server, image_response_body()).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.image("dall-e-3"); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - opts.provider_options - .insert("openai".to_string(), json!({ "style": "vivid" })); - - let result = model.do_generate(&opts).await.unwrap(); - - let meta = result.provider_metadata.unwrap(); - let openai = meta.get("openai").unwrap(); - let images = openai.get("images").unwrap().as_array().unwrap(); - assert_eq!(images.len(), 2); - // First image has revisedPrompt - assert_eq!( - images[0].get("revisedPrompt"), - Some(&json!( - "A small and adorable baby sea otter. This little creature is covered in a thick and fluffy brown fur, its tiny paws are slightly visible. The otter has bright, curious eyes and it's floating on its back on a calm sea, surrounded by floating seaweed." - )) - ); - assert_eq!(images[0].get("created"), Some(&json!(1770935200))); - // Second image has no revisedPrompt - assert!(images[1].get("revisedPrompt").is_none()); - assert_eq!(images[1].get("created"), Some(&json!(1770935200))); -} - /// TS: "should map OpenAI usage to usage" #[tokio::test] async fn should_map_openai_usage_to_usage() { @@ -862,100 +693,6 @@ async fn should_send_multiple_images_as_form_data_array() { ); } -/// TS: "should pass provider options in form data" -#[tokio::test] -async fn should_pass_provider_options_in_form_data() { - let server = MockServer::start().await; - mock_edits_response(&server, image_edit_response_body()).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.image("gpt-image-1"); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - opts.files = Some(vec![binary_file("image/png", &[137, 80, 78, 71])]); - opts.provider_options.insert( - "openai".to_string(), - json!({ - "quality": "high", - "background": "transparent" - }), - ); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body_str = String::from_utf8_lossy(&requests[0].body); - assert!( - body_str.contains("name=\"quality\""), - "should contain quality field: {body_str}" - ); - assert!( - body_str.contains("high"), - "should contain quality value: {body_str}" - ); - assert!( - body_str.contains("name=\"background\""), - "should contain background field: {body_str}" - ); - assert!( - body_str.contains("transparent"), - "should contain background value: {body_str}" - ); -} - -/// TS: "should map provider options to snake_case for /images/edits" -#[tokio::test] -async fn should_map_provider_options_to_snake_case_for_edits() { - let server = MockServer::start().await; - mock_edits_response(&server, image_edit_response_body()).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.image("gpt-image-1"); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - opts.files = Some(vec![binary_file("image/png", &[137, 80, 78, 71])]); - opts.provider_options.insert( - "openai".to_string(), - json!({ - "inputFidelity": "high", - "outputFormat": "webp", - "outputCompression": 80, - "user": "user-123" - }), - ); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body_str = String::from_utf8_lossy(&requests[0].body); - assert!( - body_str.contains("name=\"input_fidelity\""), - "should contain input_fidelity: {body_str}" - ); - assert!( - body_str.contains("name=\"output_format\""), - "should contain output_format: {body_str}" - ); - assert!( - body_str.contains("name=\"output_compression\""), - "should contain output_compression: {body_str}" - ); - assert!( - body_str.contains("name=\"user\""), - "should contain user: {body_str}" - ); - assert!( - body_str.contains("user-123"), - "should contain user value: {body_str}" - ); -} - /// TS: "should extract the edited images from response" #[tokio::test] async fn should_extract_the_edited_images_from_response() { diff --git a/aimux-providers/tests/openai_transcription_test.rs b/aimux-providers/tests/openai_transcription_test.rs index 73d930a33..f533f960e 100644 --- a/aimux-providers/tests/openai_transcription_test.rs +++ b/aimux-providers/tests/openai_transcription_test.rs @@ -1,4 +1,4 @@ -//! Rust translation of the OpenAI transcription (STT) model tests. +//! Rust translation of the OpenAI transcription (STT) model tests. //! //! Source: `reference/ai/packages/openai/src/transcription/openai-transcription-model.test.ts` //! @@ -318,93 +318,6 @@ async fn should_use_real_date() { assert_eq!(result.response.model_id, Some("whisper-1".to_string())); } -/// TS: "should pass response_format when `providerOptions.openai.timestampGranularities` is set" -#[tokio::test] -async fn should_pass_response_format_with_timestamp_granularities_word() { - let server = MockServer::start().await; - mock_json_response(&server, &fixture_response()).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.transcription("whisper-1"); - - let mut options = transcription_options(mock_audio_data(), "audio/wav"); - let mut po = HashMap::new(); - po.insert( - "openai".to_string(), - json!({"timestampGranularities": ["word"]}), - ); - options.provider_options = Some(po); - - model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let form = parse_multipart_form(&requests[0].body); - assert_eq!(form.get("model").unwrap(), "whisper-1"); - assert_eq!(form.get("response_format").unwrap(), "verbose_json"); - assert_eq!(form.get("temperature").unwrap(), "0"); - assert_eq!(form.get("timestamp_granularities[]").unwrap(), "word"); -} - -/// TS: "should not set pass response_format to "verbose_json" when model is "gpt-4o-transcribe"" -#[tokio::test] -async fn should_use_json_for_gpt4o_transcribe() { - let server = MockServer::start().await; - mock_json_response(&server, &fixture_response()).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.transcription("gpt-4o-transcribe"); - - let mut options = transcription_options(mock_audio_data(), "audio/wav"); - let mut po = HashMap::new(); - po.insert( - "openai".to_string(), - json!({"timestampGranularities": ["word"]}), - ); - options.provider_options = Some(po); - - model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let form = parse_multipart_form(&requests[0].body); - assert_eq!(form.get("model").unwrap(), "gpt-4o-transcribe"); - assert_eq!(form.get("response_format").unwrap(), "json"); - assert_eq!(form.get("temperature").unwrap(), "0"); - assert_eq!(form.get("timestamp_granularities[]").unwrap(), "word"); -} - -/// TS: "should pass timestamp_granularities when specified" -#[tokio::test] -async fn should_pass_timestamp_granularities_segment() { - let server = MockServer::start().await; - mock_json_response(&server, &fixture_response()).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.transcription("whisper-1"); - - let mut options = transcription_options(mock_audio_data(), "audio/wav"); - let mut po = HashMap::new(); - po.insert( - "openai".to_string(), - json!({"timestampGranularities": ["segment"]}), - ); - options.provider_options = Some(po); - - model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let form = parse_multipart_form(&requests[0].body); - assert_eq!(form.get("model").unwrap(), "whisper-1"); - assert_eq!(form.get("response_format").unwrap(), "verbose_json"); - assert_eq!(form.get("temperature").unwrap(), "0"); - assert_eq!(form.get("timestamp_granularities[]").unwrap(), "segment"); -} - /// TS: "should work when no words, language, or duration are returned" #[tokio::test] async fn should_work_without_optional_fields() { @@ -439,105 +352,6 @@ async fn should_work_without_optional_fields() { assert_eq!(headers.get("content-type").unwrap(), "application/json"); } -/// TS: "should parse segments when provided in response" -#[tokio::test] -async fn should_parse_segments() { - let server = MockServer::start().await; - mock_json_response( - &server, - &json!({ - "task": "transcribe", - "text": "Hello world. How are you?", - "segments": [ - { - "id": 0, "seek": 0, "start": 0.0, "end": 2.5, - "text": "Hello world.", "tokens": [1234, 5678], - "temperature": 0.0, "avg_logprob": -0.5, - "compression_ratio": 1.2, "no_speech_prob": 0.1 - }, - { - "id": 1, "seek": 250, "start": 2.5, "end": 5.0, - "text": " How are you?", "tokens": [9012, 3456], - "temperature": 0.0, "avg_logprob": -0.6, - "compression_ratio": 1.1, "no_speech_prob": 0.05 - } - ], - "language": "english", - "duration": 5.0, - "_request_id": "req_1234" - }), - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.transcription("whisper-1"); - - let mut options = transcription_options(mock_audio_data(), "audio/wav"); - let mut po = HashMap::new(); - po.insert( - "openai".to_string(), - json!({"timestampGranularities": ["segment"]}), - ); - options.provider_options = Some(po); - - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.segments.len(), 2); - assert_eq!(result.segments[0].text, "Hello world."); - assert_eq!(result.segments[0].start_second, 0.0); - assert_eq!(result.segments[0].end_second, 2.5); - assert_eq!(result.segments[1].text, " How are you?"); - assert_eq!(result.segments[1].start_second, 2.5); - assert_eq!(result.segments[1].end_second, 5.0); - assert_eq!(result.text, "Hello world. How are you?"); - assert_eq!(result.duration_in_seconds, Some(5.0)); - assert_eq!(result.language, Some("en".to_string())); -} - -/// TS: "should fallback to words when segments are not available" -#[tokio::test] -async fn should_fallback_to_words() { - let server = MockServer::start().await; - mock_json_response( - &server, - &json!({ - "task": "transcribe", - "text": "Hello world", - "words": [ - {"word": "Hello", "start": 0.0, "end": 1.0}, - {"word": "world", "start": 1.0, "end": 2.0} - ], - "language": "english", - "duration": 2.0, - "_request_id": "req_1234" - }), - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.transcription("whisper-1"); - - let mut options = transcription_options(mock_audio_data(), "audio/wav"); - let mut po = HashMap::new(); - po.insert( - "openai".to_string(), - json!({"timestampGranularities": ["word"]}), - ); - options.provider_options = Some(po); - - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.segments.len(), 2); - assert_eq!(result.segments[0].text, "Hello"); - assert_eq!(result.segments[0].start_second, 0.0); - assert_eq!(result.segments[0].end_second, 1.0); - assert_eq!(result.segments[1].text, "world"); - assert_eq!(result.segments[1].start_second, 1.0); - assert_eq!(result.segments[1].end_second, 2.0); -} - /// TS: "should handle empty segments array" #[tokio::test] async fn should_handle_empty_segments() { diff --git a/aimux-providers/tests/openrouter_test.rs b/aimux-providers/tests/openrouter_test.rs index 76d4f3bbd..cca5ff453 100644 --- a/aimux-providers/tests/openrouter_test.rs +++ b/aimux-providers/tests/openrouter_test.rs @@ -24,11 +24,9 @@ use serial_test::serial; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::{CallOptions, Tool}; use aimux_core::provider::Provider; use aimux_core::result::GenerateContent; @@ -45,11 +43,7 @@ mod common; // ── shared helpers ─────────────────────────────────────────────────────────── fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { @@ -560,35 +554,6 @@ async fn status_429_maps_to_rate_limited() { ); } -/// The raw response headers are exposed on the generate result. -#[tokio::test] -async fn exposes_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(text_completion_body()), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("openai/gpt-4o-mini"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - // ════════════════════════════════════════════════════════════════════════════ // Conformance — real recorded API responses (cassettes). // @@ -626,7 +591,7 @@ mod conformance { fn has_reasoning(content: &[GenerateContent]) -> bool { content .iter() - .any(|c| matches!(c, GenerateContent::Reasoning { .. })) + .any(|c| matches!(c, GenerateContent::Reasoning(_))) } fn has_finish(parts: &[StreamPart]) -> bool { diff --git a/aimux-providers/tests/perplexity_test.rs b/aimux-providers/tests/perplexity_test.rs index 4dd6ac16c..e59e4ea6d 100644 --- a/aimux-providers/tests/perplexity_test.rs +++ b/aimux-providers/tests/perplexity_test.rs @@ -26,11 +26,9 @@ use serial_test::serial; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; @@ -41,11 +39,7 @@ use aimux_providers::{ProviderOptions, provider, provider_from_env}; // ── shared helpers ─────────────────────────────────────────────────────────── fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { @@ -451,31 +445,3 @@ async fn status_429_maps_to_rate_limited() { "expected RateLimited, got {result:?}" ); } - -/// TS: "should expose the raw response headers". -#[tokio::test] -async fn exposes_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(text_completion_body()), - ) - .mount(&server) - .await; - - let model = make_provider(&server); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} diff --git a/aimux-providers/tests/provider_error_test.rs b/aimux-providers/tests/provider_error_test.rs index a4a2857cc..fad95c2d0 100644 --- a/aimux-providers/tests/provider_error_test.rs +++ b/aimux-providers/tests/provider_error_test.rs @@ -23,11 +23,9 @@ //! yields `AiMuxError::ApiCall` with the observed code in `status_code`; //! 408/409/429/5xx are stored retryable (`is_retryable`), other 4xx are not. -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_core::stream_part::StreamPart; use aimux_provider_utils::RetryConfig; @@ -41,11 +39,7 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; /// The TS `TEST_PROMPT`: a single user text message "Hello". fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } /// Build `CallOptions` with everything unset except `prompt`. diff --git a/aimux-providers/tests/reasoning_map_test.rs b/aimux-providers/tests/reasoning_map_test.rs index 68ffcb0c7..0f9bdc6d1 100644 --- a/aimux-providers/tests/reasoning_map_test.rs +++ b/aimux-providers/tests/reasoning_map_test.rs @@ -14,9 +14,7 @@ //! - **reasoning_effort 直传**:7 档无归一化(none/minimal/low/medium/high/xhigh 原样 //! 透传;provider-default 不发字段)。 -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_core::types::{ReasoningEffort, Warning}; use aimux_providers::openai::OpenAICompatProfile; @@ -25,11 +23,7 @@ use aimux_providers::provider_registry_entry; use serde_json::json; fn user_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn opts_with_max_tokens(n: u32) -> CallOptions { diff --git a/aimux-providers/tests/recraft_test.rs b/aimux-providers/tests/recraft_test.rs index ad3d2705f..3a7df642c 100644 --- a/aimux-providers/tests/recraft_test.rs +++ b/aimux-providers/tests/recraft_test.rs @@ -128,34 +128,6 @@ async fn should_pass_model_prompt_n_and_default_response_format() { assert_eq!(body["response_format"], "b64_json"); } -#[tokio::test] -async fn should_forward_recraft_extension_fields() { - let server = MockServer::start().await; - mock_generations_response(&server, b64_response_body()).await; - - let model = make_model(&server); - let mut opts = options(PROMPT); - opts.provider_options.insert( - "recraft".to_string(), - json!({ - "style": "digital_illustration", - "styleId": "50c0b14e-3e4f-4a18-9d8e-2b1f0a1c2d3e", - "negativePrompt": "blurry, low quality", - "randomSeed": 12345, - "responseFormat": "b64_json" - }), - ); - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["style"], "digital_illustration"); - assert_eq!(body["style_id"], "50c0b14e-3e4f-4a18-9d8e-2b1f0a1c2d3e"); - assert_eq!(body["negative_prompt"], "blurry, low quality"); - assert_eq!(body["random_seed"], 12345); - assert_eq!(body["response_format"], "b64_json"); -} - #[tokio::test] async fn should_map_options_seed_to_random_seed() { let server = MockServer::start().await; diff --git a/aimux-providers/tests/replicate_image_test.rs b/aimux-providers/tests/replicate_image_test.rs index 8d5206282..4ee5d239d 100644 --- a/aimux-providers/tests/replicate_image_test.rs +++ b/aimux-providers/tests/replicate_image_test.rs @@ -112,24 +112,6 @@ async fn should_pass_auth_headers() { ); } -#[tokio::test] -async fn should_use_custom_wait_time() { - let server = MockServer::start().await; - mock_replicate(&server, json!(format!("{}/image.png", server.uri()))).await; - let config = ReplicateConfig::new("test-token").with_base_url(server.uri()); - let model = ReplicateProvider::new(config).image("black-forest-labs/flux-schnell"); - let mut opts = options(PROMPT); - opts.n = 1; - opts.provider_options - .insert("replicate".into(), json!({ "maxWaitTimeInSeconds": 30 })); - model.do_generate(&opts).await.unwrap(); - let reqs = server.received_requests().await.unwrap(); - assert_eq!( - reqs[0].headers.get("prefer").unwrap().to_str().unwrap(), - "wait=30" - ); -} - #[tokio::test] async fn should_handle_array_output() { let server = MockServer::start().await; diff --git a/aimux-providers/tests/runtime_decision_test.rs b/aimux-providers/tests/runtime_decision_test.rs index 8473e90dd..5d029c14d 100644 --- a/aimux-providers/tests/runtime_decision_test.rs +++ b/aimux-providers/tests/runtime_decision_test.rs @@ -18,6 +18,13 @@ use wiremock::{Mock, MockServer, Request, ResponseTemplate}; mod common; +fn object(value: Value) -> serde_json::Map { + value + .as_object() + .cloned() + .expect("provider options must be an object") +} + fn fixture() -> Value { serde_json::from_str(include_str!("fixtures/runtime_decisions.json")).unwrap() } @@ -184,7 +191,9 @@ async fn conditional_skips_and_abstention_do_not_become_false_answers() { if provider == "vllm" { request.provider_options = Some(HashMap::from([( "vllm".into(), - json!({"samples":"auto","steps":4,"questions":{"severity":{"ask_if":{"team":["billing"]}}}}), + object( + json!({"samples":"auto","steps":4,"questions":{"severity":{"ask_if":{"team":["billing"]}}}}), + ), )])); expected["samples"] = json!("auto"); expected["steps"] = json!(4); @@ -194,7 +203,7 @@ async fn conditional_skips_and_abstention_do_not_become_false_answers() { } else { request.provider_options = Some(HashMap::from([( "laya".into(), - json!({"min_confidence":0.8,"lang":"en"}), + object(json!({"min_confidence":0.8,"lang":"en"})), )])); expected["min_confidence"] = json!(0.8); expected["lang"] = json!("en"); @@ -220,7 +229,9 @@ async fn sglang_native_decisions_keeps_label_mass_separate_from_confidence() { let mut request = options(); request.provider_options = Some(HashMap::from([( "sglang".into(), - json!({"protocol":"decisions","temperature":0.7,"return_prompt_token_ids":true,"prompt_format_version":1}), + object( + json!({"protocol":"decisions","temperature":0.7,"return_prompt_token_ids":true,"prompt_format_version":1}), + ), )])); let response = json!({"object":"decisions","model":"served-decision","prompt_format_version":1,"answers":{ "urgent":{"type":"yes_no","probabilities":{"yes":0.75,"no":0.25},"label_mass":0.1}, @@ -301,11 +312,13 @@ fn scoring_options(provider: &str) -> DecisionCallOptions { let mut request = options(); request.provider_options = Some(HashMap::from([( provider.into(), - json!({"protocol":if provider=="sglang" {"score"} else {"generative_scoring"},"model_revision":"weights-commit","tokenizer_revision":"tokenizer-commit","prompt_format_version":"test-v1","encoded":{ - "urgent":{"prompt_token_ids":[11,12],"label_token_ids":[1,2]}, - "team":{"prompt_token_ids":[21,22],"label_token_ids":[3,4]}, - "severity":{"prompt_token_ids":[31,32],"label_token_ids":[5,6,7]} - }}), + object( + json!({"protocol":if provider=="sglang" {"score"} else {"generative_scoring"},"model_revision":"weights-commit","tokenizer_revision":"tokenizer-commit","prompt_format_version":"test-v1","encoded":{ + "urgent":{"prompt_token_ids":[11,12],"label_token_ids":[1,2]}, + "team":{"prompt_token_ids":[21,22],"label_token_ids":[3,4]}, + "severity":{"prompt_token_ids":[31,32],"label_token_ids":[5,6,7]} + }}), + ), )])); request } @@ -500,7 +513,7 @@ async fn unsupported_fields_and_bad_encodings_fail_before_http() { let mut request = options(); request.provider_options = Some(HashMap::from([( "ollama".into(), - json!({"temperature":0.7}), + object(json!({"temperature":0.7})), )])); assert!( decide(model("ollama", &server).as_ref(), request) @@ -508,7 +521,10 @@ async fn unsupported_fields_and_bad_encodings_fail_before_http() { .is_err() ); let mut request = options(); - request.provider_options = Some(HashMap::from([("vllm".into(), json!({"ask":["urgent"]}))])); + request.provider_options = Some(HashMap::from([( + "vllm".into(), + object(json!({"ask":["urgent"]})), + )])); assert!( decide(model("vllm", &server).as_ref(), request) .await diff --git a/aimux-providers/tests/thin_wrapper_config_test.rs b/aimux-providers/tests/thin_wrapper_config_test.rs index 7e5624f2f..5eb2a5c71 100644 --- a/aimux-providers/tests/thin_wrapper_config_test.rs +++ b/aimux-providers/tests/thin_wrapper_config_test.rs @@ -21,10 +21,8 @@ use serde_json::{Value, json}; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_core::provider::Provider; @@ -35,11 +33,7 @@ use aimux_providers::{ // ── shared helpers ─────────────────────────────────────────────────────────── fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { diff --git a/aimux-providers/tests/vertex_transcription_test.rs b/aimux-providers/tests/vertex_transcription_test.rs index 4498d0bcd..15accfcb0 100644 --- a/aimux-providers/tests/vertex_transcription_test.rs +++ b/aimux-providers/tests/vertex_transcription_test.rs @@ -186,39 +186,3 @@ async fn should_handle_no_results() { assert_eq!(result.language, None); assert_eq!(result.duration_in_seconds, None); } - -#[tokio::test] -async fn should_use_provider_options_for_region_and_language() { - let server = MockServer::start().await; - // Different region → different path - let path_str = "/v2/projects/test-project/locations/us/recognizers/_:recognize"; - Mock::given(method("POST")) - .and(path(path_str)) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/json") - .set_body_json(default_body()), - ) - .mount(&server) - .await; - - let config = VertexProviderConfig::new("test-token", "test-project", "us-central1") - .with_base_url(server.uri()); - let provider = VertexProvider::new(config); - let model = provider.transcription("chirp_2").unwrap(); - - let mut opts = options(mock_audio(), "audio/wav"); - let mut po = HashMap::new(); - po.insert( - "googleVertex".to_string(), - json!({"region": "us", "languageCodes": ["en", "es"]}), - ); - opts.provider_options = Some(po); - - let result = model.do_generate(&opts).await.unwrap(); - - assert_eq!(result.text, "hello world"); - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["config"]["languageCodes"], json!(["en", "es"])); -} diff --git a/aimux-providers/tests/voyage_embedding_test.rs b/aimux-providers/tests/voyage_embedding_test.rs index 8c32565ec..fede560e0 100644 --- a/aimux-providers/tests/voyage_embedding_test.rs +++ b/aimux-providers/tests/voyage_embedding_test.rs @@ -173,70 +173,6 @@ async fn should_pass_model_and_values() { assert!(body.get("input_type").is_none()); } -/// TS: "should pass the input_type setting" -#[tokio::test] -async fn should_pass_input_type_setting() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/embeddings")) - .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) - .mount(&server) - .await; - - let config = VoyageConfig::new("test-api-key").with_base_url(server.uri()); - let provider = VoyageProvider::new(config); - let model = provider.embedding_model("voyage-3.5"); - - let mut provider_options = HashMap::new(); - provider_options.insert("voyage".to_string(), json!({"inputType": "document"})); - let options = EmbeddingCallOptions { - values: test_values(), - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["input_type"], "document"); -} - -/// TS: "should pass the output_dimension setting" -#[tokio::test] -async fn should_pass_output_dimension_setting() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/embeddings")) - .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) - .mount(&server) - .await; - - let config = VoyageConfig::new("test-api-key").with_base_url(server.uri()); - let provider = VoyageProvider::new(config); - let model = provider.embedding_model("voyage-3.5"); - - let mut provider_options = HashMap::new(); - provider_options.insert("voyage".to_string(), json!({"outputDimension": 256})); - let options = EmbeddingCallOptions { - values: test_values(), - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["output_dimension"], 256); -} - /// TS: "should pass headers" #[tokio::test] async fn should_pass_headers() { diff --git a/bindings/flutter/lib/types.dart b/bindings/flutter/lib/types.dart index 52c564fc9..07fe1e7df 100644 --- a/bindings/flutter/lib/types.dart +++ b/bindings/flutter/lib/types.dart @@ -15,7 +15,7 @@ // - ContentPart (content.rs) — sealed, internally-tagged (type) // - FileData/FileBytes (shared.rs) — sealed, externally-tagged // - ToolCall (tool.rs:102) -// - Usage / TokenUsage (types.rs:33, types.rs:44) +// - Usage / InputTokenUsage / OutputTokenUsage (types.rs:33, types.rs:44) // - FinishReason (types.rs:10) import 'dart:async'; @@ -76,43 +76,38 @@ enum ReasoningEffort { // Token usage // ───────────────────────────────────────────────────────────────────────────── -/// Token usage detail (with cache breakdown). Mirrors `TokenUsage.ts`. +/// Input token usage detail with cache breakdown. /// /// All fields are nullable: `total` is always serialized by the Rust core /// (null when unknown), the rest are omitted when `None`. @JsonSerializable() -class TokenUsage { +class InputTokenUsage { + final int? total; + @JsonKey(name: 'no_cache') final int? noCache; + @JsonKey(name: 'cache_read') final int? cacheRead; + @JsonKey(name: 'cache_write') final int? cacheWrite; + InputTokenUsage({this.total, this.noCache, this.cacheRead, this.cacheWrite}); + factory InputTokenUsage.fromJson(Map json) => _$InputTokenUsageFromJson(json); + Map toJson() => _$InputTokenUsageToJson(this); +} + +@JsonSerializable() +class OutputTokenUsage { final int? total; - @JsonKey(name: 'no_cache') - final int? noCache; - @JsonKey(name: 'cache_read') - final int? cacheRead; - @JsonKey(name: 'cache_write') - final int? cacheWrite; final int? text; final int? reasoning; - - TokenUsage({ - this.total, - this.noCache, - this.cacheRead, - this.cacheWrite, - this.text, - this.reasoning, - }); - - factory TokenUsage.fromJson(Map json) => - _$TokenUsageFromJson(json); - Map toJson() => _$TokenUsageToJson(this); + OutputTokenUsage({this.total, this.text, this.reasoning}); + factory OutputTokenUsage.fromJson(Map json) => _$OutputTokenUsageFromJson(json); + Map toJson() => _$OutputTokenUsageToJson(this); } /// Token usage statistics. Mirrors `Usage.ts`. @JsonSerializable() class Usage { @JsonKey(name: 'input_tokens') - final TokenUsage inputTokens; + final InputTokenUsage inputTokens; @JsonKey(name: 'output_tokens') - final TokenUsage outputTokens; + final OutputTokenUsage outputTokens; /// Raw usage information from the provider (opaque JSON). final Map? raw; @@ -419,6 +414,17 @@ class ResponseMetadata { Map toJson() => _$ResponseMetadataToJson(this); } +class GenerateResponse { + final String? id; + final String? timestamp; + final String? modelId; + final dynamic body; + final Map? headers; + GenerateResponse({this.id, this.timestamp, this.modelId, this.body, this.headers}); + factory GenerateResponse.fromJson(Map json) => GenerateResponse(id: json['id'] as String?, timestamp: json['timestamp'] as String?, modelId: json['model_id'] as String?, body: json['body'], headers: (json['headers'] as Map?)?.map((key, value) => MapEntry(key, value as String))); + Map toJson() => {'id': id, 'timestamp': timestamp, 'model_id': modelId, if (body != null) 'body': body, if (headers != null) 'headers': headers}; +} + /// A content item in a `GenerateResult`. Mirrors `GenerateContent.ts` /// (`aimux-core/src/result.rs`). /// @@ -455,6 +461,9 @@ sealed class GenerateContent { 'ToolCall' => GenerateContentToolCall.fromJson(payload), 'Source' => GenerateContentSource.fromJson(payload), 'Reasoning' => GenerateContentReasoning.fromJson(payload), + 'Custom' => GenerateContentCustom.fromJson(payload), + 'ReasoningFile' => GenerateContentReasoningFile.fromJson(payload), + 'ToolApprovalRequest' => GenerateContentToolApprovalRequest.fromJson(payload), 'File' => GenerateContentFile.fromJson(payload), 'ToolResult' => GenerateContentToolResult.fromJson(payload), _ => GenerateContentUnknown(tag: e.key, data: payload), @@ -533,45 +542,75 @@ final class GenerateContentToolCall extends GenerateContent { }; } -/// A source / citation (e.g. URL citation from search-preview models). -final class GenerateContentSource extends GenerateContent { +sealed class Source { + const Source(); + + factory Source.fromJson(Map json) => switch (json['source_type']) { + 'url' => UrlSource( + id: json['id'] as String, + url: json['url'] as String, + title: json['title'] as String?, + providerMetadata: json['provider_metadata'] as Map?), + 'document' => DocumentSource( + id: json['id'] as String, + mediaType: json['media_type'] as String, + title: json['title'] as String, + filename: json['filename'] as String?, + providerMetadata: json['provider_metadata'] as Map?), + _ => throw FormatException('invalid source type: ${json['source_type']}'), + }; + + Map toJson(); +} + +final class UrlSource extends Source { final String id; - final String sourceType; - final String? url; + final String url; final String? title; final Map? providerMetadata; - GenerateContentSource({ - required this.id, - required this.sourceType, - this.url, - this.title, - this.providerMetadata, - }); + const UrlSource({required this.id, required this.url, this.title, this.providerMetadata}); + + @override + Map toJson() => { + 'source_type': 'url', 'id': id, 'url': url, + if (title != null) 'title': title, + if (providerMetadata != null) 'provider_metadata': providerMetadata, + }; +} + +final class DocumentSource extends Source { + final String id; + final String mediaType; + final String title; + final String? filename; + final Map? providerMetadata; + + const DocumentSource({required this.id, required this.mediaType, required this.title, + this.filename, this.providerMetadata}); + + @override + Map toJson() => { + 'source_type': 'document', 'id': id, 'media_type': mediaType, 'title': title, + if (filename != null) 'filename': filename, + if (providerMetadata != null) 'provider_metadata': providerMetadata, + }; +} + +/// A source / citation (e.g. URL citation from search-preview models). +final class GenerateContentSource extends GenerateContent { + final Source source; + + GenerateContentSource({required this.source}); @override String get tag => 'Source'; factory GenerateContentSource.fromJson(Map json) => - GenerateContentSource( - id: json['id'] as String, - sourceType: json['source_type'] as String, - url: json['url'] as String?, - title: json['title'] as String?, - providerMetadata: - json['provider_metadata'] as Map?, - ); + GenerateContentSource(source: Source.fromJson(json)); @override - Map toJson() => { - 'Source': { - 'id': id, - 'source_type': sourceType, - if (url != null) 'url': url, - if (title != null) 'title': title, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }, - }; + Map toJson() => {'Source': source.toJson()}; } /// A reasoning / thinking segment produced by the model. @@ -603,7 +642,7 @@ final class GenerateContentReasoning extends GenerateContent { /// A file generated by the model (e.g. an image or document). The `data` /// field is a `FileData` tagged union; there is **no** `filename` field. final class GenerateContentFile extends GenerateContent { - final FileData data; + final GeneratedFileData data; final String mediaType; final Map? providerMetadata; @@ -618,7 +657,7 @@ final class GenerateContentFile extends GenerateContent { factory GenerateContentFile.fromJson(Map json) => GenerateContentFile( - data: FileData.fromJson(json['data'] as Map), + data: GeneratedFileData.fromJson(json['data'] as Map), mediaType: json['media_type'] as String, providerMetadata: json['provider_metadata'] as Map?, @@ -689,6 +728,35 @@ final class GenerateContentToolResult extends GenerateContent { /// Newer core versions may emit tags this binding does not model yet; this /// class passes them through verbatim (`{tag: data}`) instead of discarding /// or crashing. +final class GenerateContentCustom extends GenerateContent { + final String kind; + final Map? providerMetadata; + GenerateContentCustom({required this.kind, this.providerMetadata}); + @override String get tag => 'Custom'; + factory GenerateContentCustom.fromJson(Map json) => GenerateContentCustom(kind: json['kind'] as String, providerMetadata: json['provider_metadata'] as Map?); + @override Map toJson() => {'Custom': {'kind': kind, if (providerMetadata != null) 'provider_metadata': providerMetadata}}; +} + +final class GenerateContentReasoningFile extends GenerateContent { + final GeneratedFileData data; + final String mediaType; + final Map? providerMetadata; + GenerateContentReasoningFile({required this.data, required this.mediaType, this.providerMetadata}); + @override String get tag => 'ReasoningFile'; + factory GenerateContentReasoningFile.fromJson(Map json) => GenerateContentReasoningFile(data: GeneratedFileData.fromJson(json['data'] as Map), mediaType: json['media_type'] as String, providerMetadata: json['provider_metadata'] as Map?); + @override Map toJson() => {'ReasoningFile': {'data': data.toJson(), 'media_type': mediaType, if (providerMetadata != null) 'provider_metadata': providerMetadata}}; +} + +final class GenerateContentToolApprovalRequest extends GenerateContent { + final String approvalId; + final String toolCallId; + final Map? providerMetadata; + GenerateContentToolApprovalRequest({required this.approvalId, required this.toolCallId, this.providerMetadata}); + @override String get tag => 'ToolApprovalRequest'; + factory GenerateContentToolApprovalRequest.fromJson(Map json) => GenerateContentToolApprovalRequest(approvalId: json['approval_id'] as String, toolCallId: json['tool_call_id'] as String, providerMetadata: json['provider_metadata'] as Map?); + @override Map toJson() => {'ToolApprovalRequest': {'approval_id': approvalId, 'tool_call_id': toolCallId, if (providerMetadata != null) 'provider_metadata': providerMetadata}}; +} + final class GenerateContentUnknown extends GenerateContent { @override final String tag; @@ -706,21 +774,21 @@ class GenerateResult { final Usage usage; final List> warnings; final Map? providerMetadata; - final ResponseMetadata response; + final GenerateResponse? response; final Map? requestBody; final Map? responseHeaders; - GenerateResult({required this.content, required this.finishReason, required this.usage, this.warnings = const [], this.providerMetadata, required this.response, this.requestBody, this.responseHeaders}); + GenerateResult({required this.content, required this.finishReason, required this.usage, this.warnings = const [], this.providerMetadata, this.response, this.requestBody, this.responseHeaders}); factory GenerateResult.fromJson(Map json) => GenerateResult( content: (json['content'] as List? ?? []).map((e) => GenerateContent.fromJson(e as Map)).toList(), finishReason: FinishReason.fromJson(json['finish_reason'] as Map), usage: Usage.fromJson(json['usage'] as Map), warnings: (json['warnings'] as List? ?? []).map((e) => e as Map).toList(), providerMetadata: json['provider_metadata'] as Map?, - response: ResponseMetadata.fromJson(json['response'] as Map), + response: json['response'] == null ? null : GenerateResponse.fromJson(json['response'] as Map), requestBody: json['request_body'] as Map?, responseHeaders: json['response_headers'] as Map?, ); - Map toJson() => {'content': content.map((c) => c.toJson()).toList(), 'finish_reason': finishReason.toJson(), 'usage': usage.toJson(), 'warnings': warnings, 'response': response.toJson(), if (providerMetadata != null) 'provider_metadata': providerMetadata, if (requestBody != null) 'request_body': requestBody, if (responseHeaders != null) 'response_headers': responseHeaders}; + Map toJson() => {'content': content.map((c) => c.toJson()).toList(), 'finish_reason': finishReason.toJson(), 'usage': usage.toJson(), 'warnings': warnings, 'response': response?.toJson(), if (providerMetadata != null) 'provider_metadata': providerMetadata, if (requestBody != null) 'request_body': requestBody, if (responseHeaders != null) 'response_headers': responseHeaders}; } // ───────────────────────────────────────────────────────────────────────────── @@ -731,6 +799,7 @@ class GenerateResult { @JsonSerializable() class GenerateTextResult { final String text; + final List> content; @JsonKey(name: 'tool_calls') final List toolCalls; @JsonKey(name: 'finish_reason') @@ -742,7 +811,7 @@ class GenerateTextResult { final List> reasoning; @JsonKey(name: 'reasoning_text') final String reasoningText; - final List> sources; + final List sources; final List> files; @JsonKey(name: 'response_messages') final List responseMessages; @@ -762,6 +831,7 @@ class GenerateTextResult { GenerateTextResult({ required this.text, + this.content = const [], required this.toolCalls, required this.finishReason, required this.usage, @@ -828,18 +898,19 @@ class GenerateObjectResult { /// `GenerateTextResult`'s user-facing fields (without `raw`, since streaming /// has no `GenerateResult` equivalent). /// -/// Mirrors `StreamTextResultAggregated.ts`. reasoning/sources/files use weak +/// Mirrors `StreamTextResultAggregated.ts`. reasoning/files use weak /// types (`Map`) — same strategy as `GenerateTextResult`. @JsonSerializable() class StreamTextResultAggregated { final String text; - // reasoning/sources/files use weak types — same strategy as GenerateTextResult. + final List> content; + // reasoning/files use weak types — same strategy as GenerateTextResult. final List> reasoning; @JsonKey(name: 'reasoning_text') final String reasoningText; @JsonKey(name: 'tool_calls') final List toolCalls; - final List> sources; + final List sources; final List> files; @JsonKey(name: 'finish_reason') final FinishReason finishReason; @@ -1126,6 +1197,9 @@ sealed class StreamPart { StreamPartToolInputEnd() => 'ToolInputEnd', StreamPartToolCall() => 'ToolCall', StreamPartToolResult() => 'ToolResult', + StreamPartCustom() => 'Custom', + StreamPartReasoningFile() => 'ReasoningFile', + StreamPartToolApprovalRequest() => 'ToolApprovalRequest', StreamPartFile() => 'File', StreamPartReasoningStart() => 'ReasoningStart', StreamPartReasoningDelta() => 'ReasoningDelta', @@ -1156,6 +1230,9 @@ sealed class StreamPart { 'ToolInputEnd' => StreamPartToolInputEnd.fromJson(payload), 'ToolCall' => StreamPartToolCall.fromJson(payload), 'ToolResult' => StreamPartToolResult.fromJson(payload), + 'Custom' => StreamPartCustom.fromJson(payload), + 'ReasoningFile' => StreamPartReasoningFile.fromJson(payload), + 'ToolApprovalRequest' => StreamPartToolApprovalRequest.fromJson(payload), 'File' => StreamPartFile.fromJson(payload), 'ReasoningStart' => StreamPartReasoningStart.fromJson(payload), 'ReasoningDelta' => StreamPartReasoningDelta.fromJson(payload), @@ -1516,7 +1593,7 @@ final class StreamPartToolResult extends StreamPart { /// A file generated by the model (e.g. an image or document). The `data` /// field is a `FileData` tagged union; there is **no** `filename` field. final class StreamPartFile extends StreamPart { - final FileData data; + final GeneratedFileData data; final String mediaType; final Map? providerMetadata; @@ -1527,7 +1604,7 @@ final class StreamPartFile extends StreamPart { }); factory StreamPartFile.fromJson(Map json) => StreamPartFile( - data: FileData.fromJson(json['data'] as Map), + data: GeneratedFileData.fromJson(json['data'] as Map), mediaType: json['media_type'] as String, providerMetadata: json['provider_metadata'] as Map?, @@ -1650,40 +1727,15 @@ final class StreamPartResponseMetadata extends StreamPart { /// A source / citation (e.g. URL citation from search-preview models). final class StreamPartSource extends StreamPart { - final String id; - final String sourceType; - final String? url; - final String? title; - final Map? providerMetadata; + final Source source; - StreamPartSource({ - required this.id, - required this.sourceType, - this.url, - this.title, - this.providerMetadata, - }); + StreamPartSource({required this.source}); factory StreamPartSource.fromJson(Map json) => - StreamPartSource( - id: json['id'] as String, - sourceType: json['source_type'] as String, - url: json['url'] as String?, - title: json['title'] as String?, - providerMetadata: - json['provider_metadata'] as Map?, - ); + StreamPartSource(source: Source.fromJson(json)); @override - Map toJson() => { - 'Source': { - 'id': id, - 'source_type': sourceType, - if (url != null) 'url': url, - if (title != null) 'title': title, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }, - }; + Map toJson() => {'Source': source.toJson()}; } /// A raw chunk from the provider (for debugging, when `include_raw_chunks` @@ -1707,6 +1759,33 @@ final class StreamPartRaw extends StreamPart { /// Newer core versions may emit tags this binding does not model yet; this /// class passes them through verbatim (`{tag: data}`) instead of discarding /// or crashing. +final class StreamPartCustom extends StreamPart { + final String kind; + final Map? providerMetadata; + StreamPartCustom({required this.kind, this.providerMetadata}); + factory StreamPartCustom.fromJson(Map json) => StreamPartCustom(kind: json['kind'] as String, providerMetadata: json['provider_metadata'] as Map?); + @override Map toJson() => {'Custom': {'kind': kind, if (providerMetadata != null) 'provider_metadata': providerMetadata}}; +} + +final class StreamPartReasoningFile extends StreamPart { + final GenerateContentFile file; + final Map? providerMetadata; + StreamPartReasoningFile({required this.file, this.providerMetadata}); + factory StreamPartReasoningFile.fromJson(Map json) => StreamPartReasoningFile(file: GenerateContentFile.fromJson(json['file'] as Map), providerMetadata: json['provider_metadata'] as Map?); + @override Map toJson() => {'ReasoningFile': {'file': file.toJson(), if (providerMetadata != null) 'provider_metadata': providerMetadata}}; +} + +final class StreamPartToolApprovalRequest extends StreamPart { + final String approvalId; + final ToolCall toolCall; + final String? reason; + final bool? isAutomatic; + final String? signature; + StreamPartToolApprovalRequest({required this.approvalId, required this.toolCall, this.reason, this.isAutomatic, this.signature}); + factory StreamPartToolApprovalRequest.fromJson(Map json) => StreamPartToolApprovalRequest(approvalId: json['approval_id'] as String, toolCall: ToolCall.fromJson(json['tool_call'] as Map), reason: json['reason'] as String?, isAutomatic: json['is_automatic'] as bool?, signature: json['signature'] as String?); + @override Map toJson() => {'ToolApprovalRequest': {'approval_id': approvalId, 'tool_call': toolCall.toJson(), if (reason != null) 'reason': reason, if (isAutomatic != null) 'is_automatic': isAutomatic, if (signature != null) 'signature': signature}}; +} + final class StreamPartUnknown extends StreamPart { final String tag; final Map data; @@ -1759,7 +1838,7 @@ sealed class FileData { return switch (e.key) { 'Data' => FileDataData( data: FileBytes.fromJson((e.value as Map)['data'] as Map)), - 'Url' => FileDataUrl(url: (e.value as Map)['url'] as String), + 'Url' => FileDataUrl(url: (e.value as Map)['url'] as String, originalUrl: (e.value as Map)['original_url'] as String?), 'Reference' => FileDataReference( reference: (e.value as Map)['reference'] as Map), 'Text' => FileDataText(text: (e.value as Map)['text'] as String), @@ -1778,9 +1857,10 @@ final class FileDataData extends FileData { final class FileDataUrl extends FileData { final String url; - const FileDataUrl({required this.url}); + final String? originalUrl; + const FileDataUrl({required this.url, this.originalUrl}); @override - Map toJson() => {'Url': {'url': url}}; + Map toJson() => {'Url': {'url': url, if (originalUrl != null) 'original_url': originalUrl}}; } final class FileDataReference extends FileData { @@ -1797,6 +1877,35 @@ final class FileDataText extends FileData { Map toJson() => {'Text': {'text': text}}; } +sealed class GeneratedFileData { + const GeneratedFileData(); + factory GeneratedFileData.fromJson(Map json) { + final e = json.entries.first; + return switch (e.key) { + 'Data' => GeneratedFileDataData( + data: FileBytes.fromJson((e.value as Map)['data'] as Map)), + 'Url' => GeneratedFileDataUrl(url: (e.value as Map)['url'] as String, originalUrl: (e.value as Map)['original_url'] as String?), + _ => throw FormatException('unknown GeneratedFileData tag: $e'), + }; + } + Map toJson(); +} + +final class GeneratedFileDataData extends GeneratedFileData { + final FileBytes data; + const GeneratedFileDataData({required this.data}); + @override + Map toJson() => {'Data': {'data': data.toJson()}}; +} + +final class GeneratedFileDataUrl extends GeneratedFileData { + final String url; + final String? originalUrl; + const GeneratedFileDataUrl({required this.url, this.originalUrl}); + @override + Map toJson() => {'Url': {'url': url, if (originalUrl != null) 'original_url': originalUrl}}; +} + // ───────────────────────────────────────────────────────────────────────────── // ContentPart (content.rs) — internally-tagged: {"type": "text", "text": "..."} // ───────────────────────────────────────────────────────────────────────────── @@ -1806,6 +1915,9 @@ sealed class ContentPart { factory ContentPart.fromJson(Map json) { final tag = json['type'] as String; return switch (tag) { + 'custom' => ContentPartCustom.fromJson(json), + 'reasoning_file' => ContentPartReasoningFile.fromJson(json), + 'tool_approval_request' => ContentPartToolApprovalRequest.fromJson(json), 'text' => ContentPartText.fromJson(json), 'image' => ContentPartImage.fromJson(json), 'file' => ContentPartFile.fromJson(json), @@ -2031,6 +2143,35 @@ final class ContentPartToolResult extends ContentPart { }; } +final class ContentPartCustom extends ContentPart { + final String kind; + final Map? providerOptions; + ContentPartCustom({required this.kind, this.providerOptions}); + factory ContentPartCustom.fromJson(Map json) => ContentPartCustom(kind: json['kind'] as String, providerOptions: json['provider_options'] as Map?); + @override Map toJson() => {'type': 'custom', 'kind': kind, if (providerOptions != null) 'provider_options': providerOptions}; +} + +final class ContentPartReasoningFile extends ContentPart { + final GeneratedFileData data; + final String mediaType; + final Map? providerOptions; + ContentPartReasoningFile({required this.data, required this.mediaType, this.providerOptions}); + factory ContentPartReasoningFile.fromJson(Map json) => ContentPartReasoningFile(data: GeneratedFileData.fromJson(json['data'] as Map), mediaType: json['media_type'] as String, providerOptions: json['provider_options'] as Map?); + @override Map toJson() => {'type': 'reasoning_file', 'data': data.toJson(), 'media_type': mediaType, if (providerOptions != null) 'provider_options': providerOptions}; +} + +final class ContentPartToolApprovalRequest extends ContentPart { + final String approvalId; + final String toolCallId; + final String? reason; + final bool? isAutomatic; + final String? signature; + final dynamic inputSchemaInput; + ContentPartToolApprovalRequest({required this.approvalId, required this.toolCallId, this.reason, this.isAutomatic, this.signature, this.inputSchemaInput}); + factory ContentPartToolApprovalRequest.fromJson(Map json) => ContentPartToolApprovalRequest(approvalId: json['approval_id'] as String, toolCallId: json['tool_call_id'] as String, reason: json['reason'] as String?, isAutomatic: json['is_automatic'] as bool?, signature: json['signature'] as String?, inputSchemaInput: json['input_schema_input']); + @override Map toJson() => {'type': 'tool_approval_request', 'approval_id': approvalId, 'tool_call_id': toolCallId, if (reason != null) 'reason': reason, if (isAutomatic != null) 'is_automatic': isAutomatic, if (signature != null) 'signature': signature, if (inputSchemaInput != null) 'input_schema_input': inputSchemaInput}; +} + final class ContentPartUnknown extends ContentPart { final String tag; final Map data; diff --git a/bindings/flutter/lib/types.g.dart b/bindings/flutter/lib/types.g.dart index 1004fd158..5c69ff170 100644 --- a/bindings/flutter/lib/types.g.dart +++ b/bindings/flutter/lib/types.g.dart @@ -7,30 +7,17 @@ part of 'types.dart'; // JsonSerializableGenerator // ************************************************************************** -TokenUsage _$TokenUsageFromJson(Map json) => TokenUsage( - total: (json['total'] as num?)?.toInt(), - noCache: (json['no_cache'] as num?)?.toInt(), - cacheRead: (json['cache_read'] as num?)?.toInt(), - cacheWrite: (json['cache_write'] as num?)?.toInt(), - text: (json['text'] as num?)?.toInt(), - reasoning: (json['reasoning'] as num?)?.toInt(), - ); +InputTokenUsage _$InputTokenUsageFromJson(Map json) => InputTokenUsage(total: (json['total'] as num?)?.toInt(), noCache: (json['no_cache'] as num?)?.toInt(), cacheRead: (json['cache_read'] as num?)?.toInt(), cacheWrite: (json['cache_write'] as num?)?.toInt()); +Map _$InputTokenUsageToJson(InputTokenUsage instance) => {'total': instance.total, 'no_cache': instance.noCache, 'cache_read': instance.cacheRead, 'cache_write': instance.cacheWrite}; -Map _$TokenUsageToJson(TokenUsage instance) => - { - 'total': instance.total, - 'no_cache': instance.noCache, - 'cache_read': instance.cacheRead, - 'cache_write': instance.cacheWrite, - 'text': instance.text, - 'reasoning': instance.reasoning, - }; +OutputTokenUsage _$OutputTokenUsageFromJson(Map json) => OutputTokenUsage(total: (json['total'] as num?)?.toInt(), text: (json['text'] as num?)?.toInt(), reasoning: (json['reasoning'] as num?)?.toInt()); +Map _$OutputTokenUsageToJson(OutputTokenUsage instance) => {'total': instance.total, 'text': instance.text, 'reasoning': instance.reasoning}; Usage _$UsageFromJson(Map json) => Usage( inputTokens: - TokenUsage.fromJson(json['input_tokens'] as Map), + InputTokenUsage.fromJson(json['input_tokens'] as Map), outputTokens: - TokenUsage.fromJson(json['output_tokens'] as Map), + OutputTokenUsage.fromJson(json['output_tokens'] as Map), raw: json['raw'] as Map?, ); @@ -111,6 +98,7 @@ Map _$ResponseMetadataToJson(ResponseMetadata instance) => GenerateTextResult _$GenerateTextResultFromJson(Map json) => GenerateTextResult( + content: (json['content'] as List? ?? []).map((item) => item as Map).toList(), text: json['text'] as String, toolCalls: (json['tool_calls'] as List) .map((e) => ToolCall.fromJson(e as Map)) @@ -129,7 +117,7 @@ GenerateTextResult _$GenerateTextResultFromJson(Map json) => const [], reasoningText: json['reasoning_text'] as String? ?? '', sources: (json['sources'] as List?) - ?.map((e) => e as Map) + ?.map((e) => Source.fromJson(e as Map)) .toList() ?? const [], files: (json['files'] as List?) @@ -147,12 +135,13 @@ GenerateTextResult _$GenerateTextResultFromJson(Map json) => : ResponseMetadata(), totalUsage: json['total_usage'] != null ? Usage.fromJson(json['total_usage'] as Map) - : Usage(inputTokens: TokenUsage(), outputTokens: TokenUsage()), + : Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), ); Map _$GenerateTextResultToJson(GenerateTextResult instance) => { 'text': instance.text, + 'content': instance.content, 'tool_calls': instance.toolCalls, 'finish_reason': instance.finishReason, 'usage': instance.usage, @@ -178,7 +167,7 @@ GenerateObjectResult _$GenerateObjectResultFromJson( rawFinishReason: json['raw_finish_reason'] as String?, usage: json['usage'] != null ? Usage.fromJson(json['usage'] as Map) - : Usage(inputTokens: TokenUsage(), outputTokens: TokenUsage()), + : Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), warnings: (json['warnings'] as List?) ?.map((e) => e as Map) .toList() ?? @@ -208,6 +197,7 @@ Map _$GenerateObjectResultToJson( StreamTextResultAggregated _$StreamTextResultAggregatedFromJson( Map json) => StreamTextResultAggregated( + content: (json['content'] as List? ?? []).map((item) => item as Map).toList(), text: json['text'] as String? ?? '', reasoning: (json['reasoning'] as List?) ?.map((e) => e as Map) @@ -219,7 +209,7 @@ StreamTextResultAggregated _$StreamTextResultAggregatedFromJson( .toList() ?? const [], sources: (json['sources'] as List?) - ?.map((e) => e as Map) + ?.map((e) => Source.fromJson(e as Map)) .toList() ?? const [], files: (json['files'] as List?) @@ -231,10 +221,10 @@ StreamTextResultAggregated _$StreamTextResultAggregatedFromJson( rawFinishReason: json['raw_finish_reason'] as String?, usage: json['usage'] != null ? Usage.fromJson(json['usage'] as Map) - : Usage(inputTokens: TokenUsage(), outputTokens: TokenUsage()), + : Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), totalUsage: json['total_usage'] != null ? Usage.fromJson(json['total_usage'] as Map) - : Usage(inputTokens: TokenUsage(), outputTokens: TokenUsage()), + : Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), warnings: (json['warnings'] as List?) ?.map((e) => e as Map) .toList() ?? @@ -253,6 +243,7 @@ Map _$StreamTextResultAggregatedToJson( StreamTextResultAggregated instance) => { 'text': instance.text, + 'content': instance.content, 'reasoning': instance.reasoning, 'reasoning_text': instance.reasoningText, 'tool_calls': instance.toolCalls, diff --git a/bindings/flutter/test/typed_model_test.dart b/bindings/flutter/test/typed_model_test.dart index 5c967f25d..838deb5f3 100644 --- a/bindings/flutter/test/typed_model_test.dart +++ b/bindings/flutter/test/typed_model_test.dart @@ -319,58 +319,7 @@ void main() { const apiKey = 'sk-test-mock-key'; const modelId = 'gpt-4o'; - test('generateText returns a typed GenerateTextResult (text + tool calls)', () async { - final server = await startMockServer([ - {'body': toolCallOpenAIResponse}, - ]); - addTearDown(server.close); - final resultJson = await runTypedGenerateText(_TypedArgs( - baseUrl: server.baseUrl, - apiKey: apiKey, - modelId: modelId, - prompt: 'What is the weather in Tokyo?', - tools: [_weatherTool()], - toolChoice: ToolChoice.required, - maxOutputTokens: 1024, - temperature: 0.7, - )); - final result = GenerateTextResult.fromJson(resultJson); - - // Typed field access on the result. - expect(result, isA()); - expect(result.text, isA()); - expect(result.toolCalls, hasLength(1)); - expect(result.toolCalls[0], isA()); - expect(result.toolCalls[0].toolName, 'get_weather'); - expect(result.toolCalls[0].toolCallId, 'call_abc'); - expect(result.toolCalls[0].input?['location'], 'Tokyo'); - expect(result.raw, isA()); - - // finish_reason + usage are typed, not dynamic maps. - expect(result.finishReason, isA()); - expect(result.finishReason.unified, 'tool-calls'); - expect(result.finishReason.raw, 'tool_calls'); - expect(result.usage, isA()); - expect(result.usage.inputTokens, isA()); - expect(result.usage.inputTokens.total, 20); - expect(result.usage.outputTokens.total, 10); - - // The raw provider result carries a ToolCall content variant. - final content = result.raw.content; - expect(content.any((c) => c.tag == 'ToolCall'), isTrue); - - // tools / tool_choice / max_output_tokens / temperature reached provider. - expect(server.recorded, hasLength(1)); - expect(server.recorded.first.method, 'POST'); - expect(server.recorded.first.path, '/chat/completions'); - final sentBody = server.recorded.first.body as Map; - expect(sentBody, contains('tools')); - expect(sentBody['tool_choice'], 'required'); - // gpt-4o is a non-reasoning model, so max_output_tokens maps to `max_tokens`. - expect(sentBody['max_tokens'], 1024); - expect(sentBody['temperature'], closeTo(0.7, 1e-9)); - }); test('generateText with a plain prompt returns text', () async { final server = await startMockServer([ diff --git a/bindings/flutter/test/typed_round_trip_test.dart b/bindings/flutter/test/typed_round_trip_test.dart index 0bb432616..807b2b39d 100644 --- a/bindings/flutter/test/typed_round_trip_test.dart +++ b/bindings/flutter/test/typed_round_trip_test.dart @@ -120,25 +120,6 @@ void main() { expect(tc.isDynamic, false); }); - test('Source variant', () { - final original = GenerateContentSource( - id: 'src_1', - sourceType: 'url', - url: 'https://example.com', - title: 'Example', - providerMetadata: null, - ); - final decoded = GenerateContent.fromJson(original.toJson()); - - expect(decoded, isA()); - expect(decoded.tag, 'Source'); - final s = decoded as GenerateContentSource; - expect(s.id, 'src_1'); - expect(s.sourceType, 'url'); - expect(s.url, 'https://example.com'); - expect(s.title, 'Example'); - }); - test('Reasoning variant', () { final original = GenerateContentReasoning(text: 'thinking...', providerMetadata: null); @@ -150,31 +131,7 @@ void main() { expect(r.text, 'thinking...'); }); - test('File variant — no filename field', () { - final original = GenerateContentFile( - data: FileDataUrl(url: 'https://example.com/img.png'), - mediaType: 'image/png', - providerMetadata: null, - ); - final json = original.toJson(); - - // Externally-tagged shape: a single top-level key. - expect(json.keys, ['File']); - final payload = json['File'] as Map; - // Contract: `data` + `media_type`, never `filename`. - expect(payload, contains('data')); - expect(payload, contains('media_type')); - expect(payload, isNot(contains('filename'))); - - final decoded = GenerateContent.fromJson(json); - expect(decoded, isA()); - expect(decoded.tag, 'File'); - final f = decoded as GenerateContentFile; - expect(f.data, isA()); - expect((f.data as FileDataUrl).url, 'https://example.com/img.png'); - expect(f.mediaType, 'image/png'); - }); test('ToolResult variant — uses result, not output', () { final original = GenerateContentToolResult( @@ -280,31 +237,7 @@ void main() { expect(s.warnings.first['code'], 'deprecation'); }); - test('Finish variant', () { - final original = StreamPartFinish( - finishReason: FinishReason(unified: 'stop', raw: 'stop'), - usage: Usage( - inputTokens: TokenUsage(total: 10, text: 10), - outputTokens: TokenUsage(total: 5, text: 5), - ), - providerMetadata: null, - ); - // `Finish` nests `@JsonSerializable` `Usage`/`TokenUsage`, whose - // generated `toJson()` (explicitToJson: false) does not recurse — so we - // deep-flatten across the JSON boundary before re-parsing, exactly as the - // real Rust→Dart pipeline does. See `typed_model_test.dart` for the same - // note. - final decoded = - StreamPart.fromJson(deepFlatten(original.toJson())); - - expect(decoded, isA()); - expect(decoded.type, 'Finish'); - final f = decoded as StreamPartFinish; - expect(f.finishReason.unified, 'stop'); - expect(f.finishReason.raw, 'stop'); - expect(f.usage.inputTokens.total, 10); - expect(f.usage.outputTokens.total, 5); - }); + test('Error variant', () { final original = StreamPartError(error: {'message': 'boom', 'code': 500}); @@ -419,28 +352,7 @@ void main() { }); // ── File ── - test('File variant — no filename field', () { - final original = StreamPartFile( - data: FileDataUrl(url: 'https://example.com/img.png'), - mediaType: 'image/png', - providerMetadata: null, - ); - final json = original.toJson(); - - expect(json.keys, ['File']); - final payload = json['File'] as Map; - expect(payload, contains('data')); - expect(payload, contains('media_type')); - expect(payload, isNot(contains('filename'))); - final decoded = StreamPart.fromJson(json); - expect(decoded, isA()); - expect(decoded.type, 'File'); - final f = decoded as StreamPartFile; - expect(f.data, isA()); - expect((f.data as FileDataUrl).url, 'https://example.com/img.png'); - expect(f.mediaType, 'image/png'); - }); // ── Reasoning ── test('ReasoningStart variant', () { @@ -492,25 +404,6 @@ void main() { expect(m.modelId, 'gpt-4o'); }); - test('Source variant', () { - final original = StreamPartSource( - id: 'src_1', - sourceType: 'url', - url: 'https://example.com', - title: 'Example', - providerMetadata: null, - ); - final decoded = StreamPart.fromJson(original.toJson()); - - expect(decoded, isA()); - expect(decoded.type, 'Source'); - final s = decoded as StreamPartSource; - expect(s.id, 'src_1'); - expect(s.sourceType, 'url'); - expect(s.url, 'https://example.com'); - expect(s.title, 'Example'); - }); - test('Raw variant', () { final original = StreamPartRaw(rawValue: {'raw': 'chunk'}); final decoded = StreamPart.fromJson(original.toJson()); diff --git a/bindings/go/batch1_e2e_test.go b/bindings/go/batch1_e2e_test.go index bd49feca4..8cc575733 100644 --- a/bindings/go/batch1_e2e_test.go +++ b/bindings/go/batch1_e2e_test.go @@ -81,49 +81,7 @@ func TestE2E_StreamTextEmitsRawWhenEnabled(t *testing.T) { // RFC-0016 M10: streaming Finish carries the provider's raw usage object // (buildTextDeltaSSE usage has prompt_tokens=3). -func TestE2E_StreamFinishUsageRawNonNull(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetContentType("text/event-stream") - srv.SetResponse(buildTextDeltaSSE()) - - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) - defer m.Close() - - stream := m.StreamText(`"Say hello"`, "") - for part := range stream.Parts() { - sp, err := ParseStreamPart(part) - if err != nil { - t.Fatalf("failed to parse: %v", err) - } - if sp.Tag != "Finish" { - continue - } - var finish struct { - Usage struct { - Raw json.RawMessage `json:"raw"` - } `json:"usage"` - } - if err := json.Unmarshal(sp.Payload, &finish); err != nil { - t.Fatalf("failed to decode Finish payload: %v", err) - } - if len(finish.Usage.Raw) == 0 || string(finish.Usage.Raw) == "null" { - t.Fatal("usage.raw must be populated (RFC-0016 M10)") - } - var raw map[string]any - json.Unmarshal(finish.Usage.Raw, &raw) - if raw["prompt_tokens"] != float64(3) { - t.Errorf("usage.raw.prompt_tokens should be 3, got %#v", raw["prompt_tokens"]) - } - return - } - t.Fatal("no Finish part received") -} -// RFC-0016 M9: body-build warnings (topK unsupported) reach StreamStart. -// OpenAI's full profile supports top_k, so this is exercised on a profile -// that does not — the shared OpenAI path is what groq_test.rs covers in Rust; -// here we assert the Go side parses a non-empty warnings payload verbatim. func TestE2E_StreamStartCarriesNonEmptyWarnings(t *testing.T) { srv := newMockServer() defer srv.Close() diff --git a/bindings/go/roundtrip_test.go b/bindings/go/roundtrip_test.go index 091ea9183..360e830d2 100644 --- a/bindings/go/roundtrip_test.go +++ b/bindings/go/roundtrip_test.go @@ -296,41 +296,6 @@ func TestGenerateTextOptionsIncludeRawChunksRoundTrip(t *testing.T) { // ── GenerateTextResult round-trip ──────────────────────────────────────────── -func TestGenerateTextResultRoundTrip(t *testing.T) { - original := GenerateTextResult{ - Text: "hello", - ToolCalls: []ToolCall{ - {ToolCallID: "call_1", ToolName: "get_weather"}, - }, - Usage: Usage{ - InputTokens: TokenUsage{Total: u32ptr(10)}, - OutputTokens: TokenUsage{Total: u32ptr(5)}, - }, - Raw: GenerateResult{ - Content: []ContentPart{json.RawMessage(`{"Text":{"text":"hello"}}`)}, - FinishReason: FinishReason{Unified: FinishStop}, - }, - FinishReason: FinishReason{Unified: FinishStop}, - } - b, err := json.Marshal(original) - if err != nil { - t.Fatalf("marshal failed: %v", err) - } - var decoded GenerateTextResult - if err := json.Unmarshal(b, &decoded); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - if decoded.Text != "hello" { - t.Errorf("text mismatch: got %s", decoded.Text) - } - if len(decoded.ToolCalls) != 1 || decoded.ToolCalls[0].ToolName != "get_weather" { - t.Errorf("tool_calls mismatch: %+v", decoded.ToolCalls) - } - if decoded.Raw.FinishReason.Unified != FinishStop { - t.Errorf("raw.finish_reason mismatch: got %s", decoded.Raw.FinishReason.Unified) - } -} - func TestGenerateTextResultWithWarningsRoundTrip(t *testing.T) { original := GenerateTextResult{ Text: "ok", @@ -349,27 +314,6 @@ func TestGenerateTextResultWithWarningsRoundTrip(t *testing.T) { // ── Usage / TokenUsage round-trip ───────────────────────────────────────────── -func TestUsageRoundTrip(t *testing.T) { - original := Usage{ - InputTokens: TokenUsage{Total: u32ptr(100), CacheRead: u32ptr(50)}, - OutputTokens: TokenUsage{Total: u32ptr(20), Reasoning: u32ptr(5)}, - } - b, _ := json.Marshal(original) - s := string(b) - for _, want := range []string{`"input_tokens"`, `"output_tokens"`, `"total"`, `"cache_read"`, `"reasoning"`} { - if !contains(s, want) { - t.Errorf("expected %s in wire JSON, got %s", want, s) - } - } - var decoded Usage - json.Unmarshal(b, &decoded) - if decoded.InputTokens.Total == nil || *decoded.InputTokens.Total != 100 { - t.Error("input_tokens.total did not round-trip") - } -} - -// ── StreamPart parse round-trip ─────────────────────────────────────────────── - func TestStreamPartParseRoundTrip(t *testing.T) { cases := []struct { name string diff --git a/bindings/go/types.go b/bindings/go/types.go index d504c528e..845c2fa40 100644 --- a/bindings/go/types.go +++ b/bindings/go/types.go @@ -77,21 +77,103 @@ func ToolChoiceTool(name string) ToolChoice { // ── Core types ─────────────────────────────────────────────────────────────── -// TokenUsage is token usage detail with cache breakdown. -type TokenUsage struct { +// Source is a URL or document source. Exactly one variant is populated. +type Source struct { + URL *URLSource + Document *DocumentSource +} + +type URLSource struct { + ID string `json:"id"` + URL string `json:"url"` + Title *string `json:"title,omitempty"` + ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` +} + +type DocumentSource struct { + ID string `json:"id"` + MediaType string `json:"media_type"` + Title string `json:"title"` + Filename *string `json:"filename,omitempty"` + ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` +} + +func (s Source) MarshalJSON() ([]byte, error) { + if s.URL != nil && s.Document == nil { + return json.Marshal(struct { + SourceType string `json:"source_type"` + *URLSource + }{"url", s.URL}) + } + if s.Document != nil && s.URL == nil { + return json.Marshal(struct { + SourceType string `json:"source_type"` + *DocumentSource + }{"document", s.Document}) + } + return nil, fmt.Errorf("aimux: Source must have exactly one variant") +} + +func (s *Source) UnmarshalJSON(data []byte) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + var sourceType string + if err := json.Unmarshal(fields["source_type"], &sourceType); err != nil { + return fmt.Errorf("aimux: invalid source_type: %w", err) + } + required := []string{"id"} + switch sourceType { + case "url": + required = append(required, "url") + case "document": + required = append(required, "media_type", "title") + default: + return fmt.Errorf("aimux: unknown source_type %q", sourceType) + } + for _, name := range required { + var value *string + if err := json.Unmarshal(fields[name], &value); err != nil || value == nil { + return fmt.Errorf("aimux: Source requires string %s", name) + } + } + if sourceType == "url" { + var value URLSource + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *s = Source{URL: &value} + } else { + var value DocumentSource + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *s = Source{Document: &value} + } + return nil +} + +// InputTokenUsage is input token usage detail with cache breakdown. +type InputTokenUsage struct { Total *uint32 `json:"total,omitempty"` NoCache *uint32 `json:"no_cache,omitempty"` CacheRead *uint32 `json:"cache_read,omitempty"` CacheWrite *uint32 `json:"cache_write,omitempty"` - Text *uint32 `json:"text,omitempty"` - Reasoning *uint32 `json:"reasoning,omitempty"` +} + +// OutputTokenUsage is output token usage detail. +type OutputTokenUsage struct { + Total *uint32 `json:"total,omitempty"` + Text *uint32 `json:"text,omitempty"` + Reasoning *uint32 `json:"reasoning,omitempty"` } // Usage is token usage statistics. type Usage struct { - InputTokens TokenUsage `json:"input_tokens,omitempty"` - OutputTokens TokenUsage `json:"output_tokens,omitempty"` - Raw json.RawMessage `json:"raw,omitempty"` + InputTokens InputTokenUsage `json:"input_tokens,omitempty"` + OutputTokens OutputTokenUsage `json:"output_tokens,omitempty"` + Raw map[string]json.RawMessage `json:"raw,omitempty"` } // FinishReason is the finish reason. @@ -129,22 +211,30 @@ type ResponseMetadata struct { ModelID *string `json:"model_id,omitempty"` } +// GenerateResponseMetadata is response metadata with optional HTTP details. +type GenerateResponseMetadata struct { + ResponseMetadata + Body json.RawMessage `json:"body,omitempty"` + Headers map[string]string `json:"headers,omitempty"` +} + // GenerateResult is the raw provider result. // Mirrors Kotlin GenerateResult (Types.kt:853-871). type GenerateResult struct { - Content []ContentPart `json:"content,omitempty"` - FinishReason FinishReason `json:"finish_reason,omitempty"` - Usage Usage `json:"usage,omitempty"` - Warnings []json.RawMessage `json:"warnings,omitempty"` - ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` - Response ResponseMetadata `json:"response,omitempty"` - RequestBody json.RawMessage `json:"request_body,omitempty"` - ResponseHeaders map[string]string `json:"response_headers,omitempty"` + Content []ContentPart `json:"content,omitempty"` + FinishReason FinishReason `json:"finish_reason,omitempty"` + Usage Usage `json:"usage,omitempty"` + Warnings []json.RawMessage `json:"warnings,omitempty"` + ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` + Response *GenerateResponseMetadata `json:"response,omitempty"` + RequestBody json.RawMessage `json:"request_body,omitempty"` + ResponseHeaders map[string]string `json:"response_headers,omitempty"` } // GenerateTextResult is the typed result of a GenerateText call. // Mirrors Kotlin GenerateTextResult (Types.kt:878-886). type GenerateTextResult struct { + Content []ContentPart `json:"content,omitempty"` Text string `json:"text"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` FinishReason FinishReason `json:"finish_reason,omitempty"` @@ -153,7 +243,7 @@ type GenerateTextResult struct { Raw GenerateResult `json:"raw"` Reasoning []json.RawMessage `json:"reasoning,omitempty"` ReasoningText string `json:"reasoning_text,omitempty"` - Sources []json.RawMessage `json:"sources,omitempty"` + Sources []Source `json:"sources,omitempty"` Files []json.RawMessage `json:"files,omitempty"` ResponseMessages []ModelMessage `json:"response_messages,omitempty"` // RawFinishReason is the raw provider-specific finish reason string (M12). @@ -189,11 +279,12 @@ type GenerateObjectResult struct { // Mirrors Rust StreamTextResultAggregated. reasoning/sources/files use weak // types (json.RawMessage) — same strategy as GenerateTextResult. type StreamTextResultAggregated struct { + Content []ContentPart `json:"content,omitempty"` Text string `json:"text"` Reasoning []json.RawMessage `json:"reasoning,omitempty"` ReasoningText string `json:"reasoning_text,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` - Sources []json.RawMessage `json:"sources,omitempty"` + Sources []Source `json:"sources,omitempty"` Files []json.RawMessage `json:"files,omitempty"` FinishReason FinishReason `json:"finish_reason,omitempty"` RawFinishReason *string `json:"raw_finish_reason,omitempty"` @@ -364,6 +455,18 @@ func ParseStreamPart(jsonStr string) (*StreamPart, error) { return &StreamPart{}, nil } +// Source decodes the payload of a Source stream part. +func (p *StreamPart) Source() (*Source, error) { + if p.Tag != "Source" { + return nil, fmt.Errorf("aimux: expected Source stream part, got %s", p.Tag) + } + var source Source + if err := json.Unmarshal(p.Payload, &source); err != nil { + return nil, err + } + return &source, nil +} + // TextDeltaPayload is the payload of a {"TextDelta":{...}} stream part. type TextDeltaPayload struct { ID string `json:"id,omitempty"` diff --git a/bindings/java/src/main/java/ai/arcships/aimux/Types.java b/bindings/java/src/main/java/ai/arcships/aimux/Types.java index f39084310..f2eb8f775 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/Types.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/Types.java @@ -78,7 +78,7 @@ private AimuxJson() {} * instead of re-entering the polymorphic serializer — which would * otherwise infinite-recurse ({@code StackOverflowError}). Non-recursing * serializers that concrete variants may transitively contain - * ({@link ToolChoice}, {@link FileBytes}, {@link FileData}) are kept. + * ({@link ToolChoice}, {@link FileBytes}, {@link GeneratedFileData}) are kept. */ public static final ObjectMapper INNER_MAPPER = createInnerMapper(); @@ -107,6 +107,8 @@ private static ObjectMapper configure(ObjectMapper m, boolean polymorphic) { module.addDeserializer(FileBytes.class, new FileBytesDeserializer()); module.addSerializer(FileData.class, new FileDataSerializer()); module.addDeserializer(FileData.class, new FileDataDeserializer()); + module.addSerializer(GeneratedFileData.class, new GeneratedFileDataSerializer()); + module.addDeserializer(GeneratedFileData.class, new GeneratedFileDataDeserializer()); // Externally-tagged polymorphic serializers — only on the public // mapper. They use INNER_MAPPER internally to avoid self-recursion. if (polymorphic) { @@ -158,98 +160,86 @@ public enum ReasoningEffort { // Core nested types. // ───────────────────────────────────────────────────────────────────────────── - /** - * Token usage detail (with cache breakdown). Every field is nullable because - * providers do not always populate all breakdowns. - */ - public static class TokenUsage { + public static class InputTokenUsage { @JsonProperty("total") private Long total; @JsonProperty("no_cache") private Long noCache; @JsonProperty("cache_read") private Long cacheRead; @JsonProperty("cache_write") private Long cacheWrite; - @JsonProperty("text") private Long text; - @JsonProperty("reasoning") private Long reasoning; - - @JsonCreator - TokenUsage() {} - - private TokenUsage(Long total, Long noCache, Long cacheRead, Long cacheWrite, Long text, Long reasoning) { + @JsonCreator InputTokenUsage() {} + public InputTokenUsage(Long total, Long noCache, Long cacheRead, Long cacheWrite) { this.total = total; this.noCache = noCache; this.cacheRead = cacheRead; this.cacheWrite = cacheWrite; - this.text = text; - this.reasoning = reasoning; - } - - /** Convenience: a usage with only the total token count set. */ - public static TokenUsage of(Long total) { - return new TokenUsage(total, null, null, null, null, null); } - public Long getTotal() { return total; } public Long getNoCache() { return noCache; } public Long getCacheRead() { return cacheRead; } public Long getCacheWrite() { return cacheWrite; } - public Long getText() { return text; } - public Long getReasoning() { return reasoning; } - public static Builder builder() { return new Builder(); } - public static class Builder { private Long total; private Long noCache; private Long cacheRead; private Long cacheWrite; + public Builder total(Long value) { this.total = value; return this; } + public Builder noCache(Long value) { this.noCache = value; return this; } + public Builder cacheRead(Long value) { this.cacheRead = value; return this; } + public Builder cacheWrite(Long value) { this.cacheWrite = value; return this; } + public InputTokenUsage build() { return new InputTokenUsage(total, noCache, cacheRead, cacheWrite); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof InputTokenUsage)) return false; + InputTokenUsage other = (InputTokenUsage) value; + return Objects.equals(total, other.total) && Objects.equals(noCache, other.noCache) && Objects.equals(cacheRead, other.cacheRead) && Objects.equals(cacheWrite, other.cacheWrite); + } + @Override public int hashCode() { return Objects.hash(total, noCache, cacheRead, cacheWrite); } + } + public static class OutputTokenUsage { + @JsonProperty("total") private Long total; + @JsonProperty("text") private Long text; + @JsonProperty("reasoning") private Long reasoning; + @JsonCreator OutputTokenUsage() {} + public OutputTokenUsage(Long total, Long text, Long reasoning) { + this.total = total; + this.text = text; + this.reasoning = reasoning; + } + public Long getTotal() { return total; } + public Long getText() { return text; } + public Long getReasoning() { return reasoning; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private Long total; private Long text; private Long reasoning; - - public Builder total(Long v) { this.total = v; return this; } - public Builder noCache(Long v) { this.noCache = v; return this; } - public Builder cacheRead(Long v) { this.cacheRead = v; return this; } - public Builder cacheWrite(Long v) { this.cacheWrite = v; return this; } - public Builder text(Long v) { this.text = v; return this; } - public Builder reasoning(Long v) { this.reasoning = v; return this; } - - public TokenUsage build() { - return new TokenUsage(total, noCache, cacheRead, cacheWrite, text, reasoning); - } - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof TokenUsage)) return false; - TokenUsage that = (TokenUsage) o; - return Objects.equals(total, that.total) - && Objects.equals(noCache, that.noCache) - && Objects.equals(cacheRead, that.cacheRead) - && Objects.equals(cacheWrite, that.cacheWrite) - && Objects.equals(text, that.text) - && Objects.equals(reasoning, that.reasoning); + public Builder total(Long value) { this.total = value; return this; } + public Builder text(Long value) { this.text = value; return this; } + public Builder reasoning(Long value) { this.reasoning = value; return this; } + public OutputTokenUsage build() { return new OutputTokenUsage(total, text, reasoning); } } - - @Override - public int hashCode() { - return Objects.hash(total, noCache, cacheRead, cacheWrite, text, reasoning); + @Override public boolean equals(Object value) { + if (!(value instanceof OutputTokenUsage)) return false; + OutputTokenUsage other = (OutputTokenUsage) value; + return Objects.equals(total, other.total) && Objects.equals(text, other.text) && Objects.equals(reasoning, other.reasoning); } + @Override public int hashCode() { return Objects.hash(total, text, reasoning); } } - /** * Token usage statistics. * - * Mirrors `Usage.ts`: `{ input_tokens: TokenUsage, output_tokens: TokenUsage, - * raw?: JsonValue | null }`. + * Mirrors `Usage.ts`: `{ input_tokens: InputTokenUsage, output_tokens: OutputTokenUsage, + * raw?: JSONObject | null }`. */ public static class Usage { - @JsonProperty("input_tokens") private TokenUsage inputTokens = new TokenUsage(); - @JsonProperty("output_tokens") private TokenUsage outputTokens = new TokenUsage(); - @JsonProperty("raw") private JsonNode raw; + @JsonProperty("input_tokens") private InputTokenUsage inputTokens = new InputTokenUsage(); + @JsonProperty("output_tokens") private OutputTokenUsage outputTokens = new OutputTokenUsage(); + @JsonProperty("raw") private ObjectNode raw; @JsonCreator Usage() {} - private Usage(TokenUsage inputTokens, TokenUsage outputTokens, JsonNode raw) { + private Usage(InputTokenUsage inputTokens, OutputTokenUsage outputTokens, ObjectNode raw) { this.inputTokens = inputTokens; this.outputTokens = outputTokens; this.raw = raw; @@ -257,23 +247,23 @@ private Usage(TokenUsage inputTokens, TokenUsage outputTokens, JsonNode raw) { /** Convenience for tests/inspection. */ public static Usage of(Long input, Long output) { - return new Usage(TokenUsage.of(input), TokenUsage.of(output), null); + return new Usage(InputTokenUsage.builder().total(input).build(), OutputTokenUsage.builder().total(output).build(), null); } - public TokenUsage getInputTokens() { return inputTokens; } - public TokenUsage getOutputTokens() { return outputTokens; } - public JsonNode getRaw() { return raw; } + public InputTokenUsage getInputTokens() { return inputTokens; } + public OutputTokenUsage getOutputTokens() { return outputTokens; } + public ObjectNode getRaw() { return raw; } public static Builder builder() { return new Builder(); } public static class Builder { - private TokenUsage inputTokens = new TokenUsage(); - private TokenUsage outputTokens = new TokenUsage(); - private JsonNode raw; + private InputTokenUsage inputTokens = new InputTokenUsage(); + private OutputTokenUsage outputTokens = new OutputTokenUsage(); + private ObjectNode raw; - public Builder inputTokens(TokenUsage v) { this.inputTokens = v; return this; } - public Builder outputTokens(TokenUsage v) { this.outputTokens = v; return this; } - public Builder raw(JsonNode v) { this.raw = v; return this; } + public Builder inputTokens(InputTokenUsage v) { this.inputTokens = v; return this; } + public Builder outputTokens(OutputTokenUsage v) { this.outputTokens = v; return this; } + public Builder raw(ObjectNode v) { this.raw = v; return this; } public Usage build() { return new Usage(inputTokens, outputTokens, raw); } } @@ -394,6 +384,67 @@ public int hashCode() { } } + /** Response metadata and HTTP details from a generation call. */ + public static class GenerateResponse { + @JsonProperty("id") private String id; + @JsonProperty("timestamp") private String timestamp; + @JsonProperty("model_id") private String modelId; + @JsonProperty("headers") private Map headers; + @JsonProperty("body") private JsonNode body; + + @JsonCreator + GenerateResponse() {} + + private GenerateResponse(String id, String timestamp, String modelId, Map headers, JsonNode body) { + this.id = id; + this.timestamp = timestamp; + this.modelId = modelId; + this.headers = headers; + this.body = body; + } + + public String getId() { return id; } + public String getTimestamp() { return timestamp; } + public String getModelId() { return modelId; } + public Map getHeaders() { return headers; } + public JsonNode getBody() { return body; } + + public static Builder builder() { return new Builder(); } + + public static class Builder { + private String id; + private String timestamp; + private String modelId; + private Map headers; + private JsonNode body; + + public Builder id(String v) { this.id = v; return this; } + public Builder timestamp(String v) { this.timestamp = v; return this; } + public Builder modelId(String v) { this.modelId = v; return this; } + public Builder headers(Map v) { this.headers = v; return this; } + public Builder body(JsonNode v) { this.body = v; return this; } + + public GenerateResponse build() { return new GenerateResponse(id, timestamp, modelId, headers, body); } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof GenerateResponse)) return false; + GenerateResponse that = (GenerateResponse) o; + return Objects.equals(id, that.id) + && Objects.equals(timestamp, that.timestamp) + && Objects.equals(modelId, that.modelId) + && Objects.equals(headers, that.headers) + && Objects.equals(body, that.body); + } + + @Override + public int hashCode() { + return Objects.hash(id, timestamp, modelId, headers, body); + } + } + /** * A tool call requested by the model. * @@ -1418,6 +1469,106 @@ public int hashCode() { return Objects.hash(toolCallId, result, toolName, isError, preliminary, dynamic, providerOptions); } } + public static class Custom extends ContentPart { + @JsonProperty("kind") private String kind; + @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonCreator Custom() {} + public Custom(String kind, JsonNode providerOptions) { + this.kind = kind; + this.providerOptions = providerOptions; + } + public String getKind() { return kind; } + public JsonNode getProviderOptions() { return providerOptions; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private String kind; + private JsonNode providerOptions; + public Builder kind(String value) { this.kind = value; return this; } + public Builder providerOptions(JsonNode value) { this.providerOptions = value; return this; } + public Custom build() { return new Custom(kind, providerOptions); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof Custom)) return false; + Custom other = (Custom) value; + return Objects.equals(kind, other.kind) && Objects.equals(providerOptions, other.providerOptions); + } + @Override public int hashCode() { return Objects.hash(kind, providerOptions); } + } + public static class ReasoningFile extends ContentPart { + @JsonProperty("data") private GeneratedFileData data; + @JsonProperty("media_type") private String mediaType; + @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonCreator ReasoningFile() {} + public ReasoningFile(GeneratedFileData data, String mediaType, JsonNode providerOptions) { + this.data = data; + this.mediaType = mediaType; + this.providerOptions = providerOptions; + } + public GeneratedFileData getData() { return data; } + public String getMediaType() { return mediaType; } + public JsonNode getProviderOptions() { return providerOptions; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private GeneratedFileData data; + private String mediaType; + private JsonNode providerOptions; + public Builder data(GeneratedFileData value) { this.data = value; return this; } + public Builder mediaType(String value) { this.mediaType = value; return this; } + public Builder providerOptions(JsonNode value) { this.providerOptions = value; return this; } + public ReasoningFile build() { return new ReasoningFile(data, mediaType, providerOptions); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof ReasoningFile)) return false; + ReasoningFile other = (ReasoningFile) value; + return Objects.equals(data, other.data) && Objects.equals(mediaType, other.mediaType) && Objects.equals(providerOptions, other.providerOptions); + } + @Override public int hashCode() { return Objects.hash(data, mediaType, providerOptions); } + } + public static class ToolApprovalRequest extends ContentPart { + @JsonProperty("approval_id") private String approvalId; + @JsonProperty("tool_call_id") private String toolCallId; + @JsonProperty("reason") private String reason; + @JsonProperty("is_automatic") private Boolean isAutomatic; + @JsonProperty("signature") private String signature; + @JsonProperty("input_schema_input") private JsonNode inputSchemaInput; + @JsonCreator ToolApprovalRequest() {} + public ToolApprovalRequest(String approvalId, String toolCallId, String reason, Boolean isAutomatic, String signature, JsonNode inputSchemaInput) { + this.approvalId = approvalId; + this.toolCallId = toolCallId; + this.reason = reason; + this.isAutomatic = isAutomatic; + this.signature = signature; + this.inputSchemaInput = inputSchemaInput; + } + public String getApprovalId() { return approvalId; } + public String getToolCallId() { return toolCallId; } + public String getReason() { return reason; } + public Boolean getIsAutomatic() { return isAutomatic; } + public String getSignature() { return signature; } + public JsonNode getInputSchemaInput() { return inputSchemaInput; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private String approvalId; + private String toolCallId; + private String reason; + private Boolean isAutomatic; + private String signature; + private JsonNode inputSchemaInput; + public Builder approvalId(String value) { this.approvalId = value; return this; } + public Builder toolCallId(String value) { this.toolCallId = value; return this; } + public Builder reason(String value) { this.reason = value; return this; } + public Builder isAutomatic(Boolean value) { this.isAutomatic = value; return this; } + public Builder signature(String value) { this.signature = value; return this; } + public Builder inputSchemaInput(JsonNode value) { this.inputSchemaInput = value; return this; } + public ToolApprovalRequest build() { return new ToolApprovalRequest(approvalId, toolCallId, reason, isAutomatic, signature, inputSchemaInput); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof ToolApprovalRequest)) return false; + ToolApprovalRequest other = (ToolApprovalRequest) value; + return Objects.equals(approvalId, other.approvalId) && Objects.equals(toolCallId, other.toolCallId) && Objects.equals(reason, other.reason) && Objects.equals(isAutomatic, other.isAutomatic) && Objects.equals(signature, other.signature) && Objects.equals(inputSchemaInput, other.inputSchemaInput); + } + @Override public int hashCode() { return Objects.hash(approvalId, toolCallId, reason, isAutomatic, signature, inputSchemaInput); } + } } /** Custom (de)serializer for {@link ContentPart} — internally tagged on `"type"`. */ @@ -1442,6 +1593,9 @@ private static String variantTag(ContentPart value) { if (value instanceof ContentPart.Reasoning) return "reasoning"; if (value instanceof ContentPart.ToolCall) return "tool_call"; if (value instanceof ContentPart.ToolResult) return "tool_result"; + if (value instanceof ContentPart.Custom) return "custom"; + if (value instanceof ContentPart.ReasoningFile) return "reasoning_file"; + if (value instanceof ContentPart.ToolApprovalRequest) return "tool_approval_request"; throw new IllegalArgumentException("Unknown ContentPart: " + value); } } @@ -1465,6 +1619,9 @@ public ContentPart deserialize(JsonParser p, DeserializationContext ctxt) throws case "reasoning": return AimuxJson.MAPPER.treeToValue(node, ContentPart.Reasoning.class); case "tool_call": return AimuxJson.MAPPER.treeToValue(node, ContentPart.ToolCall.class); case "tool_result": return AimuxJson.MAPPER.treeToValue(node, ContentPart.ToolResult.class); + case "custom": return AimuxJson.MAPPER.treeToValue(node, ContentPart.Custom.class); + case "reasoning_file": return AimuxJson.MAPPER.treeToValue(node, ContentPart.ReasoningFile.class); + case "tool_approval_request": return AimuxJson.MAPPER.treeToValue(node, ContentPart.ToolApprovalRequest.class); default: throw new IOException("Unknown ContentPart type: '" + type + "'"); } @@ -2009,7 +2166,7 @@ public int hashCode() { // ───────────────────────────────────────────────────────────────────────────── // File bytes / file data (shared V4 file types). // - // `FileBytes` and `FileData` are externally-tagged enums + // `FileBytes` and `GeneratedFileData` are externally-tagged enums // (`{"Binary": [...]}`, `{"Data": {"data": ...}}`, ...). Each is modeled with a // custom serializer that dispatches on the single tag key. // ───────────────────────────────────────────────────────────────────────────── @@ -2147,26 +2304,30 @@ public boolean equals(Object o) { } public static class Url extends FileData { - private String url = ""; - - @JsonCreator - Url() {} - - public Url(String url) { this.url = url; } - + @JsonProperty("url") private String url; + @JsonProperty("original_url") private String originalUrl; + @JsonCreator Url() {} + public Url(String url, String originalUrl) { + this.url = url; + this.originalUrl = originalUrl; + } public String getUrl() { return url; } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof Url)) return false; - return Objects.equals(url, ((Url) o).url); + public String getOriginalUrl() { return originalUrl; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private String url; + private String originalUrl; + public Builder url(String value) { this.url = value; return this; } + public Builder originalUrl(String value) { this.originalUrl = value; return this; } + public Url build() { return new Url(url, originalUrl); } } - - @Override - public int hashCode() { return Objects.hash(url); } + @Override public boolean equals(Object value) { + if (!(value instanceof Url)) return false; + Url other = (Url) value; + return Objects.equals(url, other.url) && Objects.equals(originalUrl, other.originalUrl); + } + @Override public int hashCode() { return Objects.hash(url, originalUrl); } } - public static class Reference extends FileData { private JsonNode reference = emptyObject(); @@ -2221,11 +2382,19 @@ public void serialize(FileData value, JsonGenerator gen, SerializerProvider seri gen.writeTree(AimuxJson.MAPPER.valueToTree(((FileData.Data) value).getData())); gen.writeEndObject(); } else if (value instanceof FileData.Url) { - gen.writeObjectField("Url", ((FileData.Url) value).getUrl()); + gen.writeObjectFieldStart("Url"); + gen.writeStringField("url", ((FileData.Url) value).getUrl()); + if (((FileData.Url) value).getOriginalUrl() != null) + gen.writeStringField("original_url", ((FileData.Url) value).getOriginalUrl()); + gen.writeEndObject(); } else if (value instanceof FileData.Reference) { - gen.writeObjectField("Reference", ((FileData.Reference) value).getReference()); + gen.writeObjectFieldStart("Reference"); + gen.writeObjectField("reference", ((FileData.Reference) value).getReference()); + gen.writeEndObject(); } else if (value instanceof FileData.Text) { - gen.writeObjectField("Text", ((FileData.Text) value).getText()); + gen.writeObjectFieldStart("Text"); + gen.writeStringField("text", ((FileData.Text) value).getText()); + gen.writeEndObject(); } else { throw new IOException("Unknown FileData: " + value); } @@ -2248,17 +2417,120 @@ public FileData deserialize(JsonParser p, DeserializationContext ctxt) throws IO case "Data": return new FileData.Data(AimuxJson.MAPPER.treeToValue(innerObj.get("data"), FileBytes.class)); case "Url": - return new FileData.Url(inner.asText()); + return new FileData.Url(innerObj.path("url").asText(), innerObj.hasNonNull("original_url") ? innerObj.get("original_url").asText() : null); case "Reference": return new FileData.Reference(innerObj.get("reference") == null ? emptyObject() : innerObj.get("reference")); case "Text": - return new FileData.Text(inner.asText()); + return new FileData.Text(innerObj.path("text").asText()); default: throw new IOException("Unknown FileData tag: '" + tag + "'"); } } } + /** + * File data as a tagged discriminated union. + * + * Mirrors `GeneratedFileData.ts`: `{"Data": {"data": FileBytes}} | {"Url": {"url": ...}} + * Generated files contain only data or a URL. + */ + public abstract static class GeneratedFileData { + private GeneratedFileData() {} + + public static class Data extends GeneratedFileData { + private FileBytes data = new FileBytes.Base64(""); + + @JsonCreator + Data() {} + + public Data(FileBytes data) { this.data = data; } + + public FileBytes getData() { return data; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Data)) return false; + return Objects.equals(data, ((Data) o).data); + } + + @Override + public int hashCode() { return Objects.hash(data); } + } + + public static class Url extends GeneratedFileData { + @JsonProperty("url") private String url; + @JsonProperty("original_url") private String originalUrl; + @JsonCreator Url() {} + public Url(String url, String originalUrl) { + this.url = url; + this.originalUrl = originalUrl; + } + public String getUrl() { return url; } + public String getOriginalUrl() { return originalUrl; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private String url; + private String originalUrl; + public Builder url(String value) { this.url = value; return this; } + public Builder originalUrl(String value) { this.originalUrl = value; return this; } + public Url build() { return new Url(url, originalUrl); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof Url)) return false; + Url other = (Url) value; + return Objects.equals(url, other.url) && Objects.equals(originalUrl, other.originalUrl); + } + @Override public int hashCode() { return Objects.hash(url, originalUrl); } + } + + } + + /** Custom (de)serializer for {@link GeneratedFileData} — externally tagged. */ + public static class GeneratedFileDataSerializer extends JsonSerializer { + @Override + public void serialize(GeneratedFileData value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeStartObject(); + if (value instanceof GeneratedFileData.Data) { + gen.writeObjectFieldStart("Data"); + gen.writeFieldName("data"); + gen.writeTree(AimuxJson.MAPPER.valueToTree(((GeneratedFileData.Data) value).getData())); + gen.writeEndObject(); + } else if (value instanceof GeneratedFileData.Url) { + gen.writeObjectFieldStart("Url"); + gen.writeStringField("url", ((GeneratedFileData.Url) value).getUrl()); + if (((GeneratedFileData.Url) value).getOriginalUrl() != null) + gen.writeStringField("original_url", ((GeneratedFileData.Url) value).getOriginalUrl()); + gen.writeEndObject(); + } else { + throw new IOException("Unknown GeneratedFileData: " + value); + } + gen.writeEndObject(); + } + } + + /** Custom (de)serializer for {@link GeneratedFileData}. */ + public static class GeneratedFileDataDeserializer extends JsonDeserializer { + @Override + public GeneratedFileData deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode node = p.getCodec().readTree(p); + if (!node.isObject() || node.size() != 1) { + throw new IOException("GeneratedFileData must be a single-key externally-tagged object, got: " + node); + } + String tag = node.fieldNames().next(); + JsonNode inner = node.get(tag); + JsonNode innerObj = inner.isObject() ? inner : AimuxJson.MAPPER.createObjectNode(); + switch (tag) { + case "Data": + return new GeneratedFileData.Data(AimuxJson.MAPPER.treeToValue(innerObj.get("data"), FileBytes.class)); + case "Url": + return new GeneratedFileData.Url(innerObj.path("url").asText(), innerObj.hasNonNull("original_url") ? innerObj.get("original_url").asText() : null); + default: + throw new IOException("Unknown GeneratedFileData tag: '" + tag + "'"); + } + } + } + // ───────────────────────────────────────────────────────────────────────────── // GenerateContent / GenerateResult (the `raw` field of GenerateTextResult). // @@ -2267,6 +2539,82 @@ public FileData deserialize(JsonParser p, DeserializationContext ctxt) throws IO // for forward compatibility (mirroring StreamPart). // ───────────────────────────────────────────────────────────────────────────── + /** A URL or document used as a source for the response. */ + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "source_type") + @JsonSubTypes({ + @JsonSubTypes.Type(value = Source.Url.class, name = "url"), + @JsonSubTypes.Type(value = Source.Document.class, name = "document") + }) + public abstract static class Source { + @JsonProperty("id") private final String id; + @JsonProperty("provider_metadata") private final JsonNode providerMetadata; + + protected Source(String id, JsonNode providerMetadata) { + this.id = Objects.requireNonNull(id, "id"); + this.providerMetadata = providerMetadata; + } + + public String getId() { return id; } + public JsonNode getProviderMetadata() { return providerMetadata; } + + public static final class Url extends Source { + @JsonProperty("url") private final String url; + @JsonProperty("title") private final String title; + + @JsonCreator + public Url(@JsonProperty("id") String id, @JsonProperty("url") String url, + @JsonProperty("title") String title, + @JsonProperty("provider_metadata") JsonNode providerMetadata) { + super(id, providerMetadata); + this.url = Objects.requireNonNull(url, "url"); + this.title = title; + } + + public String getUrl() { return url; } + public String getTitle() { return title; } + + @Override public boolean equals(Object o) { + if (!(o instanceof Url)) return false; + Url that = (Url) o; + return Objects.equals(getId(), that.getId()) && Objects.equals(url, that.url) + && Objects.equals(title, that.title) + && Objects.equals(getProviderMetadata(), that.getProviderMetadata()); + } + @Override public int hashCode() { return Objects.hash(getId(), url, title, getProviderMetadata()); } + } + + public static final class Document extends Source { + @JsonProperty("media_type") private final String mediaType; + @JsonProperty("title") private final String title; + @JsonProperty("filename") private final String filename; + + @JsonCreator + public Document(@JsonProperty("id") String id, @JsonProperty("media_type") String mediaType, + @JsonProperty("title") String title, @JsonProperty("filename") String filename, + @JsonProperty("provider_metadata") JsonNode providerMetadata) { + super(id, providerMetadata); + this.mediaType = Objects.requireNonNull(mediaType, "media_type"); + this.title = Objects.requireNonNull(title, "title"); + this.filename = filename; + } + + public String getMediaType() { return mediaType; } + public String getTitle() { return title; } + public String getFilename() { return filename; } + + @Override public boolean equals(Object o) { + if (!(o instanceof Document)) return false; + Document that = (Document) o; + return Objects.equals(getId(), that.getId()) && Objects.equals(mediaType, that.mediaType) + && Objects.equals(title, that.title) && Objects.equals(filename, that.filename) + && Objects.equals(getProviderMetadata(), that.getProviderMetadata()); + } + @Override public int hashCode() { + return Objects.hash(getId(), mediaType, title, filename, getProviderMetadata()); + } + } + } + /** * A content item in the generation result. * @@ -2388,61 +2736,15 @@ public int hashCode() { } public static class Source extends GenerateContent { - @JsonProperty("id") private String id = ""; - @JsonProperty("source_type") private String sourceType = ""; - @JsonProperty("url") private String url; - @JsonProperty("title") private String title; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; - - @JsonCreator - Source() {} - - private Source(String id, String sourceType, String url, String title, JsonNode providerMetadata) { - this.id = id; - this.sourceType = sourceType; - this.url = url; - this.title = title; - this.providerMetadata = providerMetadata; - } - - public String getId() { return id; } - public String getSourceType() { return sourceType; } - public String getUrl() { return url; } - public String getTitle() { return title; } - public JsonNode getProviderMetadata() { return providerMetadata; } - - public static Builder builder() { return new Builder(); } - - public static class Builder { - private String id = ""; - private String sourceType = ""; - private String url; - private String title; - private JsonNode providerMetadata; - - public Builder id(String v) { this.id = v; return this; } - public Builder sourceType(String v) { this.sourceType = v; return this; } - public Builder url(String v) { this.url = v; return this; } - public Builder title(String v) { this.title = v; return this; } - public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } + private final Types.Source source; - public Source build() { return new Source(id, sourceType, url, title, providerMetadata); } - } + public Source(Types.Source source) { this.source = Objects.requireNonNull(source, "source"); } + public Types.Source getSource() { return source; } - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof Source)) return false; - Source that = (Source) o; - return Objects.equals(id, that.id) - && Objects.equals(sourceType, that.sourceType) - && Objects.equals(url, that.url) - && Objects.equals(title, that.title) - && Objects.equals(providerMetadata, that.providerMetadata); + @Override public boolean equals(Object o) { + return o instanceof Source && Objects.equals(source, ((Source) o).source); } - - @Override - public int hashCode() { return Objects.hash(id, sourceType, url, title, providerMetadata); } + @Override public int hashCode() { return Objects.hash(source); } } public static class Reasoning extends GenerateContent { @@ -2485,31 +2787,31 @@ public boolean equals(Object o) { } public static class File extends GenerateContent { - @JsonProperty("data") private FileData data = new FileData.Text(""); + @JsonProperty("data") private GeneratedFileData data = new GeneratedFileData.Data(new FileBytes.Base64("")); @JsonProperty("media_type") private String mediaType = ""; @JsonProperty("provider_metadata") private JsonNode providerMetadata; @JsonCreator File() {} - private File(FileData data, String mediaType, JsonNode providerMetadata) { + private File(GeneratedFileData data, String mediaType, JsonNode providerMetadata) { this.data = data; this.mediaType = mediaType; this.providerMetadata = providerMetadata; } - public FileData getData() { return data; } + public GeneratedFileData getData() { return data; } public String getMediaType() { return mediaType; } public JsonNode getProviderMetadata() { return providerMetadata; } public static Builder builder() { return new Builder(); } public static class Builder { - private FileData data = new FileData.Text(""); + private GeneratedFileData data = new GeneratedFileData.Data(new FileBytes.Base64("")); private String mediaType = ""; private JsonNode providerMetadata; - public Builder data(FileData v) { this.data = v; return this; } + public Builder data(GeneratedFileData v) { this.data = v; return this; } public Builder mediaType(String v) { this.mediaType = v; return this; } public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } @@ -2605,6 +2907,91 @@ public int hashCode() { } } + public static class Custom extends GenerateContent { + @JsonProperty("kind") private String kind; + @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonCreator Custom() {} + public Custom(String kind, JsonNode providerMetadata) { + this.kind = kind; + this.providerMetadata = providerMetadata; + } + public String getKind() { return kind; } + public JsonNode getProviderMetadata() { return providerMetadata; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private String kind; + private JsonNode providerMetadata; + public Builder kind(String value) { this.kind = value; return this; } + public Builder providerMetadata(JsonNode value) { this.providerMetadata = value; return this; } + public Custom build() { return new Custom(kind, providerMetadata); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof Custom)) return false; + Custom other = (Custom) value; + return Objects.equals(kind, other.kind) && Objects.equals(providerMetadata, other.providerMetadata); + } + @Override public int hashCode() { return Objects.hash(kind, providerMetadata); } + } + public static class ReasoningFile extends GenerateContent { + @JsonProperty("data") private GeneratedFileData data; + @JsonProperty("media_type") private String mediaType; + @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonCreator ReasoningFile() {} + public ReasoningFile(GeneratedFileData data, String mediaType, JsonNode providerMetadata) { + this.data = data; + this.mediaType = mediaType; + this.providerMetadata = providerMetadata; + } + public GeneratedFileData getData() { return data; } + public String getMediaType() { return mediaType; } + public JsonNode getProviderMetadata() { return providerMetadata; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private GeneratedFileData data; + private String mediaType; + private JsonNode providerMetadata; + public Builder data(GeneratedFileData value) { this.data = value; return this; } + public Builder mediaType(String value) { this.mediaType = value; return this; } + public Builder providerMetadata(JsonNode value) { this.providerMetadata = value; return this; } + public ReasoningFile build() { return new ReasoningFile(data, mediaType, providerMetadata); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof ReasoningFile)) return false; + ReasoningFile other = (ReasoningFile) value; + return Objects.equals(data, other.data) && Objects.equals(mediaType, other.mediaType) && Objects.equals(providerMetadata, other.providerMetadata); + } + @Override public int hashCode() { return Objects.hash(data, mediaType, providerMetadata); } + } + public static class ToolApprovalRequest extends GenerateContent { + @JsonProperty("approval_id") private String approvalId; + @JsonProperty("tool_call_id") private String toolCallId; + @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonCreator ToolApprovalRequest() {} + public ToolApprovalRequest(String approvalId, String toolCallId, JsonNode providerMetadata) { + this.approvalId = approvalId; + this.toolCallId = toolCallId; + this.providerMetadata = providerMetadata; + } + public String getApprovalId() { return approvalId; } + public String getToolCallId() { return toolCallId; } + public JsonNode getProviderMetadata() { return providerMetadata; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private String approvalId; + private String toolCallId; + private JsonNode providerMetadata; + public Builder approvalId(String value) { this.approvalId = value; return this; } + public Builder toolCallId(String value) { this.toolCallId = value; return this; } + public Builder providerMetadata(JsonNode value) { this.providerMetadata = value; return this; } + public ToolApprovalRequest build() { return new ToolApprovalRequest(approvalId, toolCallId, providerMetadata); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof ToolApprovalRequest)) return false; + ToolApprovalRequest other = (ToolApprovalRequest) value; + return Objects.equals(approvalId, other.approvalId) && Objects.equals(toolCallId, other.toolCallId) && Objects.equals(providerMetadata, other.providerMetadata); + } + @Override public int hashCode() { return Objects.hash(approvalId, toolCallId, providerMetadata); } + } /** Fallback for variants introduced after this wrapper was written. */ public static class Unknown extends GenerateContent { private String tag; @@ -2642,6 +3029,9 @@ public static String variantTag(GenerateContent value) { if (value instanceof GenerateContent.Reasoning) return "Reasoning"; if (value instanceof GenerateContent.File) return "File"; if (value instanceof GenerateContent.ToolResult) return "ToolResult"; + if (value instanceof GenerateContent.Custom) return "Custom"; + if (value instanceof GenerateContent.ReasoningFile) return "ReasoningFile"; + if (value instanceof GenerateContent.ToolApprovalRequest) return "ToolApprovalRequest"; if (value instanceof GenerateContent.Unknown) return ((GenerateContent.Unknown) value).getTag(); throw new IllegalArgumentException("Unknown GenerateContent: " + value); } @@ -2657,7 +3047,8 @@ public void serialize(GenerateContent value, JsonGenerator gen, SerializerProvid gen.writeTree(out); return; } - ObjectNode node = (ObjectNode) AimuxJson.INNER_MAPPER.valueToTree(value); + ObjectNode node = (ObjectNode) AimuxJson.INNER_MAPPER.valueToTree( + value instanceof GenerateContent.Source ? ((GenerateContent.Source) value).getSource() : value); ObjectNode out = JsonNodeFactory.instance.objectNode(); out.set(tag, node); gen.writeTree(out); @@ -2678,10 +3069,13 @@ public GenerateContent deserialize(JsonParser p, DeserializationContext ctxt) th switch (tag) { case "Text": return AimuxJson.MAPPER.treeToValue(innerObj, GenerateContent.Text.class); case "ToolCall": return AimuxJson.MAPPER.treeToValue(innerObj, GenerateContent.ToolCall.class); - case "Source": return AimuxJson.MAPPER.treeToValue(innerObj, GenerateContent.Source.class); + case "Source": return new GenerateContent.Source(AimuxJson.MAPPER.treeToValue(innerObj, Types.Source.class)); case "Reasoning": return AimuxJson.MAPPER.treeToValue(innerObj, GenerateContent.Reasoning.class); case "File": return AimuxJson.MAPPER.treeToValue(innerObj, GenerateContent.File.class); case "ToolResult": return AimuxJson.MAPPER.treeToValue(innerObj, GenerateContent.ToolResult.class); + case "Custom": return AimuxJson.MAPPER.treeToValue(innerObj, GenerateContent.Custom.class); + case "ReasoningFile": return AimuxJson.MAPPER.treeToValue(innerObj, GenerateContent.ReasoningFile.class); + case "ToolApprovalRequest": return AimuxJson.MAPPER.treeToValue(innerObj, GenerateContent.ToolApprovalRequest.class); default: return new GenerateContent.Unknown(tag, inner); } } @@ -2700,7 +3094,7 @@ public static class GenerateResult { @JsonProperty("usage") private Usage usage = new Usage(); @JsonProperty("warnings") private List warnings = new ArrayList<>(); @JsonProperty("provider_metadata") private JsonNode providerMetadata; - @JsonProperty("response") private ResponseMetadata response = new ResponseMetadata(); + @JsonProperty("response") private GenerateResponse response = new GenerateResponse(); @JsonProperty("request_body") private JsonNode requestBody; @JsonProperty("response_headers") private Map responseHeaders; @@ -2708,7 +3102,7 @@ public static class GenerateResult { GenerateResult() {} private GenerateResult(List content, FinishReason finishReason, Usage usage, - List warnings, JsonNode providerMetadata, ResponseMetadata response, + List warnings, JsonNode providerMetadata, GenerateResponse response, JsonNode requestBody, Map responseHeaders) { this.content = content; this.finishReason = finishReason; @@ -2725,7 +3119,7 @@ private GenerateResult(List content, FinishReason finishReason, public Usage getUsage() { return usage; } public List getWarnings() { return warnings; } public JsonNode getProviderMetadata() { return providerMetadata; } - public ResponseMetadata getResponse() { return response; } + public GenerateResponse getResponse() { return response; } public JsonNode getRequestBody() { return requestBody; } public Map getResponseHeaders() { return responseHeaders; } @@ -2754,7 +3148,7 @@ public static class Builder { private Usage usage = new Usage(); private List warnings = new ArrayList<>(); private JsonNode providerMetadata; - private ResponseMetadata response = new ResponseMetadata(); + private GenerateResponse response = new GenerateResponse(); private JsonNode requestBody; private Map responseHeaders; @@ -2763,7 +3157,7 @@ public static class Builder { public Builder usage(Usage v) { this.usage = v; return this; } public Builder warnings(List v) { this.warnings = v; return this; } public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } - public Builder response(ResponseMetadata v) { this.response = v; return this; } + public Builder response(GenerateResponse v) { this.response = v; return this; } public Builder requestBody(JsonNode v) { this.requestBody = v; return this; } public Builder responseHeaders(Map v) { this.responseHeaders = v; return this; } @@ -2801,6 +3195,7 @@ public int hashCode() { * Mirrors `GenerateTextResult.ts`. */ public static class GenerateTextResult { + @JsonProperty("content") private List content = new ArrayList<>(); @JsonProperty("text") private String text = ""; @JsonProperty("tool_calls") private List toolCalls = new ArrayList<>(); @JsonProperty("finish_reason") private FinishReason finishReason = new FinishReason(); @@ -2809,7 +3204,7 @@ public static class GenerateTextResult { @JsonProperty("raw") private GenerateResult raw = new GenerateResult(); @JsonProperty("reasoning") private List reasoning = new ArrayList<>(); @JsonProperty("reasoning_text") private String reasoningText = ""; - @JsonProperty("sources") private List sources = new ArrayList<>(); + @JsonProperty("sources") private List sources = new ArrayList<>(); @JsonProperty("files") private List files = new ArrayList<>(); @JsonProperty("response_messages") private List responseMessages = new ArrayList<>(); @JsonProperty("raw_finish_reason") private String rawFinishReason; @@ -2823,7 +3218,7 @@ public static class GenerateTextResult { private GenerateTextResult(String text, List toolCalls, FinishReason finishReason, Usage usage, List warnings, GenerateResult raw, List reasoning, String reasoningText, - List sources, List files, + List sources, List files, List responseMessages, String rawFinishReason, JsonNode providerMetadata, ResponseMetadata response, Usage totalUsage) { this.text = text; @@ -2843,6 +3238,7 @@ private GenerateTextResult(String text, List toolCalls, FinishReason f this.totalUsage = totalUsage; } + public List getContent() { return content; } public String getText() { return text; } public List getToolCalls() { return toolCalls; } public FinishReason getFinishReason() { return finishReason; } @@ -2851,7 +3247,7 @@ private GenerateTextResult(String text, List toolCalls, FinishReason f public GenerateResult getRaw() { return raw; } public List getReasoning() { return reasoning; } public String getReasoningText() { return reasoningText; } - public List getSources() { return sources; } + public List getSources() { return sources; } public List getFiles() { return files; } public List getResponseMessages() { return responseMessages; } public String getRawFinishReason() { return rawFinishReason; } @@ -2870,7 +3266,7 @@ public static class Builder { private GenerateResult raw = new GenerateResult(); private List reasoning = new ArrayList<>(); private String reasoningText = ""; - private List sources = new ArrayList<>(); + private List sources = new ArrayList<>(); private List files = new ArrayList<>(); private List responseMessages = new ArrayList<>(); private String rawFinishReason; @@ -2886,7 +3282,7 @@ public static class Builder { public Builder raw(GenerateResult v) { this.raw = v; return this; } public Builder reasoning(List v) { this.reasoning = v; return this; } public Builder reasoningText(String v) { this.reasoningText = v; return this; } - public Builder sources(List v) { this.sources = v; return this; } + public Builder sources(List v) { this.sources = v; return this; } public Builder files(List v) { this.files = v; return this; } public Builder responseMessages(List v) { this.responseMessages = v; return this; } public Builder rawFinishReason(String v) { this.rawFinishReason = v; return this; } @@ -3039,11 +3435,12 @@ public int hashCode() { * weak types ({@link JsonNode}) — same strategy as {@link GenerateTextResult}. */ public static class StreamTextResultAggregated { + @JsonProperty("content") private List content = new ArrayList<>(); @JsonProperty("text") private String text = ""; @JsonProperty("reasoning") private List reasoning = new ArrayList<>(); @JsonProperty("reasoning_text") private String reasoningText = ""; @JsonProperty("tool_calls") private List toolCalls = new ArrayList<>(); - @JsonProperty("sources") private List sources = new ArrayList<>(); + @JsonProperty("sources") private List sources = new ArrayList<>(); @JsonProperty("files") private List files = new ArrayList<>(); @JsonProperty("finish_reason") private FinishReason finishReason = new FinishReason(); @JsonProperty("raw_finish_reason") private String rawFinishReason; @@ -3058,7 +3455,7 @@ public static class StreamTextResultAggregated { StreamTextResultAggregated() {} private StreamTextResultAggregated(String text, List reasoning, String reasoningText, - List toolCalls, List sources, List files, + List toolCalls, List sources, List files, FinishReason finishReason, String rawFinishReason, Usage usage, Usage totalUsage, List warnings, JsonNode providerMetadata, ResponseMetadata response, List responseMessages) { @@ -3078,11 +3475,12 @@ private StreamTextResultAggregated(String text, List reasoning, String this.responseMessages = responseMessages; } + public List getContent() { return content; } public String getText() { return text; } public List getReasoning() { return reasoning; } public String getReasoningText() { return reasoningText; } public List getToolCalls() { return toolCalls; } - public List getSources() { return sources; } + public List getSources() { return sources; } public List getFiles() { return files; } public FinishReason getFinishReason() { return finishReason; } public String getRawFinishReason() { return rawFinishReason; } @@ -3100,7 +3498,7 @@ public static class Builder { private List reasoning = new ArrayList<>(); private String reasoningText = ""; private List toolCalls = new ArrayList<>(); - private List sources = new ArrayList<>(); + private List sources = new ArrayList<>(); private List files = new ArrayList<>(); private FinishReason finishReason = new FinishReason(); private String rawFinishReason; @@ -3115,7 +3513,7 @@ public static class Builder { public Builder reasoning(List v) { this.reasoning = v; return this; } public Builder reasoningText(String v) { this.reasoningText = v; return this; } public Builder toolCalls(List v) { this.toolCalls = v; return this; } - public Builder sources(List v) { this.sources = v; return this; } + public Builder sources(List v) { this.sources = v; return this; } public Builder files(List v) { this.files = v; return this; } public Builder finishReason(FinishReason v) { this.finishReason = v; return this; } public Builder rawFinishReason(String v) { this.rawFinishReason = v; return this; } @@ -3696,31 +4094,31 @@ public int hashCode() { /** A file generated by the model (e.g. an image or document). */ public static class File extends StreamPart { - @JsonProperty("data") private JsonNode data = emptyObject(); + @JsonProperty("data") private GeneratedFileData data = new GeneratedFileData.Data(new FileBytes.Base64("")); @JsonProperty("media_type") private String mediaType = ""; @JsonProperty("provider_metadata") private JsonNode providerMetadata; @JsonCreator File() {} - private File(JsonNode data, String mediaType, JsonNode providerMetadata) { + private File(GeneratedFileData data, String mediaType, JsonNode providerMetadata) { this.data = data; this.mediaType = mediaType; this.providerMetadata = providerMetadata; } - public JsonNode getData() { return data; } + public GeneratedFileData getData() { return data; } public String getMediaType() { return mediaType; } public JsonNode getProviderMetadata() { return providerMetadata; } public static Builder builder() { return new Builder(); } public static class Builder { - private JsonNode data = emptyObject(); + private GeneratedFileData data = new GeneratedFileData.Data(new FileBytes.Base64("")); private String mediaType = ""; private JsonNode providerMetadata; - public Builder data(JsonNode v) { this.data = v; return this; } + public Builder data(GeneratedFileData v) { this.data = v; return this; } public Builder mediaType(String v) { this.mediaType = v; return this; } public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } @@ -3912,61 +4310,15 @@ public boolean equals(Object o) { } public static class Source extends StreamPart { - @JsonProperty("id") private String id = ""; - @JsonProperty("source_type") private String sourceType = ""; - @JsonProperty("url") private String url; - @JsonProperty("title") private String title; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + private final Types.Source source; - @JsonCreator - Source() {} + public Source(Types.Source source) { this.source = Objects.requireNonNull(source, "source"); } + public Types.Source getSource() { return source; } - private Source(String id, String sourceType, String url, String title, JsonNode providerMetadata) { - this.id = id; - this.sourceType = sourceType; - this.url = url; - this.title = title; - this.providerMetadata = providerMetadata; + @Override public boolean equals(Object o) { + return o instanceof Source && Objects.equals(source, ((Source) o).source); } - - public String getId() { return id; } - public String getSourceType() { return sourceType; } - public String getUrl() { return url; } - public String getTitle() { return title; } - public JsonNode getProviderMetadata() { return providerMetadata; } - - public static Builder builder() { return new Builder(); } - - public static class Builder { - private String id = ""; - private String sourceType = ""; - private String url; - private String title; - private JsonNode providerMetadata; - - public Builder id(String v) { this.id = v; return this; } - public Builder sourceType(String v) { this.sourceType = v; return this; } - public Builder url(String v) { this.url = v; return this; } - public Builder title(String v) { this.title = v; return this; } - public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } - - public Source build() { return new Source(id, sourceType, url, title, providerMetadata); } - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof Source)) return false; - Source that = (Source) o; - return Objects.equals(id, that.id) - && Objects.equals(sourceType, that.sourceType) - && Objects.equals(url, that.url) - && Objects.equals(title, that.title) - && Objects.equals(providerMetadata, that.providerMetadata); - } - - @Override - public int hashCode() { return Objects.hash(id, sourceType, url, title, providerMetadata); } + @Override public int hashCode() { return Objects.hash(source); } } public static class Raw extends StreamPart { @@ -4031,6 +4383,96 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(error); } } + public static class Custom extends StreamPart { + @JsonProperty("kind") private String kind; + @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonCreator Custom() {} + public Custom(String kind, JsonNode providerMetadata) { + this.kind = kind; + this.providerMetadata = providerMetadata; + } + public String getKind() { return kind; } + public JsonNode getProviderMetadata() { return providerMetadata; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private String kind; + private JsonNode providerMetadata; + public Builder kind(String value) { this.kind = value; return this; } + public Builder providerMetadata(JsonNode value) { this.providerMetadata = value; return this; } + public Custom build() { return new Custom(kind, providerMetadata); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof Custom)) return false; + Custom other = (Custom) value; + return Objects.equals(kind, other.kind) && Objects.equals(providerMetadata, other.providerMetadata); + } + @Override public int hashCode() { return Objects.hash(kind, providerMetadata); } + } + public static class ReasoningFile extends StreamPart { + @JsonProperty("file") private GenerateContent.File file; + @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonCreator ReasoningFile() {} + public ReasoningFile(GenerateContent.File file, JsonNode providerMetadata) { + this.file = file; + this.providerMetadata = providerMetadata; + } + public GenerateContent.File getFile() { return file; } + public JsonNode getProviderMetadata() { return providerMetadata; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private GenerateContent.File file; + private JsonNode providerMetadata; + public Builder file(GenerateContent.File value) { this.file = value; return this; } + public Builder providerMetadata(JsonNode value) { this.providerMetadata = value; return this; } + public ReasoningFile build() { return new ReasoningFile(file, providerMetadata); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof ReasoningFile)) return false; + ReasoningFile other = (ReasoningFile) value; + return Objects.equals(file, other.file) && Objects.equals(providerMetadata, other.providerMetadata); + } + @Override public int hashCode() { return Objects.hash(file, providerMetadata); } + } + public static class ToolApprovalRequest extends StreamPart { + @JsonProperty("approval_id") private String approvalId; + @JsonProperty("tool_call") private Types.ToolCall toolCall; + @JsonProperty("reason") private String reason; + @JsonProperty("is_automatic") private Boolean isAutomatic; + @JsonProperty("signature") private String signature; + @JsonCreator ToolApprovalRequest() {} + public ToolApprovalRequest(String approvalId, Types.ToolCall toolCall, String reason, Boolean isAutomatic, String signature) { + this.approvalId = approvalId; + this.toolCall = toolCall; + this.reason = reason; + this.isAutomatic = isAutomatic; + this.signature = signature; + } + public String getApprovalId() { return approvalId; } + public Types.ToolCall getToolCall() { return toolCall; } + public String getReason() { return reason; } + public Boolean getIsAutomatic() { return isAutomatic; } + public String getSignature() { return signature; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private String approvalId; + private Types.ToolCall toolCall; + private String reason; + private Boolean isAutomatic; + private String signature; + public Builder approvalId(String value) { this.approvalId = value; return this; } + public Builder toolCall(Types.ToolCall value) { this.toolCall = value; return this; } + public Builder reason(String value) { this.reason = value; return this; } + public Builder isAutomatic(Boolean value) { this.isAutomatic = value; return this; } + public Builder signature(String value) { this.signature = value; return this; } + public ToolApprovalRequest build() { return new ToolApprovalRequest(approvalId, toolCall, reason, isAutomatic, signature); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof ToolApprovalRequest)) return false; + ToolApprovalRequest other = (ToolApprovalRequest) value; + return Objects.equals(approvalId, other.approvalId) && Objects.equals(toolCall, other.toolCall) && Objects.equals(reason, other.reason) && Objects.equals(isAutomatic, other.isAutomatic) && Objects.equals(signature, other.signature); + } + @Override public int hashCode() { return Objects.hash(approvalId, toolCall, reason, isAutomatic, signature); } + } /** Fallback for variants introduced after this wrapper was written. */ public static class Unknown extends StreamPart { private String tag; @@ -4080,6 +4522,9 @@ public static String variantTag(StreamPart value) { if (value instanceof StreamPart.Source) return "Source"; if (value instanceof StreamPart.Raw) return "Raw"; if (value instanceof StreamPart.Error) return "Error"; + if (value instanceof StreamPart.Custom) return "Custom"; + if (value instanceof StreamPart.ReasoningFile) return "ReasoningFile"; + if (value instanceof StreamPart.ToolApprovalRequest) return "ToolApprovalRequest"; if (value instanceof StreamPart.Unknown) return ((StreamPart.Unknown) value).getTag(); throw new IllegalArgumentException("Unknown StreamPart: " + value); } @@ -4095,7 +4540,8 @@ public void serialize(StreamPart value, JsonGenerator gen, SerializerProvider se gen.writeTree(out); return; } - ObjectNode node = (ObjectNode) AimuxJson.INNER_MAPPER.valueToTree(value); + ObjectNode node = (ObjectNode) AimuxJson.INNER_MAPPER.valueToTree( + value instanceof StreamPart.Source ? ((StreamPart.Source) value).getSource() : value); ObjectNode out = JsonNodeFactory.instance.objectNode(); out.set(tag, node); gen.writeTree(out); @@ -4129,9 +4575,12 @@ public StreamPart deserialize(JsonParser p, DeserializationContext ctxt) throws case "ReasoningDelta": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.ReasoningDelta.class); case "ReasoningEnd": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.ReasoningEnd.class); case "ResponseMetadata": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.ResponseMetadata.class); - case "Source": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.Source.class); + case "Source": return new StreamPart.Source(AimuxJson.MAPPER.treeToValue(innerObj, Types.Source.class)); case "Raw": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.Raw.class); case "Error": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.Error.class); + case "Custom": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.Custom.class); + case "ReasoningFile": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.ReasoningFile.class); + case "ToolApprovalRequest": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.ToolApprovalRequest.class); default: return new StreamPart.Unknown(tag, inner); } } diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt index d14e4fcfe..2ece2846d 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt @@ -13,6 +13,7 @@ package ai.arcships.aimux +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -24,6 +25,7 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonClassDiscriminator import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonEncoder @@ -85,40 +87,37 @@ enum class ReasoningEffort { // Core nested types. // ───────────────────────────────────────────────────────────────────────────── -/** - * Token usage detail (with cache breakdown). Every field is nullable because - * providers do not always populate all breakdowns. - */ @Serializable -data class TokenUsage( +data class InputTokenUsage( val total: Long? = null, @SerialName("no_cache") val noCache: Long? = null, @SerialName("cache_read") val cacheRead: Long? = null, @SerialName("cache_write") val cacheWrite: Long? = null, +) + +@Serializable +data class OutputTokenUsage( + val total: Long? = null, val text: Long? = null, val reasoning: Long? = null, -) { - companion object { - fun of(total: Long?): TokenUsage = TokenUsage(total = total) - } -} +) /** * Token usage statistics. * - * Mirrors `Usage.ts`: `{ input_tokens: TokenUsage, output_tokens: TokenUsage, + * Mirrors `Usage.ts`: `{ input_tokens: InputTokenUsage, output_tokens: OutputTokenUsage, * raw?: JsonValue | null }`. */ @Serializable data class Usage( - @SerialName("input_tokens") val inputTokens: TokenUsage = TokenUsage(), - @SerialName("output_tokens") val outputTokens: TokenUsage = TokenUsage(), - val raw: JsonElement? = null, + @SerialName("input_tokens") val inputTokens: InputTokenUsage = InputTokenUsage(), + @SerialName("output_tokens") val outputTokens: OutputTokenUsage = OutputTokenUsage(), + val raw: JsonObject? = null, ) { companion object { /** Convenience for tests/inspection. */ fun of(input: Long?, output: Long?): Usage = - Usage(inputTokens = TokenUsage.of(input), outputTokens = TokenUsage.of(output)) + Usage(inputTokens = InputTokenUsage(input), outputTokens = OutputTokenUsage(output)) } } @@ -146,6 +145,15 @@ data class ResponseMetadata( @SerialName("model_id") val modelId: String? = null, ) +@Serializable +data class GenerateResponse( + val id: String? = null, + val timestamp: String? = null, + @SerialName("model_id") val modelId: String? = null, + val body: JsonElement? = null, + val headers: Map? = null, +) + /** * A tool call requested by the model. * @@ -336,6 +344,15 @@ object ToolChoiceSerializer : KSerializer { */ @Serializable(with = ContentPartSerializer::class) sealed interface ContentPart { + @Serializable + data class Custom(val kind: String, @SerialName("provider_options") val providerOptions: JsonElement? = null) : ContentPart + + @Serializable + data class ReasoningFile(val data: GeneratedFileData, @SerialName("media_type") val mediaType: String, @SerialName("provider_options") val providerOptions: JsonElement? = null) : ContentPart + + @Serializable + data class ToolApprovalRequest(@SerialName("approval_id") val approvalId: String, @SerialName("tool_call_id") val toolCallId: String, val reason: String? = null, @SerialName("is_automatic") val isAutomatic: Boolean? = null, val signature: String? = null, @SerialName("input_schema_input") val inputSchemaInput: JsonElement? = null) : ContentPart + @Serializable data class Text( @@ -431,6 +448,9 @@ object ContentPartSerializer : KSerializer { ?: throw SerializationException("ContentPart is missing the 'type' discriminator: $element") val ctx = json.json return when (type) { + "custom" -> ctx.decodeFromJsonElement(ContentPart.Custom.serializer(), obj) + "reasoning_file" -> ctx.decodeFromJsonElement(ContentPart.ReasoningFile.serializer(), obj) + "tool_approval_request" -> ctx.decodeFromJsonElement(ContentPart.ToolApprovalRequest.serializer(), obj) "text" -> ctx.decodeFromJsonElement(ContentPart.Text.serializer(), obj) "image" -> ctx.decodeFromJsonElement(ContentPart.Image.serializer(), obj) "file" -> ctx.decodeFromJsonElement(ContentPart.File.serializer(), obj) @@ -449,6 +469,9 @@ object ContentPartSerializer : KSerializer { ?: throw SerializationException("ContentPart can only be encoded to JSON") val ctx = json.json val (typeTag, inner) = when (value) { + is ContentPart.Custom -> "custom" to ctx.encodeToJsonElement(ContentPart.Custom.serializer(), value) + is ContentPart.ReasoningFile -> "reasoning_file" to ctx.encodeToJsonElement(ContentPart.ReasoningFile.serializer(), value) + is ContentPart.ToolApprovalRequest -> "tool_approval_request" to ctx.encodeToJsonElement(ContentPart.ToolApprovalRequest.serializer(), value) is ContentPart.Text -> "text" to ctx.encodeToJsonElement(ContentPart.Text.serializer(), value) is ContentPart.Image -> "image" to ctx.encodeToJsonElement(ContentPart.Image.serializer(), value) is ContentPart.File -> "file" to ctx.encodeToJsonElement(ContentPart.File.serializer(), value) @@ -722,7 +745,7 @@ sealed interface FileData { data class Data(val data: FileBytes = FileBytes.Base64("")) : FileData @Serializable - data class Url(val url: String = "") : FileData + data class Url(val url: String = "", @SerialName("original_url") val originalUrl: String? = null) : FileData @Serializable data class Reference(val reference: JsonElement = JsonObject(emptyMap())) : FileData @@ -776,6 +799,59 @@ object FileDataSerializer : KSerializer { } } +@Serializable(with = GeneratedFileDataSerializer::class) +sealed interface GeneratedFileData { + + @Serializable + data class Data(val data: FileBytes = FileBytes.Base64("")) : GeneratedFileData + + @Serializable + data class Url(val url: String = "", @SerialName("original_url") val originalUrl: String? = null) : GeneratedFileData + + +} + +/** + * Custom (de)serializer for [GeneratedFileData]. + * + * Externally tagged: each variant is a single-key object whose value is the + * variant's inner object; delegation goes to the matching variant's generated + * serializer. + */ +object GeneratedFileDataSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("aimux.GeneratedFileData") + + override fun deserialize(decoder: Decoder): GeneratedFileData { + val json = decoder as? JsonDecoder + ?: throw SerializationException("GeneratedFileData can only be decoded from JSON") + val element = json.decodeJsonElement() + val obj = element.jsonObject + require(obj.size == 1) { + "GeneratedFileData must be a single-key externally-tagged object, got: $element" + } + val (tag, inner) = obj.entries.single() + val innerObj = inner as? JsonObject ?: JsonObject(emptyMap()) + val ctx = json.json + return when (tag) { + "Data" -> ctx.decodeFromJsonElement(GeneratedFileData.Data.serializer(), innerObj) + "Url" -> ctx.decodeFromJsonElement(GeneratedFileData.Url.serializer(), innerObj) + else -> throw SerializationException("Unknown GeneratedFileData tag: '$tag'") + } + } + + override fun serialize(encoder: Encoder, value: GeneratedFileData) { + val json = encoder as? JsonEncoder + ?: throw SerializationException("GeneratedFileData can only be encoded to JSON") + val ctx = json.json + val (tag, inner) = when (value) { + is GeneratedFileData.Data -> "Data" to ctx.encodeToJsonElement(GeneratedFileData.Data.serializer(), value) + is GeneratedFileData.Url -> "Url" to ctx.encodeToJsonElement(GeneratedFileData.Url.serializer(), value) + } + json.encodeJsonElement(JsonObject(mapOf(tag to inner))) + } +} + // ───────────────────────────────────────────────────────────────────────────── // GenerateContent / GenerateResult (the `raw` field of GenerateTextResult). // @@ -785,6 +861,34 @@ object FileDataSerializer : KSerializer { // [GenerateContent.Unknown] for forward compatibility (mirroring [StreamPart]). // ───────────────────────────────────────────────────────────────────────────── +/** A URL or document used as a source for the response. */ +@OptIn(ExperimentalSerializationApi::class) +@Serializable +@JsonClassDiscriminator("source_type") +sealed interface Source { + val id: String + val providerMetadata: JsonElement? + + @Serializable + @SerialName("url") + data class Url( + override val id: String, + val url: String, + val title: String? = null, + @SerialName("provider_metadata") override val providerMetadata: JsonElement? = null, + ) : Source + + @Serializable + @SerialName("document") + data class Document( + override val id: String, + @SerialName("media_type") val mediaType: String, + val title: String, + val filename: String? = null, + @SerialName("provider_metadata") override val providerMetadata: JsonElement? = null, + ) : Source +} + /** * A content item in the generation result. * @@ -794,6 +898,26 @@ object FileDataSerializer : KSerializer { */ @Serializable(with = GenerateContentSerializer::class) sealed interface GenerateContent { + @Serializable + data class Custom( + val kind: String, + @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + ) : GenerateContent + + @Serializable + data class ReasoningFile( + val data: GeneratedFileData, + @SerialName("media_type") val mediaType: String, + @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + ) : GenerateContent + + @Serializable + data class ToolApprovalRequest( + @SerialName("approval_id") val approvalId: String, + @SerialName("tool_call_id") val toolCallId: String, + @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + ) : GenerateContent + @Serializable data class Text( @@ -811,14 +935,7 @@ sealed interface GenerateContent { @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, ) : GenerateContent - @Serializable - data class Source( - val id: String = "", - @SerialName("source_type") val sourceType: String = "", - val url: String? = null, - val title: String? = null, - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, - ) : GenerateContent + data class Source(val source: ai.arcships.aimux.Source) : GenerateContent @Serializable data class Reasoning( @@ -828,7 +945,7 @@ sealed interface GenerateContent { @Serializable data class File( - val data: FileData = FileData.Text(""), + val data: GeneratedFileData = GeneratedFileData.Data(), @SerialName("media_type") val mediaType: String = "", @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, ) : GenerateContent @@ -851,6 +968,9 @@ sealed interface GenerateContent { /** The externally-tagged variant name for this [GenerateContent] (e.g. "Text", "ToolCall"). */ val GenerateContent.variantTag: String get() = when (this) { + is GenerateContent.Custom -> "Custom" + is GenerateContent.ReasoningFile -> "ReasoningFile" + is GenerateContent.ToolApprovalRequest -> "ToolApprovalRequest" is GenerateContent.Text -> "Text" is GenerateContent.ToolCall -> "ToolCall" is GenerateContent.Source -> "Source" @@ -884,9 +1004,12 @@ object GenerateContentSerializer : KSerializer { val innerObj = inner as? JsonObject ?: JsonObject(emptyMap()) val ctx = json.json return when (tag) { + "Custom" -> ctx.decodeFromJsonElement(GenerateContent.Custom.serializer(), innerObj) + "ReasoningFile" -> ctx.decodeFromJsonElement(GenerateContent.ReasoningFile.serializer(), innerObj) + "ToolApprovalRequest" -> ctx.decodeFromJsonElement(GenerateContent.ToolApprovalRequest.serializer(), innerObj) "Text" -> ctx.decodeFromJsonElement(GenerateContent.Text.serializer(), innerObj) "ToolCall" -> ctx.decodeFromJsonElement(GenerateContent.ToolCall.serializer(), innerObj) - "Source" -> ctx.decodeFromJsonElement(GenerateContent.Source.serializer(), innerObj) + "Source" -> GenerateContent.Source(ctx.decodeFromJsonElement(Source.serializer(), innerObj)) "Reasoning" -> ctx.decodeFromJsonElement(GenerateContent.Reasoning.serializer(), innerObj) "File" -> ctx.decodeFromJsonElement(GenerateContent.File.serializer(), innerObj) "ToolResult" -> ctx.decodeFromJsonElement(GenerateContent.ToolResult.serializer(), innerObj) @@ -899,9 +1022,12 @@ object GenerateContentSerializer : KSerializer { ?: throw SerializationException("GenerateContent can only be encoded to JSON") val ctx = json.json val (tag, inner) = when (value) { + is GenerateContent.Custom -> "Custom" to ctx.encodeToJsonElement(GenerateContent.Custom.serializer(), value) + is GenerateContent.ReasoningFile -> "ReasoningFile" to ctx.encodeToJsonElement(GenerateContent.ReasoningFile.serializer(), value) + is GenerateContent.ToolApprovalRequest -> "ToolApprovalRequest" to ctx.encodeToJsonElement(GenerateContent.ToolApprovalRequest.serializer(), value) is GenerateContent.Text -> "Text" to ctx.encodeToJsonElement(GenerateContent.Text.serializer(), value) is GenerateContent.ToolCall -> "ToolCall" to ctx.encodeToJsonElement(GenerateContent.ToolCall.serializer(), value) - is GenerateContent.Source -> "Source" to ctx.encodeToJsonElement(GenerateContent.Source.serializer(), value) + is GenerateContent.Source -> "Source" to ctx.encodeToJsonElement(Source.serializer(), value.source) is GenerateContent.Reasoning -> "Reasoning" to ctx.encodeToJsonElement(GenerateContent.Reasoning.serializer(), value) is GenerateContent.File -> "File" to ctx.encodeToJsonElement(GenerateContent.File.serializer(), value) is GenerateContent.ToolResult -> "ToolResult" to ctx.encodeToJsonElement(GenerateContent.ToolResult.serializer(), value) @@ -924,7 +1050,7 @@ data class GenerateResult( val usage: Usage = Usage(), val warnings: List = emptyList(), @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, - val response: ResponseMetadata = ResponseMetadata(), + val response: GenerateResponse? = null, @SerialName("request_body") val requestBody: JsonElement? = null, @SerialName("response_headers") val responseHeaders: Map? = null, ) { @@ -945,6 +1071,7 @@ data class GenerateResult( @Serializable data class GenerateTextResult( val text: String = "", + val content: List = emptyList(), @SerialName("tool_calls") val toolCalls: List = emptyList(), @SerialName("finish_reason") val finishReason: FinishReason = FinishReason(), val usage: Usage = Usage(), @@ -953,7 +1080,7 @@ data class GenerateTextResult( // M7: top-level aggregation fields val reasoning: List = emptyList(), @SerialName("reasoning_text") val reasoningText: String = "", - val sources: List = emptyList(), + val sources: List = emptyList(), val files: List = emptyList(), @SerialName("response_messages") val responseMessages: List = emptyList(), // M12: raw provider-specific finish reason string. @@ -1003,11 +1130,12 @@ data class GenerateObjectResult( @Serializable data class StreamTextResultAggregated( val text: String = "", + val content: List = emptyList(), // reasoning/sources/files use weak types (JsonElement). val reasoning: List = emptyList(), @SerialName("reasoning_text") val reasoningText: String = "", @SerialName("tool_calls") val toolCalls: List = emptyList(), - val sources: List = emptyList(), + val sources: List = emptyList(), val files: List = emptyList(), @SerialName("finish_reason") val finishReason: FinishReason = FinishReason(), @SerialName("raw_finish_reason") val rawFinishReason: String? = null, @@ -1035,6 +1163,27 @@ data class StreamTextResultAggregated( @Serializable(with = StreamPartSerializer::class) sealed interface StreamPart { + @Serializable + data class Custom( + val kind: String, + @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + ) : StreamPart + + @Serializable + data class ReasoningFile( + val file: GenerateContent.File, + @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + ) : StreamPart + + @Serializable + data class ToolApprovalRequest( + @SerialName("approval_id") val approvalId: String, + @SerialName("tool_call") val toolCall: ai.arcships.aimux.ToolCall, + val reason: String? = null, + @SerialName("is_automatic") val isAutomatic: Boolean? = null, + val signature: String? = null, + ) : StreamPart + @Serializable data class TextStart( @@ -1150,14 +1299,7 @@ sealed interface StreamPart { @SerialName("model_id") val modelId: String? = null, ) : StreamPart - @Serializable - data class Source( - val id: String = "", - @SerialName("source_type") val sourceType: String = "", - val url: String? = null, - val title: String? = null, - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, - ) : StreamPart + data class Source(val source: ai.arcships.aimux.Source) : StreamPart @Serializable data class Raw( @@ -1174,6 +1316,9 @@ sealed interface StreamPart { /** The externally-tagged variant name for this [StreamPart] (e.g. "TextDelta", "ToolCall"). */ val StreamPart.variantTag: String get() = when (this) { + is StreamPart.Custom -> "Custom" + is StreamPart.ReasoningFile -> "ReasoningFile" + is StreamPart.ToolApprovalRequest -> "ToolApprovalRequest" is StreamPart.TextStart -> "TextStart" is StreamPart.TextDelta -> "TextDelta" is StreamPart.TextEnd -> "TextEnd" @@ -1219,6 +1364,9 @@ object StreamPartSerializer : KSerializer { val innerObj = inner as? JsonObject ?: JsonObject(emptyMap()) val ctx = json.json return when (tag) { + "Custom" -> ctx.decodeFromJsonElement(StreamPart.Custom.serializer(), innerObj) + "ReasoningFile" -> ctx.decodeFromJsonElement(StreamPart.ReasoningFile.serializer(), innerObj) + "ToolApprovalRequest" -> ctx.decodeFromJsonElement(StreamPart.ToolApprovalRequest.serializer(), innerObj) "TextStart" -> ctx.decodeFromJsonElement(StreamPart.TextStart.serializer(), innerObj) "TextDelta" -> ctx.decodeFromJsonElement(StreamPart.TextDelta.serializer(), innerObj) "TextEnd" -> ctx.decodeFromJsonElement(StreamPart.TextEnd.serializer(), innerObj) @@ -1234,7 +1382,7 @@ object StreamPartSerializer : KSerializer { "ReasoningDelta" -> ctx.decodeFromJsonElement(StreamPart.ReasoningDelta.serializer(), innerObj) "ReasoningEnd" -> ctx.decodeFromJsonElement(StreamPart.ReasoningEnd.serializer(), innerObj) "ResponseMetadata" -> ctx.decodeFromJsonElement(StreamPart.ResponseMetadata.serializer(), innerObj) - "Source" -> ctx.decodeFromJsonElement(StreamPart.Source.serializer(), innerObj) + "Source" -> StreamPart.Source(ctx.decodeFromJsonElement(Source.serializer(), innerObj)) "Raw" -> ctx.decodeFromJsonElement(StreamPart.Raw.serializer(), innerObj) "Error" -> ctx.decodeFromJsonElement(StreamPart.Error.serializer(), innerObj) else -> StreamPart.Unknown(tag, inner) @@ -1246,6 +1394,9 @@ object StreamPartSerializer : KSerializer { ?: throw SerializationException("StreamPart can only be encoded to JSON") val ctx = json.json val (tag, inner) = when (value) { + is StreamPart.Custom -> "Custom" to ctx.encodeToJsonElement(StreamPart.Custom.serializer(), value) + is StreamPart.ReasoningFile -> "ReasoningFile" to ctx.encodeToJsonElement(StreamPart.ReasoningFile.serializer(), value) + is StreamPart.ToolApprovalRequest -> "ToolApprovalRequest" to ctx.encodeToJsonElement(StreamPart.ToolApprovalRequest.serializer(), value) is StreamPart.TextStart -> "TextStart" to ctx.encodeToJsonElement(StreamPart.TextStart.serializer(), value) is StreamPart.TextDelta -> "TextDelta" to ctx.encodeToJsonElement(StreamPart.TextDelta.serializer(), value) is StreamPart.TextEnd -> "TextEnd" to ctx.encodeToJsonElement(StreamPart.TextEnd.serializer(), value) @@ -1261,7 +1412,7 @@ object StreamPartSerializer : KSerializer { is StreamPart.ReasoningDelta -> "ReasoningDelta" to ctx.encodeToJsonElement(StreamPart.ReasoningDelta.serializer(), value) is StreamPart.ReasoningEnd -> "ReasoningEnd" to ctx.encodeToJsonElement(StreamPart.ReasoningEnd.serializer(), value) is StreamPart.ResponseMetadata -> "ResponseMetadata" to ctx.encodeToJsonElement(StreamPart.ResponseMetadata.serializer(), value) - is StreamPart.Source -> "Source" to ctx.encodeToJsonElement(StreamPart.Source.serializer(), value) + is StreamPart.Source -> "Source" to ctx.encodeToJsonElement(Source.serializer(), value.source) is StreamPart.Raw -> "Raw" to ctx.encodeToJsonElement(StreamPart.Raw.serializer(), value) is StreamPart.Error -> "Error" to ctx.encodeToJsonElement(StreamPart.Error.serializer(), value) is StreamPart.Unknown -> value.tag to value.data diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt index 578584ac9..83704cc46 100644 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt +++ b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt @@ -297,20 +297,6 @@ class TypedModelRoundTripTest { assertThat(decoded).isEqualTo(original) } - @Test - fun `GenerateContent Source round-trips`() { - val original = GenerateContent.Source( - id = "src_1", - sourceType = "web", - url = "https://example.com", - title = "Example", - providerMetadata = null, - ) - val json = AimuxJson.encodeToString(GenerateContent.serializer(), original) - val decoded = AimuxJson.decodeFromString(GenerateContent.serializer(), json) - assertThat(decoded).isEqualTo(original) - } - @Test fun `GenerateContent Reasoning round-trips`() { val original = GenerateContent.Reasoning(text = "thinking...", providerMetadata = null) @@ -319,19 +305,7 @@ class TypedModelRoundTripTest { assertThat(decoded).isEqualTo(original) } - @Test - fun `GenerateContent File round-trips and omits filename`() { - val original = GenerateContent.File( - data = FileData.Text("payload"), - mediaType = "text/plain", - providerMetadata = null, - ) - val json = AimuxJson.encodeToString(GenerateContent.serializer(), original) - // GenerateContent.File (unlike ContentPart.File) has no `filename` field. - assertThat(json).doesNotContain("filename") - val decoded = AimuxJson.decodeFromString(GenerateContent.serializer(), json) - assertThat(decoded).isEqualTo(original) - } + @Test fun `GenerateContent ToolResult round-trips using result not output`() { diff --git a/bindings/node/__test__/reasoning_replay.test.ts b/bindings/node/__test__/reasoning_replay.test.ts index 7498b0dad..1fa1472d2 100644 --- a/bindings/node/__test__/reasoning_replay.test.ts +++ b/bindings/node/__test__/reasoning_replay.test.ts @@ -117,8 +117,9 @@ test('issue 2: assistant reasoning + tool_call lifts reasoning to reasoning_cont const model = await openai('test-key', 'deepseek-v4-flash', url) // Reproduces the user's table row: assistant - // [{reasoning},{tool_call}] + tool string. The first turn produced a + // [{reasoning},{tool_call}] + tool result. The first turn produced a // reasoning_content + tool_call; the second turn must replay both. + // A tool message carries tool_result parts only (AI SDK `ToolModelMessage`). const messages = [ { role: 'user', content: 'inspect the repo' }, { @@ -130,7 +131,12 @@ test('issue 2: assistant reasoning + tool_call lifts reasoning to reasoning_cont }, { role: 'tool', - content: 'contents of README.md', + content: [{ + type: 'tool_result', + tool_call_id: 'call_1', + tool_name: 'read_file', + output: 'contents of README.md', + }], }, ] diff --git a/bindings/node/__test__/roundtrip-fields.test.ts b/bindings/node/__test__/roundtrip-fields.test.ts index a2237a1cf..66b8924cd 100644 --- a/bindings/node/__test__/roundtrip-fields.test.ts +++ b/bindings/node/__test__/roundtrip-fields.test.ts @@ -69,7 +69,7 @@ test('roundtrip: anthropic echoes prompt reasoning signature and surfaces respon { role: 'assistant', content: [ - { type: 'reasoning', text: 'pondering', signature: 'sig-prompt-1' }, + { type: 'reasoning', text: 'pondering', provider_options: { anthropic: { signature: 'sig-prompt-1' } } }, { type: 'text', text: 'answer' }, ], }, diff --git a/bindings/node/src/error.rs b/bindings/node/src/error.rs index bea1e27be..4585d05fc 100644 --- a/bindings/node/src/error.rs +++ b/bindings/node/src/error.rs @@ -177,7 +177,9 @@ fn aimux_error_class_name(error: &AiMuxError) -> &'static str { AiMuxError::ApiCall(_) => "APICallError", AiMuxError::Retry(_) => "RetryError", AiMuxError::JsonParse(_) => "JSONParseError", - AiMuxError::InvalidResponseData(_) => "InvalidResponseDataError", + AiMuxError::InvalidResponseData(_) | AiMuxError::ToolCallNotFoundForApproval { .. } => { + "InvalidResponseDataError" + } AiMuxError::NoSuchTool { .. } => "NoSuchToolError", AiMuxError::InvalidToolInput { .. } => "InvalidToolInputError", AiMuxError::ToolCallRepair { .. } => "ToolCallRepairError", diff --git a/bindings/node/src/index.ts b/bindings/node/src/index.ts index 38505815e..6d4be8c05 100644 --- a/bindings/node/src/index.ts +++ b/bindings/node/src/index.ts @@ -36,6 +36,15 @@ import type { ToolCall, ToolResult, Usage, + InputTokenUsage, + OutputTokenUsage, + TextContent, + ReasoningPart, + GeneratedFile, + Source, + GeneratedFileData, + RawToolApprovalRequest, + ToolApprovalRequestOutput, FinishReason, Warning, Role, @@ -143,6 +152,15 @@ export type { ToolCall, ToolResult, Usage, + InputTokenUsage, + OutputTokenUsage, + TextContent, + ReasoningPart, + GeneratedFile, + Source, + GeneratedFileData, + RawToolApprovalRequest, + ToolApprovalRequestOutput, FinishReason, Warning, Role, diff --git a/bindings/node/src/multimodal.rs b/bindings/node/src/multimodal.rs index 00666cc09..f3818c936 100644 --- a/bindings/node/src/multimodal.rs +++ b/bindings/node/src/multimodal.rs @@ -743,7 +743,7 @@ pub async fn start_transcription_session( struct SessionOpts { input_audio_format: Option, provider_options: - Option>, + Option, headers: Option>, include_raw_chunks: Option, timeout: Option, diff --git a/bindings/node/src/types.ts b/bindings/node/src/types.ts index 07041be8f..f4d5e3396 100644 --- a/bindings/node/src/types.ts +++ b/bindings/node/src/types.ts @@ -5,6 +5,7 @@ export type * from './types/AiMuxError' export type * from './types/ApiCallError' export type * from './types/AspectRatio' +export type * from './types/AssistantPart' export type * from './types/AudioChunk' export type * from './types/AudioData' export type * from './types/AudioInput' @@ -24,6 +25,7 @@ export type * from './types/ChatCompletionToolCall' export type * from './types/ChatCompletionUsage' export type * from './types/CompletionTokensDetails' export type * from './types/ContentPart' +export type * from './types/CustomPart' export type * from './types/DecisionAnswer' export type * from './types/DecisionBooleanCriteria' export type * from './types/DecisionCallOptions' @@ -52,9 +54,12 @@ export type * from './types/FunctionTool' export type * from './types/FunctionToolInputExample' export type * from './types/GenerateContent' export type * from './types/GenerateObjectResult' +export type * from './types/GenerateResponse' export type * from './types/GenerateResult' export type * from './types/GenerateTextOptions' export type * from './types/GenerateTextResult' +export type * from './types/GeneratedFile' +export type * from './types/GeneratedFileData' export type * from './types/HttpExchange' export type * from './types/HttpRecord' export type * from './types/ImageCallOptions' @@ -66,7 +71,9 @@ export type * from './types/ImageResult' export type * from './types/ImageUsage' export type * from './types/InputAudioFormat' export type * from './types/InputRecord' -export type * from './types/LanguageModelPromptMessage' +export type * from './types/InputTokenUsage' +export type * from './types/LanguageModelMessage' +export type * from './types/LanguageModelReasoningPart' export type * from './types/MessageContent' export type * from './types/Modality' export type * from './types/ModelCapabilities' @@ -80,15 +87,20 @@ export type * from './types/ModelSpec' export type * from './types/ModelType' export type * from './types/OutcomeRecord' export type * from './types/OutcomeStatus' +export type * from './types/OutputTokenUsage' export type * from './types/PrefixBreak' export type * from './types/PromptTokensDetails' export type * from './types/ProviderAuditSpec' export type * from './types/ProviderFamily' export type * from './types/ProviderRecord' export type * from './types/ProviderTool' +export type * from './types/RawToolApprovalRequest' export type * from './types/RawToolCall' export type * from './types/ReasoningEffort' +export type * from './types/ReasoningFileOutput' +export type * from './types/ReasoningFilePart' export type * from './types/ReasoningMode' +export type * from './types/ReasoningOutput' export type * from './types/ReasoningPart' export type * from './types/ReasoningSpec' export type * from './types/ReasoningVisibility' @@ -119,21 +131,30 @@ export type * from './types/SessionSource' export type * from './types/SessionStepStat' export type * from './types/SessionView' export type * from './types/Size' -export type * from './types/SourcePart' +export type * from './types/Source' export type * from './types/SpeechCallOptions' export type * from './types/SpeechRequest' export type * from './types/SpeechResponse' export type * from './types/SpeechResult' export type * from './types/StreamPart' +export type * from './types/StreamResponse' export type * from './types/StreamTextResultAggregated' +export type * from './types/TextContent' +export type * from './types/TextPart' export type * from './types/TimeoutConfiguration' export type * from './types/TimingRecord' -export type * from './types/TokenUsage' export type * from './types/Tool' +export type * from './types/ToolApprovalRequestOutput' +export type * from './types/ToolApprovalResponsePart' export type * from './types/ToolCall' +export type * from './types/ToolCallPart' export type * from './types/ToolCallRepairReply' export type * from './types/ToolChoice' +export type * from './types/ToolPart' export type * from './types/ToolResult' +export type * from './types/ToolResultContent' +export type * from './types/ToolResultOutput' +export type * from './types/ToolResultPart' export type * from './types/TraceFilter' export type * from './types/TraceRecord' export type * from './types/TraceStats' @@ -148,6 +169,7 @@ export type * from './types/UploadFileData' export type * from './types/UploadFileResult' export type * from './types/Usage' export type * from './types/UsageSnapshot' +export type * from './types/UserPart' export type * from './types/Verdict' export type * from './types/VerdictConfidence' export type * from './types/VerdictKind' diff --git a/bindings/node/src/types/AiMuxError.ts b/bindings/node/src/types/AiMuxError.ts index 0dfbed7f1..d542f7de5 100644 --- a/bindings/node/src/types/AiMuxError.ts +++ b/bindings/node/src/types/AiMuxError.ts @@ -9,7 +9,7 @@ import type { RetryError } from "./RetryError"; * failure came from. `ApiCallError` is boxed only to keep the Rust enum * compact; serde and every binding still observe the same object shape. */ -export type AiMuxError = { "ApiCall": ApiCallError } | { "Retry": RetryError } | { "JsonParse": string } | { "InvalidResponseData": string } | { "NoSuchTool": { tool_name: string, available_tools?: Array | null, +export type AiMuxError = { "ApiCall": ApiCallError } | { "Retry": RetryError } | { "JsonParse": string } | { "InvalidResponseData": string } | { "ToolCallNotFoundForApproval": { tool_call_id: string, approval_id: string, } } | { "NoSuchTool": { tool_name: string, available_tools?: Array | null, /** * Original argument text, when supplied by the provider. Absent in * older serialized errors and errors constructed without a call. diff --git a/bindings/node/src/types/AssistantPart.ts b/bindings/node/src/types/AssistantPart.ts new file mode 100644 index 000000000..850d5780e --- /dev/null +++ b/bindings/node/src/types/AssistantPart.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CustomPart } from "./CustomPart"; +import type { FilePart } from "./FilePart"; +import type { LanguageModelReasoningPart } from "./LanguageModelReasoningPart"; +import type { ReasoningFilePart } from "./ReasoningFilePart"; +import type { TextPart } from "./TextPart"; +import type { ToolCallPart } from "./ToolCallPart"; +import type { ToolResultPart } from "./ToolResultPart"; + +/** + * Parts allowed in an assistant message. + */ +export type AssistantPart = { "type": "text" } & TextPart | { "type": "file" } & FilePart | { "type": "reasoning" } & LanguageModelReasoningPart | { "type": "reasoning-file" } & ReasoningFilePart | { "type": "custom" } & CustomPart | { "type": "tool-call" } & ToolCallPart | { "type": "tool-result" } & ToolResultPart; diff --git a/bindings/node/src/types/CallOptions.ts b/bindings/node/src/types/CallOptions.ts index 40f9151ad..db98aa764 100644 --- a/bindings/node/src/types/CallOptions.ts +++ b/bindings/node/src/types/CallOptions.ts @@ -1,5 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { LanguageModelPromptMessage } from "./LanguageModelPromptMessage"; +import type { LanguageModelMessage } from "./LanguageModelMessage"; import type { ReasoningEffort } from "./ReasoningEffort"; import type { ResponseFormat } from "./ResponseFormat"; import type { TimeoutConfiguration } from "./TimeoutConfiguration"; @@ -18,7 +18,7 @@ export type CallOptions = { /** * The standardized prompt (message array). Required. */ -prompt: Array, +prompt: Array, /** * Maximum tokens to generate. */ @@ -70,7 +70,7 @@ headers: { [key in string]: string } | null, /** * Provider-specific options (keyed by provider name). */ -provider_options: { [key in string]: JsonValue } | null, +provider_options: { [key in string]: { [key in string]: JsonValue } } | null, /** * Top-level reasoning effort. Maps to OpenAI `reasoning_effort` and * Anthropic `thinking` config. diff --git a/bindings/node/src/types/ContentPart.ts b/bindings/node/src/types/ContentPart.ts index 16ca615db..fd965cc5e 100644 --- a/bindings/node/src/types/ContentPart.ts +++ b/bindings/node/src/types/ContentPart.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GeneratedFileData } from "./GeneratedFileData"; import type { JsonValue } from "./serde_json/JsonValue"; /** @@ -10,13 +11,13 @@ export type ContentPart = { "type": "text", text: string, /** * Provider-specific options for this part (e.g. `openai.promptCacheBreakpoint`). */ -provider_options?: JsonValue | null, } | { "type": "image", image: Array, media_type: string, provider_options?: JsonValue | null, } | { "type": "file", data: Array, media_type: string, filename?: string | null, provider_options?: JsonValue | null, } | { "type": "file_base64", data: string, media_type: string, filename?: string | null, provider_options?: JsonValue | null, } | { "type": "file_url", url: string, media_type: string, provider_options?: JsonValue | null, } | { "type": "file_reference", media_type: string, reference: JsonValue, filename?: string | null, provider_options?: JsonValue | null, } | { "type": "reasoning", text: string, signature: string | null, +provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "custom", kind: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "reasoning_file", data: GeneratedFileData, media_type: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "tool_approval_request", approval_id: string, tool_call_id: string, reason?: string | null, is_automatic?: boolean | null, signature?: string | null, input_schema_input?: JsonValue | null, } | { "type": "image", image: Array, media_type: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "file", data: Array, media_type: string, filename?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "file_base64", data: string, media_type: string, filename?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "file_url", url: string, media_type: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "file_reference", media_type: string, reference: JsonValue, filename?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "reasoning", text: string, signature: string | null, /** * Provider-specific options for this part (e.g. * `anthropic.signature`, `anthropic.redactedData`, * `anthropic.cacheControl`). */ -provider_options?: JsonValue | null, } | { "type": "tool_call", tool_call_id: string, tool_name: string, +provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "tool_call", tool_call_id: string, tool_name: string, /** * Arguments as a JSON value (usually an object). */ @@ -31,7 +32,7 @@ provider_executed?: boolean | null, * Provider-specific options for this part (e.g. * `anthropic.cacheControl`). */ -provider_options?: JsonValue | null, } | { "type": "tool_result", tool_call_id: string, +provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "tool_result", tool_call_id: string, /** * The tool's output (usually a JSON value or plain text). * @@ -63,4 +64,4 @@ dynamic?: boolean | null, * Provider-specific options for this part (e.g. * `anthropic.cacheControl`). */ -provider_options?: JsonValue | null, }; +provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/CustomPart.ts b/bindings/node/src/types/CustomPart.ts new file mode 100644 index 000000000..39f66564d --- /dev/null +++ b/bindings/node/src/types/CustomPart.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Provider-specific content identified by its provider and kind. + */ +export type CustomPart = { kind: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/DecisionCallOptions.ts b/bindings/node/src/types/DecisionCallOptions.ts index c84e562d6..c52f448eb 100644 --- a/bindings/node/src/types/DecisionCallOptions.ts +++ b/bindings/node/src/types/DecisionCallOptions.ts @@ -4,4 +4,4 @@ import type { DecisionQuestion } from "./DecisionQuestion"; import type { TimeoutConfiguration } from "./TimeoutConfiguration"; import type { JsonValue } from "./serde_json/JsonValue"; -export type DecisionCallOptions = { state: JsonValue, images?: Array, questions: Array, max_retries?: number | null, timeout?: TimeoutConfiguration | null, provider_options?: { [key in string]: JsonValue } | null, headers?: { [key in string]: string } | null, }; +export type DecisionCallOptions = { state: JsonValue, images?: Array, questions: Array, max_retries?: number | null, timeout?: TimeoutConfiguration | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, headers?: { [key in string]: string } | null, }; diff --git a/bindings/node/src/types/DecisionResult.ts b/bindings/node/src/types/DecisionResult.ts index 11c20a92e..f69ee1607 100644 --- a/bindings/node/src/types/DecisionResult.ts +++ b/bindings/node/src/types/DecisionResult.ts @@ -6,4 +6,4 @@ import type { DecisionRounding } from "./DecisionRounding"; import type { Usage } from "./Usage"; import type { JsonValue } from "./serde_json/JsonValue"; -export type DecisionResult = { rounding: DecisionRounding, answers: { [key in string]: DecisionAnswer }, provider: string, model: string, model_version: string | null, probability_source: DecisionProbabilitySource, usage: Usage | null, latency_ms: number | null, provider_metadata: { [key in string]: JsonValue } | null, response: DecisionResponse | null, }; +export type DecisionResult = { rounding: DecisionRounding, answers: { [key in string]: DecisionAnswer }, provider: string, model: string, model_version: string | null, probability_source: DecisionProbabilitySource, usage: Usage | null, latency_ms: number | null, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, response: DecisionResponse | null, }; diff --git a/bindings/node/src/types/EmbeddingCallOptions.ts b/bindings/node/src/types/EmbeddingCallOptions.ts index 996b05919..0d16b0ea1 100644 --- a/bindings/node/src/types/EmbeddingCallOptions.ts +++ b/bindings/node/src/types/EmbeddingCallOptions.ts @@ -23,7 +23,7 @@ timeout: TimeoutConfiguration | null, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: JsonValue } | null, +provider_options: { [key in string]: { [key in string]: JsonValue } } | null, /** * Additional HTTP headers to send with the request. */ diff --git a/bindings/node/src/types/EmbeddingResult.ts b/bindings/node/src/types/EmbeddingResult.ts index 81b741d23..fe4fc025a 100644 --- a/bindings/node/src/types/EmbeddingResult.ts +++ b/bindings/node/src/types/EmbeddingResult.ts @@ -21,7 +21,7 @@ usage: EmbeddingUsage | null, /** * Additional provider-specific metadata. */ -provider_metadata: { [key in string]: JsonValue } | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** * Optional response information for debugging. */ diff --git a/bindings/node/src/types/FileData.ts b/bindings/node/src/types/FileData.ts index 1fdfc2444..cec9eccbb 100644 --- a/bindings/node/src/types/FileData.ts +++ b/bindings/node/src/types/FileData.ts @@ -6,4 +6,4 @@ import type { FileBytes } from "./FileBytes"; * * Aligned with V4 `SharedV4FileData`. */ -export type FileData = { "Data": { data: FileBytes, } } | { "Url": { url: string, } } | { "Reference": { reference: { [key in string]: string }, } } | { "Text": { text: string, } }; +export type FileData = { "Data": { data: FileBytes, } } | { "Url": { url: string, original_url?: string | null, } } | { "Reference": { reference: { [key in string]: string }, } } | { "Text": { text: string, } }; diff --git a/bindings/node/src/types/FilePart.ts b/bindings/node/src/types/FilePart.ts index b4676404e..e412d1c0a 100644 --- a/bindings/node/src/types/FilePart.ts +++ b/bindings/node/src/types/FilePart.ts @@ -1,7 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { FileData } from "./FileData"; +import type { JsonValue } from "./serde_json/JsonValue"; /** - * A file generated by the model, extracted to the top level (M7). + * File content part (the only file part; `data` is a tagged `FileData`). */ -export type FilePart = { data: FileData, media_type: string, }; +export type FilePart = { data: FileData, media_type: string, filename?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/FunctionTool.ts b/bindings/node/src/types/FunctionTool.ts index efdcfaccf..c43b94a6a 100644 --- a/bindings/node/src/types/FunctionTool.ts +++ b/bindings/node/src/types/FunctionTool.ts @@ -27,7 +27,7 @@ strict: boolean | null, * (e.g. `{"anthropic": {"eagerInputStreaming": true}}`). Aligned with the * V4 `providerOptions` field on function tools. */ -provider_options?: { [key in string]: JsonValue } | null, +provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, /** * Example inputs for the tool (V4 `inputExamples`), used by some providers * to emit `input_examples` in the request body. diff --git a/bindings/node/src/types/GenerateContent.ts b/bindings/node/src/types/GenerateContent.ts index aa72530dc..456e003b9 100644 --- a/bindings/node/src/types/GenerateContent.ts +++ b/bindings/node/src/types/GenerateContent.ts @@ -1,10 +1,13 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FileData } from "./FileData"; +import type { GeneratedFile } from "./GeneratedFile"; +import type { RawToolApprovalRequest } from "./RawToolApprovalRequest"; import type { RawToolCall } from "./RawToolCall"; +import type { ReasoningOutput } from "./ReasoningOutput"; +import type { Source } from "./Source"; import type { ToolResult } from "./ToolResult"; import type { JsonValue } from "./serde_json/JsonValue"; /** * A content item in the generation result. */ -export type GenerateContent = { "Text": { text: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": RawToolCall } | { "Source": { id: string, source_type: string, url: string | null, title: string | null, provider_metadata?: JsonValue | null, } } | { "Reasoning": { text: string, provider_metadata: JsonValue | null, } } | { "File": { data: FileData, media_type: string, provider_metadata?: JsonValue | null, } } | { "ToolResult": ToolResult }; +export type GenerateContent = { "Text": { text: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolCall": C } | { "Source": Source } | { "Reasoning": ReasoningOutput } | { "File": GeneratedFile } | { "ReasoningFile": R } | { "Custom": { kind: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolApprovalRequest": A } | { "ToolResult": ToolResult }; diff --git a/bindings/node/src/types/GenerateObjectResult.ts b/bindings/node/src/types/GenerateObjectResult.ts index 8c46e3e1a..89e5eb445 100644 --- a/bindings/node/src/types/GenerateObjectResult.ts +++ b/bindings/node/src/types/GenerateObjectResult.ts @@ -38,7 +38,7 @@ reasoning: string | null, /** * Provider-specific metadata (e.g. Anthropic cache info). */ -provider_metadata: JsonValue | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** * Response metadata (id, timestamp, model_id). */ diff --git a/bindings/node/src/types/GenerateResponse.ts b/bindings/node/src/types/GenerateResponse.ts new file mode 100644 index 000000000..f6d04aab2 --- /dev/null +++ b/bindings/node/src/types/GenerateResponse.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Response metadata and HTTP details from a generation call. + */ +export type GenerateResponse = { id: string | null, timestamp: string | null, model_id: string | null, headers: { [key in string]: string } | null, body: JsonValue | null, }; diff --git a/bindings/node/src/types/GenerateResult.ts b/bindings/node/src/types/GenerateResult.ts index 4667bfc23..5dafcbfd9 100644 --- a/bindings/node/src/types/GenerateResult.ts +++ b/bindings/node/src/types/GenerateResult.ts @@ -1,7 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { FinishReason } from "./FinishReason"; import type { GenerateContent } from "./GenerateContent"; -import type { ResponseMetadata } from "./ResponseMetadata"; +import type { GeneratedFile } from "./GeneratedFile"; +import type { RawToolApprovalRequest } from "./RawToolApprovalRequest"; +import type { RawToolCall } from "./RawToolCall"; import type { Usage } from "./Usage"; import type { Warning } from "./Warning"; import type { JsonValue } from "./serde_json/JsonValue"; @@ -9,36 +11,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * Result of `LanguageModel::do_generate` (non-streaming). */ -export type GenerateResult = { -/** - * Generated content items (text, tool calls, etc.). - */ -content: Array, -/** - * Why generation stopped. - */ -finish_reason: FinishReason, -/** - * Token usage. - */ -usage: Usage, -/** - * Warnings issued by the provider. - */ -warnings: Array, -/** - * Provider-specific metadata. - */ -provider_metadata: JsonValue | null, -/** - * Response metadata (id, timestamp, model_id). - */ -response: ResponseMetadata, -/** - * The request body that was sent (for debugging). - */ -request_body: JsonValue | null, -/** - * Response headers. - */ -response_headers: { [key in string]: string } | null, }; +export type GenerateResult = { content: Array>, finish_reason: FinishReason, usage: Usage, warnings: Array, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, response: { id: string | null, timestamp: string | null, model_id: string | null, body?: JsonValue | null, } | null, request_body: JsonValue | null, response_headers: { [key in string]: string } | null, }; diff --git a/bindings/node/src/types/GenerateTextOptions.ts b/bindings/node/src/types/GenerateTextOptions.ts index 00b49da24..68aec90e8 100644 --- a/bindings/node/src/types/GenerateTextOptions.ts +++ b/bindings/node/src/types/GenerateTextOptions.ts @@ -12,7 +12,7 @@ import type { JsonValue } from "./serde_json/JsonValue"; * Unlike `CallOptions` (provider-facing), this does not include `prompt` * (passed separately) and defaults are more ergonomic. */ -export type GenerateTextOptions = { max_output_tokens: number | null, temperature: number | null, stop_sequences: Array | null, top_p: number | null, top_k: number | null, presence_penalty: number | null, frequency_penalty: number | null, response_format: ResponseFormat | null, seed: number | null, tools: Array | null, tool_choice: ToolChoice | null, headers: { [key in string]: string } | null, provider_options: { [key in string]: JsonValue } | null, +export type GenerateTextOptions = { max_output_tokens: number | null, temperature: number | null, stop_sequences: Array | null, top_p: number | null, top_k: number | null, presence_penalty: number | null, frequency_penalty: number | null, response_format: ResponseFormat | null, seed: number | null, tools: Array | null, tool_choice: ToolChoice | null, headers: { [key in string]: string } | null, provider_options: { [key in string]: { [key in string]: JsonValue } } | null, /** * Top-level reasoning effort. */ diff --git a/bindings/node/src/types/GenerateTextResult.ts b/bindings/node/src/types/GenerateTextResult.ts index a677b7004..1cfc38b31 100644 --- a/bindings/node/src/types/GenerateTextResult.ts +++ b/bindings/node/src/types/GenerateTextResult.ts @@ -1,11 +1,14 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FilePart } from "./FilePart"; import type { FinishReason } from "./FinishReason"; +import type { GenerateContent } from "./GenerateContent"; import type { GenerateResult } from "./GenerateResult"; +import type { GeneratedFile } from "./GeneratedFile"; import type { ModelMessage } from "./ModelMessage"; +import type { ReasoningFileOutput } from "./ReasoningFileOutput"; import type { ReasoningPart } from "./ReasoningPart"; import type { ResponseMetadata } from "./ResponseMetadata"; -import type { SourcePart } from "./SourcePart"; +import type { Source } from "./Source"; +import type { ToolApprovalRequestOutput } from "./ToolApprovalRequestOutput"; import type { ToolCall } from "./ToolCall"; import type { Usage } from "./Usage"; import type { Warning } from "./Warning"; @@ -15,6 +18,10 @@ import type { JsonValue } from "./serde_json/JsonValue"; * Result of `generate_text` (user-facing). */ export type GenerateTextResult = { +/** + * Ordered generated content with parsed tool calls. + */ +content?: Array>, /** * The generated text (concatenated from all text content parts). */ @@ -50,11 +57,11 @@ reasoning_text: string, /** * Sources / citations (search-preview models). */ -sources: Array, +sources: Array, /** * Files generated by the model. */ -files: Array, +files: Array, /** * Assistant messages ready to append to the prompt for the next turn * (solves the multi-turn "manually build assistant message" footgun). @@ -69,7 +76,7 @@ raw_finish_reason: string | null, * Provider-specific metadata (e.g. Anthropic cache info). Mirrored from * `raw.provider_metadata` for top-level convenience. */ -provider_metadata: JsonValue | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** * Response metadata (id, timestamp, model_id). Mirrored from * `raw.response` for top-level convenience. diff --git a/bindings/node/src/types/GeneratedFile.ts b/bindings/node/src/types/GeneratedFile.ts new file mode 100644 index 000000000..48333ce42 --- /dev/null +++ b/bindings/node/src/types/GeneratedFile.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GeneratedFileData } from "./GeneratedFileData"; +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * A file generated by the model (e.g. an image or document). Mirrors the Vercel + * AI SDK `file` content type: `data` is a tagged union covering base64 data, + * or URL. The payload of both `GenerateContent::File` and + * `StreamPart::File`, and the element of the text results' `files`. + */ +export type GeneratedFile = { data: GeneratedFileData, media_type: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/GeneratedFileData.ts b/bindings/node/src/types/GeneratedFileData.ts new file mode 100644 index 000000000..f8b500f58 --- /dev/null +++ b/bindings/node/src/types/GeneratedFileData.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FileBytes } from "./FileBytes"; + +/** + * Data or a URL returned for a generated file. + */ +export type GeneratedFileData = { "Data": { data: FileBytes, } } | { "Url": { url: string, original_url?: string | null, } }; diff --git a/bindings/node/src/types/ImageCallOptions.ts b/bindings/node/src/types/ImageCallOptions.ts index 93cd38902..1b490a4cb 100644 --- a/bindings/node/src/types/ImageCallOptions.ts +++ b/bindings/node/src/types/ImageCallOptions.ts @@ -45,7 +45,7 @@ mask: ImageFile | null, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: JsonValue }, +provider_options: { [key in string]: { [key in string]: JsonValue } }, /** * Per-call retry override. `None` uses the model default. */ diff --git a/bindings/node/src/types/ImageResult.ts b/bindings/node/src/types/ImageResult.ts index fbf5cbdec..4276c580f 100644 --- a/bindings/node/src/types/ImageResult.ts +++ b/bindings/node/src/types/ImageResult.ts @@ -22,7 +22,7 @@ warnings: Array, /** * Additional provider-specific metadata, keyed by provider name. */ -provider_metadata: { [key in string]: JsonValue } | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** * Response information for telemetry and debugging. */ diff --git a/bindings/node/src/types/InputRecord.ts b/bindings/node/src/types/InputRecord.ts index e187e6e3d..b7444ecf1 100644 --- a/bindings/node/src/types/InputRecord.ts +++ b/bindings/node/src/types/InputRecord.ts @@ -1,16 +1,16 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { LanguageModelPromptMessage } from "./LanguageModelPromptMessage"; +import type { LanguageModelMessage } from "./LanguageModelMessage"; import type { RecordingOperation } from "./RecordingOperation"; import type { JsonValue } from "./serde_json/JsonValue"; /** * ① 输入侧:完整调用参数,足以重建 generate_text 调用。 */ -export type InputRecord = { operation?: RecordingOperation, +export type InputRecord = { operation?: RecordingOperation, /** * 完整 prompt(消息数组,含 ContentPart::Image 等多模态)。 */ -prompt: Array, +prompt: Array, /** * 序列化的 CallOptions(abort_signal/call_id 已 serde skip); * headers/provider_options/body_overrides 已递归脱敏。 diff --git a/bindings/node/src/types/TokenUsage.ts b/bindings/node/src/types/InputTokenUsage.ts similarity index 58% rename from bindings/node/src/types/TokenUsage.ts rename to bindings/node/src/types/InputTokenUsage.ts index ddab251a8..658af2dab 100644 --- a/bindings/node/src/types/TokenUsage.ts +++ b/bindings/node/src/types/InputTokenUsage.ts @@ -1,9 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * Token usage detail (with cache breakdown). + * Input token usage detail. */ -export type TokenUsage = { +export type InputTokenUsage = { /** * Total tokens. */ @@ -19,12 +19,4 @@ cache_read: number | null, /** * Tokens written to cache. */ -cache_write: number | null, -/** - * Text (non-reasoning) tokens (output side). - */ -text: number | null, -/** - * Reasoning tokens (output side). - */ -reasoning: number | null, }; +cache_write: number | null, }; diff --git a/bindings/node/src/types/LanguageModelMessage.ts b/bindings/node/src/types/LanguageModelMessage.ts new file mode 100644 index 000000000..a7052a024 --- /dev/null +++ b/bindings/node/src/types/LanguageModelMessage.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AssistantPart } from "./AssistantPart"; +import type { ToolPart } from "./ToolPart"; +import type { UserPart } from "./UserPart"; +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * A single provider-facing message, a union by role. + * + * `provider_options` is message-level (e.g. `anthropic.cacheControl`), + * mirroring `LanguageModelV4Message.providerOptions`. + */ +export type LanguageModelMessage = { "role": "system", content: string, provider_options: { [key in string]: { [key in string]: JsonValue } } | null, } | { "role": "user", content: Array, provider_options: { [key in string]: { [key in string]: JsonValue } } | null, } | { "role": "assistant", content: Array, provider_options: { [key in string]: { [key in string]: JsonValue } } | null, } | { "role": "tool", content: Array, provider_options: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/LanguageModelPromptMessage.ts b/bindings/node/src/types/LanguageModelPromptMessage.ts deleted file mode 100644 index b2e3efa50..000000000 --- a/bindings/node/src/types/LanguageModelPromptMessage.ts +++ /dev/null @@ -1,19 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ContentPart } from "./ContentPart"; -import type { Role } from "./Role"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * A single provider-facing message. - * - * Unlike `ModelMessage` (which can have string content), this always has - * `content: Vec` — strings are normalized to `ContentPart::Text`. - */ -export type LanguageModelPromptMessage = { role: Role, content: Array, -/** - * Message-level provider-specific options (e.g. - * `anthropic.cacheControl`), applied to the last part of the message when - * converting to a provider prompt. Mirrors the Vercel AI SDK - * `LanguageModelV4Message.providerOptions`. - */ -provider_options: JsonValue | null, }; diff --git a/bindings/node/src/types/LanguageModelReasoningPart.ts b/bindings/node/src/types/LanguageModelReasoningPart.ts new file mode 100644 index 000000000..4fb52fa16 --- /dev/null +++ b/bindings/node/src/types/LanguageModelReasoningPart.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Reasoning / thinking content part. + */ +export type LanguageModelReasoningPart = { text: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/OutputTokenUsage.ts b/bindings/node/src/types/OutputTokenUsage.ts new file mode 100644 index 000000000..47528d6d8 --- /dev/null +++ b/bindings/node/src/types/OutputTokenUsage.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Output token usage detail. + */ +export type OutputTokenUsage = { total: number | null, +/** + * Text (non-reasoning) tokens (output side). + */ +text: number | null, +/** + * Reasoning tokens (output side). + */ +reasoning: number | null, }; diff --git a/bindings/node/src/types/RawToolApprovalRequest.ts b/bindings/node/src/types/RawToolApprovalRequest.ts new file mode 100644 index 000000000..5b3dd37db --- /dev/null +++ b/bindings/node/src/types/RawToolApprovalRequest.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Provider approval request for a provider-executed call. + */ +export type RawToolApprovalRequest = { approval_id: string, tool_call_id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/RawToolCall.ts b/bindings/node/src/types/RawToolCall.ts index 64adeade3..c44ead3c6 100644 --- a/bindings/node/src/types/RawToolCall.ts +++ b/bindings/node/src/types/RawToolCall.ts @@ -7,4 +7,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; * The wire shape matches [`ToolCall`] field for field, except that `input` * is the provider's raw argument *text* rather than a parsed value. */ -export type RawToolCall = { tool_call_id: string, tool_name: string, input: string, provider_executed?: boolean | null, dynamic?: boolean | null, provider_metadata?: JsonValue | null, }; +export type RawToolCall = { tool_call_id: string, tool_name: string, input: string, provider_executed?: boolean | null, dynamic?: boolean | null, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/ReasoningFileOutput.ts b/bindings/node/src/types/ReasoningFileOutput.ts new file mode 100644 index 000000000..57eaa2c0b --- /dev/null +++ b/bindings/node/src/types/ReasoningFileOutput.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GeneratedFile } from "./GeneratedFile"; +import type { JsonValue } from "./serde_json/JsonValue"; + +export type ReasoningFileOutput = { file: GeneratedFile, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/ReasoningFilePart.ts b/bindings/node/src/types/ReasoningFilePart.ts new file mode 100644 index 000000000..b8c1e4aab --- /dev/null +++ b/bindings/node/src/types/ReasoningFilePart.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GeneratedFileData } from "./GeneratedFileData"; +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * A file generated as part of reasoning. + */ +export type ReasoningFilePart = { data: GeneratedFileData, media_type: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/ReasoningOutput.ts b/bindings/node/src/types/ReasoningOutput.ts new file mode 100644 index 000000000..90a4f59b8 --- /dev/null +++ b/bindings/node/src/types/ReasoningOutput.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * A reasoning / thinking segment produced by the model. Mirrors the Vercel AI + * SDK `reasoning` content type; `provider_metadata` carries provider-specific + * data such as the Anthropic thinking-block signature + * (`providerMetadata.anthropic.signature`). The payload of + * `GenerateContent::Reasoning` and the element of the text results' `reasoning`. + */ +export type ReasoningOutput = { text: string, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/ReasoningPart.ts b/bindings/node/src/types/ReasoningPart.ts index 83feed56b..37a7c5873 100644 --- a/bindings/node/src/types/ReasoningPart.ts +++ b/bindings/node/src/types/ReasoningPart.ts @@ -1,10 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ReasoningFileOutput } from "./ReasoningFileOutput"; +import type { ReasoningOutput } from "./ReasoningOutput"; /** - * A reasoning / thinking segment, extracted to the top level (M7). + * Text or a generated file produced during reasoning. */ -export type ReasoningPart = { -/** - * The reasoning text. - */ -text: string, }; +export type ReasoningPart = ReasoningOutput | ReasoningFileOutput; diff --git a/bindings/node/src/types/RerankingCallOptions.ts b/bindings/node/src/types/RerankingCallOptions.ts index 8c41f7f5e..3de208995 100644 --- a/bindings/node/src/types/RerankingCallOptions.ts +++ b/bindings/node/src/types/RerankingCallOptions.ts @@ -32,7 +32,7 @@ timeout: TimeoutConfiguration | null, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: JsonValue } | null, +provider_options: { [key in string]: { [key in string]: JsonValue } } | null, /** * Additional HTTP headers to send with the request. */ diff --git a/bindings/node/src/types/RerankingResult.ts b/bindings/node/src/types/RerankingResult.ts index d72face95..4d339296c 100644 --- a/bindings/node/src/types/RerankingResult.ts +++ b/bindings/node/src/types/RerankingResult.ts @@ -19,7 +19,7 @@ ranking: Array, /** * Additional provider-specific metadata, keyed by provider name. */ -provider_metadata: { [key in string]: JsonValue } | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** * Warnings for the call, e.g. unsupported settings. */ diff --git a/bindings/node/src/types/SearchCallOptions.ts b/bindings/node/src/types/SearchCallOptions.ts index 0a7152b32..1658b5e28 100644 --- a/bindings/node/src/types/SearchCallOptions.ts +++ b/bindings/node/src/types/SearchCallOptions.ts @@ -45,7 +45,7 @@ timeout: TimeoutConfiguration | null, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: JsonValue } | null, +provider_options: { [key in string]: { [key in string]: JsonValue } } | null, /** * Additional HTTP headers to send with the request. */ diff --git a/bindings/node/src/types/SearchResult.ts b/bindings/node/src/types/SearchResult.ts index d1e49ac60..ee3f6a49b 100644 --- a/bindings/node/src/types/SearchResult.ts +++ b/bindings/node/src/types/SearchResult.ts @@ -20,7 +20,7 @@ answer: string | null, /** * Additional provider-specific metadata. */ -provider_metadata: { [key in string]: JsonValue } | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** * Warnings for the call. */ diff --git a/bindings/node/src/types/SearchResultItem.ts b/bindings/node/src/types/SearchResultItem.ts index 5d5c81218..0c8b8d67a 100644 --- a/bindings/node/src/types/SearchResultItem.ts +++ b/bindings/node/src/types/SearchResultItem.ts @@ -29,4 +29,4 @@ score: number | null, /** * Provider-specific metadata for this result. */ -provider_metadata: { [key in string]: JsonValue } | null, }; +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/Source.ts b/bindings/node/src/types/Source.ts new file mode 100644 index 000000000..f9239ad68 --- /dev/null +++ b/bindings/node/src/types/Source.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * A source / citation (e.g. URL citation from search-preview models). The + * payload of both `GenerateContent::Source` and `StreamPart::Source`, and the + * element of the text results' `sources`. + */ +export type Source = { "source_type": "url", id: string, url: string, title?: string | null, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "source_type": "document", id: string, media_type: string, title: string, filename?: string | null, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/SourcePart.ts b/bindings/node/src/types/SourcePart.ts deleted file mode 100644 index 050ce8115..000000000 --- a/bindings/node/src/types/SourcePart.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * A source / citation, extracted to the top level (M7). - */ -export type SourcePart = { id: string, source_type: string, url?: string | null, title?: string | null, }; diff --git a/bindings/node/src/types/SpeechCallOptions.ts b/bindings/node/src/types/SpeechCallOptions.ts index 0872b66bf..d9424d439 100644 --- a/bindings/node/src/types/SpeechCallOptions.ts +++ b/bindings/node/src/types/SpeechCallOptions.ts @@ -38,7 +38,7 @@ language: string | null, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: JsonValue } | null, +provider_options: { [key in string]: { [key in string]: JsonValue } } | null, /** * Per-call retry override. `None` uses the model default. */ diff --git a/bindings/node/src/types/SpeechResult.ts b/bindings/node/src/types/SpeechResult.ts index ab9b5304f..4dd161ffe 100644 --- a/bindings/node/src/types/SpeechResult.ts +++ b/bindings/node/src/types/SpeechResult.ts @@ -30,4 +30,4 @@ response: SpeechResponse, /** * Additional provider-specific metadata, keyed by provider name. */ -provider_metadata: { [key in string]: JsonValue } | null, }; +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/StreamPart.ts b/bindings/node/src/types/StreamPart.ts index 153fddc19..a7e850989 100644 --- a/bindings/node/src/types/StreamPart.ts +++ b/bindings/node/src/types/StreamPart.ts @@ -1,7 +1,11 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AiMuxError } from "./AiMuxError"; -import type { FileData } from "./FileData"; import type { FinishReason } from "./FinishReason"; +import type { GeneratedFile } from "./GeneratedFile"; +import type { ReasoningFileOutput } from "./ReasoningFileOutput"; +import type { ResponseMetadata } from "./ResponseMetadata"; +import type { Source } from "./Source"; +import type { ToolApprovalRequestOutput } from "./ToolApprovalRequestOutput"; import type { ToolCall } from "./ToolCall"; import type { ToolResult } from "./ToolResult"; import type { Usage } from "./Usage"; @@ -15,7 +19,7 @@ import type { JsonValue } from "./serde_json/JsonValue"; * [`StreamPart`] (raw argument text); `stream_text` emits [`TextStreamPart`] * (parsed and validated [`ToolCall`]). */ -export type StreamPart = { "TextStart": { id: string, provider_metadata?: JsonValue | null, } } | { "TextDelta": { id: string, delta: string, provider_metadata?: JsonValue | null, } } | { "TextEnd": { id: string, provider_metadata?: JsonValue | null, } } | { "StreamStart": { warnings: Array, } } | { "Finish": { finish_reason: FinishReason, usage: Usage, provider_metadata: JsonValue | null, } } | { "Error": { error: AiMuxError, } } | { "ToolInputStart": { id: string, tool_name: string, +export type StreamPart = { "TextStart": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TextDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TextEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "StreamStart": { warnings: Array, } } | { "Finish": { finish_reason: FinishReason, usage: Usage, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "Error": { error: AiMuxError, } } | { "ToolInputStart": { id: string, tool_name: string, /** * Whether the tool call will be executed by the provider. */ @@ -27,16 +31,16 @@ dynamic?: boolean | null, /** * Optional title for the tool call. */ -title?: string | null, provider_metadata?: JsonValue | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: JsonValue | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": ToolCall } | { "ToolResult": ToolResult } | { "File": { data: FileData, media_type: string, provider_metadata?: JsonValue | null, } } | { "ReasoningStart": { id: string, +title?: string | null, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolCall": ToolCall } | { "ToolResult": ToolResult } | { "File": GeneratedFile } | { "ReasoningFile": ReasoningFileOutput } | { "Custom": { kind: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolApprovalRequest": ToolApprovalRequestOutput } | { "ReasoningStart": { id: string, /** * Provider-specific metadata (e.g. xAI `itemId`). */ -provider_metadata: JsonValue | null, } } | { "ReasoningDelta": { id: string, delta: string, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ReasoningDelta": { id: string, delta: string, /** * Provider-specific metadata (e.g. xAI `itemId`). */ -provider_metadata: JsonValue | null, } } | { "ReasoningEnd": { id: string, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ReasoningEnd": { id: string, /** * Provider-specific metadata (e.g. xAI `itemId`, `reasoningEncryptedContent`). */ -provider_metadata: JsonValue | null, } } | { "ResponseMetadata": { id: string | null, timestamp: string | null, model_id: string | null, } } | { "Source": { id: string, source_type: string, url: string | null, title: string | null, provider_metadata?: JsonValue | null, } } | { "Raw": { raw_value: JsonValue, } }; +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ResponseMetadata": ResponseMetadata } | { "Source": Source } | { "Raw": { raw_value: JsonValue, } }; diff --git a/bindings/node/src/types/StreamResponse.ts b/bindings/node/src/types/StreamResponse.ts new file mode 100644 index 000000000..8edb92db1 --- /dev/null +++ b/bindings/node/src/types/StreamResponse.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * HTTP details from a streaming call. + */ +export type StreamResponse = { headers: { [key in string]: string } | null, }; diff --git a/bindings/node/src/types/StreamTextResultAggregated.ts b/bindings/node/src/types/StreamTextResultAggregated.ts index ae61b42bc..305eacfb0 100644 --- a/bindings/node/src/types/StreamTextResultAggregated.ts +++ b/bindings/node/src/types/StreamTextResultAggregated.ts @@ -1,10 +1,13 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FilePart } from "./FilePart"; import type { FinishReason } from "./FinishReason"; +import type { GenerateContent } from "./GenerateContent"; +import type { GeneratedFile } from "./GeneratedFile"; import type { ModelMessage } from "./ModelMessage"; +import type { ReasoningFileOutput } from "./ReasoningFileOutput"; import type { ReasoningPart } from "./ReasoningPart"; import type { ResponseMetadata } from "./ResponseMetadata"; -import type { SourcePart } from "./SourcePart"; +import type { Source } from "./Source"; +import type { ToolApprovalRequestOutput } from "./ToolApprovalRequestOutput"; import type { ToolCall } from "./ToolCall"; import type { Usage } from "./Usage"; import type { Warning } from "./Warning"; @@ -16,6 +19,10 @@ import type { JsonValue } from "./serde_json/JsonValue"; * has no `GenerateResult` equivalent). */ export type StreamTextResultAggregated = { +/** + * Ordered content from the last generation step. + */ +content?: Array>, /** * The generated text (concatenated `TextDelta`). */ @@ -35,11 +42,11 @@ tool_calls: Array, /** * Sources / citations. */ -sources: Array, +sources: Array, /** * Files generated by the model. */ -files: Array, +files: Array, /** * Why generation stopped. */ @@ -64,7 +71,7 @@ warnings: Array, /** * Provider-specific metadata from the Finish chunk. */ -provider_metadata: JsonValue | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** * Response metadata (id, timestamp, model_id) if emitted by the stream. */ diff --git a/bindings/node/src/types/TextContent.ts b/bindings/node/src/types/TextContent.ts new file mode 100644 index 000000000..09ad96b47 --- /dev/null +++ b/bindings/node/src/types/TextContent.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GeneratedFile } from "./GeneratedFile"; +import type { RawToolApprovalRequest } from "./RawToolApprovalRequest"; +import type { RawToolCall } from "./RawToolCall"; +import type { ReasoningFileOutput } from "./ReasoningFileOutput"; +import type { ReasoningOutput } from "./ReasoningOutput"; +import type { Source } from "./Source"; +import type { ToolApprovalRequestOutput } from "./ToolApprovalRequestOutput"; +import type { ToolCall } from "./ToolCall"; +import type { ToolResult } from "./ToolResult"; +import type { JsonValue } from "./serde_json/JsonValue"; + +export type TextContent = { "Text": { text: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolCall": ToolCall } | { "Source": Source } | { "Reasoning": ReasoningOutput } | { "File": GeneratedFile } | { "ReasoningFile": ReasoningFileOutput } | { "Custom": { kind: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolApprovalRequest": ToolApprovalRequestOutput } | { "ToolResult": ToolResult }; diff --git a/bindings/node/src/types/TextPart.ts b/bindings/node/src/types/TextPart.ts new file mode 100644 index 000000000..2e01804cf --- /dev/null +++ b/bindings/node/src/types/TextPart.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Text content part. + */ +export type TextPart = { text: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/ToolApprovalRequestOutput.ts b/bindings/node/src/types/ToolApprovalRequestOutput.ts new file mode 100644 index 000000000..8e5d70b34 --- /dev/null +++ b/bindings/node/src/types/ToolApprovalRequestOutput.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ToolCall } from "./ToolCall"; + +/** + * Approval request exposed by text generation. + */ +export type ToolApprovalRequestOutput = { approval_id: string, tool_call: ToolCall, reason?: string | null, is_automatic?: boolean | null, signature?: string | null, }; diff --git a/bindings/node/src/types/ToolApprovalResponsePart.ts b/bindings/node/src/types/ToolApprovalResponsePart.ts new file mode 100644 index 000000000..12163b325 --- /dev/null +++ b/bindings/node/src/types/ToolApprovalResponsePart.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * The user's decision for a provider-executed tool approval request. + */ +export type ToolApprovalResponsePart = { approval_id: string, approved: boolean, reason?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/ToolCall.ts b/bindings/node/src/types/ToolCall.ts index 605cb733d..46500aa89 100644 --- a/bindings/node/src/types/ToolCall.ts +++ b/bindings/node/src/types/ToolCall.ts @@ -30,7 +30,7 @@ dynamic?: boolean | null, /** * Additional provider-specific metadata associated with this call. */ -provider_metadata?: JsonValue | null, +provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, /** * Set when lookup, JSON parsing, or schema validation still failed after * the optional repair attempt. diff --git a/bindings/node/src/types/ToolCallPart.ts b/bindings/node/src/types/ToolCallPart.ts new file mode 100644 index 000000000..bac25885b --- /dev/null +++ b/bindings/node/src/types/ToolCallPart.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Tool call content part. + */ +export type ToolCallPart = { tool_call_id: string, tool_name: string, input: JsonValue, provider_executed?: boolean | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/ToolPart.ts b/bindings/node/src/types/ToolPart.ts new file mode 100644 index 000000000..df0fe1c1b --- /dev/null +++ b/bindings/node/src/types/ToolPart.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ToolApprovalResponsePart } from "./ToolApprovalResponsePart"; +import type { ToolResultPart } from "./ToolResultPart"; + +/** + * Parts allowed in a tool message. + */ +export type ToolPart = { "type": "tool-result" } & ToolResultPart | { "type": "tool-approval-response" } & ToolApprovalResponsePart; diff --git a/bindings/node/src/types/ToolResult.ts b/bindings/node/src/types/ToolResult.ts index 9c5039b52..544c2e653 100644 --- a/bindings/node/src/types/ToolResult.ts +++ b/bindings/node/src/types/ToolResult.ts @@ -29,4 +29,4 @@ dynamic?: boolean | null, /** * Additional provider-specific metadata for the tool result. */ -provider_metadata?: JsonValue | null, }; +provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/ToolResultContent.ts b/bindings/node/src/types/ToolResultContent.ts new file mode 100644 index 000000000..3511c045d --- /dev/null +++ b/bindings/node/src/types/ToolResultContent.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FilePart } from "./FilePart"; +import type { TextPart } from "./TextPart"; +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Content within a tool result. + */ +export type ToolResultContent = { "type": "text" } & TextPart | { "type": "file" } & FilePart | { "type": "custom", provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/ToolResultOutput.ts b/bindings/node/src/types/ToolResultOutput.ts new file mode 100644 index 000000000..2855f2918 --- /dev/null +++ b/bindings/node/src/types/ToolResultOutput.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ToolResultContent } from "./ToolResultContent"; +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * The provider-facing result of a tool call. + */ +export type ToolResultOutput = { "type": "text", value: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "json", value: JsonValue, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "execution-denied", reason?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "error-text", value: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "error-json", value: JsonValue, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "content", value: Array, }; diff --git a/bindings/node/src/types/ToolResultPart.ts b/bindings/node/src/types/ToolResultPart.ts new file mode 100644 index 000000000..6a479932d --- /dev/null +++ b/bindings/node/src/types/ToolResultPart.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ToolResultOutput } from "./ToolResultOutput"; +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Tool result content part. + */ +export type ToolResultPart = { tool_call_id: string, tool_name: string, output: ToolResultOutput, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/TranscriptionCallOptions.ts b/bindings/node/src/types/TranscriptionCallOptions.ts index d43f937a1..e5e7debdc 100644 --- a/bindings/node/src/types/TranscriptionCallOptions.ts +++ b/bindings/node/src/types/TranscriptionCallOptions.ts @@ -20,7 +20,7 @@ media_type: string, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: JsonValue } | null, +provider_options: { [key in string]: { [key in string]: JsonValue } } | null, /** * Per-call retry override. `None` uses the model default. */ diff --git a/bindings/node/src/types/TranscriptionResult.ts b/bindings/node/src/types/TranscriptionResult.ts index ee229f7c2..2c5356fc1 100644 --- a/bindings/node/src/types/TranscriptionResult.ts +++ b/bindings/node/src/types/TranscriptionResult.ts @@ -42,4 +42,4 @@ response: TranscriptionResponse, /** * Additional provider-specific metadata, keyed by provider name. */ -provider_metadata: { [key in string]: JsonValue } | null, }; +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/TranscriptionStreamPart.ts b/bindings/node/src/types/TranscriptionStreamPart.ts index 45228feec..ecfa20e24 100644 --- a/bindings/node/src/types/TranscriptionStreamPart.ts +++ b/bindings/node/src/types/TranscriptionStreamPart.ts @@ -8,4 +8,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; * * Aligned with V4 `TranscriptionModelV4StreamPart`. */ -export type TranscriptionStreamPart = { "StreamStart": { warnings: Array, } } | { "TranscriptDelta": { id: string | null, delta: string, provider_metadata: { [key in string]: JsonValue } | null, } } | { "TranscriptPartial": { id: string | null, text: string, start_second: number | null, duration_in_seconds: number | null, channel_index: number | null, provider_metadata: { [key in string]: JsonValue } | null, } } | { "TranscriptFinal": { id: string | null, text: string, start_second: number | null, end_second: number | null, channel_index: number | null, provider_metadata: { [key in string]: JsonValue } | null, } } | { "ResponseMetadata": { timestamp: string | null, model_id: string | null, headers: { [key in string]: string } | null, body: JsonValue | null, } } | { "Finish": { text: string, segments: Array, language: string | null, duration_in_seconds: number | null, provider_metadata: { [key in string]: JsonValue } | null, } } | { "Raw": { raw_value: JsonValue, } } | { "Error": { error: JsonValue, } }; +export type TranscriptionStreamPart = { "StreamStart": { warnings: Array, } } | { "TranscriptDelta": { id: string | null, delta: string, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TranscriptPartial": { id: string | null, text: string, start_second: number | null, duration_in_seconds: number | null, channel_index: number | null, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TranscriptFinal": { id: string | null, text: string, start_second: number | null, end_second: number | null, channel_index: number | null, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ResponseMetadata": { timestamp: string | null, model_id: string | null, headers: { [key in string]: string } | null, body: JsonValue | null, } } | { "Finish": { text: string, segments: Array, language: string | null, duration_in_seconds: number | null, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "Raw": { raw_value: JsonValue, } } | { "Error": { error: JsonValue, } }; diff --git a/bindings/node/src/types/UploadFileCallOptions.ts b/bindings/node/src/types/UploadFileCallOptions.ts index 027e634aa..b39d69c19 100644 --- a/bindings/node/src/types/UploadFileCallOptions.ts +++ b/bindings/node/src/types/UploadFileCallOptions.ts @@ -23,4 +23,4 @@ filename: string | null, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: JsonValue } | null, }; +provider_options: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/UploadFileResult.ts b/bindings/node/src/types/UploadFileResult.ts index dc9c7412e..981c04b59 100644 --- a/bindings/node/src/types/UploadFileResult.ts +++ b/bindings/node/src/types/UploadFileResult.ts @@ -25,7 +25,7 @@ filename: string | null, /** * Additional provider-specific metadata, keyed by provider name. */ -provider_metadata: { [key in string]: JsonValue } | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** * Warnings from the provider. */ diff --git a/bindings/node/src/types/Usage.ts b/bindings/node/src/types/Usage.ts index 76961cf7e..385ac2af6 100644 --- a/bindings/node/src/types/Usage.ts +++ b/bindings/node/src/types/Usage.ts @@ -1,12 +1,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { TokenUsage } from "./TokenUsage"; -import type { JsonValue } from "./serde_json/JsonValue"; +import type { InputTokenUsage } from "./InputTokenUsage"; +import type { OutputTokenUsage } from "./OutputTokenUsage"; /** * Token usage statistics. */ -export type Usage = { input_tokens: TokenUsage, output_tokens: TokenUsage, +export type Usage = { input_tokens: InputTokenUsage, output_tokens: OutputTokenUsage, /** * Raw usage information from the provider (opaque, provider-specific). */ -raw?: JsonValue | null, }; +raw?: Record | null, }; diff --git a/bindings/node/src/types/UserPart.ts b/bindings/node/src/types/UserPart.ts new file mode 100644 index 000000000..beecc56e5 --- /dev/null +++ b/bindings/node/src/types/UserPart.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FilePart } from "./FilePart"; +import type { TextPart } from "./TextPart"; + +/** + * Parts allowed in a user message. + */ +export type UserPart = { "type": "text" } & TextPart | { "type": "file" } & FilePart; diff --git a/bindings/node/src/types/VideoCallOptions.ts b/bindings/node/src/types/VideoCallOptions.ts index 22bf461a2..9f3791316 100644 --- a/bindings/node/src/types/VideoCallOptions.ts +++ b/bindings/node/src/types/VideoCallOptions.ts @@ -61,7 +61,7 @@ generate_audio: boolean | null, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: JsonValue }, +provider_options: { [key in string]: { [key in string]: JsonValue } }, /** * Per-call retry override. `None` uses the model default. */ diff --git a/bindings/node/src/types/VideoResult.ts b/bindings/node/src/types/VideoResult.ts index 8a8a96bd1..8ab4fec8f 100644 --- a/bindings/node/src/types/VideoResult.ts +++ b/bindings/node/src/types/VideoResult.ts @@ -21,7 +21,7 @@ warnings: Array, /** * Additional provider-specific metadata, keyed by provider name. */ -provider_metadata: { [key in string]: JsonValue } | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** * Response information for telemetry and debugging. */ diff --git a/bindings/python/python/aimux/wrapper.py b/bindings/python/python/aimux/wrapper.py index a6e4e0fc2..0e3710f24 100644 --- a/bindings/python/python/aimux/wrapper.py +++ b/bindings/python/python/aimux/wrapper.py @@ -39,12 +39,16 @@ "ResponseFormat", "StreamPart", "GenerateContent", + "Source", + "TextContent", "FileData", "FileBytes", "Warning", "AiMuxErrorValue", # models - "TokenUsage", + "InputTokenUsage", + "OutputTokenUsage", + "GeneratedFileData", "Usage", "FinishReason", "ResponseMetadata", @@ -62,6 +66,7 @@ "GenerateObjectResult", "StreamTextResultAggregated", "GenerateResult", + "GenerateResponseMetadata", # functions "generate_text", "stream_text", @@ -113,13 +118,15 @@ # Simple struct models # ───────────────────────────────────────────────────────────────────────────── -class TokenUsage(BaseModel): - """Token usage detail with cache breakdown (Rust ``TokenUsage``).""" - +class InputTokenUsage(BaseModel): total: Optional[int] = None no_cache: Optional[int] = None cache_read: Optional[int] = None cache_write: Optional[int] = None + + +class OutputTokenUsage(BaseModel): + total: Optional[int] = None text: Optional[int] = None reasoning: Optional[int] = None @@ -127,9 +134,9 @@ class TokenUsage(BaseModel): class Usage(BaseModel): """Token usage statistics (Rust ``Usage``).""" - input_tokens: TokenUsage = Field(default_factory=TokenUsage) - output_tokens: TokenUsage = Field(default_factory=TokenUsage) - raw: Optional[Any] = None + input_tokens: InputTokenUsage = Field(default_factory=InputTokenUsage) + output_tokens: OutputTokenUsage = Field(default_factory=OutputTokenUsage) + raw: Optional[Dict[str, Any]] = None class FinishReason(BaseModel): @@ -320,6 +327,7 @@ class _FileDataData(BaseModel): class _FileDataUrl(BaseModel): type: Literal["Url"] url: str + original_url: Optional[str] = None class _FileDataReference(BaseModel): @@ -362,6 +370,22 @@ def _int_to_ext(self, handler): # type: ignore[no-untyped-def] return _external_tag_serializer(handler(self)) +_GeneratedFileDataUnion = Annotated[ + Union[_FileDataData, _FileDataUrl], Field(discriminator="type") +] + + +class GeneratedFileData(RootModel[_GeneratedFileDataUnion]): + @model_validator(mode="before") + @classmethod + def _ext_to_int(cls, data: Any) -> Any: + return _external_tag_before(data) + + @model_serializer(mode="wrap") + def _int_to_ext(self, handler): + return _external_tag_serializer(handler(self)) + + # ── GenerateContent ───────────────────────────────────────────────────────── class _ContentText(BaseModel): @@ -380,15 +404,39 @@ class _ContentToolCall(BaseModel): provider_metadata: Optional[Any] = None -class _ContentSource(BaseModel): - type: Literal["Source"] +class _UrlSource(BaseModel): id: str - source_type: str - url: Optional[str] = None + source_type: Literal["url"] + url: str title: Optional[str] = None provider_metadata: Optional[Dict[str, Any]] = None +class _DocumentSource(BaseModel): + id: str + source_type: Literal["document"] + media_type: str + title: str + filename: Optional[str] = None + provider_metadata: Optional[Dict[str, Any]] = None + + +Source = Annotated[Union[_UrlSource, _DocumentSource], Field(discriminator="source_type")] + + +class _ContentUrlSource(_UrlSource): + type: Literal["Source"] + + +class _ContentDocumentSource(_DocumentSource): + type: Literal["Source"] + + +_ContentSource = Annotated[ + Union[_ContentUrlSource, _ContentDocumentSource], Field(discriminator="source_type") +] + + class _ContentReasoning(BaseModel): type: Literal["Reasoning"] text: str @@ -396,8 +444,8 @@ class _ContentReasoning(BaseModel): class _ContentFile(BaseModel): - type: Literal["File"] - data: FileData + type: Literal["File", "ReasoningFile"] + data: GeneratedFileData media_type: str provider_metadata: Optional[Dict[str, Any]] = None @@ -413,6 +461,19 @@ class _ContentToolResult(BaseModel): provider_metadata: Optional[Any] = None +class _ContentCustom(BaseModel): + type: Literal["Custom"] + kind: str + provider_metadata: Optional[Dict[str, Any]] = None + + +class _ContentToolApprovalRequest(BaseModel): + type: Literal["ToolApprovalRequest"] + approval_id: str + tool_call_id: str + provider_metadata: Optional[Dict[str, Any]] = None + + _GenerateContentUnion = Annotated[ Union[ _ContentText, @@ -421,6 +482,8 @@ class _ContentToolResult(BaseModel): _ContentReasoning, _ContentFile, _ContentToolResult, + _ContentCustom, + _ContentToolApprovalRequest, ], Field(discriminator="type"), ] @@ -551,13 +614,6 @@ class _SPResponseMetadata(BaseModel): model_id: Optional[str] = None -class _SPSource(BaseModel): - type: Literal["Source"] - id: str - source_type: str - url: Optional[str] = None - title: Optional[str] = None - provider_metadata: Optional[Dict[str, Any]] = None class _SPRaw(BaseModel): @@ -567,11 +623,52 @@ class _SPRaw(BaseModel): class _SPFile(BaseModel): type: Literal["File"] - data: FileData + data: GeneratedFileData + media_type: str + provider_metadata: Optional[Dict[str, Any]] = None + + +class _SPToolApprovalRequest(BaseModel): + type: Literal["ToolApprovalRequest"] + approval_id: str + tool_call: ToolCall + reason: Optional[str] = None + is_automatic: Optional[bool] = None + signature: Optional[str] = None + + +class _GeneratedFile(BaseModel): + data: GeneratedFileData media_type: str provider_metadata: Optional[Dict[str, Any]] = None +class _SPReasoningFile(BaseModel): + type: Literal["ReasoningFile"] + file: _GeneratedFile + provider_metadata: Optional[Dict[str, Any]] = None + + +_TextContentUnion = Annotated[ + Union[ + _ContentText, _SPToolCall, _ContentSource, _ContentReasoning, + _SPFile, _SPReasoningFile, _ContentToolResult, _ContentCustom, _SPToolApprovalRequest, + ], + Field(discriminator="type"), +] + + +class TextContent(RootModel[_TextContentUnion]): + @model_validator(mode="before") + @classmethod + def _ext_to_int(cls, data: Any) -> Any: + return _external_tag_before(data) + + @model_serializer(mode="wrap") + def _int_to_ext(self, handler): + return _external_tag_serializer(handler(self)) + + _StreamPartUnion = Annotated[ Union[ _SPTextStart, @@ -586,12 +683,15 @@ class _SPFile(BaseModel): _SPToolCall, _SPToolResult, _SPFile, + _SPReasoningFile, _SPReasoningStart, _SPReasoningDelta, _SPReasoningEnd, _SPResponseMetadata, - _SPSource, + _ContentSource, _SPRaw, + _ContentCustom, + _SPToolApprovalRequest, ], Field(discriminator="type"), ] @@ -768,6 +868,29 @@ class _ToolResultContentPart(BaseModel): provider_options: Optional[Any] = None +class _CustomContentPart(BaseModel): + type: Literal["custom"] = "custom" + kind: str + provider_options: Optional[Any] = None + + +class _ReasoningFileContentPart(BaseModel): + type: Literal["reasoning_file"] = "reasoning_file" + data: GeneratedFileData + media_type: str + provider_options: Optional[Any] = None + + +class _ToolApprovalRequestContentPart(BaseModel): + type: Literal["tool_approval_request"] = "tool_approval_request" + approval_id: str + tool_call_id: str + reason: Optional[str] = None + is_automatic: Optional[bool] = None + signature: Optional[str] = None + input_schema_input: Optional[Any] = None + + ContentPart = Annotated[ Union[ TextContentPart, @@ -779,6 +902,9 @@ class _ToolResultContentPart(BaseModel): _ReasoningContentPart, _ToolCallContentPart, _ToolResultContentPart, + _CustomContentPart, + _ReasoningFileContentPart, + _ToolApprovalRequestContentPart, ], Field(discriminator="type"), ] @@ -930,6 +1056,10 @@ class GenerateTextOptions(BaseModel): """ +class GenerateResponseMetadata(ResponseMetadata): + body: Optional[Any] = None + + class GenerateResult(BaseModel): """Raw provider result (Rust ``GenerateResult``), exposed via ``raw``.""" @@ -938,7 +1068,7 @@ class GenerateResult(BaseModel): usage: Usage warnings: List[Warning] provider_metadata: Optional[Any] = None - response: ResponseMetadata + response: Optional[GenerateResponseMetadata] = None request_body: Optional[Any] = None response_headers: Optional[Dict[str, str]] = None @@ -946,6 +1076,7 @@ class GenerateResult(BaseModel): class GenerateTextResult(BaseModel): """Result of ``generate_text`` (user-facing, Rust ``GenerateTextResult``).""" + content: List[TextContent] = Field(default_factory=list) text: str tool_calls: List[ToolCall] finish_reason: FinishReason @@ -953,11 +1084,11 @@ class GenerateTextResult(BaseModel): warnings: List[Warning] raw: GenerateResult # M7: top-level aggregation fields. - # reasoning/sources/files use weak types (Dict) — use raw.content for + # reasoning/files use weak types (Dict) — use raw.content for # full typing; response_messages reuses ModelMessage for next-turn prompt. reasoning: List[Dict[str, Any]] = Field(default_factory=list) reasoning_text: str = "" - sources: List[Dict[str, Any]] = Field(default_factory=list) + sources: List[Source] = Field(default_factory=list) files: List[Dict[str, Any]] = Field(default_factory=list) response_messages: List[ModelMessage] = Field(default_factory=list) # M12: raw provider-specific finish reason string (e.g. "stop", "end_turn"). @@ -1000,13 +1131,14 @@ class StreamTextResultAggregated(BaseModel): ``GenerateResult`` equivalent). """ + content: List[TextContent] = Field(default_factory=list) text: str = "" - # reasoning/sources/files use weak types (Dict) — same strategy as + # reasoning/files use weak types (Dict) — same strategy as # GenerateTextResult. reasoning: List[Dict[str, Any]] = Field(default_factory=list) reasoning_text: str = "" tool_calls: List[ToolCall] = Field(default_factory=list) - sources: List[Dict[str, Any]] = Field(default_factory=list) + sources: List[Source] = Field(default_factory=list) files: List[Dict[str, Any]] = Field(default_factory=list) finish_reason: FinishReason raw_finish_reason: Optional[str] = None diff --git a/bindings/python/src/error.rs b/bindings/python/src/error.rs index be2165d5c..3245887bd 100644 --- a/bindings/python/src/error.rs +++ b/bindings/python/src/error.rs @@ -240,7 +240,9 @@ fn variant_instance<'py>(py: Python<'py>, e: &AiMuxError) -> PyResult py.get_type_bound::(), AiMuxError::Retry(_) => py.get_type_bound::(), AiMuxError::JsonParse(_) => py.get_type_bound::(), - AiMuxError::InvalidResponseData(_) => py.get_type_bound::(), + AiMuxError::InvalidResponseData(_) | AiMuxError::ToolCallNotFoundForApproval { .. } => { + py.get_type_bound::() + } AiMuxError::NoSuchTool { .. } => py.get_type_bound::(), AiMuxError::InvalidToolInput { .. } => py.get_type_bound::(), AiMuxError::ToolCallRepair { .. } => py.get_type_bound::(), diff --git a/bindings/python/src/multimodal.rs b/bindings/python/src/multimodal.rs index 0bef00f90..4212dbcc9 100644 --- a/bindings/python/src/multimodal.rs +++ b/bindings/python/src/multimodal.rs @@ -631,7 +631,7 @@ pub fn start_transcription_session( #[derive(serde::Deserialize, Default)] struct SessionOpts { input_audio_format: Option, - provider_options: Option>, + provider_options: Option, headers: Option>, include_raw_chunks: Option, timeout: Option, diff --git a/bindings/swift/Sources/Aimux/Types.swift b/bindings/swift/Sources/Aimux/Types.swift index 529bd71ac..27167fe0b 100644 --- a/bindings/swift/Sources/Aimux/Types.swift +++ b/bindings/swift/Sources/Aimux/Types.swift @@ -138,37 +138,35 @@ public struct FinishReason: Codable, Equatable { } } -/// Token usage detail (cache breakdown). All fields optional on the wire. -public struct TokenUsage: Codable, Equatable { +public struct InputTokenUsage: Codable, Equatable { public var total: UInt32? public var noCache: UInt32? public var cacheRead: UInt32? public var cacheWrite: UInt32? - public var text: UInt32? - public var reasoning: UInt32? - enum CodingKeys: String, CodingKey { - case total - case noCache = "no_cache" - case cacheRead = "cache_read" - case cacheWrite = "cache_write" - case text - case reasoning + case total, noCache = "no_cache", cacheRead = "cache_read", cacheWrite = "cache_write" } - public init(total: UInt32? = nil, noCache: UInt32? = nil, cacheRead: UInt32? = nil, - cacheWrite: UInt32? = nil, text: UInt32? = nil, reasoning: UInt32? = nil) { - self.total = total; self.noCache = noCache; self.cacheRead = cacheRead - self.cacheWrite = cacheWrite; self.text = text; self.reasoning = reasoning + cacheWrite: UInt32? = nil) { + self.total = total; self.noCache = noCache; self.cacheRead = cacheRead; self.cacheWrite = cacheWrite + } +} + +public struct OutputTokenUsage: Codable, Equatable { + public var total: UInt32? + public var text: UInt32? + public var reasoning: UInt32? + public init(total: UInt32? = nil, text: UInt32? = nil, reasoning: UInt32? = nil) { + self.total = total; self.text = text; self.reasoning = reasoning } } /// Token usage statistics. public struct Usage: Codable, Equatable { - public var inputTokens: TokenUsage - public var outputTokens: TokenUsage + public var inputTokens: InputTokenUsage + public var outputTokens: OutputTokenUsage /// Opaque, provider-specific raw usage. - public var raw: JSONValue? + public var raw: [String: JSONValue]? enum CodingKeys: String, CodingKey { case inputTokens = "input_tokens" @@ -176,7 +174,7 @@ public struct Usage: Codable, Equatable { case raw } - public init(inputTokens: TokenUsage, outputTokens: TokenUsage, raw: JSONValue? = nil) { + public init(inputTokens: InputTokenUsage, outputTokens: OutputTokenUsage, raw: [String: JSONValue]? = nil) { self.inputTokens = inputTokens self.outputTokens = outputTokens self.raw = raw @@ -447,7 +445,12 @@ public enum ContentPart: Codable, Equatable { case toolResult(toolCallId: String, result: JSONValue, toolName: String?, isError: Bool?, preliminary: Bool?, dynamic: Bool?, providerOptions: JSONValue?) + case custom(kind: String, providerOptions: JSONValue?) + case reasoningFile(data: GeneratedFileData, mediaType: String, providerOptions: JSONValue?) + case toolApprovalRequest(approvalId: String, toolCallId: String, reason: String?, isAutomatic: Bool?, signature: String?, inputSchemaInput: JSONValue?) + private enum Field: String, CodingKey { + case kind, approvalId = "approval_id", reason, isAutomatic = "is_automatic", inputSchemaInput = "input_schema_input" case text, image, data, mediaType = "media_type", filename, url, reference case signature, toolCallId = "tool_call_id", toolName = "tool_name", input, result case providerExecuted = "provider_executed" @@ -460,6 +463,20 @@ public enum ContentPart: Codable, Equatable { let type = try c.decode(String.self, forKey: AnyCodingKey("type")) func po() throws -> JSONValue? { try c.decodeIfPresent(JSONValue.self, forKey: AnyCodingKey("provider_options")) } switch type { + case "custom": + self = .custom(kind: try c.decode(String.self, forKey: AnyCodingKey("kind")), + providerOptions: try c.decodeIfPresent(JSONValue.self, forKey: AnyCodingKey("provider_options"))) + case "reasoning_file": + self = .reasoningFile(data: try c.decode(GeneratedFileData.self, forKey: AnyCodingKey("data")), + mediaType: try c.decode(String.self, forKey: AnyCodingKey("media_type")), + providerOptions: try c.decodeIfPresent(JSONValue.self, forKey: AnyCodingKey("provider_options"))) + case "tool_approval_request": + self = .toolApprovalRequest(approvalId: try c.decode(String.self, forKey: AnyCodingKey("approval_id")), + toolCallId: try c.decode(String.self, forKey: AnyCodingKey("tool_call_id")), + reason: try c.decodeIfPresent(String.self, forKey: AnyCodingKey("reason")), + isAutomatic: try c.decodeIfPresent(Bool.self, forKey: AnyCodingKey("is_automatic")), + signature: try c.decodeIfPresent(String.self, forKey: AnyCodingKey("signature")), + inputSchemaInput: try c.decodeIfPresent(JSONValue.self, forKey: AnyCodingKey("input_schema_input"))) case "text": self = .text(text: try c.decode(String.self, forKey: AnyCodingKey("text")), providerOptions: try po()) @@ -512,6 +529,17 @@ public enum ContentPart: Codable, Equatable { public func encode(to encoder: Encoder) throws { var c = encoder.container(keyedBy: AnyCodingKey.self) switch self { + case .custom(let kind, let po): + try c.encode("custom", forKey: AnyCodingKey("type")); try c.encode(kind, forKey: AnyCodingKey("kind")) + try c.encodeIfPresent(po, forKey: AnyCodingKey("provider_options")) + case .reasoningFile(let data, let mediaType, let po): + try c.encode("reasoning_file", forKey: AnyCodingKey("type")); try c.encode(data, forKey: AnyCodingKey("data")) + try c.encode(mediaType, forKey: AnyCodingKey("media_type")); try c.encodeIfPresent(po, forKey: AnyCodingKey("provider_options")) + case .toolApprovalRequest(let approvalId, let toolCallId, let reason, let isAutomatic, let signature, let inputSchemaInput): + try c.encode("tool_approval_request", forKey: AnyCodingKey("type")); try c.encode(approvalId, forKey: AnyCodingKey("approval_id")) + try c.encode(toolCallId, forKey: AnyCodingKey("tool_call_id")); try c.encodeIfPresent(reason, forKey: AnyCodingKey("reason")) + try c.encodeIfPresent(isAutomatic, forKey: AnyCodingKey("is_automatic")); try c.encodeIfPresent(signature, forKey: AnyCodingKey("signature")) + try c.encodeIfPresent(inputSchemaInput, forKey: AnyCodingKey("input_schema_input")) case .text(let text, let po): try c.encode("text", forKey: AnyCodingKey("type")) try c.encode(text, forKey: AnyCodingKey("text")) @@ -709,12 +737,12 @@ public enum FileBytes: Codable, Equatable { /// `{"Text":{"text":"…"}}`. public enum FileData: Codable, Equatable { case data(data: FileBytes) - case url(url: String) + case url(url: String, originalUrl: String? = nil) case reference(reference: [String: String]) case text(text: String) private enum Field: String, CodingKey { - case data, url, reference, text + case data, url, reference, text, originalUrl = "original_url" } public init(from decoder: Decoder) throws { @@ -727,7 +755,8 @@ public enum FileData: Codable, Equatable { case "Data": self = .data(data: try n.decode(FileBytes.self, forKey: .data)) case "Url": - self = .url(url: try n.decode(String.self, forKey: .url)) + self = .url(url: try n.decode(String.self, forKey: .url), + originalUrl: try n.decodeIfPresent(String.self, forKey: .originalUrl)) case "Reference": self = .reference(reference: try n.decode([String: String].self, forKey: .reference)) case "Text": @@ -743,9 +772,10 @@ public enum FileData: Codable, Equatable { case .data(let data): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Data")) try n.encode(data, forKey: .data) - case .url(let url): + case .url(let url, let originalUrl): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Url")) try n.encode(url, forKey: .url) + try n.encodeIfPresent(originalUrl, forKey: .originalUrl) case .reference(let reference): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Reference")) try n.encode(reference, forKey: .reference) @@ -756,6 +786,94 @@ public enum FileData: Codable, Equatable { } } +/// Data or a URL returned for a generated file. +public enum GeneratedFileData: Codable, Equatable { + case data(data: FileBytes) + case url(url: String, originalUrl: String? = nil) + private enum Field: String, CodingKey { + case data, url, originalUrl = "original_url" + } + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: AnyCodingKey.self) + guard let key = c.allKeys.first?.stringValue else { + throw aimuxDecodingError(c.codingPath, "missing generated-file-data tag") + } + let n = try c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey(key)) + switch key { + case "Data": self = .data(data: try n.decode(FileBytes.self, forKey: .data)) + case "Url": self = .url(url: try n.decode(String.self, forKey: .url), + originalUrl: try n.decodeIfPresent(String.self, forKey: .originalUrl)) + default: throw aimuxDecodingError(c.codingPath, "invalid generated-file-data tag") + } + } + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: AnyCodingKey.self) + switch self { + case .data(let data): + var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Data")) + try n.encode(data, forKey: .data) + case .url(let url, let originalUrl): + var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Url")) + try n.encode(url, forKey: .url); try n.encodeIfPresent(originalUrl, forKey: .originalUrl) + } + } +} + +public struct GeneratedFile: Codable, Equatable { + public var data: GeneratedFileData + public var mediaType: String + public var providerMetadata: JSONValue? + enum CodingKeys: String, CodingKey { + case data, mediaType = "media_type", providerMetadata = "provider_metadata" + } + public init(data: GeneratedFileData, mediaType: String, providerMetadata: JSONValue? = nil) { + self.data = data; self.mediaType = mediaType; self.providerMetadata = providerMetadata + } +} + +public enum Source: Codable, Equatable { + case url(id: String, url: String, title: String?, providerMetadata: JSONValue?) + case document(id: String, mediaType: String, title: String, filename: String?, + providerMetadata: JSONValue?) + + private enum Field: String, CodingKey { + case id, sourceType = "source_type", url, title, filename + case mediaType = "media_type", providerMetadata = "provider_metadata" + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: Field.self) + let id = try c.decode(String.self, forKey: .id) + let pm = try c.decodeIfPresent(JSONValue.self, forKey: .providerMetadata) + switch try c.decode(String.self, forKey: .sourceType) { + case "url": + self = .url(id: id, url: try c.decode(String.self, forKey: .url), + title: try c.decodeIfPresent(String.self, forKey: .title), providerMetadata: pm) + case "document": + self = .document(id: id, mediaType: try c.decode(String.self, forKey: .mediaType), + title: try c.decode(String.self, forKey: .title), + filename: try c.decodeIfPresent(String.self, forKey: .filename), + providerMetadata: pm) + default: throw aimuxDecodingError(c.codingPath, "invalid source type") + } + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: Field.self) + switch self { + case .url(let id, let url, let title, let pm): + try c.encode("url", forKey: .sourceType); try c.encode(id, forKey: .id) + try c.encode(url, forKey: .url); try c.encodeIfPresent(title, forKey: .title) + try c.encodeIfPresent(pm, forKey: .providerMetadata) + case .document(let id, let mediaType, let title, let filename, let pm): + try c.encode("document", forKey: .sourceType); try c.encode(id, forKey: .id) + try c.encode(mediaType, forKey: .mediaType); try c.encode(title, forKey: .title) + try c.encodeIfPresent(filename, forKey: .filename) + try c.encodeIfPresent(pm, forKey: .providerMetadata) + } + } +} + /// A content item in the generation result. /// /// Wire: externally tagged — `{"Text":{...}}`, `{"ToolCall":{...}}`, … @@ -764,15 +882,19 @@ public enum GenerateContent: Codable, Equatable { case toolCall(toolCallId: String, toolName: String, input: JSONValue, providerExecuted: Bool?, dynamic: Bool?, providerMetadata: JSONValue?) - case source(id: String, sourceType: String, url: String?, title: String?, - providerMetadata: JSONValue?) + case source(Source) case reasoning(text: String, providerMetadata: JSONValue?) - case file(data: FileData, mediaType: String, providerMetadata: JSONValue?) + case file(data: GeneratedFileData, mediaType: String, providerMetadata: JSONValue?) case toolResult(toolCallId: String, toolName: String, result: JSONValue, isError: Bool?, preliminary: Bool?, dynamic: Bool?, providerMetadata: JSONValue?) + case custom(kind: String, providerMetadata: JSONValue?) + case reasoningFile(data: GeneratedFileData, mediaType: String, providerMetadata: JSONValue?) + case toolApprovalRequest(approvalId: String, toolCallId: String, providerMetadata: JSONValue?) + private enum Field: String, CodingKey { case text + case kind, approvalId = "approval_id", toolCall = "tool_call", reason, isAutomatic = "is_automatic", signature case toolCallId = "tool_call_id", toolName = "tool_name", input, result case providerExecuted = "provider_executed", dynamic case providerMetadata = "provider_metadata" @@ -799,16 +921,23 @@ public enum GenerateContent: Codable, Equatable { dynamic: try n.decodeIfPresent(Bool.self, forKey: .dynamic), providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) case "Source": - self = .source(id: try n.decode(String.self, forKey: .id), - sourceType: try n.decode(String.self, forKey: .sourceType), - url: try n.decodeIfPresent(String.self, forKey: .url), - title: try n.decodeIfPresent(String.self, forKey: .title), - providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) + self = .source(try Source(from: c.superDecoder(forKey: AnyCodingKey("Source")))) case "Reasoning": self = .reasoning(text: try n.decode(String.self, forKey: .text), providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) + case "Custom": + self = .custom(kind: try n.decode(String.self, forKey: .kind), + providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) + case "ReasoningFile": + self = .reasoningFile(data: try n.decode(GeneratedFileData.self, forKey: .data), + mediaType: try n.decode(String.self, forKey: .mediaType), + providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) + case "ToolApprovalRequest": + self = .toolApprovalRequest(approvalId: try n.decode(String.self, forKey: .approvalId), + toolCallId: try n.decode(String.self, forKey: .toolCallId), + providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) case "File": - self = .file(data: try n.decode(FileData.self, forKey: .data), + self = .file(data: try n.decode(GeneratedFileData.self, forKey: .data), mediaType: try n.decode(String.self, forKey: .mediaType), providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) case "ToolResult": @@ -839,17 +968,23 @@ public enum GenerateContent: Codable, Equatable { try n.encodeIfPresent(pe, forKey: .providerExecuted) try n.encodeIfPresent(dyn, forKey: .dynamic) try n.encodeIfPresent(pm, forKey: .providerMetadata) - case .source(let id, let sourceType, let url, let title, let pm): - var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Source")) - try n.encode(id, forKey: .id) - try n.encode(sourceType, forKey: .sourceType) - try n.encodeIfPresent(url, forKey: .url) - try n.encodeIfPresent(title, forKey: .title) - try n.encodeIfPresent(pm, forKey: .providerMetadata) + case .source(let source): + try source.encode(to: c.superEncoder(forKey: AnyCodingKey("Source"))) case .reasoning(let text, let pm): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Reasoning")) try n.encode(text, forKey: .text) try n.encodeIfPresent(pm, forKey: .providerMetadata) + case .custom(let kind, let pm): + var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Custom")) + try n.encode(kind, forKey: .kind); try n.encodeIfPresent(pm, forKey: .providerMetadata) + case .reasoningFile(let data, let mediaType, let pm): + var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("ReasoningFile")) + try n.encode(data, forKey: .data); try n.encode(mediaType, forKey: .mediaType) + try n.encodeIfPresent(pm, forKey: .providerMetadata) + case .toolApprovalRequest(let approvalId, let toolCallId, let pm): + var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("ToolApprovalRequest")) + try n.encode(approvalId, forKey: .approvalId); try n.encode(toolCallId, forKey: .toolCallId) + try n.encodeIfPresent(pm, forKey: .providerMetadata) case .file(let data, let mediaType, let pm): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("File")) try n.encode(data, forKey: .data) @@ -868,6 +1003,20 @@ public enum GenerateContent: Codable, Equatable { } } +public struct GenerateResponseMetadata: Codable, Equatable { + public var id: String? + public var timestamp: String? + public var modelId: String? + public var body: JSONValue? + public var headers: [String: String]? + enum CodingKeys: String, CodingKey { + case id, timestamp, body, headers, modelId = "model_id" + } + public init(id: String? = nil, timestamp: String? = nil, modelId: String? = nil, body: JSONValue? = nil, headers: [String: String]? = nil) { + self.id = id; self.timestamp = timestamp; self.modelId = modelId; self.body = body; self.headers = headers + } +} + /// Raw provider result (the `raw` field of `GenerateTextResult`). public struct GenerateResult: Codable, Equatable { public var content: [GenerateContent] @@ -875,7 +1024,7 @@ public struct GenerateResult: Codable, Equatable { public var usage: Usage? public var warnings: [Warning]? public var providerMetadata: JSONValue? - public var response: ResponseMetadata? + public var response: GenerateResponseMetadata? public var requestBody: JSONValue? public var responseHeaders: [String: String]? @@ -891,7 +1040,7 @@ public struct GenerateResult: Codable, Equatable { public init(content: [GenerateContent], finishReason: FinishReason? = nil, usage: Usage? = nil, warnings: [Warning]? = nil, providerMetadata: JSONValue? = nil, - response: ResponseMetadata? = nil, requestBody: JSONValue? = nil, + response: GenerateResponseMetadata? = nil, requestBody: JSONValue? = nil, responseHeaders: [String: String]? = nil) { self.content = content; self.finishReason = finishReason; self.usage = usage self.warnings = warnings; self.providerMetadata = providerMetadata @@ -902,6 +1051,7 @@ public struct GenerateResult: Codable, Equatable { /// Result of `generate_text` (user-facing). public struct GenerateTextResult: Codable, Equatable { /// The generated text (concatenated from all text content parts). + public var content: [JSONValue]? public var text: String /// Tool calls requested by the model. public var toolCalls: [ToolCall] @@ -917,8 +1067,8 @@ public struct GenerateTextResult: Codable, Equatable { public var reasoning: [JSONValue] /// Concatenated reasoning text (M7). public var reasoningText: String - /// Sources / citations (M7). Weak type. - public var sources: [JSONValue] + /// Sources / citations (M7). + public var sources: [Source] /// Files generated by the model (M7). Weak type. public var files: [JSONValue] /// Assistant messages ready to append for the next turn (M7). @@ -936,7 +1086,7 @@ public struct GenerateTextResult: Codable, Equatable { public var totalUsage: Usage enum CodingKeys: String, CodingKey { - case text + case content, text case toolCalls = "tool_calls" case finishReason = "finish_reason" case usage, warnings, raw @@ -953,12 +1103,13 @@ public struct GenerateTextResult: Codable, Equatable { public init(text: String, toolCalls: [ToolCall], finishReason: FinishReason, usage: Usage, warnings: [Warning] = [], raw: GenerateResult, reasoning: [JSONValue] = [], reasoningText: String = "", - sources: [JSONValue] = [], files: [JSONValue] = [], + sources: [Source] = [], files: [JSONValue] = [], responseMessages: [ModelMessage] = [], rawFinishReason: String? = nil, providerMetadata: JSONValue? = nil, response: ResponseMetadata = ResponseMetadata(), - totalUsage: Usage = Usage(inputTokens: TokenUsage(), outputTokens: TokenUsage())) { + totalUsage: Usage = Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), content: [JSONValue]? = nil) { + self.content = content self.text = text; self.toolCalls = toolCalls; self.finishReason = finishReason self.usage = usage; self.warnings = warnings; self.raw = raw self.reasoning = reasoning; self.reasoningText = reasoningText @@ -1005,7 +1156,7 @@ public struct GenerateObjectResult: Codable, Equatable { public init(object: JSONValue, finishReason: FinishReason, rawFinishReason: String? = nil, - usage: Usage = Usage(inputTokens: TokenUsage(), outputTokens: TokenUsage()), + usage: Usage = Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), warnings: [Warning] = [], reasoning: String? = nil, providerMetadata: JSONValue? = nil, @@ -1024,6 +1175,7 @@ public struct GenerateObjectResult: Codable, Equatable { /// has no `GenerateResult` equivalent). public struct StreamTextResultAggregated: Codable, Equatable { /// The generated text (concatenated `TextDelta`). + public var content: [JSONValue]? public var text: String /// Reasoning / thinking segments. Weak type. public var reasoning: [JSONValue] @@ -1031,8 +1183,8 @@ public struct StreamTextResultAggregated: Codable, Equatable { public var reasoningText: String /// Tool calls requested by the model. public var toolCalls: [ToolCall] - /// Sources / citations. Weak type. - public var sources: [JSONValue] + /// Sources / citations. + public var sources: [Source] /// Files generated by the model. Weak type. public var files: [JSONValue] /// Why generation stopped. @@ -1054,7 +1206,7 @@ public struct StreamTextResultAggregated: Codable, Equatable { public var responseMessages: [ModelMessage] enum CodingKeys: String, CodingKey { - case text + case content, text case reasoning case reasoningText = "reasoning_text" case toolCalls = "tool_calls" @@ -1071,14 +1223,15 @@ public struct StreamTextResultAggregated: Codable, Equatable { public init(text: String = "", reasoning: [JSONValue] = [], reasoningText: String = "", toolCalls: [ToolCall] = [], - sources: [JSONValue] = [], files: [JSONValue] = [], + sources: [Source] = [], files: [JSONValue] = [], finishReason: FinishReason, rawFinishReason: String? = nil, - usage: Usage = Usage(inputTokens: TokenUsage(), outputTokens: TokenUsage()), - totalUsage: Usage = Usage(inputTokens: TokenUsage(), outputTokens: TokenUsage()), + usage: Usage = Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), + totalUsage: Usage = Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), warnings: [Warning] = [], providerMetadata: JSONValue? = nil, response: ResponseMetadata? = nil, - responseMessages: [ModelMessage] = []) { + responseMessages: [ModelMessage] = [], content: [JSONValue]? = nil) { + self.content = content self.text = text; self.reasoning = reasoning; self.reasoningText = reasoningText self.toolCalls = toolCalls; self.sources = sources; self.files = files self.finishReason = finishReason; self.rawFinishReason = rawFinishReason @@ -1249,18 +1402,22 @@ public enum StreamPart: Codable, Equatable { case toolResult(toolCallId: String, toolName: String, result: JSONValue, isError: Bool?, preliminary: Bool?, dynamic: Bool?, providerMetadata: JSONValue?) // P2: file - case file(data: FileData, mediaType: String, providerMetadata: JSONValue?) + case file(data: GeneratedFileData, mediaType: String, providerMetadata: JSONValue?) // P2: reasoning case reasoningStart(id: String, providerMetadata: JSONValue?) case reasoningDelta(id: String, delta: String, providerMetadata: JSONValue?) case reasoningEnd(id: String, providerMetadata: JSONValue?) // P2: metadata case responseMetadata(id: String?, timestamp: String?, modelId: String?) - case source(id: String, sourceType: String, url: String?, title: String?, - providerMetadata: JSONValue?) + case source(Source) case raw(rawValue: JSONValue) + case custom(kind: String, providerMetadata: JSONValue?) + case reasoningFile(file: GeneratedFile, providerMetadata: JSONValue?) + case toolApprovalRequest(approvalId: String, toolCall: ToolCall, reason: String?, isAutomatic: Bool?, signature: String?) + private enum Field: String, CodingKey { + case file, kind, approvalId = "approval_id", toolCall = "tool_call", reason, isAutomatic = "is_automatic", signature case id, delta, warnings, usage, text case finishReason = "finish_reason", providerMetadata = "provider_metadata" case error @@ -1331,8 +1488,20 @@ public enum StreamPart: Codable, Equatable { preliminary: try n.decodeIfPresent(Bool.self, forKey: .preliminary), dynamic: try n.decodeIfPresent(Bool.self, forKey: .dynamic), providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) + case "Custom": + self = .custom(kind: try n.decode(String.self, forKey: .kind), + providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) + case "ReasoningFile": + self = .reasoningFile(file: try n.decode(GeneratedFile.self, forKey: .file), + providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) + case "ToolApprovalRequest": + self = .toolApprovalRequest(approvalId: try n.decode(String.self, forKey: .approvalId), + toolCall: try n.decode(ToolCall.self, forKey: .toolCall), + reason: try n.decodeIfPresent(String.self, forKey: .reason), + isAutomatic: try n.decodeIfPresent(Bool.self, forKey: .isAutomatic), + signature: try n.decodeIfPresent(String.self, forKey: .signature)) case "File": - self = .file(data: try n.decode(FileData.self, forKey: .data), + self = .file(data: try n.decode(GeneratedFileData.self, forKey: .data), mediaType: try n.decode(String.self, forKey: .mediaType), providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) case "ReasoningStart": @@ -1350,11 +1519,7 @@ public enum StreamPart: Codable, Equatable { timestamp: try n.decodeIfPresent(String.self, forKey: .timestamp), modelId: try n.decodeIfPresent(String.self, forKey: .modelId)) case "Source": - self = .source(id: try n.decode(String.self, forKey: .id), - sourceType: try n.decode(String.self, forKey: .sourceType), - url: try n.decodeIfPresent(String.self, forKey: .url), - title: try n.decodeIfPresent(String.self, forKey: .title), - providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) + self = .source(try Source(from: c.superDecoder(forKey: AnyCodingKey("Source")))) case "Raw": self = .raw(rawValue: try n.decode(JSONValue.self, forKey: .rawValue)) default: @@ -1411,6 +1576,18 @@ public enum StreamPart: Codable, Equatable { try n.encodeIfPresent(ie, forKey: .isError); try n.encodeIfPresent(prel, forKey: .preliminary) try n.encodeIfPresent(dyn, forKey: .dynamic) try n.encodeIfPresent(pm, forKey: .providerMetadata) + case .custom(let kind, let pm): + var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Custom")) + try n.encode(kind, forKey: .kind); try n.encodeIfPresent(pm, forKey: .providerMetadata) + case .reasoningFile(let file, let pm): + var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("ReasoningFile")) + try n.encode(file, forKey: .file) + try n.encodeIfPresent(pm, forKey: .providerMetadata) + case .toolApprovalRequest(let approvalId, let toolCall, let reason, let isAutomatic, let signature): + var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("ToolApprovalRequest")) + try n.encode(approvalId, forKey: .approvalId); try n.encode(toolCall, forKey: .toolCall) + try n.encodeIfPresent(reason, forKey: .reason); try n.encodeIfPresent(isAutomatic, forKey: .isAutomatic) + try n.encodeIfPresent(signature, forKey: .signature) case .file(let data, let mediaType, let pm): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("File")) try n.encode(data, forKey: .data); try n.encode(mediaType, forKey: .mediaType) @@ -1430,11 +1607,8 @@ public enum StreamPart: Codable, Equatable { try n.encodeIfPresent(id, forKey: .id) try n.encodeIfPresent(timestamp, forKey: .timestamp) try n.encodeIfPresent(modelId, forKey: .modelId) - case .source(let id, let sourceType, let url, let title, let pm): - var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Source")) - try n.encode(id, forKey: .id); try n.encode(sourceType, forKey: .sourceType) - try n.encodeIfPresent(url, forKey: .url); try n.encodeIfPresent(title, forKey: .title) - try n.encodeIfPresent(pm, forKey: .providerMetadata) + case .source(let source): + try source.encode(to: c.superEncoder(forKey: AnyCodingKey("Source"))) case .raw(let rawValue): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Raw")) try n.encode(rawValue, forKey: .rawValue) diff --git a/docs/api/node.md b/docs/api/node.md index 6895ccdea..974751062 100644 --- a/docs/api/node.md +++ b/docs/api/node.md @@ -540,10 +540,10 @@ export type GenerateTextResult = { usage: Usage // token usage warnings: Array // warnings raw: GenerateResult // raw provider result (includes full content) - reasoning: Array // reasoning / thinking segments + reasoning: Array // reasoning / thinking segments reasoning_text: string // the reasoning segments concatenated - sources: Array // sources / citations (search-preview models) - files: Array // files generated by the model + sources: Array // sources / citations (search-preview models) + files: Array // files generated by the model response_messages: Array // assistant messages ready for the next turn raw_finish_reason: string | null // provider's own finish-reason string provider_metadata: JsonValue | null // mirrored from raw.provider_metadata diff --git a/tools/aimux-replay/src/main.rs b/tools/aimux-replay/src/main.rs index da111e534..dd095e062 100644 --- a/tools/aimux-replay/src/main.rs +++ b/tools/aimux-replay/src/main.rs @@ -18,6 +18,7 @@ use anyhow::{Context, Result, bail}; use clap::Parser; use tokio::runtime::Runtime; +use aimux_core::language_model_message::{AssistantPart, LanguageModelMessage, UserPart}; use aimux_core::recording::Recording; use aimux_core::replay::{ReplayOverrides, replay_with_model}; use aimux_providers::rebuild_provider; @@ -129,12 +130,18 @@ fn run_dry(rec: &Recording) -> Result<()> { } /// 取消息首个文本内容(截断展示用)。 -fn prompt_text( - m: &aimux_core::language_model_message::LanguageModelPromptMessage, -) -> Option { - match m.content.first() { - Some(aimux_core::content::ContentPart::Text { text, .. }) => Some(text.clone()), - _ => None, +fn prompt_text(m: &LanguageModelMessage) -> Option { + match m { + LanguageModelMessage::System { content, .. } => Some(content.clone()), + LanguageModelMessage::User { content, .. } => match content.first() { + Some(UserPart::Text(part)) => Some(part.text.clone()), + _ => None, + }, + LanguageModelMessage::Assistant { content, .. } => match content.first() { + Some(AssistantPart::Text(part)) => Some(part.text.clone()), + _ => None, + }, + LanguageModelMessage::Tool { .. } => None, } } @@ -185,13 +192,7 @@ fn run_replay( }; let overrides = prompt.map(|text| ReplayOverrides { - prompt: Some(vec![ - aimux_core::language_model_message::LanguageModelPromptMessage { - role: aimux_core::message::Role::User, - content: vec![aimux_core::content::ContentPart::text(text)], - provider_options: None, - }, - ]), + prompt: Some(vec![LanguageModelMessage::user_text(text)]), ..Default::default() }); diff --git a/tools/aimux-web/examples/gen_demo_fixtures.rs b/tools/aimux-web/examples/gen_demo_fixtures.rs index 8de6ccff3..071b57af9 100644 --- a/tools/aimux-web/examples/gen_demo_fixtures.rs +++ b/tools/aimux-web/examples/gen_demo_fixtures.rs @@ -9,9 +9,7 @@ //! //! The rest only enrich the Traces / Sessions listing. -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::LanguageModelPromptMessage; -use aimux_core::message::Role; +use aimux_core::language_model_message::{AssistantPart, LanguageModelMessage, TextPart}; use aimux_core::options::CallOptions; use aimux_core::recording::{ HttpExchange, HttpRecord, InputRecord, OutcomeRecord, OutcomeStatus, ProviderRecord, Recording, @@ -21,21 +19,13 @@ use aimux_core::recording::{ const AGENT_SYSTEM: &str = "You are a helpful assistant. Always use the calculator tool for arithmetic, then answer."; -fn msg(role: Role, text: &str) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role, - content: vec![ContentPart::text(text)], - provider_options: None, - } -} - #[allow(clippy::too_many_arguments)] fn recording( call_id: &str, recorded_at: &str, provider: &str, model_id: &str, - prompt: Vec, + prompt: Vec, session: Option<&str>, step: Option, status: OutcomeStatus, @@ -140,7 +130,7 @@ data: [DONE]"#; "2026-08-14T09:31:10.000Z", "openai", "gpt-4o", - vec![msg(Role::User, "what is 1 + 1?")], + vec![LanguageModelMessage::user_text("what is 1 + 1?")], Some("sess-playground"), Some(0), OutcomeStatus::Success, @@ -159,8 +149,11 @@ data: [DONE]"#; "openai", "gpt-4o", vec![ - msg(Role::System, AGENT_SYSTEM), - msg(Role::User, "what is 17 * 19?"), + LanguageModelMessage::System { + content: AGENT_SYSTEM.into(), + provider_options: None, + }, + LanguageModelMessage::user_text("what is 17 * 19?"), ], Some("sess-agent"), Some(0), @@ -180,8 +173,11 @@ data: [DONE]"#; "deepseek", "deepseek-chat", vec![ - msg(Role::System, "You are a careful technical analyst."), - msg(Role::User, "Explain how prefix caching works."), + LanguageModelMessage::System { + content: "You are a careful technical analyst.".into(), + provider_options: None, + }, + LanguageModelMessage::user_text("Explain how prefix caching works."), ], Some("sess-deepseek"), Some(0), @@ -200,10 +196,19 @@ data: [DONE]"#; "deepseek", "deepseek-chat", vec![ - msg(Role::System, "You are a careful technical analyst."), - msg(Role::User, "Explain how prefix caching works."), - msg(Role::Assistant, "Prefix caching reuses the KV cache..."), - msg(Role::User, "What is the hit rate formula?"), + LanguageModelMessage::System { + content: "You are a careful technical analyst.".into(), + provider_options: None, + }, + LanguageModelMessage::user_text("Explain how prefix caching works."), + LanguageModelMessage::Assistant { + content: vec![AssistantPart::Text(TextPart { + text: "Prefix caching reuses the KV cache...".into(), + provider_options: None, + })], + provider_options: None, + }, + LanguageModelMessage::user_text("What is the hit rate formula?"), ], Some("sess-deepseek"), Some(1), @@ -222,7 +227,7 @@ data: [DONE]"#; "2026-08-14T09:25:15.000Z", "groq", "llama-3.3-70b-versatile", - vec![msg(Role::User, "hi")], + vec![LanguageModelMessage::user_text("hi")], Some("sess-err"), Some(0), OutcomeStatus::Error, @@ -237,7 +242,7 @@ data: [DONE]"#; "2026-08-14T09:10:05.000Z", "anthropic", "claude-3-5-sonnet-latest", - vec![msg(Role::User, "Write a haiku about Rust.")], + vec![LanguageModelMessage::user_text("Write a haiku about Rust.")], None, None, OutcomeStatus::Success, diff --git a/tools/aimux-web/examples/gen_fixture.rs b/tools/aimux-web/examples/gen_fixture.rs index d9cdb1cb6..edf52366e 100644 --- a/tools/aimux-web/examples/gen_fixture.rs +++ b/tools/aimux-web/examples/gen_fixture.rs @@ -3,9 +3,7 @@ //! //! Usage: `cargo run -p aimux-web --example gen_fixture [out.jsonl]` -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::LanguageModelPromptMessage; -use aimux_core::message::Role; +use aimux_core::language_model_message::LanguageModelMessage; use aimux_core::options::CallOptions; use aimux_core::recording::{ HttpExchange, HttpRecord, InputRecord, OutcomeRecord, OutcomeStatus, ProviderRecord, Recording, @@ -18,18 +16,11 @@ fn main() { .unwrap_or_else(|| "fixture.jsonl".to_string()); let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text( - "You are a helpful assistant. Always use the calculator tool.", - )], - provider_options: None, - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("what is 1 + 1?")], + LanguageModelMessage::System { + content: "You are a helpful assistant. Always use the calculator tool.".into(), provider_options: None, }, + LanguageModelMessage::user_text("what is 1 + 1?"), ]; let options = CallOptions { diff --git a/tools/aimux-web/src/api/replay.rs b/tools/aimux-web/src/api/replay.rs index 776685f07..beb2a9d10 100644 --- a/tools/aimux-web/src/api/replay.rs +++ b/tools/aimux-web/src/api/replay.rs @@ -100,7 +100,7 @@ async fn run_inner(state: AppState, req: ReplayRequest) -> Result Result>(); - out.push(ModelMessage { - role, - content: MessageContent::Parts(parts), - }); + let content = if role == Role::System { + let [WireContentPart::Text { text }] = m.content.as_slice() else { + return Err(AiMuxError::InvalidPrompt( + "system content must be plain text".into(), + )); + }; + MessageContent::Text(text.clone()) + } else { + MessageContent::Parts(m.content.iter().map(to_content_part).collect()) + }; + out.push(ModelMessage { role, content }); } Ok(ModelPrompt::Messages(out)) }