From cb0b764db73c50ff212355a9df3b22a2954b9ecc Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 14:36:32 +0000 Subject: [PATCH 1/5] fix(provider-utils): unparsable stream events report the AI SDK parse error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A streamed SSE event whose data is not JSON yielded `JsonParse` with only the parser's message. The AI SDK's `parseJsonEventStream` reads each event with `safeParseJSON`, whose `JSONParseError` reads `JSON parsing failed: Text: .\nError message: ` (`@ai-sdk/provider` src/errors/json-parse-error.ts:16-19). Found while porting the `@ai-sdk/openai-compatible` 3.0.62 tests (`doStream › should handle unparsable stream parts`, chat and completion); every stream read through `create_event_source_response_handler` now reports it the same way. Well-formed JSON of the wrong shape stays `InvalidResponseData`. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5 --- CHANGELOG.md | 6 +++ aimux-provider-utils/src/response_handler.rs | 15 ++++++- .../tests/api_helpers_test.rs | 42 +++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14e800018..03e5806a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1180,6 +1180,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A streamed SSE event that is not JSON yields `JsonParse` reading + `JSON parsing failed: Text: .\nError message: `, + the AI SDK's `JSONParseError` message, as `@ai-sdk/openai-compatible` + 3.0.62 and the other AI SDK providers do; it carried only the parser + message. This covers every stream read through + `create_event_source_response_handler`. - Vertex AI Gemini models are the Google model reading the Vertex namespaces, as `@ai-sdk/google-vertex` reuses `GoogleLanguageModel`; the separate Vertex copy is removed (`VertexModel` is now an alias of diff --git a/aimux-provider-utils/src/response_handler.rs b/aimux-provider-utils/src/response_handler.rs index bcafb46b3..fe8fa24ec 100644 --- a/aimux-provider-utils/src/response_handler.rs +++ b/aimux-provider-utils/src/response_handler.rs @@ -350,6 +350,19 @@ pub fn create_binary_response_handler() -> ResponseHandler { }) } +/// One SSE `data:` field, as the AI SDK's `parseJsonEventStream` reads it +/// (`safeParseJSON`): text that is not JSON is a JSON parse error naming the +/// text, in the AI SDK's `JSONParseError` message +/// (`@ai-sdk/provider` `src/errors/json-parse-error.ts:16-19`). +fn parse_event(data: &str) -> Result { + serde_json::from_str(data).map_err(|error| match AiMuxError::from(error) { + AiMuxError::JsonParse(message) => AiMuxError::JsonParse(format!( + "JSON parsing failed: Text: {data}.\nError message: {message}" + )), + other => other, + }) +} + /// Parse an SSE response and deserialize each `data:` field as `T`. /// /// Invalid JSON in a complete event remains a recoverable frame error. SSE @@ -405,7 +418,7 @@ where }; match item { Ok(event) if event.data == "[DONE]" => continue, - Ok(event) => yield serde_json::from_str::(&event.data).map_err(AiMuxError::from), + Ok(event) => yield parse_event::(&event.data), Err(aimux_stream::SseError::Stream(error)) => { // Preserve response transport failures as ApiCallError // items, eligible for retry before output is emitted. diff --git a/aimux-provider-utils/tests/api_helpers_test.rs b/aimux-provider-utils/tests/api_helpers_test.rs index 6b3657146..aa8b5b4e2 100644 --- a/aimux-provider-utils/tests/api_helpers_test.rs +++ b/aimux-provider-utils/tests/api_helpers_test.rs @@ -207,6 +207,48 @@ async fn event_source_handler_deserializes_each_data_event() { ); } +/// An event that is not JSON yields the AI SDK's `JSONParseError` message +/// (`@ai-sdk/provider` `src/errors/json-parse-error.ts:16-19`), and the +/// stream goes on with the next event. +#[tokio::test] +async fn event_source_handler_reports_unparsable_events_like_the_ai_sdk() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(200).set_body_string( + "data: {unparsable}\n\ndata: {\"value\":\"two\"}\n\ndata: [DONE]\n\n", + ), + ) + .expect(1) + .mount(&server) + .await; + + let output = post_json_to_api( + request(server.uri()), + json!({}), + create_event_source_response_handler::(), + failed_response_handler(), + ) + .await + .unwrap(); + let items = output.value.collect::>().await; + + assert_eq!(items.len(), 2); + match &items[0] { + Err(AiMuxError::JsonParse(message)) => assert_eq!( + message, + "JSON parsing failed: Text: {unparsable}.\nError message: key must be a string at line 1 column 2" + ), + other => panic!("expected a JSON parse error, got {other:?}"), + } + assert_eq!( + items[1].as_ref().unwrap(), + &Reply { + value: "two".into() + } + ); +} + #[tokio::test] async fn raw_post_and_get_share_the_same_dispatch_contract() { let server = MockServer::start().await; From 7002bfadb96a2b2a162968acc66a57d9d36fdc31 Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 14:36:58 +0000 Subject: [PATCH 2/5] test: port the upstream OpenAI-compatible tests Ports the tests of @ai-sdk/openai-compatible 3.0.62 (RFC-0045) into aimux-providers/src/openai_compatible/tests/, one Rust file per upstream test file, one mod per describe level and an `/// upstream:` marker on every test: 266 tests for 266 of the 268 upstream cases. The other 2 are ts-only in aimux-providers/layout.toml (`[openai_compatible]`): they read the settings handed to mocked model constructors, which aimux's completion, embedding and image models never read. Upstream fixtures are copied unchanged into chat/fixtures/, the four `toMatchSnapshot` values become chat/snapshots/*.json. The model `config` fields (and `CompletionConfig`) are widened to `pub(super)` so the tests can read the configuration a model was built with. Boundary rule 4c lets the in-crate tests name `crate::test_support`. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5 --- aimux-providers/layout.toml | 191 + aimux-providers/src/lib.rs | 3 + aimux-providers/src/openai_compatible/chat.rs | 2 +- .../src/openai_compatible/completion.rs | 4 +- .../src/openai_compatible/embedding.rs | 2 +- .../src/openai_compatible/image.rs | 2 +- aimux-providers/src/openai_compatible/mod.rs | 3 + .../tests/chat/chat_content_array.rs | 165 + .../tests/chat/chat_language_model.rs | 4551 +++++++++++++++++ .../tests/chat/convert_chat_usage.rs | 77 + .../tests/chat/convert_to_chat_messages.rs | 2076 ++++++++ .../fixtures/anthropic-fallback-tool-call.sse | 17 + .../placeholder-first-chunk.chunks.txt | 4 + .../tests/chat/fixtures/xai-text.chunks.txt | 344 ++ .../tests/chat/fixtures/xai-text.json | 38 + .../chat/fixtures/xai-tool-call.chunks.txt | 230 + .../tests/chat/fixtures/xai-tool-call.json | 48 + .../src/openai_compatible/tests/chat/mod.rs | 5 + .../tests/chat/prepare_tools.rs | 365 ++ .../snapshots/do_generate_text_fixture.json | 104 + .../do_generate_tool_call_fixture.json | 116 + .../snapshots/do_stream_text_fixture.json | 1782 +++++++ .../do_stream_tool_call_fixture.json | 1219 +++++ .../completion/completion_language_model.rs | 922 ++++ .../openai_compatible/tests/completion/mod.rs | 1 + .../tests/embedding/embedding_model.rs | 362 ++ .../openai_compatible/tests/embedding/mod.rs | 1 + .../tests/image/image_model.rs | 845 +++ .../src/openai_compatible/tests/image/mod.rs | 1 + .../src/openai_compatible/tests/mod.rs | 6 + .../src/openai_compatible/tests/provider.rs | 413 ++ .../src/openai_compatible/tests/utils/mod.rs | 1 + .../tests/utils/to_camel_case.rs | 216 + aimux-providers/src/test_support/mod.rs | 4 + scripts/check_provider_boundaries.sh | 8 +- 35 files changed, 14119 insertions(+), 9 deletions(-) create mode 100644 aimux-providers/layout.toml create mode 100644 aimux-providers/src/openai_compatible/tests/chat/chat_content_array.rs create mode 100644 aimux-providers/src/openai_compatible/tests/chat/chat_language_model.rs create mode 100644 aimux-providers/src/openai_compatible/tests/chat/convert_chat_usage.rs create mode 100644 aimux-providers/src/openai_compatible/tests/chat/convert_to_chat_messages.rs create mode 100644 aimux-providers/src/openai_compatible/tests/chat/fixtures/anthropic-fallback-tool-call.sse create mode 100644 aimux-providers/src/openai_compatible/tests/chat/fixtures/placeholder-first-chunk.chunks.txt create mode 100644 aimux-providers/src/openai_compatible/tests/chat/fixtures/xai-text.chunks.txt create mode 100644 aimux-providers/src/openai_compatible/tests/chat/fixtures/xai-text.json create mode 100644 aimux-providers/src/openai_compatible/tests/chat/fixtures/xai-tool-call.chunks.txt create mode 100644 aimux-providers/src/openai_compatible/tests/chat/fixtures/xai-tool-call.json create mode 100644 aimux-providers/src/openai_compatible/tests/chat/mod.rs create mode 100644 aimux-providers/src/openai_compatible/tests/chat/prepare_tools.rs create mode 100644 aimux-providers/src/openai_compatible/tests/chat/snapshots/do_generate_text_fixture.json create mode 100644 aimux-providers/src/openai_compatible/tests/chat/snapshots/do_generate_tool_call_fixture.json create mode 100644 aimux-providers/src/openai_compatible/tests/chat/snapshots/do_stream_text_fixture.json create mode 100644 aimux-providers/src/openai_compatible/tests/chat/snapshots/do_stream_tool_call_fixture.json create mode 100644 aimux-providers/src/openai_compatible/tests/completion/completion_language_model.rs create mode 100644 aimux-providers/src/openai_compatible/tests/completion/mod.rs create mode 100644 aimux-providers/src/openai_compatible/tests/embedding/embedding_model.rs create mode 100644 aimux-providers/src/openai_compatible/tests/embedding/mod.rs create mode 100644 aimux-providers/src/openai_compatible/tests/image/image_model.rs create mode 100644 aimux-providers/src/openai_compatible/tests/image/mod.rs create mode 100644 aimux-providers/src/openai_compatible/tests/mod.rs create mode 100644 aimux-providers/src/openai_compatible/tests/provider.rs create mode 100644 aimux-providers/src/openai_compatible/tests/utils/mod.rs create mode 100644 aimux-providers/src/openai_compatible/tests/utils/to_camel_case.rs create mode 100644 aimux-providers/src/test_support/mod.rs diff --git a/aimux-providers/layout.toml b/aimux-providers/layout.toml new file mode 100644 index 000000000..7e8398681 --- /dev/null +++ b/aimux-providers/layout.toml @@ -0,0 +1,191 @@ +# Upstream file map for aimux-providers (RFC-0046 §4.4, RFC-0045 §3). +# One table per vendor with an upstream package; versions come from +# fixtures/aisdk/VERSIONS.json. `rust` paths are relative to src/. + +[openai_compatible] +package = "@ai-sdk/openai-compatible" +# chat/openai-compatible-chat-content-array.test.ts has no source file of its own name; +# every case in it is ported, so it needs no exclusions. + +[[openai_compatible.files]] +upstream = "src/chat/convert-openai-compatible-chat-usage.ts" +rust = "openai_compatible/chat.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/chat/convert-to-openai-compatible-chat-messages.ts" +rust = "openai_compatible/convert.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/chat/get-response-metadata.ts" +rust = "openai_compatible/chat.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/chat/map-openai-compatible-finish-reason.ts" +rust = "openai_compatible/convert.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/chat/openai-compatible-api-types.ts" +rust = "openai_compatible/types.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/chat/openai-compatible-chat-language-model-options.ts" +rust = "openai_compatible/config.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/chat/openai-compatible-chat-language-model.ts" +rust = "openai_compatible/chat.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/chat/openai-compatible-metadata-extractor.ts" +rust = "openai_compatible/config.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/chat/openai-compatible-prepare-tools.ts" +rust = "openai_compatible/convert.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/completion/convert-openai-compatible-completion-usage.ts" +rust = "openai_compatible/completion.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/completion/convert-to-openai-compatible-completion-prompt.ts" +rust = "openai_compatible/completion.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/completion/get-response-metadata.ts" +rust = "openai_compatible/completion.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/completion/map-openai-compatible-finish-reason.ts" +rust = "openai_compatible/convert.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/completion/openai-compatible-completion-language-model-options.ts" +rust = "openai_compatible/completion.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/completion/openai-compatible-completion-language-model.ts" +rust = "openai_compatible/completion.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/embedding/openai-compatible-embedding-model-options.ts" +rust = "openai_compatible/embedding.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/embedding/openai-compatible-embedding-model.ts" +rust = "openai_compatible/embedding.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/image/openai-compatible-image-model-options.ts" +rust = "openai_compatible/image.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/image/openai-compatible-image-model.ts" +rust = "openai_compatible/image.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/image/openai-compatible-image-settings.ts" +rust = "openai_compatible/image.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/index.ts" +rust = "openai_compatible/mod.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/internal/index.ts" +rust = "openai_compatible/mod.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/openai-compatible-error.ts" +rust = "openai_compatible/config.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/openai-compatible-provider.ts" +rust = "openai_compatible/mod.rs" +status = "mapped" + +[openai_compatible.files.tests] +ts-only = [ + { case = "OpenAICompatibleProvider › supportsStructuredOutputs setting › should pass supportsStructuredOutputs to .chatModel() and .languageModel() only", reason = "checks the settings handed to the mocked model constructors; aimux's completion, embedding and image models share the provider's chat settings and never read supportsStructuredOutputs, so nothing observable differs" }, + { case = "OpenAICompatibleProvider › supportsMultiPartToolContent setting › should pass supportsMultiPartToolContent to chat models only", reason = "checks the settings handed to the mocked model constructors; aimux's completion, embedding and image models share the provider's chat settings and never read supportsMultiPartToolContent, so nothing observable differs" }, +] + +[[openai_compatible.files]] +upstream = "src/utils/to-camel-case.ts" +rust = "openai_compatible/convert.rs" +status = "mapped" + +[openai_compatible.files.tests] + +[[openai_compatible.files]] +upstream = "src/version.ts" +status = "not-applicable" diff --git a/aimux-providers/src/lib.rs b/aimux-providers/src/lib.rs index 970691328..e8eb8d125 100644 --- a/aimux-providers/src/lib.rs +++ b/aimux-providers/src/lib.rs @@ -403,3 +403,6 @@ mod decision_support; pub use decision_support::DecisionProbabilitySource; mod runtime_decision; mod systemone; + +#[cfg(test)] +mod test_support; diff --git a/aimux-providers/src/openai_compatible/chat.rs b/aimux-providers/src/openai_compatible/chat.rs index b85b466e3..e58702804 100644 --- a/aimux-providers/src/openai_compatible/chat.rs +++ b/aimux-providers/src/openai_compatible/chat.rs @@ -32,7 +32,7 @@ use crate::stream::{ /// of `aimux-provider-utils` use the injected or process-default transport. pub struct OpenAICompatibleChatModel { model_id: String, - config: CompatModelConfig, + pub(super) config: CompatModelConfig, } impl OpenAICompatibleChatModel { diff --git a/aimux-providers/src/openai_compatible/completion.rs b/aimux-providers/src/openai_compatible/completion.rs index 94bc61d34..b0b14c60a 100644 --- a/aimux-providers/src/openai_compatible/completion.rs +++ b/aimux-providers/src/openai_compatible/completion.rs @@ -25,7 +25,7 @@ use super::convert::{parse_finish_reason, to_camel_case}; use crate::openai::config::OpenAIModelConfig; #[derive(Clone)] -enum CompletionConfig { +pub(super) enum CompletionConfig { Compatible(CompatModelConfig), Native(OpenAIModelConfig), } @@ -34,7 +34,7 @@ enum CompletionConfig { #[derive(Clone)] pub struct OpenAICompatibleCompletionModel { model_id: String, - config: CompletionConfig, + pub(super) config: CompletionConfig, } impl OpenAICompatibleCompletionModel { diff --git a/aimux-providers/src/openai_compatible/embedding.rs b/aimux-providers/src/openai_compatible/embedding.rs index baec1c17e..410202c49 100644 --- a/aimux-providers/src/openai_compatible/embedding.rs +++ b/aimux-providers/src/openai_compatible/embedding.rs @@ -20,7 +20,7 @@ use super::convert::to_camel_case; /// An OpenAI-compatible embedding model. pub struct OpenAICompatibleEmbeddingModel { model_id: String, - config: CompatModelConfig, + pub(super) config: CompatModelConfig, } impl OpenAICompatibleEmbeddingModel { diff --git a/aimux-providers/src/openai_compatible/image.rs b/aimux-providers/src/openai_compatible/image.rs index 580807dd9..0b2618fd3 100644 --- a/aimux-providers/src/openai_compatible/image.rs +++ b/aimux-providers/src/openai_compatible/image.rs @@ -24,7 +24,7 @@ use super::convert::to_camel_case; /// An OpenAI-compatible image generation / editing model. pub struct OpenAICompatibleImageModel { model_id: String, - config: CompatModelConfig, + pub(super) config: CompatModelConfig, } impl OpenAICompatibleImageModel { diff --git a/aimux-providers/src/openai_compatible/mod.rs b/aimux-providers/src/openai_compatible/mod.rs index daed7a72a..07bac0fae 100644 --- a/aimux-providers/src/openai_compatible/mod.rs +++ b/aimux-providers/src/openai_compatible/mod.rs @@ -363,3 +363,6 @@ impl ProviderDiscovery for OpenAICompatibleProvider { Box::pin(async move { chat::list_models_once(&config).await }) } } + +#[cfg(test)] +mod tests; diff --git a/aimux-providers/src/openai_compatible/tests/chat/chat_content_array.rs b/aimux-providers/src/openai_compatible/tests/chat/chat_content_array.rs new file mode 100644 index 000000000..ef947c26b --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/chat/chat_content_array.rs @@ -0,0 +1,165 @@ +use futures::StreamExt; +use serde_json::{Value, json}; + +use aimux_core::language_model::LanguageModel; +use aimux_core::options::CallOptions; + +use crate::openai_compatible::{OpenAICompatibleProviderSettings, create_openai_compatible}; +use crate::test_support::mock_fetch::{Canned, MockFetch}; + +fn sse(chunks: &[String]) -> Canned { + Canned { + status: 200, + headers: vec![("content-type".into(), "text/event-stream".into())], + body: chunks.concat().into_bytes(), + } +} + +fn data(value: &Value) -> String { + format!("data: {value}\n\n") +} + +fn prompt() -> CallOptions { + serde_json::from_value(json!({ + "prompt": [ + { + "role": "user", + "content": [{ "type": "text", "text": "What is 17 * 23?" }], + }, + ], + })) + .expect("call options") +} + +/// `model.doStream({ prompt })` against the mock transport; every event in its +/// JSON form, a stream error as an `error` event. +async fn do_stream(chunks: &[String]) -> Vec { + let mock = MockFetch::new(vec![sse(chunks)]); + let model = create_openai_compatible(OpenAICompatibleProviderSettings { + base_url: "https://my.api.com/v1".to_string(), + name: "test-provider".to_string(), + fetch: Some(mock.transport()), + ..Default::default() + }) + .expect("provider") + .chat("test-model"); + let result = model.do_stream(&prompt()).await.expect("stream"); + let events = result + .stream + .map(|part| match part { + Ok(part) => serde_json::to_value(part).expect("part serializes"), + Err(error) => json!({ "type": "error", "error": error.to_string() }), + }) + .collect::>() + .await; + let seen = mock.seen(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].url, "https://my.api.com/v1/chat/completions"); + events +} + +fn of_type(events: &[Value], keep: impl Fn(&str) -> bool) -> Vec { + events + .iter() + .filter(|event| event["type"].as_str().is_some_and(&keep)) + .cloned() + .collect() +} + +/// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-content-array.test.ts › should stream text and thinking content parts +#[tokio::test] +async fn should_stream_text_and_thinking_content_parts() { + let events = do_stream(&[ + data(&json!({ + "choices": [ + { + "index": 0, + "delta": { + "content": [ + { + "type": "thinking", + "thinking": [{ "type": "text", "text": "Let me think." }], + }, + { "type": "text", "text": "The answer is 391." }, + ], + }, + "finish_reason": null, + }, + ], + })), + data(&json!({ + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": "stop", + }, + ], + })), + "data: [DONE]\n\n".to_string(), + ]) + .await; + + assert_eq!(of_type(&events, |t| t == "error"), Vec::::new()); + assert_eq!( + of_type(&events, |t| t.ends_with("delta")), + vec![ + json!({ + "type": "reasoning-delta", + "id": "reasoning-0", + "delta": "Let me think.", + }), + json!({ + "type": "text-delta", + "id": "txt-0", + "delta": "The answer is 391.", + }), + ] + ); +} + +/// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-content-array.test.ts › should ignore unknown streamed content parts +#[tokio::test] +async fn should_ignore_unknown_streamed_content_parts() { + let events = do_stream(&[ + data(&json!({ + "choices": [ + { + "index": 0, + "delta": { + "content": [ + { + "type": "future-part", + "text": { "nested": true }, + "thinking": { "nested": true }, + }, + { "type": "text", "text": "The answer is 391." }, + ], + }, + "finish_reason": null, + }, + ], + })), + data(&json!({ + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": "stop", + }, + ], + })), + "data: [DONE]\n\n".to_string(), + ]) + .await; + + assert_eq!(of_type(&events, |t| t == "error"), Vec::::new()); + assert_eq!( + of_type(&events, |t| t == "text-delta"), + vec![json!({ + "type": "text-delta", + "id": "txt-0", + "delta": "The answer is 391.", + })] + ); +} diff --git a/aimux-providers/src/openai_compatible/tests/chat/chat_language_model.rs b/aimux-providers/src/openai_compatible/tests/chat/chat_language_model.rs new file mode 100644 index 000000000..503212a53 --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/chat/chat_language_model.rs @@ -0,0 +1,4551 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use futures::StreamExt; +use serde_json::{Map, Value, json}; + +use aimux_core::error::AiMuxError; +use aimux_core::language_model::LanguageModel; +use aimux_core::language_model_message::{LanguageModelMessage, UserPart}; +use aimux_core::options::CallOptions; +use aimux_core::result::{GenerateResult, StreamResult}; +use aimux_core::shared::{FileBytes, FileData, provider_namespace}; +use aimux_core::stream_part::StreamPart; +use aimux_core::types::ProviderMetadata; + +use crate::openai_compatible::config::{BaseUrl, ChatSettings, CompatModelConfig}; +use crate::openai_compatible::{ + MetadataExtractor, OpenAICompatibleChatModel, OpenAICompatibleProvider, + OpenAICompatibleProviderSettings, StreamMetadataExtractor, create_openai_compatible, +}; +use crate::shared::{AuthScheme, Credential, ProviderHeaders}; +use crate::test_support::mock_fetch::{Canned, MockFetch, Seen}; + +/// The user agent aimux pins (upstream's test server sees `0.0.0-test` +/// versions and its header snapshots leave the header out). +const USER_AGENT: &str = "ai-sdk-openai-compatible/3.0.62 ai-sdk-provider-utils/5.0.53"; + +const TEST_SCHEMA_VALUE: &str = r#"{"type":"object","properties":{"value":{"type":"string"}},"required":["value"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}"#; + +/// `TEST_PROMPT`. +fn test_prompt() -> Value { + json!([{ "role": "user", "content": [{ "type": "text", "text": "Hello" }] }]) +} + +/// `LanguageModelV4CallOptions` from their JSON form; a missing `prompt` is +/// `TEST_PROMPT`. +fn call_options(mut options: Value) -> CallOptions { + options + .as_object_mut() + .expect("call options are an object") + .entry("prompt") + .or_insert_with(test_prompt); + serde_json::from_value(options).expect("valid call options") +} + +/// `{ type: 'function', name, inputSchema }`. +fn function_tool(name: &str, input_schema: Value) -> Value { + json!({ "type": "function", "name": name, "inputSchema": input_schema }) +} + +/// The `{ value: string }` input schema most tool tests use. +fn value_schema() -> Value { + serde_json::from_str(TEST_SCHEMA_VALUE).unwrap() +} + +/// `createOpenAICompatible({ baseURL: 'https://my.api.com/v1/', name: +/// 'test-provider' })` on the mock transport; the struct-update base of +/// every provider and model below. +fn settings(fetch: &Arc) -> OpenAICompatibleProviderSettings { + OpenAICompatibleProviderSettings { + name: "test-provider".into(), + base_url: "https://my.api.com/v1/".into(), + fetch: Some(fetch.transport()), + ..Default::default() + } +} + +fn headers(pairs: &[(&str, &str)]) -> HashMap> { + pairs + .iter() + .map(|(name, value)| ((*name).to_string(), Some((*value).to_string()))) + .collect() +} + +/// The file-level `provider` (with `Authorization: Bearer test-api-key`). +fn provider(fetch: &Arc) -> OpenAICompatibleProvider { + create_openai_compatible(OpenAICompatibleProviderSettings { + headers: Some(headers(&[("Authorization", "Bearer test-api-key")])), + ..settings(fetch) + }) + .unwrap() +} + +/// The file-level `model` (`provider('grok-3')`). +fn model(fetch: &Arc) -> OpenAICompatibleChatModel { + provider(fetch).chat("grok-3") +} + +/// `new OpenAICompatibleChatLanguageModel(modelId, { provider: +/// 'test-provider', url: () => 'https://my.api.com/v1/chat/completions', +/// headers: () => ({}), ...config })`: the same config through the factory. +fn direct_model( + model_id: &str, + settings: OpenAICompatibleProviderSettings, +) -> OpenAICompatibleChatModel { + create_openai_compatible(settings).unwrap().chat(model_id) +} + +/// A `json-value` response of the test server: `content-type` and +/// `content-length` of the serialized body, then `headers`. +fn json_value(body: &Value, extra_headers: &[(&str, &str)]) -> Canned { + let body = serde_json::to_vec(body).unwrap(); + let mut headers = vec![ + ("content-type".to_string(), "application/json".to_string()), + ("content-length".to_string(), body.len().to_string()), + ]; + headers.extend( + extra_headers + .iter() + .map(|(name, value)| ((*name).to_string(), (*value).to_string())), + ); + Canned { + status: 200, + headers, + body, + } +} + +/// A `stream-chunks` response of the test server. +fn stream_chunks>(chunks: &[S], extra_headers: &[(&str, &str)]) -> Canned { + let mut headers = vec![ + ("content-type".to_string(), "text/event-stream".to_string()), + ("cache-control".to_string(), "no-cache".to_string()), + ("connection".to_string(), "keep-alive".to_string()), + ]; + headers.extend( + extra_headers + .iter() + .map(|(name, value)| ((*name).to_string(), (*value).to_string())), + ); + Canned { + status: 200, + headers, + body: chunks + .iter() + .map(AsRef::as_ref) + .collect::() + .into_bytes(), + } +} + +fn fixture(filename: &str) -> &'static str { + match filename { + "xai-text.json" => include_str!("fixtures/xai-text.json"), + "xai-tool-call.json" => include_str!("fixtures/xai-tool-call.json"), + "xai-text.chunks.txt" => include_str!("fixtures/xai-text.chunks.txt"), + "xai-tool-call.chunks.txt" => include_str!("fixtures/xai-tool-call.chunks.txt"), + "placeholder-first-chunk.chunks.txt" => { + include_str!("fixtures/placeholder-first-chunk.chunks.txt") + } + "anthropic-fallback-tool-call.sse" => { + include_str!("fixtures/anthropic-fallback-tool-call.sse") + } + other => panic!("no fixture {other}"), + } +} + +/// `prepareJsonFixtureResponse`. +fn prepare_json_fixture_response(filename: &str, extra_headers: &[(&str, &str)]) -> Canned { + let body: Value = serde_json::from_str(fixture(&format!("{filename}.json"))).unwrap(); + json_value(&body, extra_headers) +} + +/// `prepareChunksFixtureResponse`. +fn prepare_chunks_fixture_response(filename: &str, extra_headers: &[(&str, &str)]) -> Canned { + let mut chunks: Vec = fixture(&format!("{filename}.chunks.txt")) + .split('\n') + .filter(|line| !line.trim().is_empty()) + .map(|line| format!("data: {line}\n\n")) + .collect(); + chunks.push("data: [DONE]\n\n".to_string()); + stream_chunks(&chunks, extra_headers) +} + +/// `prepareSseFixtureResponse`. +fn prepare_sse_fixture_response(filename: &str) -> Canned { + stream_chunks(&[fixture(&format!("{filename}.sse"))], &[]) +} + +/// A `toMatchSnapshot()` value of a `doGenerate` result. Upstream's +/// `request.body` is the JSON string that was sent; aimux keeps the body as +/// the JSON value, so the snapshot's string is compared parsed. +fn generate_snapshot(text: &str) -> Value { + let mut snapshot: Value = serde_json::from_str(text).unwrap(); + if let Some(body) = snapshot.pointer_mut("/request/body") + && let Some(text) = body.as_str() + { + *body = serde_json::from_str(text).unwrap(); + } + snapshot +} + +fn result_json(result: &GenerateResult) -> Value { + serde_json::to_value(result).unwrap() +} + +fn request_body(fetch: &MockFetch) -> Value { + fetch.seen()[0].json_body() +} + +/// `server.calls[0].requestHeaders` without `user-agent`, which is checked +/// on its own against the pinned value. +fn request_headers(seen: &Seen) -> Value { + let mut headers = seen.headers.clone(); + assert_eq!(headers.remove("user-agent").as_deref(), Some(USER_AGENT)); + serde_json::to_value(headers).unwrap() +} + +/// The error of an error part as the snapshots show it: upstream passes an +/// error chunk's `error` object on as it is (aimux's `ApiCallError.data`); +/// thrown errors are `[AI_: ]`, here `{ name, message }`. +fn error_json(error: &AiMuxError) -> Value { + match error { + AiMuxError::ApiCall(detail) => detail.data.clone().unwrap_or(Value::Null), + AiMuxError::InvalidResponseData(message) => { + json!({ "name": "AI_InvalidResponseDataError", "message": message }) + } + AiMuxError::JsonParse(message) => { + json!({ "name": "AI_JSONParseError", "message": message }) + } + other => json!({ "name": format!("{other:?}"), "message": other.to_string() }), + } +} + +fn part_json(part: StreamPart) -> Value { + match part { + StreamPart::Error { error } => json!({ "type": "error", "error": error_json(&error) }), + part => serde_json::to_value(part).unwrap(), + } +} + +/// `convertReadableStreamToArray`: rejects with the error the stream ends +/// with. +async fn convert_stream_to_array(result: StreamResult) -> Result, AiMuxError> { + let mut stream = result.stream; + let mut parts = Vec::new(); + while let Some(item) = stream.next().await { + parts.push(part_json(item?)); + } + Ok(parts) +} + +async fn stream_parts(result: StreamResult) -> Vec { + convert_stream_to_array(result).await.unwrap() +} + +fn of_type<'a>(parts: &'a [Value], kind: &str) -> Vec<&'a Value> { + parts.iter().filter(|part| part["type"] == kind).collect() +} + +fn find<'a>(parts: &'a [Value], kind: &str) -> &'a Value { + parts + .iter() + .find(|part| part["type"] == kind) + .unwrap_or_else(|| panic!("no {kind} part")) +} + +/// `toMatchObject`: every property of `expected` is in `actual` and matches +/// recursively; arrays match element by element and have the same length. +#[track_caller] +fn assert_match_object(actual: &Value, expected: &Value) { + fn matches(actual: &Value, expected: &Value) -> bool { + match (actual, expected) { + (Value::Object(actual), Value::Object(expected)) => expected + .iter() + .all(|(key, value)| actual.get(key).is_some_and(|a| matches(a, value))), + (Value::Array(actual), Value::Array(expected)) => { + actual.len() == expected.len() + && actual.iter().zip(expected).all(|(a, e)| matches(a, e)) + } + (actual, expected) => actual == expected, + } + } + assert!( + matches(actual, expected), + "toMatchObject failed\n actual: {actual:#}\nexpected: {expected:#}" + ); +} + +/// `prepareJsonResponse` of the `doGenerate` block: `overrides` holds the +/// named arguments that differ from upstream's defaults. +fn prepare_json_response(overrides: Value) -> Canned { + let get = |key: &str, default: Value| overrides.get(key).cloned().unwrap_or(default); + let mut message = Map::new(); + message.insert("role".into(), json!("assistant")); + message.insert("content".into(), get("content", json!(""))); + message.insert( + "reasoning_content".into(), + get("reasoning_content", json!("")), + ); + message.insert("reasoning".into(), get("reasoning", json!(""))); + for key in ["tool_calls", "function_call"] { + if let Some(value) = overrides.get(key) { + message.insert(key.into(), value.clone()); + } + } + json_value( + &json!({ + "id": get("id", json!("chatcmpl-95ZTZkhr0mHNKqerQfiwkuox3PHAd")), + "object": "chat.completion", + "created": get("created", json!(1711115037)), + "model": get("model", json!("grok-3")), + "choices": [{ + "index": 0, + "message": message, + "finish_reason": get("finish_reason", json!("stop")), + }], + "usage": get("usage", json!({ "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 })), + "system_fingerprint": "fp_3bc1b5746c", + }), + &[], + ) +} + +/// `prepareStreamResponse` of the `doStream` block. +fn prepare_stream_response(content: &[&str], finish_reason: &str) -> Canned { + const PREFIX: &str = r#"data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1702657020,"model":"grok-3","#; + let mut chunks = vec![ + format!( + r#"{PREFIX}"system_fingerprint":null,"choices":[{{"index":0,"delta":{{"role":"assistant","content":""}},"finish_reason":null}}]}}"# + ) + "\n\n", + ]; + for text in content { + chunks.push( + format!( + r#"{PREFIX}"system_fingerprint":null,"choices":[{{"index":1,"delta":{{"content":"{text}"}},"finish_reason":null}}]}}"# + ) + "\n\n", + ); + } + chunks.push( + format!( + r#"{PREFIX}"system_fingerprint":null,"choices":[{{"index":0,"delta":{{}},"finish_reason":"{finish_reason}"}}]}}"# + ) + "\n\n", + ); + chunks.push( + format!( + concat!( + r#"data: {{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1729171479,"model":"grok-3","#, + r#""system_fingerprint":"fp_10c08bf97d","choices":[{{"index":0,"delta":{{}},"finish_reason":"{finish_reason}"}}],"#, + r#""usage":{{"queue_time":0.061348671,"prompt_tokens":18,"prompt_time":0.000211569,"#, + r#""completion_tokens":439,"completion_time":0.798181818,"total_tokens":457,"total_time":0.798393387}}}}"# + ), + finish_reason = finish_reason + ) + "\n\n", + ); + chunks.push("data: [DONE]\n\n".to_string()); + stream_chunks(&chunks, &[]) +} + +/// `data: \n\n` for every chunk, as the test bodies spell them. +fn data(chunks: &[&str]) -> Vec { + chunks + .iter() + .map(|chunk| format!("data: {chunk}\n\n")) + .collect() +} + +/// `data(chunks)` followed by `data: [DONE]`. +fn data_done(chunks: &[&str]) -> Vec { + let mut all = data(chunks); + all.push("data: [DONE]\n\n".to_string()); + all +} + +fn json_mock(body: Value) -> Arc { + MockFetch::new(vec![json_value(&body, &[])]) +} + +fn stream_mock(chunks: &[&str]) -> Arc { + MockFetch::new(vec![stream_chunks(&data_done(chunks), &[])]) +} + +/// `new OpenAICompatibleChatLanguageModel('gpt-5', { provider, url: () => '', +/// headers: () => ({}) })`. +fn config_model(provider: &str) -> OpenAICompatibleChatModel { + OpenAICompatibleChatModel::from_config( + "gpt-5".to_string(), + CompatModelConfig { + provider: provider.to_string(), + base_url: BaseUrl::Fixed(String::new()), + query_params: None, + headers: ProviderHeaders::new(Credential::None, AuthScheme::Bearer, Vec::new(), None), + fetch_headers: None, + fetch: None, + transform_request_body: None, + chat: ChatSettings::default(), + }, + ) +} + +/// The `testMetadataExtractor` of the `metadata extraction` block. +struct TestMetadataExtractor; + +impl MetadataExtractor for TestMetadataExtractor { + fn extract_metadata(&self, parsed_body: &Value) -> Option { + let value = parsed_body.as_object()?.get("test_field")?; + Some(provider_namespace("test-provider", json!({ "value": value })).unwrap()) + } + + fn create_stream_extractor(&self) -> Box { + Box::new(TestStreamMetadataExtractor { + accumulated_value: None, + }) + } +} + +struct TestStreamMetadataExtractor { + accumulated_value: Option, +} + +impl StreamMetadataExtractor for TestStreamMetadataExtractor { + fn process_chunk(&mut self, chunk: &Value) { + let stops = chunk + .get("choices") + .and_then(Value::as_array) + .and_then(|choices| choices.first()) + .and_then(|choice| choice.get("finish_reason")) + == Some(&json!("stop")); + if stops && let Some(value) = chunk.get("test_field") { + self.accumulated_value = Some(value.clone()); + } + } + + fn build_metadata(&self) -> Option { + // `accumulatedValue ? { ... } : undefined` + let value = self + .accumulated_value + .as_ref() + .filter(|value| value.as_str().is_none_or(|text| !text.is_empty()))?; + Some(provider_namespace("test-provider", json!({ "value": value })).unwrap()) + } +} + +mod config { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › config › should extract base name from provider string + #[test] + fn should_extract_base_name_from_provider_string() { + let model = config_model("anthropic.beta"); + + assert_eq!(model.config.provider_options_name(), "anthropic"); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › config › should handle provider without dot notation + #[test] + fn should_handle_provider_without_dot_notation() { + let model = config_model("openai"); + + assert_eq!(model.config.provider_options_name(), "openai"); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › config › should return empty for empty provider + #[test] + fn should_return_empty_for_empty_provider() { + let model = config_model(""); + + assert_eq!(model.config.provider_options_name(), ""); + } +} + +mod do_generate { + use super::*; + + mod text_fixture { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › text (fixture) › should extract text content + #[tokio::test] + async fn should_extract_text_content() { + let fetch = MockFetch::new(vec![prepare_json_fixture_response("xai-text", &[])]); + + let result = model(&fetch) + .do_generate(&call_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + result_json(&result), + generate_snapshot(include_str!("snapshots/do_generate_text_fixture.json")) + ); + } + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should reject a response without choices + #[tokio::test] + async fn should_reject_a_response_without_choices() { + let fetch = json_mock(json!({ + "id": "chatcmpl-empty", + "object": "chat.completion", + "created": 1711115037, + "model": "grok-3", + "choices": [], + "usage": { "prompt_tokens": 4, "total_tokens": 4, "completion_tokens": 0 }, + })); + + let error = model(&fetch) + .do_generate(&call_options(json!({}))) + .await + .unwrap_err(); + + assert!( + matches!(&error, AiMuxError::InvalidResponseData(message) if message == "Response did not contain any choices."), + "{error:?}" + ); + } + + mod tool_call_fixture { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › tool call (fixture) › should extract tool call content + #[tokio::test] + async fn should_extract_tool_call_content() { + let fetch = MockFetch::new(vec![prepare_json_fixture_response("xai-tool-call", &[])]); + + let result = model(&fetch) + .do_generate(&call_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + result_json(&result), + generate_snapshot(include_str!("snapshots/do_generate_tool_call_fixture.json")) + ); + } + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should generate distinct IDs for parallel tool calls with empty IDs + #[tokio::test] + async fn should_generate_distinct_ids_for_parallel_tool_calls_with_empty_ids() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ + "finish_reason": "tool_calls", + "tool_calls": [ + { "id": "", "type": "function", "function": { "name": "first", "arguments": "{\"value\":1}" } }, + { "id": "", "type": "function", "function": { "name": "second", "arguments": "{\"value\":2}" } }, + ], + }))]); + + let result = model(&fetch) + .do_generate(&call_options(json!({}))) + .await + .unwrap(); + let content = result_json(&result)["content"].clone(); + let tool_call_ids: Vec = content + .as_array() + .unwrap() + .iter() + .filter(|part| part["type"] == "tool-call") + .map(|part| part["toolCallId"].as_str().unwrap().to_string()) + .collect(); + + assert_eq!(tool_call_ids.len(), 2); + assert!(tool_call_ids.iter().all(|id| !id.is_empty())); + assert_eq!( + tool_call_ids + .iter() + .collect::>() + .len(), + 2 + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should extract usage + #[tokio::test] + async fn should_extract_usage() { + let fetch = MockFetch::new(vec![prepare_json_fixture_response("xai-text", &[])]); + + let result = model(&fetch) + .do_generate(&call_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + result_json(&result)["usage"], + json!({ + "inputTokens": { + "cacheRead": 2, + "noCache": 10, + "total": 12 + }, + "outputTokens": { + "reasoning": 320, + "text": 0, + "total": 2 + }, + "raw": { + "completion_tokens": 2, + "completion_tokens_details": { + "accepted_prediction_tokens": 0, + "audio_tokens": 0, + "reasoning_tokens": 320, + "rejected_prediction_tokens": 0 + }, + "cost_in_usd_ticks": 1641500, + "num_sources_used": 0, + "prompt_tokens": 12, + "prompt_tokens_details": { + "audio_tokens": 0, + "cached_tokens": 2, + "image_tokens": 0, + "text_tokens": 12 + }, + "total_tokens": 334 + } + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should send additional response information + #[tokio::test] + async fn should_send_additional_response_information() { + let fetch = MockFetch::new(vec![prepare_json_fixture_response("xai-text", &[])]); + + let result = model(&fetch) + .do_generate(&call_options(json!({}))) + .await + .unwrap(); + let response = result.response.unwrap(); + + assert_eq!( + response.id.as_deref(), + Some("edea4703-19aa-6d74-fedb-dc1c213543e0") + ); + assert_eq!( + response.timestamp.map(|timestamp| timestamp.to_string()), + Some("2026-02-11T01:08:10.000Z".to_string()) + ); + assert_eq!(response.model_id.as_deref(), Some("grok-3-mini")); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should expose the raw response headers + #[tokio::test] + async fn should_expose_the_raw_response_headers() { + let fetch = MockFetch::new(vec![prepare_json_fixture_response( + "xai-text", + &[("test-header", "test-value")], + )]); + + let result = model(&fetch) + .do_generate(&call_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + result_json(&result)["response"]["headers"], + json!({ + "content-length": "2053", + "content-type": "application/json", + "test-header": "test-value" + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should pass user setting to requests + #[tokio::test] + async fn should_pass_user_setting_to_requests() { + let fetch = MockFetch::new(vec![prepare_json_response( + json!({ "content": "Hello, World!" }), + )]); + let model_with_user = provider(&fetch).chat("grok-3"); + + model_with_user + .do_generate(&call_options(json!({ + "providerOptions": { "xai": { "user": "test-user-id" } }, + }))) + .await + .unwrap(); + + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "grok-3" + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should extract reasoning from reasoning field when reasoning_content is not provided + #[tokio::test] + async fn should_extract_reasoning_from_reasoning_field_when_reasoning_content_is_not_provided() + { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ + "content": "Hello, World!", + "reasoning": "This is the reasoning from the reasoning field", + }))]); + + let result = model(&fetch) + .do_generate(&call_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + result_json(&result)["content"], + json!([ + { + "text": "Hello, World!", + "type": "text" + } + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should prefer reasoning_content over reasoning field when both are provided + #[tokio::test] + async fn should_prefer_reasoning_content_over_reasoning_field_when_both_are_provided() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ + "content": "Hello, World!", + "reasoning_content": "This is from reasoning_content", + "reasoning": "This is from reasoning field", + }))]); + + let result = model(&fetch) + .do_generate(&call_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + result_json(&result)["content"], + json!([ + { + "text": "Hello, World!", + "type": "text" + }, + { + "text": "This is from reasoning_content", + "type": "reasoning" + } + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should normalize text and thinking content parts + #[tokio::test] + async fn should_normalize_text_and_thinking_content_parts() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ + "content": [ + { + "type": "thinking", + "thinking": [ + { "type": "text", "text": "Let me think" }, + { "type": "text", "text": " this through." }, + ], + }, + { "type": "text", "text": "The answer is 391." }, + ], + }))]); + + let result = model(&fetch) + .do_generate(&call_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + result_json(&result)["content"], + json!([ + { "type": "reasoning", "text": "Let me think this through." }, + { "type": "text", "text": "The answer is 391." }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should ignore unknown content parts + #[tokio::test] + async fn should_ignore_unknown_content_parts() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ + "content": [ + { + "type": "future-part", + "text": { "nested": true }, + "thinking": { "nested": true }, + }, + { "type": "text", "text": "The answer is 391." }, + ], + }))]); + + let result = model(&fetch) + .do_generate(&call_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + result_json(&result)["content"], + json!([{ "type": "text", "text": "The answer is 391." }]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should support partial usage + #[tokio::test] + async fn should_support_partial_usage() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ + "usage": { "prompt_tokens": 20, "total_tokens": 20 }, + }))]); + + let result = model(&fetch) + .do_generate(&call_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + result_json(&result)["usage"], + json!({ + "inputTokens": { + "cacheRead": 0, + "noCache": 20, + "total": 20 + }, + "outputTokens": { + "reasoning": 0, + "text": 0, + "total": 0 + }, + "raw": { + "prompt_tokens": 20, + "total_tokens": 20 + } + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should support unknown finish reason + #[tokio::test] + async fn should_support_unknown_finish_reason() { + let fetch = MockFetch::new(vec![prepare_json_response( + json!({ "finish_reason": "eos" }), + )]); + + let response = model(&fetch) + .do_generate(&call_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + result_json(&response)["finishReason"], + json!({ + "raw": "eos", + "unified": "other" + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should pass the model and the messages + #[tokio::test] + async fn should_pass_the_model_and_the_messages() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ "content": "" }))]); + + model(&fetch) + .do_generate(&call_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "grok-3" + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should pass video input as video_url in doGenerate + #[tokio::test] + async fn should_pass_video_input_as_video_url_in_do_generate() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ "content": "" }))]); + let mut options = call_options(json!({ + "prompt": [{ + "role": "user", + "content": [ + { "type": "text", "text": "Describe this video." }, + { "type": "file", "data": { "type": "data", "data": "" }, "mediaType": "video/mp4" }, + ], + }], + })); + // `data: new Uint8Array([0, 1, 2, 3])` + if let LanguageModelMessage::User { content, .. } = &mut options.prompt[0] + && let UserPart::File(file) = &mut content[1] + { + file.data = FileData::Data { + data: FileBytes::Binary(vec![0, 1, 2, 3]), + }; + } + + model(&fetch).do_generate(&options).await.unwrap(); + + assert_match_object( + &request_body(&fetch), + &json!({ + "messages": [{ + "role": "user", + "content": [ + { "type": "text", "text": "Describe this video." }, + { "type": "video_url", "video_url": { "url": "data:video/mp4;base64,AAECAw==" } }, + ], + }], + }), + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should pass multi-part tool content when supported + #[tokio::test] + async fn should_pass_multi_part_tool_content_when_supported() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ "content": "" }))]); + let multi_part_provider = create_openai_compatible(OpenAICompatibleProviderSettings { + supports_multi_part_tool_content: Some(true), + ..settings(&fetch) + }) + .unwrap(); + + multi_part_provider + .chat("grok-3") + .do_generate(&call_options(json!({ + "prompt": [{ + "role": "tool", + "content": [{ + "type": "tool-result", + "toolCallId": "call-1", + "toolName": "useImage", + "output": { + "type": "content", + "value": [ + { "type": "text", "text": "image result" }, + { + "type": "file", + "data": { "type": "data", "data": "iVBORw0KGgo=" }, + "mediaType": "image/png", + }, + ], + }, + }], + }], + }))) + .await + .unwrap(); + + assert_match_object( + &request_body(&fetch), + &json!({ + "messages": [{ + "role": "tool", + "tool_call_id": "call-1", + "content": [ + { "type": "text", "text": "image result" }, + { "type": "image_url", "image_url": { "url": "data:image/png;base64,iVBORw0KGgo=" } }, + ], + }], + }), + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should pass settings + #[tokio::test] + async fn should_pass_settings() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({}))]); + + provider(&fetch) + .chat("grok-3") + .do_generate(&call_options(json!({ + "providerOptions": { "openaiCompatible": { "user": "test-user-id" } }, + }))) + .await + .unwrap(); + + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "grok-3", + "user": "test-user-id" + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should pass settings with deprecated openai-compatible key and emit warning + #[tokio::test] + async fn should_pass_settings_with_deprecated_openai_compatible_key_and_emit_warning() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({}))]); + + let result = provider(&fetch) + .chat("grok-3") + .do_generate(&call_options(json!({ + "providerOptions": { "openai-compatible": { "user": "test-user-id" } }, + }))) + .await + .unwrap(); + + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "grok-3", + "user": "test-user-id" + }) + ); + assert_eq!( + result_json(&result)["warnings"], + json!([ + { + "message": "Use 'openaiCompatible' instead.", + "setting": "providerOptions key 'openai-compatible'", + "type": "deprecated" + } + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should include provider-specific options + #[tokio::test] + async fn should_include_provider_specific_options() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({}))]); + + provider(&fetch) + .chat("grok-3") + .do_generate(&call_options(json!({ + "providerOptions": { "test-provider": { "someCustomOption": "test-value" } }, + }))) + .await + .unwrap(); + + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "grok-3", + "someCustomOption": "test-value" + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should serialize thought signatures using the custom provider metadata key + #[tokio::test] + async fn should_serialize_thought_signatures_using_the_custom_provider_metadata_key() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ "content": "" }))]); + + provider(&fetch) + .chat("grok-3") + .do_generate(&call_options(json!({ + "prompt": [{ + "role": "assistant", + "content": [{ + "type": "tool-call", + "toolCallId": "call-1", + "toolName": "test_tool", + "input": { "value": "test" }, + "providerOptions": { + "test-provider": { "thoughtSignature": "" }, + }, + }], + }], + }))) + .await + .unwrap(); + + assert_match_object( + &request_body(&fetch), + &json!({ + "messages": [{ + "role": "assistant", + "tool_calls": [{ + "extra_content": { "google": { "thought_signature": "" } }, + }], + }], + }), + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should not include provider-specific options for different provider + #[tokio::test] + async fn should_not_include_provider_specific_options_for_different_provider() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({}))]); + + provider(&fetch) + .chat("grok-3") + .do_generate(&call_options(json!({ + "providerOptions": { "notThisProviderName": { "someCustomOption": "test-value" } }, + }))) + .await + .unwrap(); + + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "grok-3" + }) + ); + } + + mod camel_case_provider_options { + use super::*; + + /// The usage of the two metadata-key tests. + fn prediction_usage() -> Value { + json!({ + "prompt_tokens": 20, + "completion_tokens": 30, + "total_tokens": 50, + "completion_tokens_details": { "accepted_prediction_tokens": 15 }, + }) + } + + fn signature_tool_calls() -> Value { + json!([{ + "id": "call-1", + "type": "function", + "function": { "name": "test_tool", "arguments": "{\"arg\":\"value\"}" }, + "extra_content": { "google": { "thought_signature": "" } }, + }]) + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › camelCase provider options › should accept camelCase provider options key for hyphenated provider name + #[tokio::test] + async fn should_accept_camel_case_provider_options_key_for_hyphenated_provider_name() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ "content": "Hello!" }))]); + + provider(&fetch) + .chat("grok-3") + .do_generate(&call_options(json!({ + "providerOptions": { "testProvider": { "someCustomOption": "test-value" } }, + }))) + .await + .unwrap(); + + assert_match_object( + &request_body(&fetch), + &json!({ "someCustomOption": "test-value" }), + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › camelCase provider options › should prefer camelCase options over raw-name options + #[tokio::test] + async fn should_prefer_camel_case_options_over_raw_name_options() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ "content": "Hello!" }))]); + + provider(&fetch) + .chat("grok-3") + .do_generate(&call_options(json!({ + "providerOptions": { + "test-provider": { "someCustomOption": "raw-value" }, + "testProvider": { "someCustomOption": "camel-value" }, + }, + }))) + .await + .unwrap(); + + assert_match_object( + &request_body(&fetch), + &json!({ "someCustomOption": "camel-value" }), + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › camelCase provider options › should use camelCase metadata key when camelCase provider options are used + #[tokio::test] + async fn should_use_camel_case_metadata_key_when_camel_case_provider_options_are_used() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ + "content": "Hello!", + "usage": prediction_usage(), + }))]); + + let result = provider(&fetch) + .chat("grok-3") + .do_generate(&call_options(json!({ + "providerOptions": { "testProvider": { "reasoningEffort": "high" } }, + }))) + .await + .unwrap(); + let metadata = result.provider_metadata.unwrap(); + + assert!(metadata.contains_key("testProvider")); + assert!(!metadata.contains_key("test-provider")); + assert_match_object( + &Value::Object(metadata["testProvider"].clone()), + &json!({ "acceptedPredictionTokens": 15 }), + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › camelCase provider options › should use raw metadata key when raw provider options are used + #[tokio::test] + async fn should_use_raw_metadata_key_when_raw_provider_options_are_used() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ + "content": "Hello!", + "usage": prediction_usage(), + }))]); + + let result = provider(&fetch) + .chat("grok-3") + .do_generate(&call_options(json!({ + "providerOptions": { "test-provider": { "reasoningEffort": "high" } }, + }))) + .await + .unwrap(); + let metadata = result.provider_metadata.unwrap(); + + assert!(metadata.contains_key("test-provider")); + assert_match_object( + &Value::Object(metadata["test-provider"].clone()), + &json!({ "acceptedPredictionTokens": 15 }), + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › camelCase provider options › should emit deprecated warning when raw provider options key is used + #[tokio::test] + async fn should_emit_deprecated_warning_when_raw_provider_options_key_is_used() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ "content": "Hello!" }))]); + + let result = provider(&fetch) + .chat("grok-3") + .do_generate(&call_options(json!({ + "providerOptions": { "test-provider": { "reasoningEffort": "high" } }, + }))) + .await + .unwrap(); + + assert_eq!( + result_json(&result)["warnings"], + json!([ + { + "message": "Use 'testProvider' instead.", + "setting": "providerOptions key 'test-provider'", + "type": "deprecated" + } + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › camelCase provider options › should not emit deprecated warning when camelCase provider options key is used + #[tokio::test] + async fn should_not_emit_deprecated_warning_when_camel_case_provider_options_key_is_used() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ "content": "Hello!" }))]); + + let result = provider(&fetch) + .chat("grok-3") + .do_generate(&call_options(json!({ + "providerOptions": { "testProvider": { "reasoningEffort": "high" } }, + }))) + .await + .unwrap(); + + assert_eq!(result_json(&result)["warnings"], json!([])); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › camelCase provider options › should use raw metadata key when no provider options are passed + #[tokio::test] + async fn should_use_raw_metadata_key_when_no_provider_options_are_passed() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ "content": "Hello!" }))]); + + let result = provider(&fetch) + .chat("grok-3") + .do_generate(&call_options(json!({}))) + .await + .unwrap(); + + assert!( + result + .provider_metadata + .unwrap() + .contains_key("test-provider") + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › camelCase provider options › should include thought signature in providerMetadata with camelCase key + #[tokio::test] + async fn should_include_thought_signature_in_provider_metadata_with_camel_case_key() { + let fetch = MockFetch::new(vec![prepare_json_response( + json!({ "tool_calls": signature_tool_calls() }), + )]); + + let result = provider(&fetch) + .chat("grok-3") + .do_generate(&call_options(json!({ + "providerOptions": { "testProvider": {} }, + }))) + .await + .unwrap(); + + assert_match_object( + &result_json(&result)["content"], + &json!([{ + "type": "tool-call", + "providerMetadata": { "testProvider": { "thoughtSignature": "" } }, + }]), + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › camelCase provider options › should include thought signature in providerMetadata with raw key + #[tokio::test] + async fn should_include_thought_signature_in_provider_metadata_with_raw_key() { + let fetch = MockFetch::new(vec![prepare_json_response( + json!({ "tool_calls": signature_tool_calls() }), + )]); + + let result = provider(&fetch) + .chat("grok-3") + .do_generate(&call_options(json!({ + "providerOptions": { "test-provider": {} }, + }))) + .await + .unwrap(); + + assert_match_object( + &result_json(&result)["content"], + &json!([{ + "type": "tool-call", + "providerMetadata": { "test-provider": { "thoughtSignature": "" } }, + }]), + ); + } + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should pass tools and toolChoice + #[tokio::test] + async fn should_pass_tools_and_tool_choice() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ "content": "" }))]); + + model(&fetch) + .do_generate(&call_options(json!({ + "tools": [function_tool("test-tool", value_schema())], + "toolChoice": { "type": "tool", "toolName": "test-tool" }, + }))) + .await + .unwrap(); + + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "grok-3", + "tool_choice": { + "function": { + "name": "test-tool" + }, + "type": "function" + }, + "tools": [ + { + "function": { + "name": "test-tool", + "parameters": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "type": "object" + } + }, + "type": "function" + } + ] + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should pass headers + #[tokio::test] + async fn should_pass_headers() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ "content": "" }))]); + let provider = create_openai_compatible(OpenAICompatibleProviderSettings { + headers: Some(headers(&[ + ("Authorization", "Bearer test-api-key"), + ("Custom-Provider-Header", "provider-header-value"), + ])), + ..settings(&fetch) + }) + .unwrap(); + + provider + .chat("grok-3") + .do_generate(&call_options(json!({ + "headers": { "Custom-Request-Header": "request-header-value" }, + }))) + .await + .unwrap(); + + assert_eq!( + request_headers(&fetch.seen()[0]), + json!({ + "authorization": "Bearer test-api-key", + "content-type": "application/json", + "custom-provider-header": "provider-header-value", + "custom-request-header": "request-header-value" + }) + ); + } + + mod google_gemini_thought_signatures_openai_compatibility { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › Google Gemini thought signatures (OpenAI compatibility) › should parse thought signature from extra_content and include in providerMetadata + #[tokio::test] + async fn should_parse_thought_signature_from_extra_content_and_include_in_provider_metadata() + { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ + "tool_calls": [{ + "id": "function-call-1", + "type": "function", + "function": { "name": "check_flight", "arguments": "{\"flight\":\"AA100\"}" }, + "extra_content": { "google": { "thought_signature": "" } }, + }], + }))]); + + let result = model(&fetch) + .do_generate(&call_options(json!({ + "tools": [function_tool("check_flight", json!({ + "type": "object", + "properties": { "flight": { "type": "string" } }, + "required": ["flight"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + }))], + }))) + .await + .unwrap(); + + assert_eq!( + result_json(&result)["content"], + json!([ + { + "input": "{\"flight\":\"AA100\"}", + "providerMetadata": { + "test-provider": { + "thoughtSignature": "" + } + }, + "toolCallId": "function-call-1", + "toolName": "check_flight", + "type": "tool-call" + } + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › Google Gemini thought signatures (OpenAI compatibility) › should handle parallel tool calls with signature only on first call + #[tokio::test] + async fn should_handle_parallel_tool_calls_with_signature_only_on_first_call() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ + "tool_calls": [ + { + "id": "function-call-paris", + "type": "function", + "function": { "name": "get_current_temperature", "arguments": "{\"location\":\"Paris\"}" }, + "extra_content": { "google": { "thought_signature": "" } }, + }, + { + "id": "function-call-london", + "type": "function", + "function": { "name": "get_current_temperature", "arguments": "{\"location\":\"London\"}" }, + }, + ], + }))]); + + let result = model(&fetch) + .do_generate(&call_options(json!({ + "tools": [function_tool("get_current_temperature", json!({ + "type": "object", + "properties": { "location": { "type": "string" } }, + "required": ["location"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + }))], + }))) + .await + .unwrap(); + + assert_eq!( + result_json(&result)["content"], + json!([ + { + "input": "{\"location\":\"Paris\"}", + "providerMetadata": { + "test-provider": { + "thoughtSignature": "" + } + }, + "toolCallId": "function-call-paris", + "toolName": "get_current_temperature", + "type": "tool-call" + }, + { + "input": "{\"location\":\"London\"}", + "toolCallId": "function-call-london", + "toolName": "get_current_temperature", + "type": "tool-call" + } + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › Google Gemini thought signatures (OpenAI compatibility) › should not include providerMetadata when no thought signature is present + #[tokio::test] + async fn should_not_include_provider_metadata_when_no_thought_signature_is_present() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ + "tool_calls": [{ + "id": "call-1", + "type": "function", + "function": { "name": "some_tool", "arguments": "{\"param\":\"value\"}" }, + }], + }))]); + + let result = model(&fetch) + .do_generate(&call_options(json!({ + "tools": [function_tool("some_tool", json!({ + "type": "object", + "properties": { "param": { "type": "string" } }, + "required": ["param"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + }))], + }))) + .await + .unwrap(); + + assert_eq!( + result_json(&result)["content"], + json!([ + { + "input": "{\"param\":\"value\"}", + "toolCallId": "call-1", + "toolName": "some_tool", + "type": "tool-call" + } + ]) + ); + } + } + + mod response_format { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › response format › should not send a response_format when response format is text + #[tokio::test] + async fn should_not_send_a_response_format_when_response_format_is_text() { + let fetch = MockFetch::new(vec![prepare_json_response( + json!({ "content": "{\"value\":\"Spark\"}" }), + )]); + let model = direct_model( + "gpt-4o-2024-08-06", + OpenAICompatibleProviderSettings { + supports_structured_outputs: Some(false), + ..settings(&fetch) + }, + ); + + model + .do_generate(&call_options( + json!({ "responseFormat": { "type": "text" } }), + )) + .await + .unwrap(); + + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "gpt-4o-2024-08-06" + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › response format › should forward json response format as "json_object" without schema + #[tokio::test] + async fn should_forward_json_response_format_as_json_object_without_schema() { + let fetch = MockFetch::new(vec![prepare_json_response( + json!({ "content": "{\"value\":\"Spark\"}" }), + )]); + let model = provider(&fetch).chat("gpt-4o-2024-08-06"); + + model + .do_generate(&call_options( + json!({ "responseFormat": { "type": "json" } }), + )) + .await + .unwrap(); + + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "gpt-4o-2024-08-06", + "response_format": { + "type": "json_object" + } + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › response format › should forward json response format as "json_object" and omit schema when structuredOutputs are disabled + #[tokio::test] + async fn should_forward_json_object_and_omit_schema_when_structured_outputs_are_disabled() { + let fetch = MockFetch::new(vec![prepare_json_response( + json!({ "content": "{\"value\":\"Spark\"}" }), + )]); + let model = direct_model( + "gpt-4o-2024-08-06", + OpenAICompatibleProviderSettings { + supports_structured_outputs: Some(false), + ..settings(&fetch) + }, + ); + + let result = model + .do_generate(&call_options(json!({ + "responseFormat": { "type": "json", "schema": value_schema() }, + }))) + .await + .unwrap(); + + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "gpt-4o-2024-08-06", + "response_format": { + "type": "json_object" + } + }) + ); + assert_eq!( + result_json(&result)["warnings"], + json!([ + { + "details": "JSON response format schema is only supported with structuredOutputs", + "feature": "responseFormat", + "type": "unsupported" + } + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › response format › should forward json response format as "json_object" and include schema when structuredOutputs are enabled + #[tokio::test] + async fn should_forward_json_object_and_include_schema_when_structured_outputs_are_enabled() + { + let fetch = MockFetch::new(vec![prepare_json_response( + json!({ "content": "{\"value\":\"Spark\"}" }), + )]); + let model = direct_model( + "gpt-4o-2024-08-06", + OpenAICompatibleProviderSettings { + supports_structured_outputs: Some(true), + ..settings(&fetch) + }, + ); + + let result = model + .do_generate(&call_options(json!({ + "responseFormat": { "type": "json", "schema": value_schema() }, + }))) + .await + .unwrap(); + + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "gpt-4o-2024-08-06", + "response_format": { + "json_schema": { + "name": "response", + "schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "type": "object" + }, + "strict": true + }, + "type": "json_schema" + } + }) + ); + assert_eq!(result_json(&result)["warnings"], json!([])); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › response format › should pass reasoningEffort setting from providerOptions + #[tokio::test] + async fn should_pass_reasoning_effort_setting_from_provider_options() { + let fetch = MockFetch::new(vec![prepare_json_response( + json!({ "content": "{\"value\":\"test\"}" }), + )]); + let model = direct_model("gpt-5", settings(&fetch)); + + model + .do_generate(&call_options(json!({ + "providerOptions": { "test-provider": { "reasoningEffort": "high" } }, + }))) + .await + .unwrap(); + + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "gpt-5", + "reasoning_effort": "high" + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › response format › should not duplicate reasoningEffort in request body + #[tokio::test] + async fn should_not_duplicate_reasoning_effort_in_request_body() { + let fetch = MockFetch::new(vec![prepare_json_response( + json!({ "content": "{\"value\":\"test\"}" }), + )]); + let model = direct_model("gpt-5", settings(&fetch)); + + model + .do_generate(&call_options(json!({ + "providerOptions": { + "test-provider": { + "reasoningEffort": "high", + "customOption": "should-be-included", + }, + }, + }))) + .await + .unwrap(); + let body = request_body(&fetch); + + assert_eq!(body["reasoning_effort"], "high"); + assert_eq!(body.get("reasoningEffort"), None); + assert_eq!(body["customOption"], "should-be-included"); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › response format › should pass top-level reasoning as reasoning_effort + #[tokio::test] + async fn should_pass_top_level_reasoning_as_reasoning_effort() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ "content": "test" }))]); + + model(&fetch) + .do_generate(&call_options(json!({ "reasoning": "medium" }))) + .await + .unwrap(); + + assert_eq!(request_body(&fetch)["reasoning_effort"], "medium"); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › response format › should pass top-level reasoning none as reasoning_effort + #[tokio::test] + async fn should_pass_top_level_reasoning_none_as_reasoning_effort() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ "content": "test" }))]); + + model(&fetch) + .do_generate(&call_options(json!({ "reasoning": "none" }))) + .await + .unwrap(); + + assert_eq!(request_body(&fetch)["reasoning_effort"], "none"); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › response format › should prefer providerOptions reasoningEffort over top-level reasoning + #[tokio::test] + async fn should_prefer_provider_options_reasoning_effort_over_top_level_reasoning() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ "content": "test" }))]); + let model = direct_model("gpt-5", settings(&fetch)); + + model + .do_generate(&call_options(json!({ + "reasoning": "medium", + "providerOptions": { "test-provider": { "reasoningEffort": "high" } }, + }))) + .await + .unwrap(); + + assert_eq!(request_body(&fetch)["reasoning_effort"], "high"); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › response format › should pass textVerbosity setting from providerOptions + #[tokio::test] + async fn should_pass_text_verbosity_setting_from_provider_options() { + let fetch = MockFetch::new(vec![prepare_json_response( + json!({ "content": "{\"value\":\"test\"}" }), + )]); + let model = direct_model("gpt-5", settings(&fetch)); + + model + .do_generate(&call_options(json!({ + "providerOptions": { "test-provider": { "textVerbosity": "low" } }, + }))) + .await + .unwrap(); + + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "gpt-5", + "verbosity": "low" + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › response format › should not duplicate textVerbosity in request body + #[tokio::test] + async fn should_not_duplicate_text_verbosity_in_request_body() { + let fetch = MockFetch::new(vec![prepare_json_response( + json!({ "content": "{\"value\":\"test\"}" }), + )]); + let model = direct_model("gpt-5", settings(&fetch)); + + model + .do_generate(&call_options(json!({ + "providerOptions": { + "test-provider": { + "textVerbosity": "medium", + "customOption": "should-be-included", + }, + }, + }))) + .await + .unwrap(); + let body = request_body(&fetch); + + assert_eq!(body["verbosity"], "medium"); + assert_eq!(body.get("textVerbosity"), None); + assert_eq!(body["customOption"], "should-be-included"); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › response format › should use json_schema & strict with responseFormat json when structuredOutputs are enabled + #[tokio::test] + async fn should_use_json_schema_and_strict_when_structured_outputs_are_enabled() { + let fetch = MockFetch::new(vec![prepare_json_response( + json!({ "content": "{\"value\":\"Spark\"}" }), + )]); + let model = direct_model( + "gpt-4o-2024-08-06", + OpenAICompatibleProviderSettings { + supports_structured_outputs: Some(true), + ..settings(&fetch) + }, + ); + + model + .do_generate(&call_options(json!({ + "responseFormat": { "type": "json", "schema": value_schema() }, + }))) + .await + .unwrap(); + + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "gpt-4o-2024-08-06", + "response_format": { + "json_schema": { + "name": "response", + "schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "type": "object" + }, + "strict": true + }, + "type": "json_schema" + } + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › response format › should set name & description with responseFormat json when structuredOutputs are enabled + #[tokio::test] + async fn should_set_name_and_description_when_structured_outputs_are_enabled() { + let fetch = MockFetch::new(vec![prepare_json_response( + json!({ "content": "{\"value\":\"Spark\"}" }), + )]); + let model = direct_model( + "gpt-4o-2024-08-06", + OpenAICompatibleProviderSettings { + supports_structured_outputs: Some(true), + ..settings(&fetch) + }, + ); + + model + .do_generate(&call_options(json!({ + "responseFormat": { + "type": "json", + "name": "test-name", + "description": "test description", + "schema": value_schema(), + }, + }))) + .await + .unwrap(); + + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "gpt-4o-2024-08-06", + "response_format": { + "json_schema": { + "description": "test description", + "name": "test-name", + "schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "type": "object" + }, + "strict": true + }, + "type": "json_schema" + } + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › response format › should send strict: false when strictJsonSchema is explicitly disabled + #[tokio::test] + async fn should_send_strict_false_when_strict_json_schema_is_explicitly_disabled() { + let fetch = MockFetch::new(vec![prepare_json_response( + json!({ "content": "{\"value\":\"Spark\"}" }), + )]); + let model = direct_model( + "gpt-4o-2024-08-06", + OpenAICompatibleProviderSettings { + supports_structured_outputs: Some(true), + ..settings(&fetch) + }, + ); + + model + .do_generate(&call_options(json!({ + "responseFormat": { + "type": "json", + "name": "test-name", + "description": "test description", + "schema": value_schema(), + }, + "providerOptions": { "test-provider": { "strictJsonSchema": false } }, + }))) + .await + .unwrap(); + + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "gpt-4o-2024-08-06", + "response_format": { + "json_schema": { + "description": "test description", + "name": "test-name", + "schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "type": "object" + }, + "strict": false + }, + "type": "json_schema" + } + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › response format › should allow for undefined schema with responseFormat json when structuredOutputs are enabled + #[tokio::test] + async fn should_allow_for_undefined_schema_when_structured_outputs_are_enabled() { + let fetch = MockFetch::new(vec![prepare_json_response( + json!({ "content": "{\"value\":\"Spark\"}" }), + )]); + let model = direct_model( + "gpt-4o-2024-08-06", + OpenAICompatibleProviderSettings { + supports_structured_outputs: Some(true), + ..settings(&fetch) + }, + ); + + model + .do_generate(&call_options(json!({ + "responseFormat": { + "type": "json", + "name": "test-name", + "description": "test description", + }, + }))) + .await + .unwrap(); + + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "gpt-4o-2024-08-06", + "response_format": { + "type": "json_object" + } + }) + ); + } + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › should send request body + #[tokio::test] + async fn should_send_request_body() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ "content": "" }))]); + + let result = model(&fetch) + .do_generate(&call_options(json!({}))) + .await + .unwrap(); + + // Upstream's `request.body` is the JSON string; aimux keeps the value. + let mut expected = json!({ + "body": "{\"model\":\"grok-3\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}" + }); + expected["body"] = serde_json::from_str(expected["body"].as_str().unwrap()).unwrap(); + assert_eq!(result_json(&result)["request"], expected); + } + + mod usage_details { + use super::*; + + fn usage_response(usage: Value) -> Value { + json!({ + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1711115037, + "model": "grok-3", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": "Hello!" }, + "finish_reason": "stop", + }], + "usage": usage, + }) + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › usage details › should extract detailed token usage when available + #[tokio::test] + async fn should_extract_detailed_token_usage_when_available() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ + "usage": { + "prompt_tokens": 20, + "completion_tokens": 30, + "total_tokens": 50, + "prompt_tokens_details": { "cached_tokens": 5 }, + "completion_tokens_details": { + "reasoning_tokens": 10, + "accepted_prediction_tokens": 15, + "rejected_prediction_tokens": 5, + }, + }, + }))]); + + let result = model(&fetch) + .do_generate(&call_options(json!({}))) + .await + .unwrap(); + let result = result_json(&result); + + assert_eq!( + result["usage"], + json!({ + "inputTokens": { + "cacheRead": 5, + "noCache": 15, + "total": 20 + }, + "outputTokens": { + "reasoning": 10, + "text": 20, + "total": 30 + }, + "raw": { + "completion_tokens": 30, + "completion_tokens_details": { + "accepted_prediction_tokens": 15, + "reasoning_tokens": 10, + "rejected_prediction_tokens": 5 + }, + "prompt_tokens": 20, + "prompt_tokens_details": { + "cached_tokens": 5 + }, + "total_tokens": 50 + } + }) + ); + assert_eq!( + result["providerMetadata"], + json!({ + "test-provider": { + "acceptedPredictionTokens": 15, + "rejectedPredictionTokens": 5 + } + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › usage details › should handle missing token details + #[tokio::test] + async fn should_handle_missing_token_details() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ + "usage": { "prompt_tokens": 20, "completion_tokens": 30 }, + }))]); + + let result = model(&fetch) + .do_generate(&call_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + result_json(&result)["providerMetadata"]["test-provider"], + json!({}) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › usage details › should handle partial token details + #[tokio::test] + async fn should_handle_partial_token_details() { + let fetch = MockFetch::new(vec![prepare_json_response(json!({ + "usage": { + "prompt_tokens": 20, + "completion_tokens": 30, + "total_tokens": 50, + "prompt_tokens_details": { "cached_tokens": 5 }, + "completion_tokens_details": { "reasoning_tokens": 10 }, + }, + }))]); + + let result = model(&fetch) + .do_generate(&call_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + result_json(&result)["usage"], + json!({ + "inputTokens": { + "cacheRead": 5, + "noCache": 15, + "total": 20 + }, + "outputTokens": { + "reasoning": 10, + "text": 20, + "total": 30 + }, + "raw": { + "completion_tokens": 30, + "completion_tokens_details": { + "reasoning_tokens": 10 + }, + "prompt_tokens": 20, + "prompt_tokens_details": { + "cached_tokens": 5 + }, + "total_tokens": 50 + } + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › usage details › should preserve extra usage fields from provider-specific responses + #[tokio::test] + async fn should_preserve_extra_usage_fields_from_provider_specific_responses() { + let fetch = json_mock(usage_response(json!({ + "prompt_tokens": 18, + "completion_tokens": 439, + "total_tokens": 457, + "queue_time": 0.061348671, + "prompt_time": 0.000211569, + "completion_time": 0.798181818, + "total_time": 0.798393387, + }))); + + let result = model(&fetch) + .do_generate(&call_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + result_json(&result)["usage"]["raw"], + json!({ + "completion_time": 0.798181818, + "completion_tokens": 439, + "prompt_time": 0.000211569, + "prompt_tokens": 18, + "queue_time": 0.061348671, + "total_time": 0.798393387, + "total_tokens": 457 + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doGenerate › usage details › should preserve extra usage fields nested inside token details + #[tokio::test] + async fn should_preserve_extra_usage_fields_nested_inside_token_details() { + let fetch = json_mock(usage_response(json!({ + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150, + "prompt_tokens_details": { "cached_tokens": 80, "cache_type": "ephemeral" }, + "completion_tokens_details": { "reasoning_tokens": 10, "provider_specific_detail": 7 }, + }))); + + let result = model(&fetch) + .do_generate(&call_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + result_json(&result)["usage"]["raw"], + json!({ + "completion_tokens": 50, + "completion_tokens_details": { + "provider_specific_detail": 7, + "reasoning_tokens": 10 + }, + "prompt_tokens": 100, + "prompt_tokens_details": { + "cache_type": "ephemeral", + "cached_tokens": 80 + }, + "total_tokens": 150 + }) + ); + } + } +} + +mod do_stream { + use super::*; + + fn no_raw_chunks() -> Value { + json!({ "includeRawChunks": false }) + } + + /// `{ ...no_raw_chunks(), ...extra }`. + fn stream_options(extra: Value) -> CallOptions { + let mut options = no_raw_chunks(); + options + .as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + call_options(options) + } + + /// `tools: [{ type: 'function', name: 'test-tool', inputSchema: { value } }]`. + fn test_tool_options() -> CallOptions { + stream_options(json!({ "tools": [function_tool("test-tool", value_schema())] })) + } + + mod text_fixture { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › text (fixture) › should stream text content + #[tokio::test] + async fn should_stream_text_content() { + let fetch = MockFetch::new(vec![prepare_chunks_fixture_response("xai-text", &[])]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({}))) + .await + .unwrap(); + + let expected: Value = + serde_json::from_str(include_str!("snapshots/do_stream_text_fixture.json")) + .unwrap(); + assert_eq!(Value::Array(stream_parts(result).await), expected); + } + } + + mod tool_call_fixture { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › tool call (fixture) › should stream tool call content + #[tokio::test] + async fn should_stream_tool_call_content() { + let fetch = MockFetch::new(vec![prepare_chunks_fixture_response("xai-tool-call", &[])]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({}))) + .await + .unwrap(); + + let expected: Value = + serde_json::from_str(include_str!("snapshots/do_stream_tool_call_fixture.json")) + .unwrap(); + assert_eq!(Value::Array(stream_parts(result).await), expected); + } + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should expose the raw response headers + #[tokio::test] + async fn should_expose_the_raw_response_headers() { + let fetch = MockFetch::new(vec![prepare_chunks_fixture_response( + "xai-text", + &[("test-header", "test-value")], + )]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + serde_json::to_value(result.response.unwrap().headers).unwrap(), + json!({ + "cache-control": "no-cache", + "connection": "keep-alive", + "content-type": "text/event-stream", + "test-header": "test-value" + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should report an error when $scenario without a finish reason + #[tokio::test] + async fn should_report_an_error_when_scenario_without_a_finish_reason() { + let scenarios: [(&str, &[&str]); 2] = [ + ("the connection closes", &[]), + ("[DONE] is received", &["data: [DONE]\n\n"]), + ]; + for (scenario, final_chunks) in scenarios { + let mut chunks = data(&[ + r#"{"id":"chatcmpl-test","object":"chat.completion.chunk","created":1702657020,"model":"grok-3","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-test","object":"chat.completion.chunk","created":1702657020,"model":"grok-3","choices":[{"index":0,"delta":{"content":" World"},"finish_reason":null}]}"#, + ]); + chunks.extend(final_chunks.iter().map(|chunk| (*chunk).to_string())); + let fetch = MockFetch::new(vec![stream_chunks(&chunks, &[])]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({}))) + .await + .unwrap(); + let events = stream_parts(result).await; + + assert_eq!( + of_type(&events, "text-delta"), + [ + &json!({ "type": "text-delta", "delta": "Hello", "id": "txt-0" }), + &json!({ "type": "text-delta", "delta": " World", "id": "txt-0" }), + ], + "{scenario}" + ); + let errors = of_type(&events, "error"); + assert_eq!(errors.len(), 1, "{scenario}"); + assert_eq!( + errors[0]["error"]["name"], "AI_InvalidResponseDataError", + "{scenario}" + ); + assert_match_object( + &errors[0]["error"], + &json!({ "message": "Response stream ended without a finish reason." }), + ); + let finishes = of_type(&events, "finish"); + assert_eq!(finishes.len(), 1, "{scenario}"); + assert_eq!(finishes[0]["type"], "finish", "{scenario}"); + assert_eq!( + finishes[0]["finishReason"], + json!({ "unified": "error" }), + "{scenario}" + ); + } + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should handle empty string role in delta chunks + #[tokio::test] + async fn should_handle_empty_string_role_in_delta_chunks() { + let fetch = stream_mock(&[ + r#"{"id":"chatcmpl-test","object":"chat.completion.chunk","created":1702657020,"model":"grok-3","choices":[{"index":0,"delta":{"role":"","content":"Hello"},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-test","object":"chat.completion.chunk","created":1702657020,"model":"grok-3","choices":[{"index":0,"delta":{"role":"","content":" World"},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-test","object":"chat.completion.chunk","created":1702657020,"model":"grok-3","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, + ]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({}))) + .await + .unwrap(); + let events = stream_parts(result).await; + + assert_eq!( + of_type(&events, "text-delta"), + [ + &json!({ "type": "text-delta", "delta": "Hello", "id": "txt-0" }), + &json!({ "type": "text-delta", "delta": " World", "id": "txt-0" }), + ] + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should respect the includeUsage option + #[tokio::test] + async fn should_respect_the_include_usage_option() { + let fetch = MockFetch::new(vec![prepare_stream_response( + &["Hello", ", ", "World!"], + "stop", + )]); + let model = direct_model( + "gpt-4o-2024-08-06", + OpenAICompatibleProviderSettings { + include_usage: Some(true), + ..settings(&fetch) + }, + ); + + model.do_stream(&stream_options(json!({}))).await.unwrap(); + let body = request_body(&fetch); + + assert_eq!(body["stream"], true); + assert_eq!( + body["stream_options"], + json!({ + "include_usage": true + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should stream reasoning content before text deltas + #[tokio::test] + async fn should_stream_reasoning_content_before_text_deltas() { + let fetch = stream_mock(&[ + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":"", "reasoning_content":"Let me think"},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"content":"", "reasoning_content":" about this"},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"content":"Here's"},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"content":" my response"},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1729171479,"model":"grok-3","system_fingerprint":"fp_10c08bf97d","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":18,"completion_tokens":439}}"#, + ]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + Value::Array(stream_parts(result).await), + json!([ + { + "type": "stream-start", + "warnings": [] + }, + { + "id": "chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798", + "modelId": "grok-3", + "timestamp": "2024-03-25T09:06:38.000Z", + "type": "response-metadata" + }, + { + "id": "reasoning-0", + "type": "reasoning-start" + }, + { + "delta": "Let me think", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " about this", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "id": "reasoning-0", + "type": "reasoning-end" + }, + { + "id": "txt-0", + "type": "text-start" + }, + { + "delta": "Here's", + "id": "txt-0", + "type": "text-delta" + }, + { + "delta": " my response", + "id": "txt-0", + "type": "text-delta" + }, + { + "id": "txt-0", + "type": "text-end" + }, + { + "finishReason": { + "raw": "stop", + "unified": "stop" + }, + "providerMetadata": { + "test-provider": {} + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 18, + "total": 18 + }, + "outputTokens": { + "reasoning": 0, + "text": 439, + "total": 439 + }, + "raw": { + "completion_tokens": 439, + "prompt_tokens": 18 + } + } + } + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should keep reasoning active when deltas include empty tool calls + #[tokio::test] + async fn should_keep_reasoning_active_when_deltas_include_empty_tool_calls() { + let fetch = stream_mock(&[ + r#"{"id":"chatcmpl-test","object":"chat.completion.chunk","model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":"Think ","tool_calls":[]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-test","object":"chat.completion.chunk","model":"test-model","choices":[{"index":0,"delta":{"content":"","reasoning_content":"more...","tool_calls":[]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-test","object":"chat.completion.chunk","model":"test-model","choices":[{"index":0,"delta":{"content":"Hello","reasoning_content":"","tool_calls":[]},"finish_reason":"stop"}]}"#, + ]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({}))) + .await + .unwrap(); + let events = stream_parts(result).await; + + assert_eq!( + events + .iter() + .filter(|event| event["type"] + .as_str() + .is_some_and(|kind| kind.starts_with("reasoning-"))) + .collect::>(), + [ + &json!({ "type": "reasoning-start", "id": "reasoning-0" }), + &json!({ "type": "reasoning-delta", "id": "reasoning-0", "delta": "Think " }), + &json!({ "type": "reasoning-delta", "id": "reasoning-0", "delta": "more..." }), + &json!({ "type": "reasoning-end", "id": "reasoning-0" }), + ] + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should stream reasoning from reasoning field when reasoning_content is not provided + #[tokio::test] + async fn should_stream_reasoning_from_reasoning_field_when_reasoning_content_is_not_provided() { + let fetch = stream_mock(&[ + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":"", "reasoning":"Let me consider"},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"content":"", "reasoning":" this carefully"},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"content":"My answer is"},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"content":" correct"},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1729171479,"model":"grok-3","system_fingerprint":"fp_10c08bf97d","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":18,"completion_tokens":439}}"#, + ]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + Value::Array(stream_parts(result).await), + json!([ + { + "type": "stream-start", + "warnings": [] + }, + { + "id": "chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798", + "modelId": "grok-3", + "timestamp": "2024-03-25T09:06:38.000Z", + "type": "response-metadata" + }, + { + "id": "reasoning-0", + "type": "reasoning-start" + }, + { + "delta": "Let me consider", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " this carefully", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "id": "reasoning-0", + "type": "reasoning-end" + }, + { + "id": "txt-0", + "type": "text-start" + }, + { + "delta": "My answer is", + "id": "txt-0", + "type": "text-delta" + }, + { + "delta": " correct", + "id": "txt-0", + "type": "text-delta" + }, + { + "id": "txt-0", + "type": "text-end" + }, + { + "finishReason": { + "raw": "stop", + "unified": "stop" + }, + "providerMetadata": { + "test-provider": {} + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 18, + "total": 18 + }, + "outputTokens": { + "reasoning": 0, + "text": 439, + "total": 439 + }, + "raw": { + "completion_tokens": 439, + "prompt_tokens": 18 + } + } + } + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should prefer reasoning_content over reasoning field in streaming when both are provided + #[tokio::test] + async fn should_prefer_reasoning_content_over_reasoning_field_in_streaming() { + let fetch = stream_mock(&[ + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":"", "reasoning_content":"From reasoning_content", "reasoning":"From reasoning"},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"content":"Final response"},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1729171479,"model":"grok-3","system_fingerprint":"fp_10c08bf97d","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":18,"completion_tokens":439}}"#, + ]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + Value::Array(stream_parts(result).await), + json!([ + { + "type": "stream-start", + "warnings": [] + }, + { + "id": "chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798", + "modelId": "grok-3", + "timestamp": "2024-03-25T09:06:38.000Z", + "type": "response-metadata" + }, + { + "id": "reasoning-0", + "type": "reasoning-start" + }, + { + "delta": "From reasoning_content", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "id": "reasoning-0", + "type": "reasoning-end" + }, + { + "id": "txt-0", + "type": "text-start" + }, + { + "delta": "Final response", + "id": "txt-0", + "type": "text-delta" + }, + { + "id": "txt-0", + "type": "text-end" + }, + { + "finishReason": { + "raw": "stop", + "unified": "stop" + }, + "providerMetadata": { + "test-provider": {} + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 18, + "total": 18 + }, + "outputTokens": { + "reasoning": 0, + "text": 439, + "total": 439 + }, + "raw": { + "completion_tokens": 439, + "prompt_tokens": 18 + } + } + } + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should stream tool deltas + #[tokio::test] + async fn should_stream_tool_deltas() { + let fetch = stream_mock(&[ + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_O17Uplv4lJvD6DVdIvFFeRMw","type":"function","function":{"name":"test-tool","arguments":""}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"value"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\""}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Spark"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"le"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" Day"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"}"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1729171479,"model":"grok-3","system_fingerprint":"fp_10c08bf97d","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"queue_time":0.061348671,"prompt_tokens":18,"prompt_time":0.000211569,"completion_tokens":439,"completion_time":0.798181818,"total_tokens":457,"total_time":0.798393387}}"#, + ]); + + let result = model(&fetch).do_stream(&test_tool_options()).await.unwrap(); + + assert_eq!( + Value::Array(stream_parts(result).await), + json!([ + { + "type": "stream-start", + "warnings": [] + }, + { + "id": "chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798", + "modelId": "grok-3", + "timestamp": "2024-03-25T09:06:38.000Z", + "type": "response-metadata" + }, + { + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "toolName": "test-tool", + "type": "tool-input-start" + }, + { + "delta": "{\"", + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "type": "tool-input-delta" + }, + { + "delta": "value", + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "type": "tool-input-delta" + }, + { + "delta": "\":\"", + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "type": "tool-input-delta" + }, + { + "delta": "Spark", + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "type": "tool-input-delta" + }, + { + "delta": "le", + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "type": "tool-input-delta" + }, + { + "delta": " Day", + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "type": "tool-input-delta" + }, + { + "delta": "\"}", + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "type": "tool-input-delta" + }, + { + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "type": "tool-input-end" + }, + { + "input": "{\"value\":\"Sparkle Day\"}", + "toolCallId": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "toolName": "test-tool", + "type": "tool-call" + }, + { + "finishReason": { + "raw": "tool_calls", + "unified": "tool-calls" + }, + "providerMetadata": { + "test-provider": {} + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 18, + "total": 18 + }, + "outputTokens": { + "reasoning": 0, + "text": 439, + "total": 439 + }, + "raw": { + "completion_time": 0.798181818, + "completion_tokens": 439, + "prompt_time": 0.000211569, + "prompt_tokens": 18, + "queue_time": 0.061348671, + "total_time": 0.798393387, + "total_tokens": 457 + } + } + } + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should stream tool deltas when function.name arrives in a later chunk + #[tokio::test] + async fn should_stream_tool_deltas_when_function_name_arrives_in_a_later_chunk() { + let fetch = stream_mock(&[ + r#"{"id":"chatcmpl-late-name","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}"#, + // First tool_calls delta carries id and (empty) arguments but no function.name + r#"{"id":"chatcmpl-late-name","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_late","type":"function","function":{"arguments":""}}]},"finish_reason":null}]}"#, + // function.name arrives in the next chunk together with the start of the arguments + r#"{"id":"chatcmpl-late-name","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_late","type":"function","function":{"name":"test-tool","arguments":"{\""}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-late-name","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"value"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-late-name","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\"hi\"}"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-late-name","object":"chat.completion.chunk","created":1729171479,"model":"grok-3","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":18,"completion_tokens":10,"total_tokens":28}}"#, + ]); + + let result = model(&fetch).do_stream(&test_tool_options()).await.unwrap(); + + assert_eq!( + Value::Array(stream_parts(result).await), + json!([ + { + "type": "stream-start", + "warnings": [] + }, + { + "id": "chatcmpl-late-name", + "modelId": "grok-3", + "timestamp": "2024-03-25T09:06:38.000Z", + "type": "response-metadata" + }, + { + "id": "call_late", + "toolName": "test-tool", + "type": "tool-input-start" + }, + { + "delta": "{\"", + "id": "call_late", + "type": "tool-input-delta" + }, + { + "delta": "value", + "id": "call_late", + "type": "tool-input-delta" + }, + { + "delta": "\":\"hi\"}", + "id": "call_late", + "type": "tool-input-delta" + }, + { + "id": "call_late", + "type": "tool-input-end" + }, + { + "input": "{\"value\":\"hi\"}", + "toolCallId": "call_late", + "toolName": "test-tool", + "type": "tool-call" + }, + { + "finishReason": { + "raw": "tool_calls", + "unified": "tool-calls" + }, + "providerMetadata": { + "test-provider": {} + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 18, + "total": 18 + }, + "outputTokens": { + "reasoning": 0, + "text": 10, + "total": 10 + }, + "raw": { + "completion_tokens": 10, + "prompt_tokens": 18, + "total_tokens": 28 + } + } + } + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should stream a tool call whose index starts at one + #[tokio::test] + async fn should_stream_a_tool_call_whose_index_starts_at_one() { + let fetch = MockFetch::new(vec![prepare_sse_fixture_response( + "anthropic-fallback-tool-call", + )]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({ + "tools": [function_tool("read_file", json!({ + "type": "object", + "properties": { "path": { "type": "string" } }, + }))], + }))) + .await + .unwrap(); + let parts = stream_parts(result).await; + + assert_eq!( + of_type(&parts, "text-delta") + .iter() + .map(|part| part["delta"].as_str().unwrap()) + .collect::(), + "Reading it." + ); + assert_eq!( + of_type(&parts, "tool-call"), + [&json!({ + "type": "tool-call", + "toolCallId": "toolu_sanitized", + "toolName": "read_file", + "input": "{\"path\": \"a.txt\"}", + })] + ); + assert_eq!(parts.last().unwrap()["type"], "finish"); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should keep id-less tool calls distinct when the index is reused + #[tokio::test] + async fn should_keep_id_less_tool_calls_distinct_when_the_index_is_reused() { + let fetch = stream_mock(&[ + r#"{"id":"chatcmpl-reused-index","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"type":"function","function":{"name":"read_file","arguments":"{\"path\":\"p0\"}"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-reused-index","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"type":"function","function":{"name":"write_file","arguments":"{\"path\":\"p1\"}"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-reused-index","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"type":"function","function":{"name":"read_file","arguments":"{\"path\":\"p2\"}"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-reused-index","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":18,"completion_tokens":10,"total_tokens":28}}"#, + ]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({}))) + .await + .unwrap(); + let parts = stream_parts(result).await; + let tool_calls = of_type(&parts, "tool-call"); + + assert_eq!( + tool_calls + .iter() + .map(|call| json!({ "toolName": call["toolName"], "input": call["input"] })) + .collect::>(), + [ + json!({ "toolName": "read_file", "input": "{\"path\":\"p0\"}" }), + json!({ "toolName": "write_file", "input": "{\"path\":\"p1\"}" }), + json!({ "toolName": "read_file", "input": "{\"path\":\"p2\"}" }), + ] + ); + assert!( + tool_calls + .iter() + .all(|call| !call["toolCallId"].as_str().unwrap().trim().is_empty()) + ); + assert_eq!( + tool_calls + .iter() + .map(|call| call["toolCallId"].as_str().unwrap()) + .collect::>() + .len(), + 3 + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should error when streamed tool call never receives a function.name + #[tokio::test] + async fn should_error_when_streamed_tool_call_never_receives_a_function_name() { + let fetch = stream_mock(&[ + r#"{"id":"chatcmpl-no-name","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-no-name","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_missing","type":"function","function":{"arguments":"{}"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-no-name","object":"chat.completion.chunk","created":1729171479,"model":"grok-3","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":18,"completion_tokens":10,"total_tokens":28}}"#, + ]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({ + "tools": [function_tool("test-tool", json!({ + "type": "object", + "properties": { "value": { "type": "string" } }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + }))], + }))) + .await + .unwrap(); + let error = convert_stream_to_array(result).await.unwrap_err(); + + // `[AI_InvalidResponseDataError: Expected 'function.name' to be a string.]` + assert_eq!( + error_json(&error), + json!({ + "name": "AI_InvalidResponseDataError", + "message": "Expected 'function.name' to be a string.", + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should stream tool call with thought signature from extra_content + #[tokio::test] + async fn should_stream_tool_call_with_thought_signature_from_extra_content() { + let fetch = stream_mock(&[ + // First chunk with tool call start and thought signature in extra_content + r#"{"id":"chatcmpl-gemini-thought","object":"chat.completion.chunk","created":1711357598,"model":"gemini-3-pro","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"function-call-1","type":"function","function":{"name":"check_flight","arguments":""},"extra_content":{"google":{"thought_signature":""}}}]},"finish_reason":null}]}"#, + // Subsequent chunks with arguments + r#"{"id":"chatcmpl-gemini-thought","object":"chat.completion.chunk","created":1711357598,"model":"gemini-3-pro","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"flight\":"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-gemini-thought","object":"chat.completion.chunk","created":1711357598,"model":"gemini-3-pro","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"AA100\"}"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-gemini-thought","object":"chat.completion.chunk","created":1711357598,"model":"gemini-3-pro","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":20,"total_tokens":30}}"#, + ]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({ + "tools": [function_tool("check_flight", json!({ + "type": "object", + "properties": { "flight": { "type": "string" } }, + "required": ["flight"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + }))], + }))) + .await + .unwrap(); + let result = stream_parts(result).await; + + assert_match_object( + find(&result, "tool-call"), + &json!({ + "type": "tool-call", + "toolCallId": "function-call-1", + "toolName": "check_flight", + "input": "{\"flight\":\"AA100\"}", + "providerMetadata": { "test-provider": { "thoughtSignature": "" } }, + }), + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should stream parallel tool calls with signature only on first call + #[tokio::test] + async fn should_stream_parallel_tool_calls_with_signature_only_on_first_call() { + let fetch = stream_mock(&[ + // First chunk with two tool calls - only first has thought signature + r#"{"id":"chatcmpl-gemini-parallel","object":"chat.completion.chunk","created":1711357598,"model":"gemini-3-pro","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call-paris","type":"function","function":{"name":"get_weather","arguments":""},"extra_content":{"google":{"thought_signature":""}}},{"index":1,"id":"call-london","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}"#, + // Arguments for first call + r#"{"id":"chatcmpl-gemini-parallel","object":"chat.completion.chunk","created":1711357598,"model":"gemini-3-pro","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"location\":\"Paris\"}"}}]},"finish_reason":null}]}"#, + // Arguments for second call + r#"{"id":"chatcmpl-gemini-parallel","object":"chat.completion.chunk","created":1711357598,"model":"gemini-3-pro","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\"location\":\"London\"}"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-gemini-parallel","object":"chat.completion.chunk","created":1711357598,"model":"gemini-3-pro","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":20,"total_tokens":30}}"#, + ]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({ + "tools": [function_tool("get_weather", json!({ + "type": "object", + "properties": { "location": { "type": "string" } }, + "required": ["location"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + }))], + }))) + .await + .unwrap(); + let result = stream_parts(result).await; + let tool_call_events = of_type(&result, "tool-call"); + + assert_eq!(tool_call_events.len(), 2); + // First tool call should have thought signature + assert_match_object( + tool_call_events[0], + &json!({ + "type": "tool-call", + "toolCallId": "call-paris", + "toolName": "get_weather", + "providerMetadata": { "test-provider": { "thoughtSignature": "" } }, + }), + ); + // Second tool call should NOT have thought signature + assert_match_object( + tool_call_events[1], + &json!({ + "type": "tool-call", + "toolCallId": "call-london", + "toolName": "get_weather", + }), + ); + assert_eq!(tool_call_events[1].get("providerMetadata"), None); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should stream tool call deltas when tool call arguments are passed in the first chunk + #[tokio::test] + async fn should_stream_tool_call_deltas_when_tool_call_arguments_are_passed_in_the_first_chunk() + { + let fetch = stream_mock(&[ + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_O17Uplv4lJvD6DVdIvFFeRMw","type":"function","function":{"name":"test-tool","arguments":"{\""}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"va"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"lue"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\""}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Spark"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"le"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" Day"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"}"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1729171479,"model":"grok-3","system_fingerprint":"fp_10c08bf97d","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"queue_time":0.061348671,"prompt_tokens":18,"prompt_time":0.000211569,"completion_tokens":439,"completion_time":0.798181818,"total_tokens":457,"total_time":0.798393387}}"#, + ]); + + let result = model(&fetch).do_stream(&test_tool_options()).await.unwrap(); + + assert_eq!( + Value::Array(stream_parts(result).await), + json!([ + { + "type": "stream-start", + "warnings": [] + }, + { + "id": "chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798", + "modelId": "grok-3", + "timestamp": "2024-03-25T09:06:38.000Z", + "type": "response-metadata" + }, + { + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "toolName": "test-tool", + "type": "tool-input-start" + }, + { + "delta": "{\"", + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "type": "tool-input-delta" + }, + { + "delta": "va", + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "type": "tool-input-delta" + }, + { + "delta": "lue", + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "type": "tool-input-delta" + }, + { + "delta": "\":\"", + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "type": "tool-input-delta" + }, + { + "delta": "Spark", + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "type": "tool-input-delta" + }, + { + "delta": "le", + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "type": "tool-input-delta" + }, + { + "delta": " Day", + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "type": "tool-input-delta" + }, + { + "delta": "\"}", + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "type": "tool-input-delta" + }, + { + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "type": "tool-input-end" + }, + { + "input": "{\"value\":\"Sparkle Day\"}", + "toolCallId": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "toolName": "test-tool", + "type": "tool-call" + }, + { + "finishReason": { + "raw": "tool_calls", + "unified": "tool-calls" + }, + "providerMetadata": { + "test-provider": {} + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 18, + "total": 18 + }, + "outputTokens": { + "reasoning": 0, + "text": 439, + "total": 439 + }, + "raw": { + "completion_time": 0.798181818, + "completion_tokens": 439, + "prompt_time": 0.000211569, + "prompt_tokens": 18, + "queue_time": 0.061348671, + "total_time": 0.798393387, + "total_tokens": 457 + } + } + } + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should not duplicate tool calls when there is an additional empty chunk after the tool call has been completed + #[tokio::test] + async fn should_not_duplicate_tool_calls_when_there_is_an_additional_empty_chunk() { + let fetch = stream_mock(&[ + r#"{"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":226,"total_tokens":226,"completion_tokens":0}}"#, + r#"{"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"id":"chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa","type":"function","index":0,"function":{"name":"searchGoogle"}}]},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":226,"total_tokens":233,"completion_tokens":7}}"#, + r#"{"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"query\": \""}}]},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":226,"total_tokens":241,"completion_tokens":15}}"#, + r#"{"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"latest"}}]},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":226,"total_tokens":242,"completion_tokens":16}}"#, + r#"{"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" news"}}]},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":226,"total_tokens":243,"completion_tokens":17}}"#, + r#"{"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" on"}}]},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":226,"total_tokens":244,"completion_tokens":18}}"#, + r#"{"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" ai\"}"}}]},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":226,"total_tokens":245,"completion_tokens":19}}"#, + // empty arguments chunk after the tool call has already been finished: + r#"{"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":""}}]},"logprobs":null,"finish_reason":"tool_calls","stop_reason":128008}],"usage":{"prompt_tokens":226,"total_tokens":246,"completion_tokens":20}}"#, + r#"{"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,"model":"meta/llama-3.1-8b-instruct:fp8","choices":[],"usage":{"prompt_tokens":226,"total_tokens":246,"completion_tokens":20}}"#, + ]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({ + "tools": [function_tool("searchGoogle", json!({ + "type": "object", + "properties": { "query": { "type": "string" } }, + "required": ["query"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + }))], + }))) + .await + .unwrap(); + + assert_eq!( + Value::Array(stream_parts(result).await), + json!([ + { + "type": "stream-start", + "warnings": [] + }, + { + "id": "chat-2267f7e2910a4254bac0650ba74cfc1c", + "modelId": "meta/llama-3.1-8b-instruct:fp8", + "timestamp": "2024-12-02T17:57:21.000Z", + "type": "response-metadata" + }, + { + "id": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa", + "toolName": "searchGoogle", + "type": "tool-input-start" + }, + { + "delta": "{\"query\": \"", + "id": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa", + "type": "tool-input-delta" + }, + { + "delta": "latest", + "id": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa", + "type": "tool-input-delta" + }, + { + "delta": " news", + "id": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa", + "type": "tool-input-delta" + }, + { + "delta": " on", + "id": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa", + "type": "tool-input-delta" + }, + { + "delta": " ai\"}", + "id": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa", + "type": "tool-input-delta" + }, + { + "delta": "", + "id": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa", + "type": "tool-input-delta" + }, + { + "id": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa", + "type": "tool-input-end" + }, + { + "input": "{\"query\": \"latest news on ai\"}", + "toolCallId": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa", + "toolName": "searchGoogle", + "type": "tool-call" + }, + { + "finishReason": { + "raw": "tool_calls", + "unified": "tool-calls" + }, + "providerMetadata": { + "test-provider": {} + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 226, + "total": 226 + }, + "outputTokens": { + "reasoning": 0, + "text": 20, + "total": 20 + }, + "raw": { + "completion_tokens": 20, + "prompt_tokens": 226, + "total_tokens": 246 + } + } + } + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should stream tool call that is sent in one chunk + #[tokio::test] + async fn should_stream_tool_call_that_is_sent_in_one_chunk() { + let fetch = stream_mock(&[ + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_O17Uplv4lJvD6DVdIvFFeRMw","type":"function","function":{"name":"test-tool","arguments":"{\"value\":\"Sparkle Day\"}"}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1729171479,"model":"grok-3","system_fingerprint":"fp_10c08bf97d","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"queue_time":0.061348671,"prompt_tokens":18,"prompt_time":0.000211569,"completion_tokens":439,"completion_time":0.798181818,"total_tokens":457,"total_time":0.798393387}}"#, + ]); + + let result = model(&fetch).do_stream(&test_tool_options()).await.unwrap(); + + assert_eq!( + Value::Array(stream_parts(result).await), + json!([ + { + "type": "stream-start", + "warnings": [] + }, + { + "id": "chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798", + "modelId": "grok-3", + "timestamp": "2024-03-25T09:06:38.000Z", + "type": "response-metadata" + }, + { + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "toolName": "test-tool", + "type": "tool-input-start" + }, + { + "delta": "{\"value\":\"Sparkle Day\"}", + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "type": "tool-input-delta" + }, + { + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "type": "tool-input-end" + }, + { + "input": "{\"value\":\"Sparkle Day\"}", + "toolCallId": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "toolName": "test-tool", + "type": "tool-call" + }, + { + "finishReason": { + "raw": "tool_calls", + "unified": "tool-calls" + }, + "providerMetadata": { + "test-provider": {} + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 18, + "total": 18 + }, + "outputTokens": { + "reasoning": 0, + "text": 439, + "total": 439 + }, + "raw": { + "completion_time": 0.798181818, + "completion_tokens": 439, + "prompt_time": 0.000211569, + "prompt_tokens": 18, + "queue_time": 0.061348671, + "total_time": 0.798393387, + "total_tokens": 457 + } + } + } + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should stream empty tool call that is sent in one chunk + #[tokio::test] + async fn should_stream_empty_tool_call_that_is_sent_in_one_chunk() { + let fetch = stream_mock(&[ + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-3","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_O17Uplv4lJvD6DVdIvFFeRMw","type":"function","function":{"name":"test-tool","arguments":""}}]},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1729171479,"model":"grok-3","system_fingerprint":"fp_10c08bf97d","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"queue_time":0.061348671,"prompt_tokens":18,"prompt_time":0.000211569,"completion_tokens":439,"completion_time":0.798181818,"total_tokens":457,"total_time":0.798393387}}"#, + ]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({ + "tools": [function_tool("test-tool", json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + }))], + }))) + .await + .unwrap(); + + assert_eq!( + Value::Array(stream_parts(result).await), + json!([ + { + "type": "stream-start", + "warnings": [] + }, + { + "id": "chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798", + "modelId": "grok-3", + "timestamp": "2024-03-25T09:06:38.000Z", + "type": "response-metadata" + }, + { + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "toolName": "test-tool", + "type": "tool-input-start" + }, + { + "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "type": "tool-input-end" + }, + { + "input": "", + "toolCallId": "call_O17Uplv4lJvD6DVdIvFFeRMw", + "toolName": "test-tool", + "type": "tool-call" + }, + { + "finishReason": { + "raw": "tool_calls", + "unified": "tool-calls" + }, + "providerMetadata": { + "test-provider": {} + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 18, + "total": 18 + }, + "outputTokens": { + "reasoning": 0, + "text": 439, + "total": 439 + }, + "raw": { + "completion_time": 0.798181818, + "completion_tokens": 439, + "prompt_time": 0.000211569, + "prompt_tokens": 18, + "queue_time": 0.061348671, + "total_time": 0.798393387, + "total_tokens": 457 + } + } + } + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should preserve structured error stream parts + #[tokio::test] + async fn should_preserve_structured_error_stream_parts() { + let fetch = stream_mock(&[ + r#"{"error": {"message": "Context length exceeded", "code": "CONTEXT_LENGTH_EXCEEDED"}}"#, + ]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + Value::Array(stream_parts(result).await), + json!([ + { + "type": "stream-start", + "warnings": [] + }, + { + "error": { + "code": "CONTEXT_LENGTH_EXCEEDED", + "message": "Context length exceeded" + }, + "type": "error" + }, + { + "finishReason": { + "unified": "error" + }, + "providerMetadata": { + "test-provider": {} + }, + "type": "finish", + "usage": { + "inputTokens": {}, + "outputTokens": {} + } + } + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should handle unparsable stream parts + #[tokio::test] + async fn should_handle_unparsable_stream_parts() { + let fetch = stream_mock(&["{unparsable}"]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({}))) + .await + .unwrap(); + let mut parts = stream_parts(result).await; + + let mut expected = json!([ + { + "type": "stream-start", + "warnings": [] + }, + { + "error": { + "name": "AI_JSONParseError", + "message": "JSON parsing failed: Text: {unparsable}.\nError message: SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)" + }, + "type": "error" + }, + { + "finishReason": { + "unified": "error" + }, + "providerMetadata": { + "test-provider": {} + }, + "type": "finish", + "usage": { + "inputTokens": {}, + "outputTokens": {} + } + } + ]); + // The `SyntaxError` text after "Error message: " is the JS runtime's + // `JSON.parse` message; only the part before it is compared. + let runtime_specific = |parts: &mut Vec| { + let message = parts[1]["error"]["message"].take(); + let message = message.as_str().unwrap_or_default(); + message + .split_once("\nError message: ") + .map_or(message.to_string(), |(prefix, _)| prefix.to_string()) + }; + let message = runtime_specific(&mut parts); + let expected_message = runtime_specific(expected.as_array_mut().unwrap()); + assert_eq!(Value::Array(parts), expected); + assert_eq!(message, expected_message); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should pass the messages and the model + #[tokio::test] + async fn should_pass_the_messages_and_the_model() { + let fetch = MockFetch::new(vec![prepare_stream_response(&[], "stop")]); + + model(&fetch) + .do_stream(&stream_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "grok-3", + "stream": true + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should pass video input as video_url in doStream + #[tokio::test] + async fn should_pass_video_input_as_video_url_in_do_stream() { + let fetch = MockFetch::new(vec![prepare_stream_response(&[], "stop")]); + + model(&fetch) + .do_stream(&stream_options(json!({ + "prompt": [{ + "role": "user", + "content": [{ + "type": "file", + "data": { "type": "url", "url": "https://example.com/video.mp4" }, + "mediaType": "video/mp4", + }], + }], + }))) + .await + .unwrap(); + + assert_match_object( + &request_body(&fetch), + &json!({ + "messages": [{ + "role": "user", + "content": [{ + "type": "video_url", + "video_url": { "url": "https://example.com/video.mp4" }, + }], + }], + "stream": true, + }), + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should pass headers + #[tokio::test] + async fn should_pass_headers() { + let fetch = MockFetch::new(vec![prepare_stream_response(&[], "stop")]); + let provider = create_openai_compatible(OpenAICompatibleProviderSettings { + base_url: "https://my.api.com/v1".into(), + headers: Some(headers(&[ + ("Authorization", "Bearer test-api-key"), + ("Custom-Provider-Header", "provider-header-value"), + ])), + ..settings(&fetch) + }) + .unwrap(); + + provider + .chat("grok-3") + .do_stream(&stream_options(json!({ + "headers": { "Custom-Request-Header": "request-header-value" }, + }))) + .await + .unwrap(); + + assert_eq!( + request_headers(&fetch.seen()[0]), + json!({ + "authorization": "Bearer test-api-key", + "content-type": "application/json", + "custom-provider-header": "provider-header-value", + "custom-request-header": "request-header-value" + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should include provider-specific options + #[tokio::test] + async fn should_include_provider_specific_options() { + let fetch = MockFetch::new(vec![prepare_stream_response(&[], "stop")]); + + provider(&fetch) + .chat("grok-3") + .do_stream(&stream_options(json!({ + "providerOptions": { "test-provider": { "someCustomOption": "test-value" } }, + }))) + .await + .unwrap(); + + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "grok-3", + "someCustomOption": "test-value", + "stream": true + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should not include provider-specific options for different provider + #[tokio::test] + async fn should_not_include_provider_specific_options_for_different_provider() { + let fetch = MockFetch::new(vec![prepare_stream_response(&[], "stop")]); + + provider(&fetch) + .chat("grok-3") + .do_stream(&stream_options(json!({ + "providerOptions": { "notThisProviderName": { "someCustomOption": "test-value" } }, + }))) + .await + .unwrap(); + + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "grok-3", + "stream": true + }) + ); + } + + mod camel_case_provider_options { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › camelCase provider options › should accept camelCase provider options key for hyphenated provider name + #[tokio::test] + async fn should_accept_camel_case_provider_options_key_for_hyphenated_provider_name() { + let fetch = MockFetch::new(vec![prepare_stream_response(&[], "stop")]); + + provider(&fetch) + .chat("grok-3") + .do_stream(&stream_options(json!({ + "providerOptions": { "testProvider": { "someCustomOption": "test-value" } }, + }))) + .await + .unwrap(); + + assert_match_object( + &request_body(&fetch), + &json!({ "someCustomOption": "test-value" }), + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › camelCase provider options › should prefer camelCase options over raw-name options + #[tokio::test] + async fn should_prefer_camel_case_options_over_raw_name_options() { + let fetch = MockFetch::new(vec![prepare_stream_response(&[], "stop")]); + + provider(&fetch) + .chat("grok-3") + .do_stream(&stream_options(json!({ + "providerOptions": { + "test-provider": { "someCustomOption": "raw-value" }, + "testProvider": { "someCustomOption": "camel-value" }, + }, + }))) + .await + .unwrap(); + + assert_match_object( + &request_body(&fetch), + &json!({ "someCustomOption": "camel-value" }), + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › camelCase provider options › should emit deprecated warning when raw provider options key is used + #[tokio::test] + async fn should_emit_deprecated_warning_when_raw_provider_options_key_is_used() { + let fetch = MockFetch::new(vec![prepare_stream_response(&["Hello"], "stop")]); + + let result = provider(&fetch) + .chat("grok-3") + .do_stream(&stream_options(json!({ + "providerOptions": { "test-provider": { "reasoningEffort": "high" } }, + }))) + .await + .unwrap(); + let parts = stream_parts(result).await; + + assert_eq!( + find(&parts, "stream-start")["warnings"], + json!([ + { + "message": "Use 'testProvider' instead.", + "setting": "providerOptions key 'test-provider'", + "type": "deprecated" + } + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › camelCase provider options › should not emit deprecated warning when camelCase provider options key is used + #[tokio::test] + async fn should_not_emit_deprecated_warning_when_camel_case_provider_options_key_is_used() { + let fetch = MockFetch::new(vec![prepare_stream_response(&["Hello"], "stop")]); + + let result = provider(&fetch) + .chat("grok-3") + .do_stream(&stream_options(json!({ + "providerOptions": { "testProvider": { "reasoningEffort": "high" } }, + }))) + .await + .unwrap(); + let parts = stream_parts(result).await; + + assert_eq!(find(&parts, "stream-start")["warnings"], json!([])); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › camelCase provider options › should use camelCase metadata key in finish event when camelCase options are used + #[tokio::test] + async fn should_use_camel_case_metadata_key_in_finish_event_when_camel_case_options_are_used() + { + let fetch = MockFetch::new(vec![prepare_stream_response(&["Hello"], "stop")]); + + let result = provider(&fetch) + .chat("grok-3") + .do_stream(&stream_options(json!({ + "providerOptions": { "testProvider": {} }, + }))) + .await + .unwrap(); + let parts = stream_parts(result).await; + let finish_part = find(&parts, "finish"); + + assert!( + finish_part["providerMetadata"] + .get("testProvider") + .is_some() + ); + assert!( + finish_part["providerMetadata"] + .get("test-provider") + .is_none() + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › camelCase provider options › should use raw metadata key in finish event when raw options are used + #[tokio::test] + async fn should_use_raw_metadata_key_in_finish_event_when_raw_options_are_used() { + let fetch = MockFetch::new(vec![prepare_stream_response(&["Hello"], "stop")]); + + let result = provider(&fetch) + .chat("grok-3") + .do_stream(&stream_options(json!({ + "providerOptions": { "test-provider": {} }, + }))) + .await + .unwrap(); + let parts = stream_parts(result).await; + + assert!( + find(&parts, "finish")["providerMetadata"] + .get("test-provider") + .is_some() + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › camelCase provider options › should use raw metadata key in finish event when no provider options are passed + #[tokio::test] + async fn should_use_raw_metadata_key_in_finish_event_when_no_provider_options_are_passed() { + let fetch = MockFetch::new(vec![prepare_stream_response(&["Hello"], "stop")]); + + let result = provider(&fetch) + .chat("grok-3") + .do_stream(&stream_options(json!({}))) + .await + .unwrap(); + let parts = stream_parts(result).await; + + assert!( + find(&parts, "finish")["providerMetadata"] + .get("test-provider") + .is_some() + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › camelCase provider options › should use camelCase metadata key for thought signatures in streamed tool calls + #[tokio::test] + async fn should_use_camel_case_metadata_key_for_thought_signatures_in_streamed_tool_calls() + { + let fetch = stream_mock(&[ + r#"{"id":"chat-id","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"test_tool","arguments":"{\"a\":1}"},"extra_content":{"google":{"thought_signature":""}}}]},"finish_reason":null}]}"#, + r#"{"id":"chat-id","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}"#, + ]); + + let result = provider(&fetch) + .chat("grok-3") + .do_stream(&stream_options(json!({ + "providerOptions": { "testProvider": {} }, + }))) + .await + .unwrap(); + let parts = stream_parts(result).await; + + assert_match_object( + find(&parts, "tool-call"), + &json!({ + "type": "tool-call", + "providerMetadata": { "testProvider": { "thoughtSignature": "" } }, + }), + ); + } + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should send request body + #[tokio::test] + async fn should_send_request_body() { + let fetch = MockFetch::new(vec![prepare_stream_response(&[], "stop")]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + serde_json::to_value(result.request).unwrap(), + json!({ + "body": { + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "grok-3", + "stream": true + } + }) + ); + } + + mod usage_details_in_streaming { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › usage details in streaming › should extract detailed token usage from stream finish + #[tokio::test] + async fn should_extract_detailed_token_usage_from_stream_finish() { + let fetch = stream_mock(&[ + r#"{"id":"chat-id","choices":[{"delta":{"content":"Hello"}}]}"#, + r#"{"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":20,"completion_tokens":30,"prompt_tokens_details":{"cached_tokens":5},"completion_tokens_details":{"reasoning_tokens":10,"accepted_prediction_tokens":15,"rejected_prediction_tokens":5}}}"#, + ]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({}))) + .await + .unwrap(); + let parts = stream_parts(result).await; + + assert_eq!( + find(&parts, "finish"), + &json!({ + "finishReason": { + "raw": "stop", + "unified": "stop" + }, + "providerMetadata": { + "test-provider": { + "acceptedPredictionTokens": 15, + "rejectedPredictionTokens": 5 + } + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 5, + "noCache": 15, + "total": 20 + }, + "outputTokens": { + "reasoning": 10, + "text": 20, + "total": 30 + }, + "raw": { + "completion_tokens": 30, + "completion_tokens_details": { + "accepted_prediction_tokens": 15, + "reasoning_tokens": 10, + "rejected_prediction_tokens": 5 + }, + "prompt_tokens": 20, + "prompt_tokens_details": { + "cached_tokens": 5 + } + } + } + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › usage details in streaming › should handle missing token details in stream + #[tokio::test] + async fn should_handle_missing_token_details_in_stream() { + let fetch = stream_mock(&[ + r#"{"id":"chat-id","choices":[{"delta":{"content":"Hello"}}]}"#, + r#"{"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":20,"completion_tokens":30}}"#, + ]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({}))) + .await + .unwrap(); + let parts = stream_parts(result).await; + + assert_eq!( + find(&parts, "finish")["providerMetadata"]["test-provider"], + json!({}) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › usage details in streaming › should handle partial token details in stream + #[tokio::test] + async fn should_handle_partial_token_details_in_stream() { + let fetch = stream_mock(&[ + r#"{"id":"chat-id","choices":[{"delta":{"content":"Hello"}}]}"#, + r#"{"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":20,"completion_tokens":30,"total_tokens":50,"prompt_tokens_details":{"cached_tokens":5},"completion_tokens_details":{"reasoning_tokens":10}}}"#, + ]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({}))) + .await + .unwrap(); + let parts = stream_parts(result).await; + + assert_eq!( + find(&parts, "finish"), + &json!({ + "finishReason": { + "raw": "stop", + "unified": "stop" + }, + "providerMetadata": { + "test-provider": {} + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 5, + "noCache": 15, + "total": 20 + }, + "outputTokens": { + "reasoning": 10, + "text": 20, + "total": 30 + }, + "raw": { + "completion_tokens": 30, + "completion_tokens_details": { + "reasoning_tokens": 10 + }, + "prompt_tokens": 20, + "prompt_tokens_details": { + "cached_tokens": 5 + }, + "total_tokens": 50 + } + } + }) + ); + } + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › doStream › should extract response metadata after an empty placeholder chunk + #[tokio::test] + async fn should_extract_response_metadata_after_an_empty_placeholder_chunk() { + let fetch = MockFetch::new(vec![prepare_chunks_fixture_response( + "placeholder-first-chunk", + &[], + )]); + + let result = model(&fetch) + .do_stream(&stream_options(json!({}))) + .await + .unwrap(); + let events = stream_parts(result).await; + + assert_eq!( + find(&events, "response-metadata"), + &json!({ + "type": "response-metadata", + "id": "chatcmpl-123", + "modelId": "actual-model-name", + // `new Date(1700000000 * 1000)` + "timestamp": "2023-11-14T22:13:20.000Z", + }) + ); + } +} + +mod metadata_extraction { + use super::*; + + /// The model of both tests: `metadataExtractor: testMetadataExtractor`. + fn extractor_model(fetch: &Arc) -> OpenAICompatibleChatModel { + direct_model( + "gpt-5", + OpenAICompatibleProviderSettings { + metadata_extractor: Some(Arc::new(TestMetadataExtractor)), + ..settings(fetch) + }, + ) + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › metadata extraction › should process metadata from complete response + #[tokio::test] + async fn should_process_metadata_from_complete_response() { + let fetch = json_mock(json!({ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1711115037, + "model": "gpt-5", + "choices": [{ + "index": 0, + "message": { "role": "assistant" }, + "finish_reason": "stop", + }], + "test_field": "test_value", + })); + + let result = extractor_model(&fetch) + .do_generate(&call_options(json!({}))) + .await + .unwrap(); + + assert_eq!( + result_json(&result)["providerMetadata"], + json!({ "test-provider": { "value": "test_value" } }) + ); + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "gpt-5" + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › metadata extraction › should process metadata from streaming response + #[tokio::test] + async fn should_process_metadata_from_streaming_response() { + let fetch = stream_mock(&[ + r#"{"choices":[{"delta":{"content":"Hello"}}]}"#, + r#"{"choices":[{"finish_reason":"stop"}],"test_field":"test_value"}"#, + ]); + + let result = extractor_model(&fetch) + .do_stream(&call_options(json!({ "includeRawChunks": false }))) + .await + .unwrap(); + let parts = stream_parts(result).await; + + assert_eq!( + find(&parts, "finish")["providerMetadata"], + json!({ "test-provider": { "value": "test_value" } }) + ); + assert_eq!( + request_body(&fetch), + json!({ + "messages": [ + { + "content": "Hello", + "role": "user" + } + ], + "model": "gpt-5", + "stream": true + }) + ); + } +} + +mod raw_chunks { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › raw chunks › should include raw chunks when includeRawChunks is true + #[tokio::test] + async fn should_include_raw_chunks_when_include_raw_chunks_is_true() { + let fetch = stream_mock(&[ + r#"{"id":"chat-id","choices":[{"delta":{"content":"Hello"}}]}"#, + r#"{"choices":[{"delta":{},"finish_reason":"stop"}]}"#, + ]); + + let result = model(&fetch) + .do_stream(&call_options(json!({ "includeRawChunks": true }))) + .await + .unwrap(); + + assert_eq!( + Value::Array(stream_parts(result).await), + json!([ + { + "type": "stream-start", + "warnings": [] + }, + { + "rawValue": { + "choices": [ + { + "delta": { + "content": "Hello" + } + } + ], + "id": "chat-id" + }, + "type": "raw" + }, + { + "id": "chat-id", + "type": "response-metadata" + }, + { + "id": "txt-0", + "type": "text-start" + }, + { + "delta": "Hello", + "id": "txt-0", + "type": "text-delta" + }, + { + "rawValue": { + "choices": [ + { + "delta": {}, + "finish_reason": "stop" + } + ] + }, + "type": "raw" + }, + { + "id": "txt-0", + "type": "text-end" + }, + { + "finishReason": { + "raw": "stop", + "unified": "stop" + }, + "providerMetadata": { + "test-provider": {} + }, + "type": "finish", + "usage": { + "inputTokens": {}, + "outputTokens": {} + } + } + ]) + ); + } +} + +mod transform_request_body { + use super::*; + + /// `prepareTransformJsonResponse`. + fn prepare_transform_json_response() -> Arc { + json_mock(json!({ + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1711115037, + "model": "grok-3", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": "Hello!" }, + "finish_reason": "stop", + }], + "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, + })) + } + + /// `prepareTransformStreamResponse`. + fn prepare_transform_stream_response() -> Arc { + stream_mock(&[ + r#"{"id":"chatcmpl-test","object":"chat.completion.chunk","created":1711115037,"model":"grok-3","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}"#, + r#"{"id":"chatcmpl-test","object":"chat.completion.chunk","created":1711115037,"model":"grok-3","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":2,"total_tokens":6}}"#, + ]) + } + + /// `vi.fn(body => ({ ...body, custom_field: 'added-by-transform' }))`: + /// the transform and the bodies it was called with. + fn transform_fn() -> ( + crate::openai_compatible::TransformRequestBody, + Arc>>, + ) { + let calls = Arc::new(Mutex::new(Vec::new())); + let seen = calls.clone(); + let transform: crate::openai_compatible::TransformRequestBody = + Arc::new(move |mut body: Value| { + seen.lock().unwrap().push(body.clone()); + body["custom_field"] = json!("added-by-transform"); + body + }); + (transform, calls) + } + + /// `new OpenAICompatibleChatLanguageModel('grok-3', { provider: + /// 'test-provider', url: ({ path }) => `https://my.api.com/v1${path}`, + /// headers: () => ({}), ... })`. + fn transform_model( + fetch: &Arc, + transform: Option, + ) -> OpenAICompatibleChatModel { + direct_model( + "grok-3", + OpenAICompatibleProviderSettings { + base_url: "https://my.api.com/v1".into(), + transform_request_body: transform, + ..settings(fetch) + }, + ) + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › transformRequestBody › should transform request body in doGenerate when transformRequestBody is provided + #[tokio::test] + async fn should_transform_request_body_in_do_generate_when_transform_request_body_is_provided() + { + let (transform, calls) = transform_fn(); + let fetch = prepare_transform_json_response(); + let model = transform_model(&fetch, Some(transform)); + + model.do_generate(&call_options(json!({}))).await.unwrap(); + + // Verify transform was called + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0]["model"], "grok-3"); + assert_eq!( + calls[0]["messages"], + json!([{ "role": "user", "content": "Hello" }]) + ); + // Verify transformed body was sent + assert_match_object( + &request_body(&fetch), + &json!({ "custom_field": "added-by-transform" }), + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › transformRequestBody › should transform request body in doStream when transformRequestBody is provided + #[tokio::test] + async fn should_transform_request_body_in_do_stream_when_transform_request_body_is_provided() { + let (transform, calls) = transform_fn(); + let fetch = prepare_transform_stream_response(); + let model = transform_model(&fetch, Some(transform)); + + let result = model.do_stream(&call_options(json!({}))).await.unwrap(); + // Consume the stream + stream_parts(result).await; + + // Verify transform was called + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0]["model"], "grok-3"); + assert_eq!( + calls[0]["messages"], + json!([{ "role": "user", "content": "Hello" }]) + ); + assert_eq!(calls[0]["stream"], true); + // Verify transformed body was sent + assert_match_object( + &request_body(&fetch), + &json!({ "custom_field": "added-by-transform" }), + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-chat-language-model.test.ts › transformRequestBody › should work without transformRequestBody + #[tokio::test] + async fn should_work_without_transform_request_body() { + let fetch = prepare_transform_json_response(); + let model = transform_model(&fetch, None); + + model.do_generate(&call_options(json!({}))).await.unwrap(); + let request_body = request_body(&fetch); + + assert_match_object(&request_body, &json!({ "model": "grok-3" })); + assert_eq!(request_body.get("custom_field"), None); + } +} diff --git a/aimux-providers/src/openai_compatible/tests/chat/convert_chat_usage.rs b/aimux-providers/src/openai_compatible/tests/chat/convert_chat_usage.rs new file mode 100644 index 000000000..e88425b95 --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/chat/convert_chat_usage.rs @@ -0,0 +1,77 @@ +use serde_json::{Value, json}; + +use crate::openai_compatible::chat::usage_from_raw; + +/// `convertOpenAICompatibleChatUsage`: the Rust form takes the raw `usage` +/// value (absent for `undefined`); the result is compared in its JSON form, +/// where an absent count is an `undefined` key. +fn convert_open_ai_compatible_chat_usage(usage: Option<&Value>) -> Value { + serde_json::to_value(usage_from_raw(usage)).expect("usage serializes") +} + +mod convert_open_ai_compatible_chat_usage { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-openai-compatible-chat-usage.test.ts › convertOpenAICompatibleChatUsage › returns null usage when usage is missing + #[test] + fn returns_null_usage_when_usage_is_missing() { + // createNullLanguageModelUsage(): every count undefined, no raw. + assert_eq!( + convert_open_ai_compatible_chat_usage(None), + json!({ "inputTokens": {}, "outputTokens": {} }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-openai-compatible-chat-usage.test.ts › convertOpenAICompatibleChatUsage › splits completion tokens into text and reasoning + #[test] + fn splits_completion_tokens_into_text_and_reasoning() { + let usage = json!({ + "prompt_tokens": 10, + "completion_tokens": 20, + "completion_tokens_details": { "reasoning_tokens": 5 }, + }); + assert_eq!( + convert_open_ai_compatible_chat_usage(Some(&usage)), + json!({ + "inputTokens": { + "total": 10, + "noCache": 10, + "cacheRead": 0, + }, + "outputTokens": { "total": 20, "text": 15, "reasoning": 5 }, + "raw": { + "prompt_tokens": 10, + "completion_tokens": 20, + "completion_tokens_details": { "reasoning_tokens": 5 }, + }, + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-openai-compatible-chat-usage.test.ts › convertOpenAICompatibleChatUsage › clamps text tokens at 0 when reasoning exceeds completion + #[test] + fn clamps_text_tokens_at_0_when_reasoning_exceeds_completion() { + // Provider-inconsistent usage (Baseten Kimi-K3, finish_reason 'length'): + // completion_tokens undercounts the actual generation, so + // completion_tokens_details.reasoning_tokens > completion_tokens. + let usage = json!({ + "prompt_tokens": 951, + "completion_tokens": 6000, + "total_tokens": 6952, + "prompt_tokens_details": { "cached_tokens": 60 }, + "completion_tokens_details": { "reasoning_tokens": 6001 }, + }); + assert_eq!( + convert_open_ai_compatible_chat_usage(Some(&usage)), + json!({ + "inputTokens": { + "total": 951, + "noCache": 891, + "cacheRead": 60, + }, + "outputTokens": { "total": 6000, "text": 0, "reasoning": 6001 }, + "raw": usage, + }) + ); + } +} diff --git a/aimux-providers/src/openai_compatible/tests/chat/convert_to_chat_messages.rs b/aimux-providers/src/openai_compatible/tests/chat/convert_to_chat_messages.rs new file mode 100644 index 000000000..85419b9d2 --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/chat/convert_to_chat_messages.rs @@ -0,0 +1,2076 @@ +use serde_json::{Value, json}; + +use aimux_core::error::AiMuxError; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt, UserPart}; +use aimux_core::shared::{FileBytes, FileData}; + +use crate::openai_compatible::convert::{MessageSpec, convert_messages}; + +/// A prompt in its AI SDK JSON form. +fn prompt(value: Value) -> LanguageModelPrompt { + serde_json::from_value(value).expect("prompt") +} + +/// Replace the data of file part `part` of user message `message` with raw +/// bytes: the JSON form only carries base64 strings, upstream passes a +/// `Uint8Array` there. +fn with_uint8_array( + mut prompt: LanguageModelPrompt, + message: usize, + part: usize, + bytes: &[u8], +) -> LanguageModelPrompt { + let LanguageModelMessage::User { content, .. } = &mut prompt[message] else { + panic!("message {message} is not a user message"); + }; + let UserPart::File(file) = &mut content[part] else { + panic!("part {part} is not a file part"); + }; + file.data = FileData::Data { + data: FileBytes::Binary(bytes.to_vec()), + }; + prompt +} + +/// `convertToOpenAICompatibleChatMessages(prompt, { providerOptionsKey, +/// supportsMultiPartToolContent })`. +fn convert_with( + prompt: &LanguageModelPrompt, + provider_options_key: &str, + supports_multi_part_tool_content: bool, +) -> Result { + convert_messages( + prompt, + &MessageSpec { + metadata_key: provider_options_key, + supports_multi_part_tool_content, + }, + ) + .map(Value::Array) +} + +/// `convertToOpenAICompatibleChatMessages(prompt)` with upstream's defaults +/// (`providerOptionsKey: 'google'`, `supportsMultiPartToolContent: false`). +fn convert(prompt: &LanguageModelPrompt) -> Value { + convert_with(prompt, "google", false).expect("conversion succeeds") +} + +/// `expect(() => convert(prompt)).toThrow("'' functionality not +/// supported")`: an `UnsupportedFunctionalityError` for that functionality. +fn assert_unsupported(prompt: &LanguageModelPrompt, functionality: &str) { + match convert_with(prompt, "google", false) { + Err(AiMuxError::UnsupportedFunctionality(actual)) => { + assert_eq!(actual, functionality); + } + other => panic!("expected UnsupportedFunctionality({functionality:?}), got {other:?}"), + } +} + +/// `toMatchObject`: objects match by subset, arrays element-wise with the +/// same length, other values by equality. +fn assert_match_object(actual: &Value, expected: &Value) { + match (actual, expected) { + (Value::Object(actual_map), Value::Object(expected_map)) => { + for (key, expected_value) in expected_map { + let actual_value = actual_map + .get(key) + .unwrap_or_else(|| panic!("missing key {key:?} in {actual}")); + assert_match_object(actual_value, expected_value); + } + } + (Value::Array(actual_items), Value::Array(expected_items)) => { + assert_eq!( + actual_items.len(), + expected_items.len(), + "array length differs: {actual} vs {expected}" + ); + for (actual_item, expected_item) in actual_items.iter().zip(expected_items) { + assert_match_object(actual_item, expected_item); + } + } + _ => assert_eq!(actual, expected), + } +} + +/// `JSON.stringify(value)` (the JSON maps keep insertion order). +fn stringify(value: &Value) -> String { + value.to_string() +} + +mod user_messages { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › user messages › should convert messages with only a text part to a string content + #[test] + fn should_convert_messages_with_only_a_text_part_to_a_string_content() { + let result = convert(&prompt(json!([ + { + "role": "user", + "content": [{ "type": "text", "text": "Hello" }], + }, + ]))); + + assert_eq!(result, json!([{ "role": "user", "content": "Hello" }])); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › user messages › should convert messages with image parts + #[test] + fn should_convert_messages_with_image_parts() { + let result = convert(&prompt(json!([ + { + "role": "user", + "content": [ + { "type": "text", "text": "Hello" }, + { + "type": "file", + "data": { "type": "data", "data": "AAECAw==" }, + "mediaType": "image/png", + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "user", + "content": [ + { "type": "text", "text": "Hello" }, + { + "type": "image_url", + "image_url": { "url": "data:image/png;base64,AAECAw==" }, + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › user messages › should convert messages with image parts from Uint8Array + #[test] + fn should_convert_messages_with_image_parts_from_uint8_array() { + let result = convert(&with_uint8_array( + prompt(json!([ + { + "role": "user", + "content": [ + { "type": "text", "text": "Hi" }, + { + "type": "file", + "data": { "type": "data", "data": "" }, + "mediaType": "image/png", + }, + ], + }, + ])), + 0, + 1, + &[0, 1, 2, 3], + )); + + assert_eq!( + result, + json!([ + { + "role": "user", + "content": [ + { "type": "text", "text": "Hi" }, + { + "type": "image_url", + "image_url": { "url": "data:image/png;base64,AAECAw==" }, + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › user messages › should handle URL-based images + #[test] + fn should_handle_url_based_images() { + let result = convert(&prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "data": { "type": "url", "url": "https://example.com/image.jpg" }, + "mediaType": "image/*", + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": { "url": "https://example.com/image.jpg" }, + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › user messages › should convert video data parts to video_url data URLs + #[test] + fn should_convert_video_data_parts_to_video_url_data_urls() { + let result = convert(&with_uint8_array( + prompt(json!([ + { + "role": "user", + "content": [ + { "type": "text", "text": "Describe this video" }, + { + "type": "file", + "data": { "type": "data", "data": "" }, + "mediaType": "video/mp4", + "providerOptions": { + "openaiCompatible": { + "fps": 1, + }, + }, + }, + ], + }, + ])), + 0, + 1, + &[0, 1, 2, 3], + )); + + assert_eq!( + result, + json!([ + { + "role": "user", + "content": [ + { "type": "text", "text": "Describe this video" }, + { + "type": "video_url", + "video_url": { "url": "data:video/mp4;base64,AAECAw==" }, + "fps": 1, + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › user messages › should convert base64-encoded video data parts + #[test] + fn should_convert_base64_encoded_video_data_parts() { + let result = convert(&prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "data": { "type": "data", "data": "AAECAw==" }, + "mediaType": "video/webm", + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "user", + "content": [ + { + "type": "video_url", + "video_url": { "url": "data:video/webm;base64,AAECAw==" }, + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › user messages › should pass through video URLs + #[test] + fn should_pass_through_video_urls() { + let result = convert(&prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "data": { "type": "url", "url": "https://example.com/video.mp4" }, + "mediaType": "video/*", + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "user", + "content": [ + { + "type": "video_url", + "video_url": { "url": "https://example.com/video.mp4" }, + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › user messages › should convert messages with audio/wav parts + #[test] + fn should_convert_messages_with_audio_wav_parts() { + let result = convert(&prompt(json!([ + { + "role": "user", + "content": [ + { "type": "text", "text": "Transcribe this audio" }, + { + "type": "file", + "data": { "type": "data", "data": "AAECAw==" }, + "mediaType": "audio/wav", + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "user", + "content": [ + { "type": "text", "text": "Transcribe this audio" }, + { + "type": "input_audio", + "input_audio": { "data": "AAECAw==", "format": "wav" }, + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › user messages › should convert messages with audio/mp3 parts + #[test] + fn should_convert_messages_with_audio_mp3_parts() { + let result = convert(&with_uint8_array( + prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "data": { "type": "data", "data": "" }, + "mediaType": "audio/mp3", + }, + ], + }, + ])), + 0, + 0, + &[0, 1, 2, 3], + )); + + assert_eq!( + result, + json!([ + { + "role": "user", + "content": [ + { + "type": "input_audio", + "input_audio": { "data": "AAECAw==", "format": "mp3" }, + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › user messages › should convert messages with audio/mpeg parts to mp3 format + #[test] + fn should_convert_messages_with_audio_mpeg_parts_to_mp3_format() { + let result = convert(&with_uint8_array( + prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "data": { "type": "data", "data": "" }, + "mediaType": "audio/mpeg", + }, + ], + }, + ])), + 0, + 0, + &[0, 1, 2, 3], + )); + + assert_eq!( + result, + json!([ + { + "role": "user", + "content": [ + { + "type": "input_audio", + "input_audio": { "data": "AAECAw==", "format": "mp3" }, + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › user messages › should throw error for audio parts with URLs + #[test] + fn should_throw_error_for_audio_parts_with_urls() { + assert_unsupported( + &prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "data": { "type": "url", "url": "https://example.com/audio.wav" }, + "mediaType": "audio/wav", + }, + ], + }, + ])), + "audio file parts with URLs", + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › user messages › should throw error for unsupported audio format + #[test] + fn should_throw_error_for_unsupported_audio_format() { + assert_unsupported( + &with_uint8_array( + prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "data": { "type": "data", "data": "" }, + "mediaType": "audio/ogg", + }, + ], + }, + ])), + 0, + 0, + &[0, 1, 2, 3], + ), + "audio media type audio/ogg", + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › user messages › should convert messages with PDF parts + #[test] + fn should_convert_messages_with_pdf_parts() { + let result = convert(&prompt(json!([ + { + "role": "user", + "content": [ + { "type": "text", "text": "Summarize this PDF" }, + { + "type": "file", + "data": { "type": "data", "data": "AAECAw==" }, + "mediaType": "application/pdf", + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "user", + "content": [ + { "type": "text", "text": "Summarize this PDF" }, + { + "type": "file", + "file": { + "filename": "document.pdf", + "file_data": "data:application/pdf;base64,AAECAw==", + }, + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › user messages › should convert messages with PDF parts using provided filename + #[test] + fn should_convert_messages_with_pdf_parts_using_provided_filename() { + let result = convert(&with_uint8_array( + prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "data": { "type": "data", "data": "" }, + "mediaType": "application/pdf", + "filename": "report.pdf", + }, + ], + }, + ])), + 0, + 0, + &[0, 1, 2, 3], + )); + + assert_eq!( + result, + json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "file": { + "filename": "report.pdf", + "file_data": "data:application/pdf;base64,AAECAw==", + }, + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › user messages › should throw error for PDF parts with URLs + #[test] + fn should_throw_error_for_pdf_parts_with_urls() { + assert_unsupported( + &prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "data": { "type": "url", "url": "https://example.com/document.pdf" }, + "mediaType": "application/pdf", + }, + ], + }, + ])), + "PDF file parts with URLs", + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › user messages › should convert messages with base64-encoded text/markdown parts + #[test] + fn should_convert_messages_with_base64_encoded_text_markdown_parts() { + use base64::Engine; + + let markdown_text = "# Hello World\n\nThis is **markdown** content."; + let base64_data = base64::engine::general_purpose::STANDARD.encode(markdown_text); + + let result = convert(&prompt(json!([ + { + "role": "user", + "content": [ + { "type": "text", "text": "Summarize this document" }, + { + "type": "file", + "data": { "type": "data", "data": base64_data }, + "mediaType": "text/markdown", + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "user", + "content": [ + { "type": "text", "text": "Summarize this document" }, + { + "type": "text", + "text": markdown_text, + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › user messages › should convert messages with text/plain parts from Uint8Array + #[test] + fn should_convert_messages_with_text_plain_parts_from_uint8_array() { + let result = convert(&with_uint8_array( + prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "data": { "type": "data", "data": "" }, + "mediaType": "text/plain", + }, + ], + }, + ])), + 0, + 0, + "Plain text content".as_bytes(), + )); + + assert_eq!( + result, + json!([ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Plain text content", + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › user messages › should decode base64 string data for text/* file parts + #[test] + fn should_decode_base64_string_data_for_text_file_parts() { + use base64::Engine; + + let result = convert(&prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "data": { + "type": "data", + "data": base64::engine::general_purpose::STANDARD + .encode("Plain text content"), + }, + "mediaType": "text/plain", + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Plain text content", + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › user messages › should convert text file URL to string + #[test] + fn should_convert_text_file_url_to_string() { + let result = convert(&prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "data": { "type": "url", "url": "https://example.com/readme.md" }, + "mediaType": "text/markdown", + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "https://example.com/readme.md", + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › user messages › should throw error for unsupported file types + #[test] + fn should_throw_error_for_unsupported_file_types() { + assert_unsupported( + &with_uint8_array( + prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "data": { "type": "data", "data": "" }, + "mediaType": "application/zip", + }, + ], + }, + ])), + 0, + 0, + &[0, 1, 2, 3], + ), + "file part media type application/zip", + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › user messages › should throw error for file parts with provider references + #[test] + fn should_throw_error_for_file_parts_with_provider_references() { + assert_unsupported( + &prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "data": { + "type": "reference", + "reference": { "openaiCompatible": "file-123" }, + }, + "mediaType": "image/png", + }, + ], + }, + ])), + "file parts with provider references", + ); + } +} + +mod tool_calls { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › tool calls › should stringify arguments to tool calls + #[test] + fn should_stringify_arguments_to_tool_calls() { + let result = convert(&prompt(json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "input": { "foo": "bar123" }, + "toolCallId": "quux", + "toolName": "thwomp", + }, + ], + }, + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolCallId": "quux", + "toolName": "thwomp", + "output": { "type": "json", "value": { "oof": "321rab" } }, + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "type": "function", + "id": "quux", + "function": { + "name": "thwomp", + "arguments": stringify(&json!({ "foo": "bar123" })), + }, + }, + ], + }, + { + "role": "tool", + "content": stringify(&json!({ "oof": "321rab" })), + "tool_call_id": "quux", + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › tool calls › should send empty string content for assistant messages with no tool calls + #[test] + fn should_send_empty_string_content_for_assistant_messages_with_no_tool_calls() { + let result = convert(&prompt(json!([ + { + "role": "assistant", + "content": [{ "type": "text", "text": "" }], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "content": "", + "role": "assistant", + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › tool calls › should handle text output type in tool results + #[test] + fn should_handle_text_output_type_in_tool_results() { + let result = convert(&prompt(json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "input": { "query": "weather" }, + "toolCallId": "call-1", + "toolName": "getWeather", + }, + ], + }, + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolCallId": "call-1", + "toolName": "getWeather", + "output": { "type": "text", "value": "It is sunny today" }, + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "type": "function", + "id": "call-1", + "function": { + "name": "getWeather", + "arguments": stringify(&json!({ "query": "weather" })), + }, + }, + ], + }, + { + "role": "tool", + "content": "It is sunny today", + "tool_call_id": "call-1", + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › tool calls › should stringify multi-part tool content by default + #[test] + fn should_stringify_multi_part_tool_content_by_default() { + let value = json!([ + { "type": "text", "text": "image result" }, + { + "type": "file", + "data": { "type": "data", "data": "iVBORw0KGgo=" }, + "mediaType": "image/png", + }, + ]); + + let result = convert(&prompt(json!([ + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolCallId": "call-1", + "toolName": "useImage", + "output": { "type": "content", "value": value }, + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "tool", + "tool_call_id": "call-1", + "content": stringify(&value), + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › tool calls › should convert multi-part tool content when supported + #[test] + fn should_convert_multi_part_tool_content_when_supported() { + let result = convert_with( + &prompt(json!([ + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolCallId": "call-1", + "toolName": "useImage", + "output": { + "type": "content", + "value": [ + { "type": "text", "text": "image result" }, + { + "type": "file", + "data": { "type": "data", "data": "iVBORw0KGgo=" }, + "mediaType": "image/png", + }, + { + "type": "file", + "data": { + "type": "url", + "url": "https://example.com/image.png", + }, + "mediaType": "image/png", + }, + ], + }, + }, + ], + }, + ])), + "google", + true, + ) + .expect("conversion succeeds"); + + assert_eq!( + result, + json!([ + { + "role": "tool", + "tool_call_id": "call-1", + "content": [ + { "type": "text", "text": "image result" }, + { + "type": "image_url", + "image_url": { + "url": "data:image/png;base64,iVBORw0KGgo=", + }, + }, + { + "type": "image_url", + "image_url": { "url": "https://example.com/image.png" }, + }, + ], + }, + ]) + ); + } +} + +mod provider_specific_metadata_merging { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › provider-specific metadata merging › should merge system message metadata + #[test] + fn should_merge_system_message_metadata() { + let result = convert(&prompt(json!([ + { + "role": "system", + "content": "You are a helpful assistant.", + "providerOptions": { + "openaiCompatible": { + "cacheControl": { "type": "ephemeral" }, + }, + }, + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "system", + "content": "You are a helpful assistant.", + "cacheControl": { "type": "ephemeral" }, + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › provider-specific metadata merging › should merge user message content metadata + #[test] + fn should_merge_user_message_content_metadata() { + let result = convert(&prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Hello", + "providerOptions": { + "openaiCompatible": { + "cacheControl": { "type": "ephemeral" }, + }, + }, + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "user", + "content": "Hello", + "cacheControl": { "type": "ephemeral" }, + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › provider-specific metadata merging › should prioritize content-level metadata when merging + #[test] + fn should_prioritize_content_level_metadata_when_merging() { + let result = convert(&prompt(json!([ + { + "role": "user", + "providerOptions": { + "openaiCompatible": { + "messageLevel": true, + }, + }, + "content": [ + { + "type": "text", + "text": "Hello", + "providerOptions": { + "openaiCompatible": { + "contentLevel": true, + }, + }, + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "user", + "content": "Hello", + "contentLevel": true, + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › provider-specific metadata merging › should handle tool calls with metadata + #[test] + fn should_handle_tool_calls_with_metadata() { + let result = convert(&prompt(json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "call1", + "toolName": "calculator", + "input": { "x": 1, "y": 2 }, + "providerOptions": { + "openaiCompatible": { + "cacheControl": { "type": "ephemeral" }, + }, + }, + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call1", + "type": "function", + "function": { + "name": "calculator", + "arguments": stringify(&json!({ "x": 1, "y": 2 })), + }, + "cacheControl": { "type": "ephemeral" }, + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › provider-specific metadata merging › should handle image content with metadata + #[test] + fn should_handle_image_content_with_metadata() { + let image_url = "https://example.com/image.jpg"; + let result = convert(&prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "data": { "type": "url", "url": image_url }, + "mediaType": "image/*", + "providerOptions": { + "openaiCompatible": { + "cacheControl": { "type": "ephemeral" }, + }, + }, + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": { "url": image_url }, + "cacheControl": { "type": "ephemeral" }, + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › provider-specific metadata merging › should omit non-openaiCompatible metadata + #[test] + fn should_omit_non_openai_compatible_metadata() { + let result = convert(&prompt(json!([ + { + "role": "system", + "content": "Hello", + "providerOptions": { + "someOtherProvider": { + "shouldBeIgnored": true, + }, + }, + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "system", + "content": "Hello", + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › provider-specific metadata merging › should handle a user message with multiple content parts (text + image) + #[test] + fn should_handle_a_user_message_with_multiple_content_parts_text_image() { + let result = convert(&prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Hello from part 1", + "providerOptions": { + "openaiCompatible": { "sentiment": "positive" }, + "leftoverKey": { "foo": "some leftover data" }, + }, + }, + { + "type": "file", + "data": { "type": "data", "data": "AAECAw==" }, + "mediaType": "image/png", + "providerOptions": { + "openaiCompatible": { "alt_text": "A sample image" }, + }, + }, + ], + "providerOptions": { + "openaiCompatible": { "priority": "high" }, + }, + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "user", + "priority": "high", // hoisted from message-level providerOptions + "content": [ + { + "type": "text", + "text": "Hello from part 1", + "sentiment": "positive", // hoisted from part-level openaiCompatible + }, + { + "type": "image_url", + "image_url": { + "url": "data:image/png;base64,AAECAw==", + }, + "alt_text": "A sample image", + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › provider-specific metadata merging › should handle a user message with multiple text parts (flattening disabled) + #[test] + fn should_handle_a_user_message_with_multiple_text_parts_flattening_disabled() { + let result = convert(&prompt(json!([ + { + "role": "user", + "content": [ + { "type": "text", "text": "Part 1" }, + { "type": "text", "text": "Part 2" }, + ], + }, + ]))); + + // Because there are multiple text parts, the converter won't flatten them + assert_eq!( + result, + json!([ + { + "role": "user", + "content": [ + { "type": "text", "text": "Part 1" }, + { "type": "text", "text": "Part 2" }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › provider-specific metadata merging › should handle an assistant message with text plus multiple tool calls + #[test] + fn should_handle_an_assistant_message_with_text_plus_multiple_tool_calls() { + let result = convert(&prompt(json!([ + { + "role": "assistant", + "content": [ + { "type": "text", "text": "Checking that now..." }, + { + "type": "tool-call", + "toolCallId": "call1", + "toolName": "searchTool", + "input": { "query": "Weather" }, + "providerOptions": { + "openaiCompatible": { "function_call_reason": "user request" }, + }, + }, + { "type": "text", "text": "Almost there..." }, + { + "type": "tool-call", + "toolCallId": "call2", + "toolName": "mapsTool", + "input": { "location": "Paris" }, + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "assistant", + "content": "Checking that now...Almost there...", + "tool_calls": [ + { + "id": "call1", + "type": "function", + "function": { + "name": "searchTool", + "arguments": stringify(&json!({ "query": "Weather" })), + }, + "function_call_reason": "user request", + }, + { + "id": "call2", + "type": "function", + "function": { + "name": "mapsTool", + "arguments": stringify(&json!({ "location": "Paris" })), + }, + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › provider-specific metadata merging › should handle a single tool role message with multiple tool-result parts + #[test] + fn should_handle_a_single_tool_role_message_with_multiple_tool_result_parts() { + let result = convert(&prompt(json!([ + { + "role": "tool", + "providerOptions": { + // this just gets omitted as we prioritize content-level metadata + "openaiCompatible": { "responseTier": "detailed" }, + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "call123", + "toolName": "calculator", + "output": { "type": "json", "value": { "stepOne": "data chunk 1" } }, + }, + { + "type": "tool-result", + "toolCallId": "call123", + "toolName": "calculator", + "providerOptions": { + "openaiCompatible": { "partial": true }, + }, + "output": { "type": "json", "value": { "stepTwo": "data chunk 2" } }, + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "tool", + "tool_call_id": "call123", + "content": stringify(&json!({ "stepOne": "data chunk 1" })), + }, + { + "role": "tool", + "tool_call_id": "call123", + "content": stringify(&json!({ "stepTwo": "data chunk 2" })), + "partial": true, + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › provider-specific metadata merging › should handle multiple content parts with multiple metadata layers + #[test] + fn should_handle_multiple_content_parts_with_multiple_metadata_layers() { + let result = convert(&prompt(json!([ + { + "role": "user", + "providerOptions": { + "openaiCompatible": { "messageLevel": "global-metadata" }, + "leftoverForMessage": { "x": 123 }, + }, + "content": [ + { + "type": "text", + "text": "Part A", + "providerOptions": { + "openaiCompatible": { "textPartLevel": "localized" }, + "leftoverForText": { "info": "text leftover" }, + }, + }, + { + "type": "file", + // Buffer.from([9, 8, 7, 6]).toString('base64') + "data": { "type": "data", "data": "CQgHBg==" }, + "mediaType": "image/png", + "providerOptions": { + "openaiCompatible": { "imagePartLevel": "image-data" }, + }, + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "user", + "messageLevel": "global-metadata", + "content": [ + { + "type": "text", + "text": "Part A", + "textPartLevel": "localized", + }, + { + "type": "image_url", + "image_url": { + "url": "data:image/png;base64,CQgHBg==", + }, + "imagePartLevel": "image-data", + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › provider-specific metadata merging › should handle different tool metadata vs. message-level metadata + #[test] + fn should_handle_different_tool_metadata_vs_message_level_metadata() { + let result = convert(&prompt(json!([ + { + "role": "assistant", + "providerOptions": { + "openaiCompatible": { "globalPriority": "high" }, + }, + "content": [ + { "type": "text", "text": "Initiating tool calls..." }, + { + "type": "tool-call", + "toolCallId": "callXYZ", + "toolName": "awesomeTool", + "input": { "param": "someValue" }, + "providerOptions": { + "openaiCompatible": { + "toolPriority": "critical", + }, + }, + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "assistant", + "globalPriority": "high", + "content": "Initiating tool calls...", + "tool_calls": [ + { + "id": "callXYZ", + "type": "function", + "function": { + "name": "awesomeTool", + "arguments": stringify(&json!({ "param": "someValue" })), + }, + "toolPriority": "critical", + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › provider-specific metadata merging › should handle metadata collisions and overwrites in tool calls + #[test] + fn should_handle_metadata_collisions_and_overwrites_in_tool_calls() { + let result = convert(&prompt(json!([ + { + "role": "assistant", + "providerOptions": { + "openaiCompatible": { + "cacheControl": { "type": "default" }, + "sharedKey": "assistantLevel", + }, + }, + "content": [ + { + "type": "tool-call", + "toolCallId": "collisionToolCall", + "toolName": "collider", + "input": { "num": 42 }, + "providerOptions": { + "openaiCompatible": { + "cacheControl": { "type": "ephemeral" }, // overwrites top-level + "sharedKey": "toolLevel", + }, + }, + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "assistant", + "cacheControl": { "type": "default" }, + "sharedKey": "assistantLevel", + "content": null, + "tool_calls": [ + { + "id": "collisionToolCall", + "type": "function", + "function": { + "name": "collider", + "arguments": stringify(&json!({ "num": 42 })), + }, + "cacheControl": { "type": "ephemeral" }, + "sharedKey": "toolLevel", + }, + ], + }, + ]) + ); + } +} + +mod google_gemini_thought_signatures_open_ai_compatibility { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › Google Gemini thought signatures (OpenAI compatibility) › should serialize thought signature from a custom provider namespace + #[test] + fn should_serialize_thought_signature_from_a_custom_provider_namespace() { + let result = convert_with( + &prompt(json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "function-call-1", + "toolName": "check_flight", + "input": { "flight": "AA100" }, + "providerOptions": { + "myGateway": { + "thoughtSignature": "", + }, + }, + }, + ], + }, + ])), + "myGateway", + false, + ) + .expect("conversion succeeds"); + + assert_match_object( + &result[0], + &json!({ + "tool_calls": [ + { + "extra_content": { + "google": { "thought_signature": "" }, + }, + }, + ], + }), + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › Google Gemini thought signatures (OpenAI compatibility) › should fall back to the google namespace + #[test] + fn should_fall_back_to_the_google_namespace() { + let result = convert_with( + &prompt(json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "function-call-1", + "toolName": "check_flight", + "input": { "flight": "AA100" }, + "providerOptions": { + "google": { + "thoughtSignature": "", + }, + }, + }, + ], + }, + ])), + "myGateway", + false, + ) + .expect("conversion succeeds"); + + assert_match_object( + &result[0], + &json!({ + "tool_calls": [ + { + "extra_content": { + "google": { "thought_signature": "" }, + }, + }, + ], + }), + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › Google Gemini thought signatures (OpenAI compatibility) › should prefer the custom provider namespace over google + #[test] + fn should_prefer_the_custom_provider_namespace_over_google() { + let result = convert_with( + &prompt(json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "function-call-1", + "toolName": "check_flight", + "input": { "flight": "AA100" }, + "providerOptions": { + "myGateway": { + "thoughtSignature": "", + }, + "google": { + "thoughtSignature": "", + }, + }, + }, + ], + }, + ])), + "myGateway", + false, + ) + .expect("conversion succeeds"); + + assert_match_object( + &result[0], + &json!({ + "tool_calls": [ + { + "extra_content": { + "google": { "thought_signature": "" }, + }, + }, + ], + }), + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › Google Gemini thought signatures (OpenAI compatibility) › should serialize thought signature to extra_content for single tool call + #[test] + fn should_serialize_thought_signature_to_extra_content_for_single_tool_call() { + let result = convert(&prompt(json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "function-call-1", + "toolName": "check_flight", + "input": { "flight": "AA100" }, + "providerOptions": { + "google": { + "thoughtSignature": "", + }, + }, + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "function-call-1", + "type": "function", + "function": { + "name": "check_flight", + "arguments": stringify(&json!({ "flight": "AA100" })), + }, + "extra_content": { + "google": { + "thought_signature": "", + }, + }, + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › Google Gemini thought signatures (OpenAI compatibility) › should handle sequential tool calls with separate signatures (Turn 1 Step 3 scenario) + #[test] + fn should_handle_sequential_tool_calls_with_separate_signatures() { + let result = convert(&prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Check flight status for AA100 and book a taxi 2 hours before if delayed.", + }, + ], + }, + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "function-call-1", + "toolName": "check_flight", + "input": { "flight": "AA100" }, + "providerOptions": { + "google": { + "thoughtSignature": "", + }, + }, + }, + ], + }, + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolCallId": "function-call-1", + "toolName": "check_flight", + "output": { + "type": "json", + "value": { "status": "delayed", "departure_time": "12 PM" }, + }, + }, + ], + }, + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "function-call-2", + "toolName": "book_taxi", + "input": { "time": "10 AM" }, + "providerOptions": { + "google": { + "thoughtSignature": "", + }, + }, + }, + ], + }, + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolCallId": "function-call-2", + "toolName": "book_taxi", + "output": { "type": "json", "value": { "booking_status": "success" } }, + }, + ], + }, + ]))); + + // Verify both signatures are preserved + assert_eq!( + result[1], + json!({ + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "function-call-1", + "type": "function", + "function": { + "name": "check_flight", + "arguments": stringify(&json!({ "flight": "AA100" })), + }, + "extra_content": { + "google": { + "thought_signature": "", + }, + }, + }, + ], + }) + ); + + assert_eq!( + result[3], + json!({ + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "function-call-2", + "type": "function", + "function": { + "name": "book_taxi", + "arguments": stringify(&json!({ "time": "10 AM" })), + }, + "extra_content": { + "google": { + "thought_signature": "", + }, + }, + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › Google Gemini thought signatures (OpenAI compatibility) › should handle parallel tool calls with signature only on first call + #[test] + fn should_handle_parallel_tool_calls_with_signature_only_on_first_call() { + let result = convert(&prompt(json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "function-call-paris", + "toolName": "get_current_temperature", + "input": { "location": "Paris" }, + "providerOptions": { + "google": { + "thoughtSignature": "", + }, + }, + }, + { + "type": "tool-call", + "toolCallId": "function-call-london", + "toolName": "get_current_temperature", + "input": { "location": "London" }, + // No signature on parallel function call + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "function-call-paris", + "type": "function", + "function": { + "name": "get_current_temperature", + "arguments": stringify(&json!({ "location": "Paris" })), + }, + "extra_content": { + "google": { + "thought_signature": "", + }, + }, + }, + { + "id": "function-call-london", + "type": "function", + "function": { + "name": "get_current_temperature", + "arguments": stringify(&json!({ "location": "London" })), + }, + // No extra_content for second parallel call + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › Google Gemini thought signatures (OpenAI compatibility) › should not include extra_content when no thought signature is present + #[test] + fn should_not_include_extra_content_when_no_thought_signature_is_present() { + let result = convert(&prompt(json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "call-1", + "toolName": "some_tool", + "input": { "param": "value" }, + }, + ], + }, + ]))); + + assert_eq!( + result, + json!([ + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": { + "name": "some_tool", + "arguments": stringify(&json!({ "param": "value" })), + }, + }, + ], + }, + ]) + ); + } +} + +mod top_level_only_media_type_resolution { + use super::*; + + const PNG_BASE64: &str = "iVBORw0KGgo="; + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › top-level-only media type resolution › passes full image/png through unchanged for inline data + #[test] + fn passes_full_image_png_through_unchanged_for_inline_data() { + let result = convert(&prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "mediaType": "image/png", + "data": { "type": "data", "data": PNG_BASE64 }, + }, + ], + }, + ]))); + + assert_eq!( + result[0]["content"][0], + json!({ + "type": "image_url", + "image_url": { "url": format!("data:image/png;base64,{PNG_BASE64}") }, + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › top-level-only media type resolution › detects image subtype from inline bytes for top-level "image" + #[test] + fn detects_image_subtype_from_inline_bytes_for_top_level_image() { + let result = convert(&prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "mediaType": "image", + "data": { "type": "data", "data": PNG_BASE64 }, + }, + ], + }, + ]))); + + assert_eq!( + result[0]["content"][0], + json!({ + "type": "image_url", + "image_url": { "url": format!("data:image/png;base64,{PNG_BASE64}") }, + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › top-level-only media type resolution › passes through URL source for top-level-only image (provider accepts raw URL) + #[test] + fn passes_through_url_source_for_top_level_only_image() { + let result = convert(&prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "mediaType": "image", + "data": { + "type": "url", + "url": "https://example.com/x.png", + }, + }, + ], + }, + ]))); + + assert_eq!( + result[0]["content"][0], + json!({ + "type": "image_url", + "image_url": { "url": "https://example.com/x.png" }, + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/convert-to-openai-compatible-chat-messages.test.ts › top-level-only media type resolution › normalizes image/* wildcard via detection + #[test] + fn normalizes_image_wildcard_via_detection() { + let result = convert(&prompt(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "mediaType": "image/*", + "data": { "type": "data", "data": PNG_BASE64 }, + }, + ], + }, + ]))); + + assert_eq!( + result[0]["content"][0], + json!({ + "type": "image_url", + "image_url": { "url": format!("data:image/png;base64,{PNG_BASE64}") }, + }) + ); + } +} diff --git a/aimux-providers/src/openai_compatible/tests/chat/fixtures/anthropic-fallback-tool-call.sse b/aimux-providers/src/openai_compatible/tests/chat/fixtures/anthropic-fallback-tool-call.sse new file mode 100644 index 000000000..539aa5d2a --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/chat/fixtures/anthropic-fallback-tool-call.sse @@ -0,0 +1,17 @@ +data: {"id":"msg_sanitized","object":"chat.completion.chunk","created":0,"model":"claude-haiku-4-5-20251001","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"id":"msg_sanitized","object":"chat.completion.chunk","created":0,"model":"claude-haiku-4-5-20251001","choices":[{"index":0,"delta":{"content":"Reading"},"finish_reason":null}]} + +data: {"id":"msg_sanitized","object":"chat.completion.chunk","created":0,"model":"claude-haiku-4-5-20251001","choices":[{"index":0,"delta":{"content":" it."},"finish_reason":null}]} + +data: {"id":"msg_sanitized","object":"chat.completion.chunk","created":0,"model":"claude-haiku-4-5-20251001","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"toolu_sanitized","type":"function","function":{"name":"read_file","arguments":""}}]},"finish_reason":null}]} + +data: {"id":"msg_sanitized","object":"chat.completion.chunk","created":0,"model":"claude-haiku-4-5-20251001","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":""}}]},"finish_reason":null}]} + +data: {"id":"msg_sanitized","object":"chat.completion.chunk","created":0,"model":"claude-haiku-4-5-20251001","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\"pa"}}]},"finish_reason":null}]} + +data: {"id":"msg_sanitized","object":"chat.completion.chunk","created":0,"model":"claude-haiku-4-5-20251001","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"th\": \"a.txt\"}"}}]},"finish_reason":null}]} + +data: {"id":"msg_sanitized","object":"chat.completion.chunk","created":0,"model":"claude-haiku-4-5-20251001","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: [DONE] diff --git a/aimux-providers/src/openai_compatible/tests/chat/fixtures/placeholder-first-chunk.chunks.txt b/aimux-providers/src/openai_compatible/tests/chat/fixtures/placeholder-first-chunk.chunks.txt new file mode 100644 index 000000000..6297d4087 --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/chat/fixtures/placeholder-first-chunk.chunks.txt @@ -0,0 +1,4 @@ +{"choices":[],"created":0,"id":"","model":"","object":""} +{"choices":[{"delta":{"content":"Hello","role":"assistant"},"finish_reason":null,"index":0}],"created":1700000000,"id":"chatcmpl-123","model":"actual-model-name","object":"chat.completion.chunk"} +{"choices":[{"delta":{},"finish_reason":"stop","index":0}],"created":1700000000,"id":"chatcmpl-123","model":"actual-model-name","object":"chat.completion.chunk"} +{"choices":[],"created":1700000000,"id":"chatcmpl-123","model":"actual-model-name","object":"chat.completion.chunk","usage":{"prompt_tokens":10,"completion_tokens":1,"total_tokens":11}} diff --git a/aimux-providers/src/openai_compatible/tests/chat/fixtures/xai-text.chunks.txt b/aimux-providers/src/openai_compatible/tests/chat/fixtures/xai-text.chunks.txt new file mode 100644 index 000000000..b723c58d4 --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/chat/fixtures/xai-text.chunks.txt @@ -0,0 +1,344 @@ +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772287,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"First","role":"assistant"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772287,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772287,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772287,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" user"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772287,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" said"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772287,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":":"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772287,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772287,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Say"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772287,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" a"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772287,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" single"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" word"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" That's"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" straightforward"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"."}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" They"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" want"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" me"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" to"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" respond"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" with"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" just"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" one"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" word"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"As"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" Gro"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"k"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I'm"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" supposed"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" to"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" be"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" helpful"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" truthful"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" and"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" not"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" verbose"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"."}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" So"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" should"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" keep"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" my"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" response"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" simple"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" and"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" directly"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" answer"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" request"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"The"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" instruction"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" is"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" to"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" say"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" a"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" single"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" word"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" so"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" my"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" response"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" should"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" be"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" exactly"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" that"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":":"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" one"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" word"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"."}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" shouldn't"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" add"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" any"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" explanations"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" greetings"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" or"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" extra"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" text"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"What"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" word"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" should"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" choose"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"?"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" The"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" user"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" didn't"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" specify"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" which"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" word"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" so"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" have"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" freedom"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" to"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" pick"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" any"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772288,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"."}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" To"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" make"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" it"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" fun"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" and"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" aligned"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" with"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" my"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" personality"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" could"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" choose"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" something"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" relevant"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" or"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" witty"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"I'm"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" Gro"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"k"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" created"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" by"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" x"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"AI"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" and"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I'm"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" meant"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" to"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" be"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" maximally"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" truthful"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" and"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" helpful"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"."}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" Maybe"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" could"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" choose"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" a"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" word"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" that"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" reflects"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" that"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" like"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Hello"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"\""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" or"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"G"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"rok"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"\","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" but"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" need"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" to"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" keep"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" it"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" simple"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Possible"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" words"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":":\n\n"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"-"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Hello"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"\""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" –"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" Common"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" greeting"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"-"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Yes"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"\""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" –"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" Affirm"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"ative"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"-"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"G"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"rok"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"\""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" –"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" My"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" name"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" which"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" might"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" be"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" meta"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772289,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"-"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Word"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"\""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" –"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" Echo"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"ing"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" request"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"-"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" Something"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" random"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" like"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Banana"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"\""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" for"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" humor"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Since"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" user"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" asked"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" for"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" a"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" single"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" word"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" and"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I'm"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" an"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" AI"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" should"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" ensure"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" it's"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" appropriate"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" and"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" not"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" misleading"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"The"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" system"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" prompt"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" says"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":":"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"You"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" are"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" Gro"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"k"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" a"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" helpful"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" and"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" maximally"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" truthful"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" AI"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" built"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" by"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" x"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"AI"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" not"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" based"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" on"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" any"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" other"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" companies"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" and"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" their"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" models"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" So"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" should"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" stay"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" true"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" to"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" that"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"To"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" be"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" helpful"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I'll"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" just"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" pick"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" a"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772290,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" simple"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" word"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"."}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" Let's"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" go"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" with"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"G"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"rok"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"\""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" to"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" make"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" it"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" personal"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Finally"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" my"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" response"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" should"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" be"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" only"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" that"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" one"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" word"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" nothing"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" else"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"."}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" No"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" punctuation"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" if"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" possible"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" just"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" word"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Response"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":":"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" Gro"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"k"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":"G"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":"rok"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"system_fingerprint":"fp_2a885414fb"} +{"id":"f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94","object":"chat.completion.chunk","created":1770772291,"model":"grok-3-mini","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":2,"total_tokens":354,"prompt_tokens_details":{"text_tokens":12,"audio_tokens":0,"image_tokens":0,"cached_tokens":11},"completion_tokens_details":{"reasoning_tokens":340,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0},"num_sources_used":0,"cost_in_usd_ticks":1721250},"system_fingerprint":"fp_2a885414fb"} \ No newline at end of file diff --git a/aimux-providers/src/openai_compatible/tests/chat/fixtures/xai-text.json b/aimux-providers/src/openai_compatible/tests/chat/fixtures/xai-text.json new file mode 100644 index 000000000..8dcf6c87f --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/chat/fixtures/xai-text.json @@ -0,0 +1,38 @@ +{ + "id": "edea4703-19aa-6d74-fedb-dc1c213543e0", + "object": "chat.completion", + "created": 1770772090, + "model": "grok-3-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Grok", + "reasoning_content": "First, the user said: \"Say a single word.\" That's straightforward. They want me to respond with just one word.\n\nAs Grok, I'm supposed to be helpful, truthful, and maximally truthful, but in this case, it's a simple request. I don't need to overcomplicate it.\n\nMy response should be exactly one word, as per their instruction. If I say more than one word, I'm not following the request accurately.\n\nWhat word should I choose? The user didn't specify which word, so I have some freedom. I could pick a random word, or something relevant to the context.\n\nTo make it engaging and true to my identity, I could choose a word that reflects my personality. For example, something fun, helpful, or related to AI.\n\nPossible options:\n- \"Hello\" – Simple greeting.\n- \"Grok\" – My name, which is relevant.\n- \"Word\" – Echoing the request.\n- \"Yes\" – But that might not make sense alone.\n- Something random like \"Apple\" or \"Mountain\".\n\nSince I'm Grok, built by xAI, I should stay true to that. Maybe \"xAI\" or \"Truth\" to emphasize my truthful nature.\n\nThe simplest and most direct response is just to say any single word. To be maximally helpful, I should ensure it's appropriate.\n\nThe user might be testing me or playing a game, so I should respond in kind.\n\nFinally, decide on a word: Let's go with \"Grok\" – it's unique to me and reinforces my identity.\n\nSo, my response will be: Grok", + "refusal": null + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 12, + "completion_tokens": 2, + "total_tokens": 334, + "prompt_tokens_details": { + "text_tokens": 12, + "audio_tokens": 0, + "image_tokens": 0, + "cached_tokens": 2 + }, + "completion_tokens_details": { + "reasoning_tokens": 320, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + }, + "num_sources_used": 0, + "cost_in_usd_ticks": 1641500 + }, + "system_fingerprint": "fp_2a885414fb" +} diff --git a/aimux-providers/src/openai_compatible/tests/chat/fixtures/xai-tool-call.chunks.txt b/aimux-providers/src/openai_compatible/tests/chat/fixtures/xai-tool-call.chunks.txt new file mode 100644 index 000000000..ec2bb66a1 --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/chat/fixtures/xai-tool-call.chunks.txt @@ -0,0 +1,230 @@ +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772293,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"First","role":"assistant"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772293,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772293,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772293,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" user"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772293,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" is"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772293,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" asking"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772293,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" about"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" weather"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" in"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" San"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" Francisco"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"."}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" have"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" a"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" available"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" function"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" called"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"weather"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"\""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" that"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" retrieves"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" weather"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" for"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" a"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" given"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" location"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"The"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" function"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" requires"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" a"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" parameter"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":":"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"location"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"\","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" which"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" is"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" a"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" string"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"."}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" The"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" user"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" has"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" provided"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"San"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" Francisco"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"\""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" as"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" location"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" so"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" that's"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" clear"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" and"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" infer"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"able"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"I"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" should"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" call"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" this"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" function"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" to"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" get"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" weather"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" information"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"."}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" The"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" format"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" for"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" calling"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" function"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" is"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" specific"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":":"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" need"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" to"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" use"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" <"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"function"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"_call"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":">"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" tags"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" with"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" JSON"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" inside"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" like"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" <"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"function"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772294,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"_call"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":">{\""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"action"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"\":"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"weather"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"\","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"action"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"_input"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"\":"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" {\""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"location"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"\":"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"San"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" Francisco"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"\""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"}}.\n\n"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"This"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" seems"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" to"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" be"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" a"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" direct"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" match"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" so"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" don't"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" need"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" to"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" ask"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" for"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" clarification"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"."}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" My"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" response"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" should"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" only"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" contain"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" function"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" call"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" if"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" that's"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" next"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" step"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" which"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" it"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" is"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"The"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" instructions"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" say"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":":"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Keep"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" your"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" response"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" to"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" user"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" clear"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":";"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" please"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" do"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" not"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" make"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" your"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" response"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" verbose"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"!\""}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" So"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" shouldn't"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" add"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" any"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" extra"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" text"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":";"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" just"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" function"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" call"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Finally"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" after"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772295,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" calling"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" function"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" if"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" this"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" were"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" a"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" multi"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"-turn"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" conversation"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" might"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" need"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" to"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" respond"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" based"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" on"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" result"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" but"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" for"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" now"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" this"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" is"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" logical"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" next"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" step"}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"."}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_79382389","function":{"name":"weather","arguments":"{\"location\":\"San Francisco\"}"},"index":0,"type":"function"}]}}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"system_fingerprint":"fp_2a885414fb"} +{"id":"7027d986-3c59-a37a-9a5f-50713e01c8a6","object":"chat.completion.chunk","created":1770772296,"model":"grok-3-mini","choices":[],"usage":{"prompt_tokens":307,"completion_tokens":26,"total_tokens":560,"prompt_tokens_details":{"text_tokens":307,"audio_tokens":0,"image_tokens":0,"cached_tokens":306},"completion_tokens_details":{"reasoning_tokens":227,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0},"num_sources_used":0,"cost_in_usd_ticks":1497500},"system_fingerprint":"fp_2a885414fb"} \ No newline at end of file diff --git a/aimux-providers/src/openai_compatible/tests/chat/fixtures/xai-tool-call.json b/aimux-providers/src/openai_compatible/tests/chat/fixtures/xai-tool-call.json new file mode 100644 index 000000000..541f3ba1a --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/chat/fixtures/xai-tool-call.json @@ -0,0 +1,48 @@ +{ + "id": "acfa24c3-b556-0f2c-731e-64fb836d544b", + "object": "chat.completion", + "created": 1770772214, + "model": "grok-3-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "", + "reasoning_content": "First, the user is asking about the weather in San Francisco. I have a function available called \"weather\" that retrieves the weather for a given location.\n\nThe function requires a parameter: \"location\", which is a string. The user has provided \"San Francisco\" as the location, so that's clear and inferable.\n\nI should call this function to get the weather information. The format for calling the function is specific: I need to use tags with a JSON object like {\"action\": \"function_name\", \"action_input\": {\"parameter\": \"argument\"}}.\n\nFor this, the action is \"weather\", and the action_input should be {\"location\": \"San Francisco\"}.\n\nAfter calling the function, I might need to handle the response in subsequent turns, but for now, this is the logical next step.\n\nMy response should only include the function call if I'm deciding to make one. I shouldn't add any extra text unless necessary.\n\nThe instructions say: \"Keep your response to user clear; please do not make your response verbose!\" So, I should not add any chit-chat; just the function call.\n\nFinally, structure it as: {\"action\": \"weather\", \"action_input\": {\"location\": \"San Francisco\"}}", + "tool_calls": [ + { + "id": "call_46427107", + "function": { + "name": "weather", + "arguments": "{\"location\":\"San Francisco\"}" + }, + "type": "function" + } + ], + "refusal": null + }, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 307, + "completion_tokens": 26, + "total_tokens": 588, + "prompt_tokens_details": { + "text_tokens": 307, + "audio_tokens": 0, + "image_tokens": 0, + "cached_tokens": 244 + }, + "completion_tokens_details": { + "reasoning_tokens": 255, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + }, + "num_sources_used": 0, + "cost_in_usd_ticks": 1777000 + }, + "system_fingerprint": "fp_2a885414fb" +} diff --git a/aimux-providers/src/openai_compatible/tests/chat/mod.rs b/aimux-providers/src/openai_compatible/tests/chat/mod.rs new file mode 100644 index 000000000..e50bced16 --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/chat/mod.rs @@ -0,0 +1,5 @@ +mod chat_content_array; +mod chat_language_model; +mod convert_chat_usage; +mod convert_to_chat_messages; +mod prepare_tools; diff --git a/aimux-providers/src/openai_compatible/tests/chat/prepare_tools.rs b/aimux-providers/src/openai_compatible/tests/chat/prepare_tools.rs new file mode 100644 index 000000000..ddc522b8a --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/chat/prepare_tools.rs @@ -0,0 +1,365 @@ +use serde_json::{Map, Value, json}; + +use aimux_core::options::CallOptions; + +use crate::openai_compatible::convert::{PreparedTools, prepare_function_tools}; + +/// `prepareTools({ tools, toolChoice })`: the Rust form reads both from the +/// call options, given here in their AI SDK JSON form. +fn prepare_tools(tools: Value, tool_choice: Value) -> PreparedTools { + let mut options = Map::new(); + options.insert("prompt".into(), json!([])); + if !tools.is_null() { + options.insert("tools".into(), tools); + } + if !tool_choice.is_null() { + options.insert("toolChoice".into(), tool_choice); + } + let options: CallOptions = + serde_json::from_value(Value::Object(options)).expect("call options"); + prepare_function_tools(&options) +} + +/// The result in its upstream shape; `undefined` fields are omitted. +fn result_json(result: &PreparedTools) -> Value { + let mut out = Map::new(); + if let Some(choice) = &result.tool_choice { + out.insert("toolChoice".into(), choice.clone()); + } + out.insert( + "toolWarnings".into(), + serde_json::to_value(&result.warnings).expect("warnings serialize"), + ); + if let Some(tools) = &result.tools { + out.insert("tools".into(), Value::Array(tools.clone())); + } + Value::Object(out) +} + +fn test_function_tool() -> Value { + json!([ + { + "type": "function", + "name": "testFunction", + "description": "Test", + "inputSchema": {}, + }, + ]) +} + +// The `prepareTools` describe block shares the file's name. +#[allow(clippy::module_inception)] +mod prepare_tools { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-prepare-tools.test.ts › prepareTools › should return undefined tools and toolChoice when tools are null + #[test] + fn should_return_undefined_tools_and_tool_choice_when_tools_are_null() { + let result = prepare_tools(Value::Null, Value::Null); + + assert_eq!(result_json(&result), json!({ "toolWarnings": [] })); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-prepare-tools.test.ts › prepareTools › should return undefined tools and toolChoice when tools are empty + #[test] + fn should_return_undefined_tools_and_tool_choice_when_tools_are_empty() { + let result = prepare_tools(json!([]), Value::Null); + + assert_eq!(result_json(&result), json!({ "toolWarnings": [] })); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-prepare-tools.test.ts › prepareTools › should correctly prepare function tools + #[test] + fn should_correctly_prepare_function_tools() { + let result = prepare_tools( + json!([ + { + "type": "function", + "name": "testFunction", + "description": "A test function", + "inputSchema": { "type": "object", "properties": {} }, + }, + ]), + Value::Null, + ); + + assert_eq!( + result_json(&result), + json!({ + "toolWarnings": [], + "tools": [ + { + "function": { + "description": "A test function", + "name": "testFunction", + "parameters": { + "properties": {}, + "type": "object", + }, + }, + "type": "function", + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-prepare-tools.test.ts › prepareTools › should add warnings for unsupported provider-defined tools + #[test] + fn should_add_warnings_for_unsupported_provider_defined_tools() { + let result = prepare_tools( + json!([ + { + "type": "provider", + "id": "some.unsupported_tool", + "name": "unsupported_tool", + "args": {}, + }, + ]), + Value::Null, + ); + + assert_eq!(result.tools, Some(vec![])); + assert_eq!(result.tool_choice, None); + assert_eq!( + serde_json::to_value(&result.warnings).unwrap(), + json!([ + { + "feature": "provider-defined tool some.unsupported_tool", + "type": "unsupported", + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-prepare-tools.test.ts › prepareTools › should handle tool choice "auto" + #[test] + fn should_handle_tool_choice_auto() { + let result = prepare_tools(test_function_tool(), json!({ "type": "auto" })); + + assert_eq!(result.tool_choice, Some(json!("auto"))); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-prepare-tools.test.ts › prepareTools › should handle tool choice "required" + #[test] + fn should_handle_tool_choice_required() { + let result = prepare_tools(test_function_tool(), json!({ "type": "required" })); + + assert_eq!(result.tool_choice, Some(json!("required"))); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-prepare-tools.test.ts › prepareTools › should handle tool choice "none" + #[test] + fn should_handle_tool_choice_none() { + let result = prepare_tools(test_function_tool(), json!({ "type": "none" })); + + assert_eq!(result.tool_choice, Some(json!("none"))); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-prepare-tools.test.ts › prepareTools › should handle tool choice "tool" + #[test] + fn should_handle_tool_choice_tool() { + let result = prepare_tools( + test_function_tool(), + json!({ "type": "tool", "toolName": "testFunction" }), + ); + + assert_eq!( + result.tool_choice, + Some(json!({ + "type": "function", + "function": { "name": "testFunction" }, + })) + ); + } + + mod strict_mode { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-prepare-tools.test.ts › prepareTools › strict mode › should pass through strict mode when strict is true + #[test] + fn should_pass_through_strict_mode_when_strict_is_true() { + let result = prepare_tools( + json!([ + { + "type": "function", + "name": "testFunction", + "description": "A test function", + "inputSchema": { "type": "object", "properties": {} }, + "strict": true, + }, + ]), + Value::Null, + ); + + assert_eq!( + result_json(&result), + json!({ + "toolWarnings": [], + "tools": [ + { + "function": { + "description": "A test function", + "name": "testFunction", + "parameters": { + "properties": {}, + "type": "object", + }, + "strict": true, + }, + "type": "function", + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-prepare-tools.test.ts › prepareTools › strict mode › should pass through strict mode when strict is false + #[test] + fn should_pass_through_strict_mode_when_strict_is_false() { + let result = prepare_tools( + json!([ + { + "type": "function", + "name": "testFunction", + "description": "A test function", + "inputSchema": { "type": "object", "properties": {} }, + "strict": false, + }, + ]), + Value::Null, + ); + + assert_eq!( + result_json(&result), + json!({ + "toolWarnings": [], + "tools": [ + { + "function": { + "description": "A test function", + "name": "testFunction", + "parameters": { + "properties": {}, + "type": "object", + }, + "strict": false, + }, + "type": "function", + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-prepare-tools.test.ts › prepareTools › strict mode › should not include strict mode when strict is undefined + #[test] + fn should_not_include_strict_mode_when_strict_is_undefined() { + let result = prepare_tools( + json!([ + { + "type": "function", + "name": "testFunction", + "description": "A test function", + "inputSchema": { "type": "object", "properties": {} }, + }, + ]), + Value::Null, + ); + + assert_eq!( + result_json(&result), + json!({ + "toolWarnings": [], + "tools": [ + { + "function": { + "description": "A test function", + "name": "testFunction", + "parameters": { + "properties": {}, + "type": "object", + }, + }, + "type": "function", + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/chat/openai-compatible-prepare-tools.test.ts › prepareTools › strict mode › should pass through strict mode for multiple tools with different strict settings + #[test] + fn should_pass_through_strict_mode_for_multiple_tools_with_different_strict() { + let result = prepare_tools( + json!([ + { + "type": "function", + "name": "strictTool", + "description": "A strict tool", + "inputSchema": { "type": "object", "properties": {} }, + "strict": true, + }, + { + "type": "function", + "name": "nonStrictTool", + "description": "A non-strict tool", + "inputSchema": { "type": "object", "properties": {} }, + "strict": false, + }, + { + "type": "function", + "name": "defaultTool", + "description": "A tool without strict setting", + "inputSchema": { "type": "object", "properties": {} }, + }, + ]), + Value::Null, + ); + + assert_eq!( + result_json(&result), + json!({ + "toolWarnings": [], + "tools": [ + { + "function": { + "description": "A strict tool", + "name": "strictTool", + "parameters": { + "properties": {}, + "type": "object", + }, + "strict": true, + }, + "type": "function", + }, + { + "function": { + "description": "A non-strict tool", + "name": "nonStrictTool", + "parameters": { + "properties": {}, + "type": "object", + }, + "strict": false, + }, + "type": "function", + }, + { + "function": { + "description": "A tool without strict setting", + "name": "defaultTool", + "parameters": { + "properties": {}, + "type": "object", + }, + }, + "type": "function", + }, + ], + }) + ); + } + } +} diff --git a/aimux-providers/src/openai_compatible/tests/chat/snapshots/do_generate_text_fixture.json b/aimux-providers/src/openai_compatible/tests/chat/snapshots/do_generate_text_fixture.json new file mode 100644 index 000000000..7c0c514e2 --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/chat/snapshots/do_generate_text_fixture.json @@ -0,0 +1,104 @@ +{ + "content": [ + { + "text": "Grok", + "type": "text" + }, + { + "text": "First, the user said: \"Say a single word.\" That's straightforward. They want me to respond with just one word.\n\nAs Grok, I'm supposed to be helpful, truthful, and maximally truthful, but in this case, it's a simple request. I don't need to overcomplicate it.\n\nMy response should be exactly one word, as per their instruction. If I say more than one word, I'm not following the request accurately.\n\nWhat word should I choose? The user didn't specify which word, so I have some freedom. I could pick a random word, or something relevant to the context.\n\nTo make it engaging and true to my identity, I could choose a word that reflects my personality. For example, something fun, helpful, or related to AI.\n\nPossible options:\n- \"Hello\" – Simple greeting.\n- \"Grok\" – My name, which is relevant.\n- \"Word\" – Echoing the request.\n- \"Yes\" – But that might not make sense alone.\n- Something random like \"Apple\" or \"Mountain\".\n\nSince I'm Grok, built by xAI, I should stay true to that. Maybe \"xAI\" or \"Truth\" to emphasize my truthful nature.\n\nThe simplest and most direct response is just to say any single word. To be maximally helpful, I should ensure it's appropriate.\n\nThe user might be testing me or playing a game, so I should respond in kind.\n\nFinally, decide on a word: Let's go with \"Grok\" – it's unique to me and reinforces my identity.\n\nSo, my response will be: Grok", + "type": "reasoning" + } + ], + "finishReason": { + "raw": "stop", + "unified": "stop" + }, + "providerMetadata": { + "test-provider": { + "acceptedPredictionTokens": 0, + "rejectedPredictionTokens": 0 + } + }, + "request": { + "body": "{\"model\":\"grok-3\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}" + }, + "response": { + "body": { + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "Grok", + "reasoning_content": "First, the user said: \"Say a single word.\" That's straightforward. They want me to respond with just one word.\n\nAs Grok, I'm supposed to be helpful, truthful, and maximally truthful, but in this case, it's a simple request. I don't need to overcomplicate it.\n\nMy response should be exactly one word, as per their instruction. If I say more than one word, I'm not following the request accurately.\n\nWhat word should I choose? The user didn't specify which word, so I have some freedom. I could pick a random word, or something relevant to the context.\n\nTo make it engaging and true to my identity, I could choose a word that reflects my personality. For example, something fun, helpful, or related to AI.\n\nPossible options:\n- \"Hello\" – Simple greeting.\n- \"Grok\" – My name, which is relevant.\n- \"Word\" – Echoing the request.\n- \"Yes\" – But that might not make sense alone.\n- Something random like \"Apple\" or \"Mountain\".\n\nSince I'm Grok, built by xAI, I should stay true to that. Maybe \"xAI\" or \"Truth\" to emphasize my truthful nature.\n\nThe simplest and most direct response is just to say any single word. To be maximally helpful, I should ensure it's appropriate.\n\nThe user might be testing me or playing a game, so I should respond in kind.\n\nFinally, decide on a word: Let's go with \"Grok\" – it's unique to me and reinforces my identity.\n\nSo, my response will be: Grok", + "refusal": null, + "role": "assistant" + } + } + ], + "created": 1770772090, + "id": "edea4703-19aa-6d74-fedb-dc1c213543e0", + "model": "grok-3-mini", + "object": "chat.completion", + "system_fingerprint": "fp_2a885414fb", + "usage": { + "completion_tokens": 2, + "completion_tokens_details": { + "accepted_prediction_tokens": 0, + "audio_tokens": 0, + "reasoning_tokens": 320, + "rejected_prediction_tokens": 0 + }, + "cost_in_usd_ticks": 1641500, + "num_sources_used": 0, + "prompt_tokens": 12, + "prompt_tokens_details": { + "audio_tokens": 0, + "cached_tokens": 2, + "image_tokens": 0, + "text_tokens": 12 + }, + "total_tokens": 334 + } + }, + "headers": { + "content-length": "2053", + "content-type": "application/json" + }, + "id": "edea4703-19aa-6d74-fedb-dc1c213543e0", + "modelId": "grok-3-mini", + "timestamp": "2026-02-11T01:08:10.000Z" + }, + "usage": { + "inputTokens": { + "cacheRead": 2, + "noCache": 10, + "total": 12 + }, + "outputTokens": { + "reasoning": 320, + "text": 0, + "total": 2 + }, + "raw": { + "completion_tokens": 2, + "completion_tokens_details": { + "accepted_prediction_tokens": 0, + "audio_tokens": 0, + "reasoning_tokens": 320, + "rejected_prediction_tokens": 0 + }, + "cost_in_usd_ticks": 1641500, + "num_sources_used": 0, + "prompt_tokens": 12, + "prompt_tokens_details": { + "audio_tokens": 0, + "cached_tokens": 2, + "image_tokens": 0, + "text_tokens": 12 + }, + "total_tokens": 334 + } + }, + "warnings": [] +} diff --git a/aimux-providers/src/openai_compatible/tests/chat/snapshots/do_generate_tool_call_fixture.json b/aimux-providers/src/openai_compatible/tests/chat/snapshots/do_generate_tool_call_fixture.json new file mode 100644 index 000000000..8ef5c2ecc --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/chat/snapshots/do_generate_tool_call_fixture.json @@ -0,0 +1,116 @@ +{ + "content": [ + { + "text": "First, the user is asking about the weather in San Francisco. I have a function available called \"weather\" that retrieves the weather for a given location.\n\nThe function requires a parameter: \"location\", which is a string. The user has provided \"San Francisco\" as the location, so that's clear and inferable.\n\nI should call this function to get the weather information. The format for calling the function is specific: I need to use tags with a JSON object like {\"action\": \"function_name\", \"action_input\": {\"parameter\": \"argument\"}}.\n\nFor this, the action is \"weather\", and the action_input should be {\"location\": \"San Francisco\"}.\n\nAfter calling the function, I might need to handle the response in subsequent turns, but for now, this is the logical next step.\n\nMy response should only include the function call if I'm deciding to make one. I shouldn't add any extra text unless necessary.\n\nThe instructions say: \"Keep your response to user clear; please do not make your response verbose!\" So, I should not add any chit-chat; just the function call.\n\nFinally, structure it as: {\"action\": \"weather\", \"action_input\": {\"location\": \"San Francisco\"}}", + "type": "reasoning" + }, + { + "input": "{\"location\":\"San Francisco\"}", + "toolCallId": "call_46427107", + "toolName": "weather", + "type": "tool-call" + } + ], + "finishReason": { + "raw": "tool_calls", + "unified": "tool-calls" + }, + "providerMetadata": { + "test-provider": { + "acceptedPredictionTokens": 0, + "rejectedPredictionTokens": 0 + } + }, + "request": { + "body": "{\"model\":\"grok-3\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}" + }, + "response": { + "body": { + "choices": [ + { + "finish_reason": "tool_calls", + "index": 0, + "message": { + "content": "", + "reasoning_content": "First, the user is asking about the weather in San Francisco. I have a function available called \"weather\" that retrieves the weather for a given location.\n\nThe function requires a parameter: \"location\", which is a string. The user has provided \"San Francisco\" as the location, so that's clear and inferable.\n\nI should call this function to get the weather information. The format for calling the function is specific: I need to use tags with a JSON object like {\"action\": \"function_name\", \"action_input\": {\"parameter\": \"argument\"}}.\n\nFor this, the action is \"weather\", and the action_input should be {\"location\": \"San Francisco\"}.\n\nAfter calling the function, I might need to handle the response in subsequent turns, but for now, this is the logical next step.\n\nMy response should only include the function call if I'm deciding to make one. I shouldn't add any extra text unless necessary.\n\nThe instructions say: \"Keep your response to user clear; please do not make your response verbose!\" So, I should not add any chit-chat; just the function call.\n\nFinally, structure it as: {\"action\": \"weather\", \"action_input\": {\"location\": \"San Francisco\"}}", + "refusal": null, + "role": "assistant", + "tool_calls": [ + { + "function": { + "arguments": "{\"location\":\"San Francisco\"}", + "name": "weather" + }, + "id": "call_46427107", + "type": "function" + } + ] + } + } + ], + "created": 1770772214, + "id": "acfa24c3-b556-0f2c-731e-64fb836d544b", + "model": "grok-3-mini", + "object": "chat.completion", + "system_fingerprint": "fp_2a885414fb", + "usage": { + "completion_tokens": 26, + "completion_tokens_details": { + "accepted_prediction_tokens": 0, + "audio_tokens": 0, + "reasoning_tokens": 255, + "rejected_prediction_tokens": 0 + }, + "cost_in_usd_ticks": 1777000, + "num_sources_used": 0, + "prompt_tokens": 307, + "prompt_tokens_details": { + "audio_tokens": 0, + "cached_tokens": 244, + "image_tokens": 0, + "text_tokens": 307 + }, + "total_tokens": 588 + } + }, + "headers": { + "content-length": "2013", + "content-type": "application/json" + }, + "id": "acfa24c3-b556-0f2c-731e-64fb836d544b", + "modelId": "grok-3-mini", + "timestamp": "2026-02-11T01:10:14.000Z" + }, + "usage": { + "inputTokens": { + "cacheRead": 244, + "noCache": 63, + "total": 307 + }, + "outputTokens": { + "reasoning": 255, + "text": 0, + "total": 26 + }, + "raw": { + "completion_tokens": 26, + "completion_tokens_details": { + "accepted_prediction_tokens": 0, + "audio_tokens": 0, + "reasoning_tokens": 255, + "rejected_prediction_tokens": 0 + }, + "cost_in_usd_ticks": 1777000, + "num_sources_used": 0, + "prompt_tokens": 307, + "prompt_tokens_details": { + "audio_tokens": 0, + "cached_tokens": 244, + "image_tokens": 0, + "text_tokens": 307 + }, + "total_tokens": 588 + } + }, + "warnings": [] +} diff --git a/aimux-providers/src/openai_compatible/tests/chat/snapshots/do_stream_text_fixture.json b/aimux-providers/src/openai_compatible/tests/chat/snapshots/do_stream_text_fixture.json new file mode 100644 index 000000000..67c3d8786 --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/chat/snapshots/do_stream_text_fixture.json @@ -0,0 +1,1782 @@ +[ + { + "type": "stream-start", + "warnings": [] + }, + { + "id": "f0f0f217-c24d-1fee-5fe3-28fa1d3c8c94", + "modelId": "grok-3-mini", + "timestamp": "2026-02-11T01:11:27.000Z", + "type": "response-metadata" + }, + { + "id": "reasoning-0", + "type": "reasoning-start" + }, + { + "delta": "First", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " the", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " user", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " said", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ":", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " \"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "Say", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " a", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " single", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " word", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".\"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " That's", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " straightforward", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " They", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " want", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " me", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " to", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " respond", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " with", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " just", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " one", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " word", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".\n\n", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "As", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " Gro", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "k", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " I'm", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " supposed", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " to", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " be", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " helpful", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " truthful", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " and", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " not", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " verbose", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " So", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " I", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " should", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " keep", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " my", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " response", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " simple", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " and", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " directly", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " answer", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " the", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " request", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".\n\n", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "The", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " instruction", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " is", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " to", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " say", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " a", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " single", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " word", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " so", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " my", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " response", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " should", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " be", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " exactly", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " that", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ":", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " one", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " word", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " I", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " shouldn't", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " add", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " any", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " explanations", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " greetings", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " or", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " extra", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " text", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".\n\n", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "What", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " word", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " should", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " I", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " choose", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "?", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " The", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " user", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " didn't", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " specify", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " which", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " word", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " so", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " I", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " have", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " the", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " freedom", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " to", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " pick", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " any", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " To", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " make", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " it", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " fun", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " and", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " aligned", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " with", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " my", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " personality", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " I", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " could", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " choose", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " something", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " relevant", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " or", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " witty", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".\n\n", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "I'm", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " Gro", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "k", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " created", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " by", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " x", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "AI", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " and", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " I'm", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " meant", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " to", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " be", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " maximally", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " truthful", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " and", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " helpful", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " Maybe", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " I", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " could", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " choose", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " a", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " word", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " that", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " reflects", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " that", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " like", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " \"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "Hello", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "\"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " or", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " \"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "G", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "rok", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "\",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " but", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " I", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " need", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " to", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " keep", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " it", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " simple", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".\n\n", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "Possible", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " words", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ":\n\n", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "-", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " \"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "Hello", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "\"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " –", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " Common", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " greeting", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".\n\n", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "-", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " \"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "Yes", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "\"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " –", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " Affirm", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "ative", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".\n\n", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "-", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " \"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "G", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "rok", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "\"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " –", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " My", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " name", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " which", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " might", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " be", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " meta", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".\n\n", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "-", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " \"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "Word", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "\"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " –", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " Echo", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "ing", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " the", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " request", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".\n\n", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "-", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " Something", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " random", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " like", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " \"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "Banana", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "\"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " for", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " humor", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".\n\n", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "Since", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " the", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " user", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " asked", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " for", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " a", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " single", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " word", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " and", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " I'm", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " an", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " AI", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " I", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " should", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " ensure", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " it's", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " appropriate", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " and", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " not", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " misleading", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".\n\n", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "The", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " system", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " prompt", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " says", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ":", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " \"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "You", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " are", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " Gro", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "k", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " a", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " helpful", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " and", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " maximally", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " truthful", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " AI", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " built", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " by", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " x", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "AI", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " not", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " based", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " on", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " any", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " other", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " companies", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " and", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " their", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " models", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".\"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " So", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " I", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " should", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " stay", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " true", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " to", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " that", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".\n\n", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "To", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " be", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " helpful", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " I'll", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " just", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " pick", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " a", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " simple", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " word", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " Let's", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " go", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " with", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " \"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "G", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "rok", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "\"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " to", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " make", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " it", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " personal", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".\n\n", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "Finally", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " my", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " response", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " should", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " be", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " only", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " that", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " one", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " word", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " nothing", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " else", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " No", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " punctuation", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " if", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " possible", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " just", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " the", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " word", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".\n\n", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "Response", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ":", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " Gro", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "k", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "id": "reasoning-0", + "type": "reasoning-end" + }, + { + "id": "txt-0", + "type": "text-start" + }, + { + "delta": "G", + "id": "txt-0", + "type": "text-delta" + }, + { + "delta": "rok", + "id": "txt-0", + "type": "text-delta" + }, + { + "id": "txt-0", + "type": "text-end" + }, + { + "finishReason": { + "raw": "stop", + "unified": "stop" + }, + "providerMetadata": { + "test-provider": { + "acceptedPredictionTokens": 0, + "rejectedPredictionTokens": 0 + } + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 11, + "noCache": 1, + "total": 12 + }, + "outputTokens": { + "reasoning": 340, + "text": 0, + "total": 2 + }, + "raw": { + "completion_tokens": 2, + "completion_tokens_details": { + "accepted_prediction_tokens": 0, + "audio_tokens": 0, + "reasoning_tokens": 340, + "rejected_prediction_tokens": 0 + }, + "cost_in_usd_ticks": 1721250, + "num_sources_used": 0, + "prompt_tokens": 12, + "prompt_tokens_details": { + "audio_tokens": 0, + "cached_tokens": 11, + "image_tokens": 0, + "text_tokens": 12 + }, + "total_tokens": 354 + } + } + } +] diff --git a/aimux-providers/src/openai_compatible/tests/chat/snapshots/do_stream_tool_call_fixture.json b/aimux-providers/src/openai_compatible/tests/chat/snapshots/do_stream_tool_call_fixture.json new file mode 100644 index 000000000..e7f86a725 --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/chat/snapshots/do_stream_tool_call_fixture.json @@ -0,0 +1,1219 @@ +[ + { + "type": "stream-start", + "warnings": [] + }, + { + "id": "7027d986-3c59-a37a-9a5f-50713e01c8a6", + "modelId": "grok-3-mini", + "timestamp": "2026-02-11T01:11:33.000Z", + "type": "response-metadata" + }, + { + "id": "reasoning-0", + "type": "reasoning-start" + }, + { + "delta": "First", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " the", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " user", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " is", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " asking", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " about", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " the", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " weather", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " in", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " San", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " Francisco", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " I", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " have", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " a", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " available", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " function", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " called", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " \"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "weather", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "\"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " that", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " retrieves", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " the", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " weather", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " for", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " a", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " given", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " location", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".\n\n", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "The", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " function", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " requires", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " a", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " parameter", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ":", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " \"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "location", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "\",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " which", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " is", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " a", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " string", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " The", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " user", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " has", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " provided", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " \"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "San", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " Francisco", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "\"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " as", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " the", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " location", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " so", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " that's", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " clear", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " and", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " infer", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "able", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".\n\n", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "I", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " should", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " call", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " this", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " function", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " to", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " get", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " the", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " weather", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " information", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " The", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " format", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " for", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " calling", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " the", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " function", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " is", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " specific", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ":", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " I", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " need", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " to", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " use", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " <", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "function", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "_call", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ">", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " tags", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " with", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " JSON", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " inside", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " like", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " <", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "function", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "_call", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ">{\"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "action", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "\":", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " \"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "weather", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "\",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " \"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "action", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "_input", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "\":", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " {\"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "location", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "\":", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " \"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "San", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " Francisco", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "\"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "}}.\n\n", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "This", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " seems", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " to", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " be", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " a", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " direct", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " match", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " so", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " I", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " don't", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " need", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " to", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " ask", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " for", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " clarification", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " My", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " response", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " should", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " only", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " contain", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " the", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " function", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " call", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " if", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " that's", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " the", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " next", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " step", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " which", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " it", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " is", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".\n\n", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "The", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " instructions", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " say", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ":", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " \"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "Keep", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " your", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " response", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " to", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " user", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " clear", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ";", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " please", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " do", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " not", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " make", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " your", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " response", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " verbose", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "!\"", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " So", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " I", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " shouldn't", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " add", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " any", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " extra", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " text", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ";", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " just", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " the", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " function", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " call", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".\n\n", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "Finally", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " after", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " calling", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " the", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " function", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " if", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " this", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " were", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " a", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " multi", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": "-turn", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " conversation", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " I", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " might", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " need", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " to", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " respond", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " based", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " on", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " the", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " result", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " but", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " for", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " now", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ",", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " this", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " is", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " the", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " logical", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " next", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": " step", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "delta": ".", + "id": "reasoning-0", + "type": "reasoning-delta" + }, + { + "id": "reasoning-0", + "type": "reasoning-end" + }, + { + "id": "call_79382389", + "toolName": "weather", + "type": "tool-input-start" + }, + { + "delta": "{\"location\":\"San Francisco\"}", + "id": "call_79382389", + "type": "tool-input-delta" + }, + { + "id": "call_79382389", + "type": "tool-input-end" + }, + { + "input": "{\"location\":\"San Francisco\"}", + "toolCallId": "call_79382389", + "toolName": "weather", + "type": "tool-call" + }, + { + "finishReason": { + "raw": "tool_calls", + "unified": "tool-calls" + }, + "providerMetadata": { + "test-provider": { + "acceptedPredictionTokens": 0, + "rejectedPredictionTokens": 0 + } + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 306, + "noCache": 1, + "total": 307 + }, + "outputTokens": { + "reasoning": 227, + "text": 0, + "total": 26 + }, + "raw": { + "completion_tokens": 26, + "completion_tokens_details": { + "accepted_prediction_tokens": 0, + "audio_tokens": 0, + "reasoning_tokens": 227, + "rejected_prediction_tokens": 0 + }, + "cost_in_usd_ticks": 1497500, + "num_sources_used": 0, + "prompt_tokens": 307, + "prompt_tokens_details": { + "audio_tokens": 0, + "cached_tokens": 306, + "image_tokens": 0, + "text_tokens": 307 + }, + "total_tokens": 560 + } + } + } +] diff --git a/aimux-providers/src/openai_compatible/tests/completion/completion_language_model.rs b/aimux-providers/src/openai_compatible/tests/completion/completion_language_model.rs new file mode 100644 index 000000000..e6f5d3780 --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/completion/completion_language_model.rs @@ -0,0 +1,922 @@ +//! Port of `@ai-sdk/openai-compatible` 3.0.62 +//! `src/completion/openai-compatible-completion-language-model.test.ts`. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +use futures::StreamExt; +use serde_json::{Value, json}; + +use aimux_core::AiMuxError; +use aimux_core::language_model::LanguageModel; +use aimux_core::language_model_message::LanguageModelMessage; +use aimux_core::options::CallOptions; +use aimux_core::result::StreamResult; +use aimux_core::stream_part::StreamPart; + +use crate::openai_compatible::completion::{CompletionConfig, OpenAICompatibleCompletionModel}; +use crate::openai_compatible::config::{BaseUrl, ChatSettings, CompatModelConfig}; +use crate::openai_compatible::{ + OpenAICompatibleProvider, OpenAICompatibleProviderSettings, create_openai_compatible, +}; +use crate::shared::{Credential, ProviderHeaders}; +use crate::test_support::mock_fetch::{Canned, MockFetch, Seen}; + +/// The user agent aimux pins (upstream mocks `0.0.0-test`); compared apart +/// from the other request headers, which upstream's test server lists +/// without it. +const USER_AGENT: &str = "ai-sdk-openai-compatible/3.0.62 ai-sdk-provider-utils/5.0.53"; + +const PROMPT_TEXT: &str = "user:\nHello\n\nassistant:\n"; + +fn test_prompt() -> Vec { + vec![LanguageModelMessage::user_text("Hello")] +} + +fn call_options() -> CallOptions { + CallOptions::new(test_prompt()) +} + +fn stream_options() -> CallOptions { + let mut options = call_options(); + options.include_raw_chunks = Some(false); + options +} + +fn with_provider_options(mut options: CallOptions, provider_options: Value) -> CallOptions { + options.provider_options = Some(serde_json::from_value(provider_options).unwrap()); + options +} + +fn header_map(entries: &[(&str, &str)]) -> HashMap { + entries + .iter() + .map(|(name, value)| ((*name).to_string(), (*value).to_string())) + .collect() +} + +/// `createOpenAICompatible({ baseURL: 'https://my.api.com/v1/', name: +/// 'test-provider', headers })` on the mock transport. +fn create_provider(mock: &Arc, headers: &[(&str, &str)]) -> OpenAICompatibleProvider { + create_openai_compatible(OpenAICompatibleProviderSettings { + base_url: "https://my.api.com/v1/".into(), + name: "test-provider".into(), + headers: Some( + headers + .iter() + .map(|(name, value)| ((*name).to_string(), Some((*value).to_string()))) + .collect(), + ), + fetch: Some(mock.transport()), + ..Default::default() + }) + .unwrap() +} + +/// The module-level `provider` of the upstream file. +fn provider(mock: &Arc) -> OpenAICompatibleProvider { + create_provider(mock, &[("Authorization", "Bearer test-api-key")]) +} + +/// The module-level `model` of the upstream file. +fn model(mock: &Arc) -> OpenAICompatibleCompletionModel { + provider(mock).completion("gpt-3.5-turbo-instruct") +} + +/// `new OpenAICompatibleCompletionLanguageModel(modelId, { provider, url: () +/// => '', headers: () => ({}) })`. +fn model_with_provider(model_id: &str, provider: &str) -> OpenAICompatibleCompletionModel { + OpenAICompatibleCompletionModel::from_config( + model_id.to_string(), + CompatModelConfig { + provider: provider.to_string(), + base_url: BaseUrl::Fixed(String::new()), + query_params: None, + headers: ProviderHeaders::bearer(Credential::None, Vec::new(), None), + fetch_headers: None, + fetch: None, + transform_request_body: None, + chat: ChatSettings::default(), + }, + ) +} + +/// `model['providerOptionsName']`: the name the compatible config derives +/// from the provider string. +fn provider_options_name(model: &OpenAICompatibleCompletionModel) -> String { + match &model.config { + CompletionConfig::Compatible(config) => config.provider_options_name().to_string(), + CompletionConfig::Native(_) => unreachable!("a compatible model"), + } +} + +/// A `json-value` response of the test server: `content-type` and +/// `content-length` are its default headers. +fn json_response(body: &Value, headers: &[(&str, &str)]) -> Canned { + let body = serde_json::to_vec(body).unwrap(); + let mut all = vec![ + ("content-type".to_string(), "application/json".to_string()), + ("content-length".to_string(), body.len().to_string()), + ]; + all.extend( + headers + .iter() + .map(|(name, value)| ((*name).to_string(), (*value).to_string())), + ); + Canned { + status: 200, + headers: all, + body, + } +} + +/// A `stream-chunks` response of the test server with its default headers. +fn stream_response(chunks: &[&str], headers: &[(&str, &str)]) -> Canned { + let mut all = vec![ + ("content-type".to_string(), "text/event-stream".to_string()), + ("cache-control".to_string(), "no-cache".to_string()), + ("connection".to_string(), "keep-alive".to_string()), + ]; + all.extend( + headers + .iter() + .map(|(name, value)| ((*name).to_string(), (*value).to_string())), + ); + Canned { + status: 200, + headers: all, + body: chunks.concat().into_bytes(), + } +} + +/// The request headers without `user-agent`, which is checked against the +/// pinned value. +fn request_headers(seen: &Seen) -> BTreeMap { + let mut headers = seen.headers.clone(); + assert_eq!( + headers.remove("user-agent").as_deref(), + Some(USER_AGENT), + "pinned user agent" + ); + headers +} + +fn expected_headers(entries: &[(&str, &str)]) -> BTreeMap { + entries + .iter() + .map(|(name, value)| ((*name).to_string(), (*value).to_string())) + .collect() +} + +/// One stream part as upstream's snapshot shows it. An error part of an +/// `{"error": ...}` chunk carries that chunk's `error` value upstream; aimux +/// wraps it in an API call error whose `data` is that value. +fn part_json(part: StreamPart) -> Value { + match part { + StreamPart::Error { + error: AiMuxError::ApiCall(error), + } => json!({ "type": "error", "error": error.data }), + other => serde_json::to_value(other).unwrap(), + } +} + +async fn stream_parts(result: StreamResult) -> Vec { + result + .stream + .map(|part| part_json(part.expect("stream item"))) + .collect() + .await +} + +mod config { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › config › should extract base name from provider string + #[test] + fn should_extract_base_name_from_provider_string() { + let model = model_with_provider("gpt-5", "anthropic.beta"); + + assert_eq!(provider_options_name(&model), "anthropic"); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › config › should handle provider without dot notation + #[test] + fn should_handle_provider_without_dot_notation() { + let model = model_with_provider("gpt-5", "openai"); + + assert_eq!(provider_options_name(&model), "openai"); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › config › should return empty for empty provider + #[test] + fn should_return_empty_for_empty_provider() { + let model = model_with_provider("gpt-5", ""); + + assert_eq!(provider_options_name(&model), ""); + } +} + +mod do_generate { + use super::*; + + /// The arguments of upstream's `prepareJsonResponse`. + struct JsonResponse { + content: &'static str, + usage: Value, + finish_reason: &'static str, + id: &'static str, + created: i64, + model: &'static str, + headers: &'static [(&'static str, &'static str)], + } + + impl Default for JsonResponse { + fn default() -> Self { + Self { + content: "", + usage: json!({ "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }), + finish_reason: "stop", + id: "cmpl-96cAM1v77r4jXa4qb2NSmRREV5oWB", + created: 1_711_363_706, + model: "gpt-3.5-turbo-instruct", + headers: &[], + } + } + } + + fn prepare_json_response(response: JsonResponse) -> Arc { + MockFetch::new(vec![json_response( + &json!({ + "id": response.id, + "object": "text_completion", + "created": response.created, + "model": response.model, + "choices": [{ + "text": response.content, + "index": 0, + "finish_reason": response.finish_reason, + }], + "usage": response.usage, + }), + response.headers, + )]) + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doGenerate › should extract text response + #[tokio::test] + async fn should_extract_text_response() { + let mock = prepare_json_response(JsonResponse { + content: "Hello, World!", + ..Default::default() + }); + + let result = model(&mock).do_generate(&call_options()).await.unwrap(); + + assert_eq!( + serde_json::to_value(&result.content).unwrap(), + json!([{ "text": "Hello, World!", "type": "text" }]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doGenerate › should extract usage + #[tokio::test] + async fn should_extract_usage() { + let mock = prepare_json_response(JsonResponse { + usage: json!({ "prompt_tokens": 20, "total_tokens": 25, "completion_tokens": 5 }), + ..Default::default() + }); + + let result = model(&mock).do_generate(&call_options()).await.unwrap(); + + assert_eq!( + serde_json::to_value(&result.usage).unwrap(), + json!({ + "inputTokens": { "noCache": 20, "total": 20 }, + "outputTokens": { "text": 5, "total": 5 }, + "raw": { "completion_tokens": 5, "prompt_tokens": 20, "total_tokens": 25 }, + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doGenerate › should send request body + #[tokio::test] + async fn should_send_request_body() { + let mock = prepare_json_response(JsonResponse::default()); + + let result = model(&mock).do_generate(&call_options()).await.unwrap(); + + assert_eq!( + serde_json::to_value(&result.request).unwrap(), + json!({ + "body": { + "model": "gpt-3.5-turbo-instruct", + "prompt": PROMPT_TEXT, + "stop": ["\nuser:"], + }, + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doGenerate › should send additional response information + #[tokio::test] + async fn should_send_additional_response_information() { + let mock = prepare_json_response(JsonResponse { + id: "test-id", + created: 123, + model: "test-model", + ..Default::default() + }); + + let result = model(&mock).do_generate(&call_options()).await.unwrap(); + + assert_eq!( + serde_json::to_value(&result.response).unwrap(), + json!({ + "body": { + "choices": [{ "finish_reason": "stop", "index": 0, "text": "" }], + "created": 123, + "id": "test-id", + "model": "test-model", + "object": "text_completion", + "usage": { "completion_tokens": 30, "prompt_tokens": 4, "total_tokens": 34 }, + }, + "headers": { + "content-length": "204", + "content-type": "application/json", + }, + "id": "test-id", + "modelId": "test-model", + "timestamp": "1970-01-01T00:02:03.000Z", + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doGenerate › should extract finish reason + #[tokio::test] + async fn should_extract_finish_reason() { + let mock = prepare_json_response(JsonResponse { + finish_reason: "stop", + ..Default::default() + }); + + let result = provider(&mock) + .completion("gpt-3.5-turbo-instruct") + .do_generate(&call_options()) + .await + .unwrap(); + + assert_eq!( + serde_json::to_value(&result.finish_reason).unwrap(), + json!({ "raw": "stop", "unified": "stop" }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doGenerate › should support unknown finish reason + #[tokio::test] + async fn should_support_unknown_finish_reason() { + let mock = prepare_json_response(JsonResponse { + finish_reason: "eos", + ..Default::default() + }); + + let result = provider(&mock) + .completion("gpt-3.5-turbo-instruct") + .do_generate(&call_options()) + .await + .unwrap(); + + assert_eq!( + serde_json::to_value(&result.finish_reason).unwrap(), + json!({ "raw": "eos", "unified": "other" }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doGenerate › should expose the raw response headers + #[tokio::test] + async fn should_expose_the_raw_response_headers() { + let mock = prepare_json_response(JsonResponse { + headers: &[("test-header", "test-value")], + ..Default::default() + }); + + let result = model(&mock).do_generate(&call_options()).await.unwrap(); + + assert_eq!( + result.response.unwrap().headers.unwrap(), + header_map(&[ + // default headers: + ("content-length", "250"), + ("content-type", "application/json"), + // custom header + ("test-header", "test-value"), + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doGenerate › should pass the model and the prompt + #[tokio::test] + async fn should_pass_the_model_and_the_prompt() { + let mock = prepare_json_response(JsonResponse::default()); + + model(&mock).do_generate(&call_options()).await.unwrap(); + + assert_eq!( + mock.seen()[0].json_body(), + json!({ + "model": "gpt-3.5-turbo-instruct", + "prompt": PROMPT_TEXT, + "stop": ["\nuser:"], + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doGenerate › should pass headers + #[tokio::test] + async fn should_pass_headers() { + let mock = prepare_json_response(JsonResponse::default()); + + let provider = create_provider( + &mock, + &[ + ("Authorization", "Bearer test-api-key"), + ("Custom-Provider-Header", "provider-header-value"), + ], + ); + + let mut options = call_options(); + options.headers = Some(header_map(&[( + "Custom-Request-Header", + "request-header-value", + )])); + provider + .completion("gpt-3.5-turbo-instruct") + .do_generate(&options) + .await + .unwrap(); + + assert_eq!( + request_headers(&mock.seen()[0]), + expected_headers(&[ + ("authorization", "Bearer test-api-key"), + ("content-type", "application/json"), + ("custom-provider-header", "provider-header-value"), + ("custom-request-header", "request-header-value"), + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doGenerate › should include provider-specific options + #[tokio::test] + async fn should_include_provider_specific_options() { + let mock = prepare_json_response(JsonResponse::default()); + + provider(&mock) + .completion("gpt-3.5-turbo-instruct") + .do_generate(&with_provider_options( + call_options(), + json!({ "test-provider": { "someCustomOption": "test-value" } }), + )) + .await + .unwrap(); + + assert_eq!( + mock.seen()[0].json_body(), + json!({ + "model": "gpt-3.5-turbo-instruct", + "prompt": PROMPT_TEXT, + "someCustomOption": "test-value", + "stop": ["\nuser:"], + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doGenerate › should not include provider-specific options for different provider + #[tokio::test] + async fn should_not_include_provider_specific_options_for_different_provider() { + let mock = prepare_json_response(JsonResponse::default()); + + provider(&mock) + .completion("gpt-3.5-turbo-instruct") + .do_generate(&with_provider_options( + call_options(), + json!({ "notThisProviderName": { "someCustomOption": "test-value" } }), + )) + .await + .unwrap(); + + assert_eq!( + mock.seen()[0].json_body(), + json!({ + "model": "gpt-3.5-turbo-instruct", + "prompt": PROMPT_TEXT, + "stop": ["\nuser:"], + }) + ); + } + + mod camel_case_provider_options { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doGenerate › camelCase provider options › should accept camelCase provider options key for hyphenated provider name + #[tokio::test] + async fn should_accept_camel_case_provider_options_key_for_hyphenated_provider_name() { + let mock = prepare_json_response(JsonResponse::default()); + + provider(&mock) + .completion("gpt-3.5-turbo-instruct") + .do_generate(&with_provider_options( + call_options(), + json!({ "testProvider": { "someCustomOption": "test-value" } }), + )) + .await + .unwrap(); + + assert_eq!( + mock.seen()[0].json_body()["someCustomOption"], + json!("test-value") + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doGenerate › camelCase provider options › should prefer camelCase options over raw-name options + #[tokio::test] + async fn should_prefer_camel_case_options_over_raw_name_options() { + let mock = prepare_json_response(JsonResponse::default()); + + provider(&mock) + .completion("gpt-3.5-turbo-instruct") + .do_generate(&with_provider_options( + call_options(), + json!({ + "test-provider": { "someCustomOption": "raw-value" }, + "testProvider": { "someCustomOption": "camel-value" }, + }), + )) + .await + .unwrap(); + + assert_eq!( + mock.seen()[0].json_body()["someCustomOption"], + json!("camel-value") + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doGenerate › camelCase provider options › should emit deprecated warning when raw provider options key is used + #[tokio::test] + async fn should_emit_deprecated_warning_when_raw_provider_options_key_is_used() { + let mock = prepare_json_response(JsonResponse::default()); + + let result = provider(&mock) + .completion("gpt-3.5-turbo-instruct") + .do_generate(&with_provider_options( + call_options(), + json!({ "test-provider": { "someCustomOption": "test-value" } }), + )) + .await + .unwrap(); + + assert_eq!( + serde_json::to_value(&result.warnings).unwrap(), + json!([{ + "message": "Use 'testProvider' instead.", + "setting": "providerOptions key 'test-provider'", + "type": "deprecated", + }]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doGenerate › camelCase provider options › should not emit deprecated warning when camelCase provider options key is used + #[tokio::test] + async fn should_not_emit_deprecated_warning_when_camel_case_provider_options_key_is_used() { + let mock = prepare_json_response(JsonResponse::default()); + + let result = provider(&mock) + .completion("gpt-3.5-turbo-instruct") + .do_generate(&with_provider_options( + call_options(), + json!({ "testProvider": { "someCustomOption": "test-value" } }), + )) + .await + .unwrap(); + + assert_eq!(serde_json::to_value(&result.warnings).unwrap(), json!([])); + } + } +} + +mod do_stream { + use super::*; + + fn prepare_empty_stream_response(headers: &[(&str, &str)]) -> Arc { + MockFetch::new(vec![stream_response( + &[ + "data: {\"id\":\"cmpl-96c3yLQE1TtZCd6n6OILVmzev8M8H\",\"object\":\"text_completion\",\"created\":1711363310,\"model\":\"gpt-3.5-turbo-instruct\",\"choices\":[{\"text\":\"\",\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\"}]}\n\n", + "data: {\"id\":\"cmpl-96c3yLQE1TtZCd6n6OILVmzev8M8H\",\"object\":\"text_completion\",\"created\":1711363310,\"model\":\"gpt-3.5-turbo-instruct\",\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":0,\"total_tokens\":10},\"choices\":[]}\n\n", + "data: [DONE]\n\n", + ], + headers, + )]) + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doStream › should stream text deltas + #[tokio::test] + async fn should_stream_text_deltas() { + let mock = MockFetch::new(vec![stream_response( + &[ + "data: {\"id\":\"cmpl-96c64EdfhOw8pjFFgVpLuT8k2MtdT\",\"object\":\"text_completion\",\"created\":1711363440,\"model\":\"gpt-3.5-turbo-instruct\",\"choices\":[{\"text\":\"Hello\",\"index\":0,\"logprobs\":null,\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"cmpl-96c64EdfhOw8pjFFgVpLuT8k2MtdT\",\"object\":\"text_completion\",\"created\":1711363440,\"model\":\"gpt-3.5-turbo-instruct\",\"choices\":[{\"text\":\",\",\"index\":0,\"logprobs\":null,\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"cmpl-96c64EdfhOw8pjFFgVpLuT8k2MtdT\",\"object\":\"text_completion\",\"created\":1711363440,\"model\":\"gpt-3.5-turbo-instruct\",\"choices\":[{\"text\":\" World!\",\"index\":0,\"logprobs\":null,\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"cmpl-96c3yLQE1TtZCd6n6OILVmzev8M8H\",\"object\":\"text_completion\",\"created\":1711363310,\"model\":\"gpt-3.5-turbo-instruct\",\"choices\":[{\"text\":\"\",\"index\":0,\"logprobs\":null,\"finish_reason\":\"stop\"}]}\n\n", + "data: {\"id\":\"cmpl-96c3yLQE1TtZCd6n6OILVmzev8M8H\",\"object\":\"text_completion\",\"created\":1711363310,\"model\":\"gpt-3.5-turbo-instruct\",\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":362,\"total_tokens\":372},\"choices\":[]}\n\n", + "data: [DONE]\n\n", + ], + &[], + )]); + + let result = model(&mock).do_stream(&stream_options()).await.unwrap(); + + assert_eq!( + stream_parts(result).await, + vec![ + json!({ "type": "stream-start", "warnings": [] }), + json!({ + "id": "cmpl-96c64EdfhOw8pjFFgVpLuT8k2MtdT", + "modelId": "gpt-3.5-turbo-instruct", + "timestamp": "2024-03-25T10:44:00.000Z", + "type": "response-metadata", + }), + json!({ "id": "0", "type": "text-start" }), + json!({ "delta": "Hello", "id": "0", "type": "text-delta" }), + json!({ "delta": ",", "id": "0", "type": "text-delta" }), + json!({ "delta": " World!", "id": "0", "type": "text-delta" }), + json!({ "delta": "", "id": "0", "type": "text-delta" }), + json!({ "id": "0", "type": "text-end" }), + json!({ + "finishReason": { "raw": "stop", "unified": "stop" }, + "type": "finish", + "usage": { + "inputTokens": { "noCache": 10, "total": 10 }, + "outputTokens": { "text": 362, "total": 362 }, + "raw": { "completion_tokens": 362, "prompt_tokens": 10, "total_tokens": 372 }, + }, + }), + ] + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doStream › should handle error stream parts + #[tokio::test] + async fn should_handle_error_stream_parts() { + let mock = MockFetch::new(vec![stream_response( + &[ + "data: {\"error\":{\"message\":\"The server had an error processing your request. Sorry about that! You can retry your request, or contact us through our help center at help.openai.com if you keep seeing this error.\",\"type\":\"server_error\",\"param\":null,\"code\":null}}\n\n", + "data: [DONE]\n\n", + ], + &[], + )]); + + let result = model(&mock).do_stream(&stream_options()).await.unwrap(); + + assert_eq!( + stream_parts(result).await, + vec![ + json!({ "type": "stream-start", "warnings": [] }), + json!({ + "error": { + "code": null, + "message": "The server had an error processing your request. Sorry about that! You can retry your request, or contact us through our help center at help.openai.com if you keep seeing this error.", + "param": null, + "type": "server_error", + }, + "type": "error", + }), + json!({ + "finishReason": { "unified": "error" }, + "type": "finish", + "usage": { "inputTokens": {}, "outputTokens": {} }, + }), + ] + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doStream › should handle unparsable stream parts + #[tokio::test] + async fn should_handle_unparsable_stream_parts() { + let mock = MockFetch::new(vec![stream_response( + &["data: {unparsable}\n\n", "data: [DONE]\n\n"], + &[], + )]); + + let result = model(&mock).do_stream(&stream_options()).await.unwrap(); + + let mut parts = stream_parts(result).await; + // Upstream's error is `AI_JSONParseError: JSON parsing failed: Text: + // {unparsable}.\nError message: SyntaxError: ...`; the text after + // `Error message:` is the JavaScript parser's own wording. + let error = parts[1]["error"].take(); + assert_eq!(error["name"], json!("AI_JSONParseError"), "{error}"); + assert!( + error["message"].as_str().is_some_and(|message| message + .starts_with("JSON parsing failed: Text: {unparsable}.\nError message: ")), + "{error}" + ); + assert_eq!( + parts, + vec![ + json!({ "type": "stream-start", "warnings": [] }), + json!({ "error": null, "type": "error" }), + json!({ + "finishReason": { "unified": "error" }, + "type": "finish", + "usage": { "inputTokens": {}, "outputTokens": {} }, + }), + ] + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doStream › should send request body + #[tokio::test] + async fn should_send_request_body() { + let mock = prepare_empty_stream_response(&[]); + + let result = model(&mock).do_stream(&stream_options()).await.unwrap(); + + assert_eq!( + serde_json::to_value(&result.request).unwrap(), + json!({ + "body": { + "model": "gpt-3.5-turbo-instruct", + "prompt": PROMPT_TEXT, + "stop": ["\nuser:"], + "stream": true, + }, + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doStream › should expose the raw response headers + #[tokio::test] + async fn should_expose_the_raw_response_headers() { + let mock = prepare_empty_stream_response(&[("test-header", "test-value")]); + + let result = model(&mock).do_stream(&stream_options()).await.unwrap(); + + assert_eq!( + result.response.unwrap().headers.unwrap(), + header_map(&[ + // default headers: + ("content-type", "text/event-stream"), + ("cache-control", "no-cache"), + ("connection", "keep-alive"), + // custom header + ("test-header", "test-value"), + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doStream › should pass the model and the prompt + #[tokio::test] + async fn should_pass_the_model_and_the_prompt() { + let mock = prepare_empty_stream_response(&[]); + + model(&mock).do_stream(&stream_options()).await.unwrap(); + + assert_eq!( + mock.seen()[0].json_body(), + json!({ + "model": "gpt-3.5-turbo-instruct", + "prompt": PROMPT_TEXT, + "stop": ["\nuser:"], + "stream": true, + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doStream › should pass headers + #[tokio::test] + async fn should_pass_headers() { + let mock = prepare_empty_stream_response(&[]); + + let provider = create_provider( + &mock, + &[ + ("Authorization", "Bearer test-api-key"), + ("Custom-Provider-Header", "provider-header-value"), + ], + ); + + let mut options = stream_options(); + options.headers = Some(header_map(&[( + "Custom-Request-Header", + "request-header-value", + )])); + provider + .completion("gpt-3.5-turbo-instruct") + .do_stream(&options) + .await + .unwrap(); + + assert_eq!( + request_headers(&mock.seen()[0]), + expected_headers(&[ + ("authorization", "Bearer test-api-key"), + ("content-type", "application/json"), + ("custom-provider-header", "provider-header-value"), + ("custom-request-header", "request-header-value"), + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doStream › should include provider-specific options + #[tokio::test] + async fn should_include_provider_specific_options() { + let mock = prepare_empty_stream_response(&[]); + + model(&mock) + .do_stream(&with_provider_options( + stream_options(), + json!({ "test-provider": { "someCustomOption": "test-value" } }), + )) + .await + .unwrap(); + + assert_eq!( + mock.seen()[0].json_body(), + json!({ + "model": "gpt-3.5-turbo-instruct", + "prompt": PROMPT_TEXT, + "someCustomOption": "test-value", + "stop": ["\nuser:"], + "stream": true, + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doStream › should not include provider-specific options for different provider + #[tokio::test] + async fn should_not_include_provider_specific_options_for_different_provider() { + let mock = prepare_empty_stream_response(&[]); + + model(&mock) + .do_stream(&with_provider_options( + stream_options(), + json!({ "notThisProviderName": { "someCustomOption": "test-value" } }), + )) + .await + .unwrap(); + + assert_eq!( + mock.seen()[0].json_body(), + json!({ + "model": "gpt-3.5-turbo-instruct", + "prompt": PROMPT_TEXT, + "stop": ["\nuser:"], + "stream": true, + }) + ); + } + + mod camel_case_provider_options { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doStream › camelCase provider options › should accept camelCase provider options key for hyphenated provider name + #[tokio::test] + async fn should_accept_camel_case_provider_options_key_for_hyphenated_provider_name() { + let mock = prepare_empty_stream_response(&[]); + + model(&mock) + .do_stream(&with_provider_options( + stream_options(), + json!({ "testProvider": { "someCustomOption": "test-value" } }), + )) + .await + .unwrap(); + + assert_eq!( + mock.seen()[0].json_body()["someCustomOption"], + json!("test-value") + ); + } + + /// upstream: @ai-sdk/openai-compatible src/completion/openai-compatible-completion-language-model.test.ts › doStream › camelCase provider options › should prefer camelCase options over raw-name options + #[tokio::test] + async fn should_prefer_camel_case_options_over_raw_name_options() { + let mock = prepare_empty_stream_response(&[]); + + model(&mock) + .do_stream(&with_provider_options( + stream_options(), + json!({ + "test-provider": { "someCustomOption": "raw-value" }, + "testProvider": { "someCustomOption": "camel-value" }, + }), + )) + .await + .unwrap(); + + assert_eq!( + mock.seen()[0].json_body()["someCustomOption"], + json!("camel-value") + ); + } + } +} diff --git a/aimux-providers/src/openai_compatible/tests/completion/mod.rs b/aimux-providers/src/openai_compatible/tests/completion/mod.rs new file mode 100644 index 000000000..0ae7656d9 --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/completion/mod.rs @@ -0,0 +1 @@ +mod completion_language_model; diff --git a/aimux-providers/src/openai_compatible/tests/embedding/embedding_model.rs b/aimux-providers/src/openai_compatible/tests/embedding/embedding_model.rs new file mode 100644 index 000000000..e7e521d34 --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/embedding/embedding_model.rs @@ -0,0 +1,362 @@ +//! Port of `@ai-sdk/openai-compatible` 3.0.62 +//! `src/embedding/openai-compatible-embedding-model.test.ts`. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +use serde_json::{Value, json}; + +use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; + +use crate::openai_compatible::{ + OpenAICompatibleEmbeddingModel, OpenAICompatibleProvider, OpenAICompatibleProviderSettings, + create_openai_compatible, +}; +use crate::test_support::mock_fetch::{Canned, MockFetch, Seen}; + +/// The user agent aimux pins (upstream mocks `0.0.0-test`); compared apart +/// from the other request headers, which upstream's test server lists +/// without it. +const USER_AGENT: &str = "ai-sdk-openai-compatible/3.0.62 ai-sdk-provider-utils/5.0.53"; + +fn dummy_embeddings() -> Vec> { + vec![vec![0.1, 0.2, 0.3, 0.4, 0.5], vec![0.6, 0.7, 0.8, 0.9, 1.0]] +} + +const TEST_VALUES: [&str; 2] = ["sunny day at the beach", "rainy day in the city"]; + +fn test_values() -> Vec { + TEST_VALUES.iter().map(ToString::to_string).collect() +} + +/// `{ values: testValues }`. +fn embed_options() -> EmbeddingCallOptions { + EmbeddingCallOptions { + values: test_values(), + ..EmbeddingCallOptions::new("") + } +} + +fn with_provider_options( + mut options: EmbeddingCallOptions, + provider_options: Value, +) -> EmbeddingCallOptions { + options.provider_options = Some(serde_json::from_value(provider_options).unwrap()); + options +} + +fn header_map(entries: &[(&str, &str)]) -> HashMap { + entries + .iter() + .map(|(name, value)| ((*name).to_string(), (*value).to_string())) + .collect() +} + +/// `createOpenAICompatible({ baseURL: 'https://my.api.com/v1/', name: +/// 'test-provider', headers })` on the mock transport. +fn create_provider(mock: &Arc, headers: &[(&str, &str)]) -> OpenAICompatibleProvider { + create_openai_compatible(OpenAICompatibleProviderSettings { + base_url: "https://my.api.com/v1/".into(), + name: "test-provider".into(), + headers: Some( + headers + .iter() + .map(|(name, value)| ((*name).to_string(), Some((*value).to_string()))) + .collect(), + ), + fetch: Some(mock.transport()), + ..Default::default() + }) + .unwrap() +} + +/// The module-level `provider` of the upstream file. +fn provider(mock: &Arc) -> OpenAICompatibleProvider { + create_provider(mock, &[("Authorization", "Bearer test-api-key")]) +} + +/// The module-level `model` of the upstream file. +fn model(mock: &Arc) -> OpenAICompatibleEmbeddingModel { + provider(mock).embedding("text-embedding-3-large") +} + +/// The request headers without `user-agent`, which is checked against the +/// pinned value. +fn request_headers(seen: &Seen) -> BTreeMap { + let mut headers = seen.headers.clone(); + assert_eq!( + headers.remove("user-agent").as_deref(), + Some(USER_AGENT), + "pinned user agent" + ); + headers +} + +mod do_embed { + use super::*; + + /// The arguments of upstream's `prepareJsonResponse`. + struct JsonResponse { + embeddings: Vec>, + usage: Value, + headers: &'static [(&'static str, &'static str)], + } + + impl Default for JsonResponse { + fn default() -> Self { + Self { + embeddings: dummy_embeddings(), + usage: json!({ "prompt_tokens": 8, "total_tokens": 8 }), + headers: &[], + } + } + } + + /// The `json-value` response of the test server: the body as + /// `JSON.stringify` writes it, with its `content-type` and + /// `content-length` headers. + fn prepare_json_response(response: JsonResponse) -> Arc { + let data: Vec = response + .embeddings + .iter() + .enumerate() + .map(|(index, embedding)| { + // `Display` writes `1.0` as `1`, as `JSON.stringify` does. + let numbers: Vec = embedding.iter().map(ToString::to_string).collect(); + format!( + "{{\"object\":\"embedding\",\"index\":{index},\"embedding\":[{}]}}", + numbers.join(",") + ) + }) + .collect(); + let body = format!( + "{{\"object\":\"list\",\"data\":[{}],\"model\":\"text-embedding-3-large\",\"usage\":{}}}", + data.join(","), + serde_json::to_string(&response.usage).unwrap() + ); + let mut headers = vec![ + ("content-type".to_string(), "application/json".to_string()), + ("content-length".to_string(), body.len().to_string()), + ]; + headers.extend( + response + .headers + .iter() + .map(|(name, value)| ((*name).to_string(), (*value).to_string())), + ); + MockFetch::new(vec![Canned { + status: 200, + headers, + body: body.into_bytes(), + }]) + } + + /// upstream: @ai-sdk/openai-compatible src/embedding/openai-compatible-embedding-model.test.ts › doEmbed › should extract embedding + #[tokio::test] + async fn should_extract_embedding() { + let mock = prepare_json_response(JsonResponse::default()); + + let result = model(&mock).do_embed(&embed_options()).await.unwrap(); + + assert_eq!(result.embeddings, dummy_embeddings()); + } + + /// upstream: @ai-sdk/openai-compatible src/embedding/openai-compatible-embedding-model.test.ts › doEmbed › should expose the raw response headers + #[tokio::test] + async fn should_expose_the_raw_response_headers() { + let mock = prepare_json_response(JsonResponse { + headers: &[("test-header", "test-value")], + ..Default::default() + }); + + let result = model(&mock).do_embed(&embed_options()).await.unwrap(); + + assert_eq!( + result.response.unwrap().headers.unwrap(), + header_map(&[ + // default headers: + ("content-length", "236"), + ("content-type", "application/json"), + // custom header + ("test-header", "test-value"), + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/embedding/openai-compatible-embedding-model.test.ts › doEmbed › should extract usage + #[tokio::test] + async fn should_extract_usage() { + let mock = prepare_json_response(JsonResponse { + usage: json!({ "prompt_tokens": 20, "total_tokens": 20 }), + ..Default::default() + }); + + let result = model(&mock).do_embed(&embed_options()).await.unwrap(); + + assert_eq!( + serde_json::to_value(&result.usage).unwrap(), + json!({ "tokens": 20 }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/embedding/openai-compatible-embedding-model.test.ts › doEmbed › should pass the model and the values + #[tokio::test] + async fn should_pass_the_model_and_the_values() { + let mock = prepare_json_response(JsonResponse::default()); + + model(&mock).do_embed(&embed_options()).await.unwrap(); + + assert_eq!( + mock.seen()[0].json_body(), + json!({ + "model": "text-embedding-3-large", + "input": TEST_VALUES, + "encoding_format": "float", + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/embedding/openai-compatible-embedding-model.test.ts › doEmbed › should pass the dimensions setting + #[tokio::test] + async fn should_pass_the_dimensions_setting() { + let mock = prepare_json_response(JsonResponse::default()); + + provider(&mock) + .embedding("text-embedding-3-large") + .do_embed(&with_provider_options( + embed_options(), + json!({ "openaiCompatible": { "dimensions": 64 } }), + )) + .await + .unwrap(); + + assert_eq!( + mock.seen()[0].json_body(), + json!({ + "model": "text-embedding-3-large", + "input": TEST_VALUES, + "encoding_format": "float", + "dimensions": 64, + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/embedding/openai-compatible-embedding-model.test.ts › doEmbed › should pass settings with deprecated openai-compatible key and emit warning + #[tokio::test] + async fn should_pass_settings_with_deprecated_openai_compatible_key_and_emit_warning() { + let mock = prepare_json_response(JsonResponse::default()); + + let result = provider(&mock) + .embedding("text-embedding-3-large") + .do_embed(&with_provider_options( + embed_options(), + json!({ "openai-compatible": { "dimensions": 64 } }), + )) + .await + .unwrap(); + + assert_eq!( + mock.seen()[0].json_body(), + json!({ + "model": "text-embedding-3-large", + "input": TEST_VALUES, + "encoding_format": "float", + "dimensions": 64, + }) + ); + + assert_eq!( + serde_json::to_value(&result.warnings).unwrap(), + json!([{ + "message": "Use 'openaiCompatible' instead.", + "setting": "providerOptions key 'openai-compatible'", + "type": "deprecated", + }]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/embedding/openai-compatible-embedding-model.test.ts › doEmbed › should emit deprecated warning when raw provider name key is used + #[tokio::test] + async fn should_emit_deprecated_warning_when_raw_provider_name_key_is_used() { + let mock = prepare_json_response(JsonResponse::default()); + + let result = provider(&mock) + .embedding("text-embedding-3-large") + .do_embed(&with_provider_options( + embed_options(), + json!({ "test-provider": { "dimensions": 64 } }), + )) + .await + .unwrap(); + + assert_eq!( + serde_json::to_value(&result.warnings).unwrap(), + json!([{ + "message": "Use 'testProvider' instead.", + "setting": "providerOptions key 'test-provider'", + "type": "deprecated", + }]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/embedding/openai-compatible-embedding-model.test.ts › doEmbed › should not emit deprecated warning when camelCase provider name key is used + #[tokio::test] + async fn should_not_emit_deprecated_warning_when_camel_case_provider_name_key_is_used() { + let mock = prepare_json_response(JsonResponse::default()); + + let result = provider(&mock) + .embedding("text-embedding-3-large") + .do_embed(&with_provider_options( + embed_options(), + json!({ "testProvider": { "dimensions": 64 } }), + )) + .await + .unwrap(); + + assert_eq!(serde_json::to_value(&result.warnings).unwrap(), json!([])); + } + + /// upstream: @ai-sdk/openai-compatible src/embedding/openai-compatible-embedding-model.test.ts › doEmbed › should pass headers + #[tokio::test] + async fn should_pass_headers() { + let mock = prepare_json_response(JsonResponse::default()); + + let provider = create_provider( + &mock, + &[ + ("Authorization", "Bearer test-api-key"), + ("Custom-Provider-Header", "provider-header-value"), + ], + ); + + let mut options = embed_options(); + options.headers = Some(header_map(&[( + "Custom-Request-Header", + "request-header-value", + )])); + provider + .embedding("text-embedding-3-large") + .do_embed(&options) + .await + .unwrap(); + + assert_eq!( + request_headers(&mock.seen()[0]), + BTreeMap::from([ + ( + "authorization".to_string(), + "Bearer test-api-key".to_string() + ), + ("content-type".to_string(), "application/json".to_string()), + ( + "custom-provider-header".to_string(), + "provider-header-value".to_string() + ), + ( + "custom-request-header".to_string(), + "request-header-value".to_string() + ), + ]) + ); + } +} diff --git a/aimux-providers/src/openai_compatible/tests/embedding/mod.rs b/aimux-providers/src/openai_compatible/tests/embedding/mod.rs new file mode 100644 index 000000000..55753b1ba --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/embedding/mod.rs @@ -0,0 +1 @@ +mod embedding_model; diff --git a/aimux-providers/src/openai_compatible/tests/image/image_model.rs b/aimux-providers/src/openai_compatible/tests/image/image_model.rs new file mode 100644 index 000000000..8f5e2cab1 --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/image/image_model.rs @@ -0,0 +1,845 @@ +//! Port of `@ai-sdk/openai-compatible` 3.0.62 +//! `src/image/openai-compatible-image-model.test.ts`. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +use serde_json::{Value, json}; + +use aimux_core::AiMuxError; +use aimux_core::Timestamp; +use aimux_core::image_model::{ + ImageCallOptions, ImageFile, ImageFileData, ImageModel, ImageOutputs, +}; +use aimux_core::shared::{AspectRatio, Size}; +use aimux_provider_utils::{HeaderMapOpt, ProviderErrorParts}; + +use crate::openai_compatible::OpenAICompatibleImageModel; +use crate::openai_compatible::config::{BaseUrl, ChatSettings, CompatModelConfig, ErrorStructure}; +use crate::shared::{Credential, ProviderHeaders}; +use crate::test_support::mock_fetch::{Canned, RouteFetch, Seen}; + +const PROMPT: &str = "A photorealistic astronaut riding a horse"; + +const GENERATIONS_URL: &str = "https://api.example.com/dall-e-3/images/generations"; +const RECRAFT_URL: &str = "https://external.api.recraft.ai/v1/images/generations"; +const EDITS_URL: &str = "https://api.example.com/dall-e-3/images/edits"; + +/// The provider-utils part of the user agent the openai-compatible package +/// pins (upstream's helper layer adds it; its test server lists headers +/// without it). +const USER_AGENT: &str = "ai-sdk-provider-utils/5.0.53"; + +fn json_response(body: Value) -> Canned { + Canned::json(&body) +} + +/// Upstream's `createTestServer` routes: the generation, recraft and edit +/// endpoints with their default responses; `generations` / `edits` replace +/// a route's response. +fn create_test_server(generations: Option, edits: Option) -> Arc { + let generations = generations.unwrap_or_else(|| { + json_response(json!({ + "data": [{ "b64_json": "test1234" }, { "b64_json": "test5678" }], + })) + }); + let recraft = json_response(json!({ "data": [{ "b64_json": "recraft-test-image" }] })); + let edits = edits.unwrap_or_else(|| { + json_response(json!({ "data": [{ "b64_json": "edited-image-base64" }] })) + }); + RouteFetch::new(move |seen: &Seen| match seen.url.as_str() { + GENERATIONS_URL => generations.clone(), + RECRAFT_URL => recraft.clone(), + EDITS_URL => edits.clone(), + _ => Canned { + status: 404, + headers: Vec::new(), + body: Vec::new(), + }, + }) +} + +fn server() -> Arc { + create_test_server(None, None) +} + +fn header_opt_map(entries: &[(&str, &str)]) -> HeaderMapOpt { + entries + .iter() + .map(|(name, value)| ((*name).to_string(), Some((*value).to_string()))) + .collect() +} + +/// `new OpenAICompatibleImageModel(modelId, { provider, headers, url, +/// errorStructure })` on the test server's transport. The base URL is what +/// upstream's `url({ modelId, path })` puts before `path`. +fn image_model( + server: &Arc, + model_id: &str, + provider: &str, + base_url: &str, + headers: &[(&str, &str)], + error_structure: Option, +) -> OpenAICompatibleImageModel { + let mut chat = ChatSettings::default(); + if let Some(error_structure) = error_structure { + chat.error_structure = error_structure; + } + OpenAICompatibleImageModel::from_config( + model_id.to_string(), + CompatModelConfig { + provider: provider.to_string(), + base_url: BaseUrl::Fixed(base_url.to_string()), + query_params: None, + headers: ProviderHeaders::bearer( + Credential::None, + Vec::new(), + Some(header_opt_map(headers)), + ), + fetch_headers: None, + fetch: Some(crate::shared::package_fetch( + Some(server.transport()), + crate::shared::aisdk_pins::OPENAI_COMPATIBLE, + )), + transform_request_body: None, + chat, + }, + ) +} + +/// Upstream's `createBasicModel({ headers, errorStructure })`. +fn create_basic_model( + server: &Arc, + headers: Option<&[(&str, &str)]>, + error_structure: Option, +) -> OpenAICompatibleImageModel { + image_model( + server, + "dall-e-3", + "openai-compatible", + "https://api.example.com/dall-e-3", + headers.unwrap_or(&[("Authorization", "Bearer test-key")]), + error_structure, + ) +} + +/// Upstream's `createDefaultGenerateParams()`. +fn create_default_generate_params() -> ImageCallOptions { + ImageCallOptions { + size: Some(Size::new(1024, 1024)), + headers: Some(HashMap::new()), + ..ImageCallOptions::new(PROMPT) + } +} + +fn with_provider_options( + mut options: ImageCallOptions, + provider_options: Value, +) -> ImageCallOptions { + options.provider_options = serde_json::from_value(provider_options).unwrap(); + options +} + +fn png_file(bytes: &[u8]) -> ImageFile { + ImageFile::File { + media_type: "image/png".to_string(), + data: ImageFileData::Binary(bytes.to_vec()), + } +} + +fn base64_images(outputs: &ImageOutputs) -> Vec { + match outputs { + ImageOutputs::Base64(images) => images.clone(), + ImageOutputs::Binary(_) => panic!("expected base64 images"), + } +} + +/// The request headers without `user-agent`, which is checked against the +/// pinned value. +fn request_headers(seen: &Seen) -> BTreeMap { + let mut headers = seen.headers.clone(); + assert_eq!( + headers.remove("user-agent").as_deref(), + Some(USER_AGENT), + "pinned user agent" + ); + headers +} + +/// The API call error's `message`, `statusCode` and `url` +/// (`rejects.toMatchObject`). +fn api_error_parts(error: &AiMuxError) -> (String, Option, String) { + match error { + AiMuxError::ApiCall(error) => (error.message.clone(), error.status_code, error.url.clone()), + other => panic!("expected an API call error, got {other:?}"), + } +} + +mod open_ai_compatible_image_model { + use super::*; + + mod constructor { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › constructor › should expose correct provider and model information + #[test] + fn should_expose_correct_provider_and_model_information() { + let model = create_basic_model(&server(), None, None); + + assert_eq!(model.provider(), "openai-compatible"); + assert_eq!(model.model_id(), "dall-e-3"); + assert_eq!(model.specification_version(), "v4"); + assert_eq!(model.max_images_per_call(), Some(10)); + assert_eq!(model.supports_file_inputs(), None); + assert_eq!(model.supports_mask_inputs(), None); + } + } + + mod do_generate { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › doGenerate › should pass the correct parameters + #[tokio::test] + async fn should_pass_the_correct_parameters() { + let server = server(); + let model = create_basic_model(&server, None, None); + + let mut options = with_provider_options( + create_default_generate_params(), + json!({ "openaiCompatible": { "quality": "hd" } }), + ); + options.n = 2; + model.do_generate(&options).await.unwrap(); + + assert_eq!( + server.seen()[0].json_body(), + json!({ + "model": "dall-e-3", + "prompt": PROMPT, + "n": 2, + "size": "1024x1024", + "quality": "hd", + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › doGenerate › should use provider name from config for providerOptions key + #[tokio::test] + async fn should_use_provider_name_from_config_for_provider_options_key() { + let server = server(); + let recraft_model = image_model( + &server, + "recraft-v3", + "recraft.image", + "https://external.api.recraft.ai/v1", + &[("Authorization", "Bearer test-key")], + None, + ); + + let mut options = with_provider_options( + create_default_generate_params(), + json!({ "recraft": { "style": "vector_illustration" } }), + ); + options.prompt = Some("A beautiful sunset".to_string()); + recraft_model.do_generate(&options).await.unwrap(); + + assert_eq!( + server.seen()[0].json_body(), + json!({ + "model": "recraft-v3", + "prompt": "A beautiful sunset", + "n": 1, + "size": "1024x1024", + "style": "vector_illustration", + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › doGenerate › should pass typed image output options + #[tokio::test] + async fn should_pass_typed_image_output_options() { + let server = server(); + let model = create_basic_model(&server, None, None); + + model + .do_generate(&with_provider_options( + create_default_generate_params(), + json!({ + "openaiCompatible": { + "size": "auto", + "quality": "high", + "output_format": "jpeg", + "output_compression": 80, + "background": "opaque", + }, + }), + )) + .await + .unwrap(); + + assert_eq!( + server.seen()[0].json_body(), + json!({ + "model": "dall-e-3", + "prompt": PROMPT, + "n": 1, + "size": "auto", + "quality": "high", + "output_format": "jpeg", + "output_compression": 80, + "background": "opaque", + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › doGenerate › should emit deprecated warning when raw provider options key is used for hyphenated provider + #[tokio::test] + async fn should_emit_deprecated_warning_for_raw_key_of_hyphenated_provider() { + let server = server(); + let model = image_model( + &server, + "dall-e-3", + "black-forest-labs.image", + "https://api.example.com/dall-e-3", + &[("Authorization", "Bearer test-key")], + None, + ); + + let result = model + .do_generate(&with_provider_options( + create_default_generate_params(), + json!({ "black-forest-labs": { "quality": "hd" } }), + )) + .await + .unwrap(); + + assert_eq!( + serde_json::to_value(&result.warnings).unwrap(), + json!([{ + "message": "Use 'blackForestLabs' instead.", + "setting": "providerOptions key 'black-forest-labs'", + "type": "deprecated", + }]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › doGenerate › should not emit deprecated warning when camelCase provider options key is used + #[tokio::test] + async fn should_not_emit_deprecated_warning_when_camel_case_provider_options_key_is_used() { + let server = server(); + let model = image_model( + &server, + "dall-e-3", + "black-forest-labs.image", + "https://api.example.com/dall-e-3", + &[("Authorization", "Bearer test-key")], + None, + ); + + let result = model + .do_generate(&with_provider_options( + create_default_generate_params(), + json!({ "blackForestLabs": { "quality": "hd" } }), + )) + .await + .unwrap(); + + assert_eq!(serde_json::to_value(&result.warnings).unwrap(), json!([])); + } + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › doGenerate › should add warnings for unsupported settings + #[tokio::test] + async fn should_add_warnings_for_unsupported_settings() { + let server = server(); + let model = create_basic_model(&server, None, None); + + let mut options = create_default_generate_params(); + options.aspect_ratio = Some(AspectRatio::new(16, 9)); + options.seed = Some(123); + let result = model.do_generate(&options).await.unwrap(); + + assert_eq!( + serde_json::to_value(&result.warnings).unwrap(), + json!([ + { + "details": "This model does not support aspect ratio. Use `size` instead.", + "feature": "aspectRatio", + "type": "unsupported", + }, + { + "feature": "seed", + "type": "unsupported", + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › doGenerate › should pass headers + #[tokio::test] + async fn should_pass_headers() { + let server = server(); + let model_with_headers = create_basic_model( + &server, + Some(&[("Custom-Provider-Header", "provider-header-value")]), + None, + ); + + let mut options = create_default_generate_params(); + options.headers = Some(HashMap::from([( + "Custom-Request-Header".to_string(), + "request-header-value".to_string(), + )])); + model_with_headers.do_generate(&options).await.unwrap(); + + assert_eq!( + request_headers(&server.seen()[0]), + BTreeMap::from([ + ("content-type".to_string(), "application/json".to_string()), + ( + "custom-provider-header".to_string(), + "provider-header-value".to_string() + ), + ( + "custom-request-header".to_string(), + "request-header-value".to_string() + ), + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › doGenerate › should handle API errors with custom error structure + #[tokio::test] + async fn should_handle_api_errors_with_custom_error_structure() { + let server = create_test_server( + Some(Canned { + status: 400, + headers: Vec::new(), + body: serde_json::to_vec(&json!({ + "status": "error", + "details": { + "errorMessage": "Custom provider error format", + "errorCode": 1234, + }, + })) + .unwrap(), + }), + None, + ); + + // `errorSchema` + `errorToMessage`: `Error ${errorCode}: ${errorMessage}`. + let error_structure: ErrorStructure = Arc::new(|data: &Value| ProviderErrorParts { + message: format!( + "Error {}: {}", + data["details"]["errorCode"], + data["details"]["errorMessage"].as_str().unwrap_or_default() + ), + provider_code: None, + }); + let model = create_basic_model(&server, None, Some(error_structure)); + + let error = model + .do_generate(&create_default_generate_params()) + .await + .unwrap_err(); + + assert_eq!( + api_error_parts(&error), + ( + "Error 1234: Custom provider error format".to_string(), + Some(400), + GENERATIONS_URL.to_string(), + ) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › doGenerate › should handle API errors with default error structure + #[tokio::test] + async fn should_handle_api_errors_with_default_error_structure() { + let server = create_test_server( + Some(Canned { + status: 400, + headers: Vec::new(), + body: serde_json::to_vec(&json!({ + "error": { + "message": "Invalid prompt content", + "type": "invalid_request_error", + "param": null, + "code": null, + }, + })) + .unwrap(), + }), + None, + ); + + let model = create_basic_model(&server, None, None); + + let error = model + .do_generate(&create_default_generate_params()) + .await + .unwrap_err(); + + assert_eq!( + api_error_parts(&error), + ( + "Invalid prompt content".to_string(), + Some(400), + GENERATIONS_URL.to_string(), + ) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › doGenerate › should return the raw b64_json content + #[tokio::test] + async fn should_return_the_raw_b64_json_content() { + let model = create_basic_model(&server(), None, None); + let mut options = create_default_generate_params(); + options.n = 2; + let result = model.do_generate(&options).await.unwrap(); + + let images = base64_images(&result.images); + assert_eq!(images.len(), 2); + assert_eq!(images[0], "test1234"); + assert_eq!(images[1], "test5678"); + } + + mod usage { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › doGenerate › usage › should map the usage object reported by the provider + #[tokio::test] + async fn should_map_the_usage_object_reported_by_the_provider() { + let server = create_test_server( + Some(json_response(json!({ + "data": [{ "b64_json": "test1234" }], + "usage": { + "input_tokens": 12, + "output_tokens": 4, + "total_tokens": 16, + }, + }))), + None, + ); + + let model = create_basic_model(&server, None, None); + let result = model + .do_generate(&create_default_generate_params()) + .await + .unwrap(); + + assert_eq!( + serde_json::to_value(&result.usage).unwrap(), + json!({ "inputTokens": 12, "outputTokens": 4, "totalTokens": 16 }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › doGenerate › usage › should return undefined usage when the provider omits it + #[tokio::test] + async fn should_return_undefined_usage_when_the_provider_omits_it() { + let model = create_basic_model(&server(), None, None); + let result = model + .do_generate(&create_default_generate_params()) + .await + .unwrap(); + + assert!(result.usage.is_none()); + } + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › doGenerate › usage › should map null usage fields to undefined + #[tokio::test] + async fn should_map_null_usage_fields_to_undefined() { + let server = create_test_server( + Some(json_response(json!({ + "data": [{ "b64_json": "test1234" }], + "usage": { + "input_tokens": 7, + "output_tokens": null, + "total_tokens": null, + }, + }))), + None, + ); + + let model = create_basic_model(&server, None, None); + let result = model + .do_generate(&create_default_generate_params()) + .await + .unwrap(); + + assert_eq!( + serde_json::to_value(&result.usage).unwrap(), + json!({ "inputTokens": 7 }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › doGenerate › usage › should ignore unknown usage fields + #[tokio::test] + async fn should_ignore_unknown_usage_fields() { + let server = create_test_server( + Some(json_response(json!({ + "data": [{ "b64_json": "test1234" }], + "usage": { + "input_tokens": 3, + "output_tokens": 1, + "total_tokens": 4, + "input_tokens_details": { "image_tokens": 2, "text_tokens": 1 }, + }, + }))), + None, + ); + + let model = create_basic_model(&server, None, None); + let result = model + .do_generate(&create_default_generate_params()) + .await + .unwrap(); + + assert_eq!( + serde_json::to_value(&result.usage).unwrap(), + json!({ "inputTokens": 3, "outputTokens": 1, "totalTokens": 4 }) + ); + } + } + + mod response_metadata { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › doGenerate › response metadata › should include timestamp, headers and modelId in response + #[tokio::test] + async fn should_include_timestamp_headers_and_model_id_in_response() { + // Upstream injects `currentDate: () => testDate`; aimux has no + // date seam, so the timestamp is checked to be the call's time. + let model = create_basic_model(&server(), None, None); + + let before = Timestamp::now(); + let result = model + .do_generate(&create_default_generate_params()) + .await + .unwrap(); + let after = Timestamp::now(); + + let timestamp = result.response.timestamp.expect("timestamp"); + assert!(before <= timestamp && timestamp <= after); + assert_eq!(result.response.model_id.as_deref(), Some("dall-e-3")); + assert!(result.response.headers.is_some()); + } + } + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › doGenerate › should use real date when no custom date provider is specified + #[tokio::test] + async fn should_use_real_date_when_no_custom_date_provider_is_specified() { + let before_date = Timestamp::now(); + + let model = image_model( + &server(), + "dall-e-3", + "openai-compatible", + "https://api.example.com/dall-e-3", + &[("Authorization", "Bearer test-key")], + None, + ); + + let result = model + .do_generate(&create_default_generate_params()) + .await + .unwrap(); + + let after_date = Timestamp::now(); + + let timestamp = result.response.timestamp.expect("timestamp"); + assert!(timestamp.unix_millis() >= before_date.unix_millis()); + assert!(timestamp.unix_millis() <= after_date.unix_millis()); + assert_eq!(result.response.model_id.as_deref(), Some("dall-e-3")); + } + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › doGenerate › should pass the user setting in the request + #[tokio::test] + async fn should_pass_the_user_setting_in_the_request() { + let server = server(); + let model = create_basic_model(&server, None, None); + + model + .do_generate(&with_provider_options( + create_default_generate_params(), + json!({ "openaiCompatible": { "user": "test-user-id" } }), + )) + .await + .unwrap(); + + assert_eq!( + server.seen()[0].json_body(), + json!({ + "model": "dall-e-3", + "prompt": PROMPT, + "n": 1, + "size": "1024x1024", + "user": "test-user-id", + }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › doGenerate › should not include user field in request when not set via provider options + #[tokio::test] + async fn should_not_include_user_field_in_request_when_not_set_via_provider_options() { + let server = server(); + let model = create_basic_model(&server, None, None); + + model + .do_generate(&with_provider_options( + create_default_generate_params(), + json!({ "openaiCompatible": {} }), + )) + .await + .unwrap(); + + let request_body = server.seen()[0].json_body(); + assert_eq!( + request_body, + json!({ + "model": "dall-e-3", + "prompt": PROMPT, + "n": 1, + "size": "1024x1024", + }) + ); + assert!(request_body.get("user").is_none()); + } + } + + mod image_editing { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › Image Editing › should send edit request with files + #[tokio::test] + async fn should_send_edit_request_with_files() { + let edit_server = server(); + let model = create_basic_model(&edit_server, None, None); + + // PNG magic bytes + let image_data = [137, 80, 78, 71]; + + let mut options = create_default_generate_params(); + options.prompt = Some("Turn the cat into a dog".to_string()); + options.files = Some(vec![png_file(&image_data)]); + let result = model.do_generate(&options).await.unwrap(); + + assert_eq!(base64_images(&result.images), ["edited-image-base64"]); + assert_eq!(edit_server.seen()[0].url, EDITS_URL); + } + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › Image Editing › should send edit request with files and mask + #[tokio::test] + async fn should_send_edit_request_with_files_and_mask() { + let edit_server = server(); + let model = create_basic_model(&edit_server, None, None); + + let image_data = [137, 80, 78, 71]; + let mask_data = [137, 80, 78, 71]; + + let mut options = create_default_generate_params(); + options.prompt = Some("Add a flamingo to the pool".to_string()); + options.files = Some(vec![png_file(&image_data)]); + options.mask = Some(png_file(&mask_data)); + let result = model.do_generate(&options).await.unwrap(); + + assert_eq!(base64_images(&result.images), ["edited-image-base64"]); + assert_eq!(edit_server.seen()[0].url, EDITS_URL); + } + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › Image Editing › should send edit request with Uint8Array data + #[tokio::test] + async fn should_send_edit_request_with_uint8_array_data() { + let model = create_basic_model(&server(), None, None); + + let image_uint8_array = [104, 101, 108, 108, 111]; + + let mut options = create_default_generate_params(); + options.prompt = Some("Edit this image".to_string()); + options.files = Some(vec![png_file(&image_uint8_array)]); + let result = model.do_generate(&options).await.unwrap(); + + assert_eq!(base64_images(&result.images), ["edited-image-base64"]); + } + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › Image Editing › should send edit request with multiple images + #[tokio::test] + async fn should_send_edit_request_with_multiple_images() { + let model = create_basic_model(&server(), None, None); + + let image1 = [137, 80, 78, 71]; + let image2 = [137, 80, 78, 71]; + + let mut options = create_default_generate_params(); + options.prompt = Some("Combine these images".to_string()); + options.files = Some(vec![png_file(&image1), png_file(&image2)]); + let result = model.do_generate(&options).await.unwrap(); + + assert_eq!(base64_images(&result.images), ["edited-image-base64"]); + } + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › Image Editing › should include response metadata for edit requests + #[tokio::test] + async fn should_include_response_metadata_for_edit_requests() { + // Upstream injects `currentDate: () => testDate`; aimux has no + // date seam, so the timestamp is checked to be the call's time. + let model = create_basic_model(&server(), None, None); + + let image_data = [137, 80, 78, 71]; + + let mut options = create_default_generate_params(); + options.prompt = Some("Edit this image".to_string()); + options.files = Some(vec![png_file(&image_data)]); + let before = Timestamp::now(); + let result = model.do_generate(&options).await.unwrap(); + let after = Timestamp::now(); + + let timestamp = result.response.timestamp.expect("timestamp"); + assert!(before <= timestamp && timestamp <= after); + assert_eq!(result.response.model_id.as_deref(), Some("dall-e-3")); + assert!(result.response.headers.is_some()); + } + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › Image Editing › should map usage for edit requests + #[tokio::test] + async fn should_map_usage_for_edit_requests() { + let edit_server = create_test_server( + None, + Some(json_response(json!({ + "data": [{ "b64_json": "edited-image-base64" }], + "usage": { + "input_tokens": 20, + "output_tokens": 5, + "total_tokens": 25, + }, + }))), + ); + + let model = create_basic_model(&edit_server, None, None); + let image_data = [137, 80, 78, 71]; + + let mut options = create_default_generate_params(); + options.prompt = Some("Edit this image".to_string()); + options.files = Some(vec![png_file(&image_data)]); + let result = model.do_generate(&options).await.unwrap(); + + assert_eq!( + serde_json::to_value(&result.usage).unwrap(), + json!({ "inputTokens": 20, "outputTokens": 5, "totalTokens": 25 }) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/image/openai-compatible-image-model.test.ts › OpenAICompatibleImageModel › Image Editing › should return undefined usage for edit requests when omitted + #[tokio::test] + async fn should_return_undefined_usage_for_edit_requests_when_omitted() { + let model = create_basic_model(&server(), None, None); + let image_data = [137, 80, 78, 71]; + + let mut options = create_default_generate_params(); + options.prompt = Some("Edit this image".to_string()); + options.files = Some(vec![png_file(&image_data)]); + let result = model.do_generate(&options).await.unwrap(); + + assert!(result.usage.is_none()); + } + } +} diff --git a/aimux-providers/src/openai_compatible/tests/image/mod.rs b/aimux-providers/src/openai_compatible/tests/image/mod.rs new file mode 100644 index 000000000..4fb462e4c --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/image/mod.rs @@ -0,0 +1 @@ +mod image_model; diff --git a/aimux-providers/src/openai_compatible/tests/mod.rs b/aimux-providers/src/openai_compatible/tests/mod.rs new file mode 100644 index 000000000..2b4c89c6c --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/mod.rs @@ -0,0 +1,6 @@ +mod chat; +mod completion; +mod embedding; +mod image; +mod provider; +mod utils; diff --git a/aimux-providers/src/openai_compatible/tests/provider.rs b/aimux-providers/src/openai_compatible/tests/provider.rs new file mode 100644 index 000000000..29205aebf --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/provider.rs @@ -0,0 +1,413 @@ +//! Port of `@ai-sdk/openai-compatible` 3.0.62 +//! `src/openai-compatible-provider.test.ts`. +//! +//! Upstream mocks the model classes and reads the config each constructor +//! was called with. Here the config is the one the returned model holds +//! (`model.config`). `provider(modelId)` and `provider.languageModel(modelId)` +//! are `call` / `Provider::language_model`, which return an +//! `Arc` whose config cannot be read; for them the setting +//! is observed in the request the model sends. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +use serde_json::Value; + +use aimux_core::language_model::LanguageModel; +use aimux_core::language_model_message::LanguageModelMessage; +use aimux_core::options::CallOptions; +use aimux_core::types::ProviderMetadata; + +use crate::openai_compatible::completion::{CompletionConfig, OpenAICompatibleCompletionModel}; +use crate::openai_compatible::config::CompatModelConfig; +use crate::openai_compatible::{ + MetadataExtractor, OpenAICompatibleProvider, OpenAICompatibleProviderSettings, + StreamMetadataExtractor, create_openai_compatible, +}; +use crate::test_support::mock_fetch::{Canned, MockFetch}; + +/// The user-agent part the provider headers carry: the pinned +/// `ai-sdk-openai-compatible/` (upstream mocks `0.0.0-test`). +const USER_AGENT: &str = "ai-sdk-openai-compatible/3.0.62"; + +fn header_opt_map(entries: &[(&str, &str)]) -> aimux_provider_utils::HeaderMapOpt { + entries + .iter() + .map(|(name, value)| ((*name).to_string(), Some((*value).to_string()))) + .collect() +} + +fn expected_headers(entries: &[(&str, &str)]) -> BTreeMap { + entries + .iter() + .map(|(name, value)| ((*name).to_string(), (*value).to_string())) + .collect() +} + +/// `config.headers()`, with the user agent checked against the pinned value +/// and left out. +async fn config_headers(config: &CompatModelConfig) -> BTreeMap { + let mut headers: BTreeMap = config + .headers + .resolve() + .await + .unwrap() + .into_iter() + .filter_map(|(name, value)| value.map(|value| (name, value))) + .collect(); + assert_eq!( + headers.remove("user-agent").as_deref(), + Some(USER_AGENT), + "pinned user agent" + ); + headers +} + +/// `config.url({ path })`. +fn config_url(config: &CompatModelConfig, path: &str) -> String { + config.url(path).unwrap().0 +} + +fn completion_config(model: &OpenAICompatibleCompletionModel) -> &CompatModelConfig { + match &model.config { + CompletionConfig::Compatible(config) => config, + CompletionConfig::Native(_) => unreachable!("a compatible model"), + } +} + +/// A server-sent-events chat response; the request body is what the probes +/// read, so the stream itself is never consumed. +fn chat_stream_response() -> Canned { + Canned { + status: 200, + headers: vec![("content-type".into(), "text/event-stream".into())], + body: b"data: [DONE]\n\n".to_vec(), + } +} + +/// The body a `doStream` call of `model` sends with `options`. +async fn sent_stream_body( + model: &dyn LanguageModel, + mock: &Arc, + options: &CallOptions, +) -> Value { + model.do_stream(options).await.unwrap(); + mock.seen().last().expect("a request").json_body() +} + +fn prompt_options() -> CallOptions { + CallOptions::new(vec![LanguageModelMessage::user_text("Hello")]) +} + +/// The `includeUsage` a language model got, read from the +/// `stream_options` it sends (`undefined` and `false` send none). +async fn sends_include_usage(model: &dyn LanguageModel, mock: &Arc) -> Option { + let body = sent_stream_body(model, mock, &prompt_options()).await; + body.get("stream_options") + .map(|stream_options| stream_options["include_usage"].as_bool().unwrap()) +} + +mod open_ai_compatible_provider { + use super::*; + + mod create_open_ai_compatible { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/openai-compatible-provider.test.ts › OpenAICompatibleProvider › createOpenAICompatible › should create provider with correct configuration + #[tokio::test] + async fn should_create_provider_with_correct_configuration() { + let options = OpenAICompatibleProviderSettings { + base_url: "https://api.example.com".into(), + name: "test-provider".into(), + api_key: Some("test-api-key".into()), + headers: Some(header_opt_map(&[("custom-header", "value")])), + query_params: Some(HashMap::from([( + "Custom-Param".to_string(), + "value".to_string(), + )])), + ..Default::default() + }; + + let provider = create_openai_compatible(options).unwrap(); + // `provider('model-id')` is `call`, which returns `chat('model-id')`. + assert_eq!(provider.call("model-id").provider(), "test-provider.chat"); + let model = provider.chat("model-id"); + + let config = &model.config; + let headers = config_headers(config).await; + + assert_eq!( + headers, + expected_headers(&[ + ("authorization", "Bearer test-api-key"), + ("custom-header", "value"), + ]) + ); + assert_eq!(config.provider, "test-provider.chat"); + assert_eq!( + config_url(config, "/v1/chat"), + "https://api.example.com/v1/chat?Custom-Param=value" + ); + } + + /// upstream: @ai-sdk/openai-compatible src/openai-compatible-provider.test.ts › OpenAICompatibleProvider › createOpenAICompatible › should create headers without authorization when no apiKey provided + #[tokio::test] + async fn should_create_headers_without_authorization_when_no_api_key_provided() { + let options = OpenAICompatibleProviderSettings { + base_url: "https://api.example.com".into(), + name: "test-provider".into(), + headers: Some(header_opt_map(&[("custom-header", "value")])), + ..Default::default() + }; + + let provider = create_openai_compatible(options).unwrap(); + // `provider('model-id')` is `call`, which returns `chat('model-id')`. + assert_eq!(provider.call("model-id").provider(), "test-provider.chat"); + let model = provider.chat("model-id"); + + let headers = config_headers(&model.config).await; + + assert_eq!(headers, expected_headers(&[("custom-header", "value")])); + } + } + + mod model_creation_methods { + use super::*; + + fn default_options() -> OpenAICompatibleProviderSettings { + OpenAICompatibleProviderSettings { + base_url: "https://api.example.com".into(), + name: "test-provider".into(), + api_key: Some("test-api-key".into()), + headers: Some(header_opt_map(&[("custom-header", "value")])), + query_params: Some(HashMap::from([( + "Custom-Param".to_string(), + "value".to_string(), + )])), + ..Default::default() + } + } + + /// upstream: @ai-sdk/openai-compatible src/openai-compatible-provider.test.ts › OpenAICompatibleProvider › model creation methods › should create chat model with correct configuration + #[tokio::test] + async fn should_create_chat_model_with_correct_configuration() { + let provider = create_openai_compatible(default_options()).unwrap(); + + let model = provider.chat("chat-model"); + + let config = &model.config; + let headers = config_headers(config).await; + + assert_eq!( + headers, + expected_headers(&[ + ("authorization", "Bearer test-api-key"), + ("custom-header", "value"), + ]) + ); + assert_eq!(config.provider, "test-provider.chat"); + assert_eq!( + config_url(config, "/v1/chat"), + "https://api.example.com/v1/chat?Custom-Param=value" + ); + } + + /// upstream: @ai-sdk/openai-compatible src/openai-compatible-provider.test.ts › OpenAICompatibleProvider › model creation methods › should create completion model with correct configuration + #[tokio::test] + async fn should_create_completion_model_with_correct_configuration() { + let provider = create_openai_compatible(default_options()).unwrap(); + + let model = provider.completion("completion-model"); + + let config = completion_config(&model); + let headers = config_headers(config).await; + + assert_eq!( + headers, + expected_headers(&[ + ("authorization", "Bearer test-api-key"), + ("custom-header", "value"), + ]) + ); + assert_eq!(config.provider, "test-provider.completion"); + assert_eq!( + config_url(config, "/v1/completions"), + "https://api.example.com/v1/completions?Custom-Param=value" + ); + } + + /// upstream: @ai-sdk/openai-compatible src/openai-compatible-provider.test.ts › OpenAICompatibleProvider › model creation methods › should create embedding model with correct configuration + #[tokio::test] + async fn should_create_embedding_model_with_correct_configuration() { + let provider = create_openai_compatible(default_options()).unwrap(); + + let model = provider.embedding("embedding-model"); + + let config = &model.config; + let headers = config_headers(config).await; + + assert_eq!( + headers, + expected_headers(&[ + ("authorization", "Bearer test-api-key"), + ("custom-header", "value"), + ]) + ); + assert_eq!(config.provider, "test-provider.embedding"); + assert_eq!( + config_url(config, "/v1/embeddings"), + "https://api.example.com/v1/embeddings?Custom-Param=value" + ); + } + + /// upstream: @ai-sdk/openai-compatible src/openai-compatible-provider.test.ts › OpenAICompatibleProvider › model creation methods › should use languageModel as default when called as function + #[test] + fn should_use_language_model_as_default_when_called_as_function() { + let provider = create_openai_compatible(default_options()).unwrap(); + + let model = provider.call("model-id"); + + assert_eq!(model.model_id(), "model-id"); + assert_eq!(model.provider(), "test-provider.chat"); + } + + /// upstream: @ai-sdk/openai-compatible src/openai-compatible-provider.test.ts › OpenAICompatibleProvider › model creation methods › should create URL without query parameters when queryParams is not specified + #[test] + fn should_create_url_without_query_parameters_when_query_params_is_not_specified() { + let options = OpenAICompatibleProviderSettings { + base_url: "https://api.example.com".into(), + name: "test-provider".into(), + api_key: Some("test-api-key".into()), + ..Default::default() + }; + + let provider = create_openai_compatible(options).unwrap(); + // `provider('model-id')` is `call`, which returns `chat('model-id')`. + assert_eq!(provider.call("model-id").provider(), "test-provider.chat"); + let model = provider.chat("model-id"); + + assert_eq!( + config_url(&model.config, "/v1/chat"), + "https://api.example.com/v1/chat" + ); + } + } + + mod include_usage_setting { + use super::*; + + fn options( + mock: &Arc, + include_usage: Option, + ) -> OpenAICompatibleProviderSettings { + OpenAICompatibleProviderSettings { + base_url: "https://api.example.com".into(), + name: "test-provider".into(), + include_usage, + fetch: Some(mock.transport()), + ..Default::default() + } + } + + /// upstream: @ai-sdk/openai-compatible src/openai-compatible-provider.test.ts › OpenAICompatibleProvider › includeUsage setting › should pass includeUsage: true to all model types when specified in provider settings + #[tokio::test] + async fn should_pass_include_usage_true_to_all_model_types_when_specified_in_provider_settings() + { + let mock = MockFetch::new(vec![chat_stream_response()]); + let provider = create_openai_compatible(options(&mock, Some(true))).unwrap(); + + let chat = provider.chat("chat-model"); + assert!(chat.config.chat.include_usage); + + let completion = provider.completion("completion-model"); + assert!(completion_config(&completion).chat.include_usage); + + let model = provider.call("model-id"); + assert_eq!(sends_include_usage(model.as_ref(), &mock).await, Some(true)); + } + + /// upstream: @ai-sdk/openai-compatible src/openai-compatible-provider.test.ts › OpenAICompatibleProvider › includeUsage setting › should pass includeUsage: false to all model types when specified in provider settings + #[tokio::test] + async fn should_pass_include_usage_false_to_all_model_types_when_specified_in_provider_settings() + { + let mock = MockFetch::new(vec![chat_stream_response()]); + let provider = create_openai_compatible(options(&mock, Some(false))).unwrap(); + + let chat = provider.chat("chat-model"); + assert!(!chat.config.chat.include_usage); + + let completion = provider.completion("completion-model"); + assert!(!completion_config(&completion).chat.include_usage); + + let model = provider.call("model-id"); + // `false` and `undefined` both send no `stream_options`. + assert_eq!(sends_include_usage(model.as_ref(), &mock).await, None); + } + + /// upstream: @ai-sdk/openai-compatible src/openai-compatible-provider.test.ts › OpenAICompatibleProvider › includeUsage setting › should pass includeUsage: undefined to all model types when not specified in provider settings + #[tokio::test] + async fn should_pass_include_usage_undefined_to_all_model_types_when_not_specified() { + let mock = MockFetch::new(vec![chat_stream_response()]); + let provider = create_openai_compatible(options(&mock, None)).unwrap(); + + // The model config holds a `bool`: upstream's `undefined` is its + // `false` default. + let chat = provider.chat("chat-model"); + assert!(!chat.config.chat.include_usage); + + let completion = provider.completion("completion-model"); + assert!(!completion_config(&completion).chat.include_usage); + + let model = provider.call("model-id"); + assert_eq!(sends_include_usage(model.as_ref(), &mock).await, None); + } + } + + mod metadata_extractor_setting { + use super::*; + + struct MockExtractor; + + struct MockStreamExtractor; + + impl MetadataExtractor for MockExtractor { + fn extract_metadata(&self, _parsed_body: &Value) -> Option { + None + } + + fn create_stream_extractor(&self) -> Box { + Box::new(MockStreamExtractor) + } + } + + impl StreamMetadataExtractor for MockStreamExtractor { + fn process_chunk(&mut self, _parsed_chunk: &Value) {} + + fn build_metadata(&self) -> Option { + None + } + } + + /// upstream: @ai-sdk/openai-compatible src/openai-compatible-provider.test.ts › OpenAICompatibleProvider › metadataExtractor setting › should pass metadataExtractor to chat model + #[test] + fn should_pass_metadata_extractor_to_chat_model() { + let mock_extractor: Arc = Arc::new(MockExtractor); + + let provider: OpenAICompatibleProvider = + create_openai_compatible(OpenAICompatibleProviderSettings { + base_url: "https://api.example.com".into(), + name: "test-provider".into(), + metadata_extractor: Some(mock_extractor.clone()), + ..Default::default() + }) + .unwrap(); + + let chat = provider.chat("chat-model"); + + assert!(Arc::ptr_eq( + chat.config.chat.metadata_extractor.as_ref().unwrap(), + &mock_extractor + )); + } + } +} diff --git a/aimux-providers/src/openai_compatible/tests/utils/mod.rs b/aimux-providers/src/openai_compatible/tests/utils/mod.rs new file mode 100644 index 000000000..a8f67761c --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/utils/mod.rs @@ -0,0 +1 @@ +mod to_camel_case; diff --git a/aimux-providers/src/openai_compatible/tests/utils/to_camel_case.rs b/aimux-providers/src/openai_compatible/tests/utils/to_camel_case.rs new file mode 100644 index 000000000..9f1f703d2 --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/utils/to_camel_case.rs @@ -0,0 +1,216 @@ +use serde_json::{Value, json}; + +use aimux_core::options::CallOptions; +use aimux_core::types::Warning; + +use crate::openai_compatible::config::ChatSettings; +use crate::openai_compatible::convert::{ + ChatBodySpec, RequestBodyResult, build_request_body, to_camel_case, +}; + +/// `resolveProviderOptionsKey` and `warnIfDeprecatedProviderOptionsKey` have +/// no standalone Rust form: both run inside `build_request_body` (the +/// metadata key it reports, and the deprecation warning it raises). An empty +/// prompt with the baseline chat settings raises no other warning. +fn build(raw_name: &str, provider_options: Option) -> RequestBodyResult { + let options = CallOptions { + provider_options: provider_options + .map(|value| serde_json::from_value(value).expect("provider options")), + ..CallOptions::default() + }; + build_request_body( + "model", + &options, + false, + &ChatBodySpec { + provider_options_name: raw_name, + chat: &ChatSettings::default(), + }, + ) + .expect("request body") +} + +fn resolve_provider_options_key(raw_name: &str, provider_options: Option) -> String { + build(raw_name, provider_options).metadata_key +} + +fn warn_if_deprecated_provider_options_key( + raw_name: &str, + provider_options: Option, +) -> Vec { + build(raw_name, provider_options).warnings +} + +fn warnings_json(warnings: &[Warning]) -> Value { + serde_json::to_value(warnings).expect("warnings serialize") +} + +// The `toCamelCase` describe block shares the file's name. +#[allow(clippy::module_inception)] +mod to_camel_case { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/utils/to-camel-case.test.ts › toCamelCase › should convert hyphenated names to camelCase + #[test] + fn should_convert_hyphenated_names_to_camel_case() { + assert_eq!(to_camel_case("provider-name"), "providerName"); + } + + /// upstream: @ai-sdk/openai-compatible src/utils/to-camel-case.test.ts › toCamelCase › should convert underscored names to camelCase + #[test] + fn should_convert_underscored_names_to_camel_case() { + assert_eq!(to_camel_case("provider_name"), "providerName"); + } + + /// upstream: @ai-sdk/openai-compatible src/utils/to-camel-case.test.ts › toCamelCase › should handle multiple separators + #[test] + fn should_handle_multiple_separators() { + assert_eq!(to_camel_case("my-provider-name"), "myProviderName"); + } + + /// upstream: @ai-sdk/openai-compatible src/utils/to-camel-case.test.ts › toCamelCase › should return the same string when already camelCase + #[test] + fn should_return_the_same_string_when_already_camel_case() { + assert_eq!(to_camel_case("providerName"), "providerName"); + } + + /// upstream: @ai-sdk/openai-compatible src/utils/to-camel-case.test.ts › toCamelCase › should return the same string when no separators + #[test] + fn should_return_the_same_string_when_no_separators() { + assert_eq!(to_camel_case("openai"), "openai"); + } + + /// upstream: @ai-sdk/openai-compatible src/utils/to-camel-case.test.ts › toCamelCase › should handle empty string + #[test] + fn should_handle_empty_string() { + assert_eq!(to_camel_case(""), ""); + } +} + +mod resolve_provider_options_key { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/utils/to-camel-case.test.ts › resolveProviderOptionsKey › should return camelCase key when camelCase options are present + #[test] + fn should_return_camel_case_key_when_camel_case_options_are_present() { + assert_eq!( + resolve_provider_options_key( + "provider-name", + Some(json!({ "providerName": { "someOption": "value" } })), + ), + "providerName" + ); + } + + /// upstream: @ai-sdk/openai-compatible src/utils/to-camel-case.test.ts › resolveProviderOptionsKey › should return raw key when only raw options are present + #[test] + fn should_return_raw_key_when_only_raw_options_are_present() { + assert_eq!( + resolve_provider_options_key( + "provider-name", + Some(json!({ "provider-name": { "someOption": "value" } })), + ), + "provider-name" + ); + } + + /// upstream: @ai-sdk/openai-compatible src/utils/to-camel-case.test.ts › resolveProviderOptionsKey › should return camelCase key when both are present + #[test] + fn should_return_camel_case_key_when_both_are_present() { + assert_eq!( + resolve_provider_options_key( + "provider-name", + Some(json!({ + "provider-name": { "a": 1 }, + "providerName": { "b": 2 }, + })), + ), + "providerName" + ); + } + + /// upstream: @ai-sdk/openai-compatible src/utils/to-camel-case.test.ts › resolveProviderOptionsKey › should return raw key when no options are present + #[test] + fn should_return_raw_key_when_no_options_are_present() { + assert_eq!( + resolve_provider_options_key("provider-name", Some(json!({}))), + "provider-name" + ); + } + + /// upstream: @ai-sdk/openai-compatible src/utils/to-camel-case.test.ts › resolveProviderOptionsKey › should return raw key when providerOptions is undefined + #[test] + fn should_return_raw_key_when_provider_options_is_undefined() { + assert_eq!( + resolve_provider_options_key("provider-name", None), + "provider-name" + ); + } + + /// upstream: @ai-sdk/openai-compatible src/utils/to-camel-case.test.ts › resolveProviderOptionsKey › should return raw key when name has no separators + #[test] + fn should_return_raw_key_when_name_has_no_separators() { + assert_eq!( + resolve_provider_options_key("openai", Some(json!({ "openai": { "a": 1 } }))), + "openai" + ); + } +} + +mod warn_if_deprecated_provider_options_key { + use super::*; + + /// upstream: @ai-sdk/openai-compatible src/utils/to-camel-case.test.ts › warnIfDeprecatedProviderOptionsKey › should push a deprecated warning when raw key is used and differs from camelCase + #[test] + fn should_push_a_deprecated_warning_when_raw_key_is_used_and_differs() { + let warnings = warn_if_deprecated_provider_options_key( + "black-forest-labs", + Some(json!({ "black-forest-labs": { "style": "hd" } })), + ); + assert_eq!( + warnings_json(&warnings), + json!([ + { + "type": "deprecated", + "setting": "providerOptions key 'black-forest-labs'", + "message": "Use 'blackForestLabs' instead.", + }, + ]) + ); + } + + /// upstream: @ai-sdk/openai-compatible src/utils/to-camel-case.test.ts › warnIfDeprecatedProviderOptionsKey › should not push a warning when only camelCase key is used + #[test] + fn should_not_push_a_warning_when_only_camel_case_key_is_used() { + let warnings = warn_if_deprecated_provider_options_key( + "black-forest-labs", + Some(json!({ "blackForestLabs": { "style": "hd" } })), + ); + assert_eq!(warnings_json(&warnings), json!([])); + } + + /// upstream: @ai-sdk/openai-compatible src/utils/to-camel-case.test.ts › warnIfDeprecatedProviderOptionsKey › should not push a warning when raw name is already camelCase + #[test] + fn should_not_push_a_warning_when_raw_name_is_already_camel_case() { + let warnings = warn_if_deprecated_provider_options_key( + "openai", + Some(json!({ "openai": { "user": "test" } })), + ); + assert_eq!(warnings_json(&warnings), json!([])); + } + + /// upstream: @ai-sdk/openai-compatible src/utils/to-camel-case.test.ts › warnIfDeprecatedProviderOptionsKey › should not push a warning when raw key is not present in providerOptions + #[test] + fn should_not_push_a_warning_when_raw_key_is_not_present_in_provider_options() { + let warnings = + warn_if_deprecated_provider_options_key("black-forest-labs", Some(json!({}))); + assert_eq!(warnings_json(&warnings), json!([])); + } + + /// upstream: @ai-sdk/openai-compatible src/utils/to-camel-case.test.ts › warnIfDeprecatedProviderOptionsKey › should not push a warning when providerOptions is undefined + #[test] + fn should_not_push_a_warning_when_provider_options_is_undefined() { + let warnings = warn_if_deprecated_provider_options_key("black-forest-labs", None); + assert_eq!(warnings_json(&warnings), json!([])); + } +} diff --git a/aimux-providers/src/test_support/mod.rs b/aimux-providers/src/test_support/mod.rs new file mode 100644 index 000000000..9bbf0e551 --- /dev/null +++ b/aimux-providers/src/test_support/mod.rs @@ -0,0 +1,4 @@ +//! Shared test helpers for the in-crate vendor tests. + +#[path = "../../tests/common/mock_fetch.rs"] +pub(crate) mod mock_fetch; diff --git a/scripts/check_provider_boundaries.sh b/scripts/check_provider_boundaries.sh index df1578cd2..72b71d382 100755 --- a/scripts/check_provider_boundaries.sh +++ b/scripts/check_provider_boundaries.sh @@ -153,13 +153,13 @@ violation 'groq / deepseek literal in the shared OpenAI or compatible package' " # vendor's endpoint, base URL, headers or usage accounting is its own data # behind a trait or hook of the shared layer. Their code (doc links aside) # names only the shared layers (including `stream`), the preset registry and -# `runtime_decision`. +# `runtime_decision`; the in-crate tests also name `test_support`. rule4c_hits="$( grep -rnE 'crate::[a-z_]+' aimux-providers/src/shared \ aimux-providers/src/openai_compatible --include='*.rs' | awk '{ code = $0; sub(/^[^:]*:[0-9]+:/, "", code) } code ~ /^[[:space:]]*\/\// { next } - { gsub(/crate::(shared|stream|openai|openai_compatible|preset|runtime_decision)([^a-z_]|$)/, "", code) } + { gsub(/crate::(shared|stream|openai|openai_compatible|preset|runtime_decision|test_support)([^a-z_]|$)/, "", code) } code ~ /crate::[a-z_]/' || true )" violation 'shared or openai_compatible names a vendor package (implement its trait in the vendor module)' "$rule4c_hits" @@ -214,7 +214,7 @@ rule7b_hits="$( violation 'bedrock::sigv4 is back (use SigV4Fetch)' "$rule7b_hits" rule7c_hits="$( # Non-test, non-comment lines of every file but the two helpers. - find $gvb_family -name '*.rs' ! -name options.rs -print0 | + find $gvb_family -name '*.rs' ! -name options.rs ! -path '*/tests/*' -print0 | xargs -0 awk ' FNR == 1 { in_tests = 0 } /^#\[cfg\(test\)\]/ { in_tests = 1 } @@ -245,7 +245,7 @@ rule8_hits="$( violation 'Azure / xAI / Mistral / Cohere / Hugging Face / Codex / Open Responses / Voyage / ElevenLabs reads builder-era config state' "$rule8_hits" rule8b_hits="$( # Non-test, non-comment lines of every file but the options helpers. - find $vendor_family -name '*.rs' ! -name options.rs -print0 | + find $vendor_family -name '*.rs' ! -name options.rs ! -path '*/tests/*' -print0 | xargs -0 awk ' FNR == 1 { in_tests = 0 } /^#\[cfg\(test\)\]/ { in_tests = 1 } From 85453d9daf369112da1e5db55ddc58c5932b61a2 Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 14:36:58 +0000 Subject: [PATCH 3/5] test(openai-compatible): mark the old tests that cover upstream cases Old tests in openai_compatible_upstream_e2e_test.rs and openai_compatible_factory_test.rs that cover part of an upstream case get a `/// partial:` line naming the case and the file with its full port (20 lines). None of them covers a whole case, so none gets an `/// upstream:` marker. Test bodies are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5 --- .../tests/openai_compatible_factory_test.rs | 7 ++++++ .../openai_compatible_upstream_e2e_test.rs | 23 +++++++++++-------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/aimux-providers/tests/openai_compatible_factory_test.rs b/aimux-providers/tests/openai_compatible_factory_test.rs index 077bd1fd9..44da42f92 100644 --- a/aimux-providers/tests/openai_compatible_factory_test.rs +++ b/aimux-providers/tests/openai_compatible_factory_test.rs @@ -562,6 +562,7 @@ async fn chat_basic() { replay_groq_chat("chat-basic").await; } +/// partial: `should include provider-specific options`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. #[tokio::test] async fn chat_provider_options_namespace() { // `groq.foo` passes through at the top level; `openaiCompatible.user` is a @@ -584,6 +585,8 @@ async fn unknown_fields_of_the_generic_namespace_are_not_forwarded() { ); } +/// partial: `should pass headers`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. +/// partial: `should create provider with correct configuration`; full port in src/openai_compatible/tests/provider.rs. #[tokio::test] async fn chat_query_params_and_headers() { let fixture = Fixture::load("chat-query-params-and-headers"); @@ -620,6 +623,7 @@ async fn chat_query_params_and_headers() { ); } +/// partial: `should create headers without authorization when no apiKey provided`; full port in src/openai_compatible/tests/provider.rs. #[tokio::test] async fn chat_without_an_api_key_sends_no_authorization() { let fixture = Fixture::load("chat-no-api-key"); @@ -744,6 +748,9 @@ async fn chat_stream_basic() { ); } +/// partial: `should extract embedding`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. +/// partial: `should extract usage`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. +/// partial: `should pass the model and the values`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. #[tokio::test] async fn embedding_basic() { let fixture = Fixture::load("embedding-basic"); diff --git a/aimux-providers/tests/openai_compatible_upstream_e2e_test.rs b/aimux-providers/tests/openai_compatible_upstream_e2e_test.rs index 0265b6f33..2a3ccaa0a 100644 --- a/aimux-providers/tests/openai_compatible_upstream_e2e_test.rs +++ b/aimux-providers/tests/openai_compatible_upstream_e2e_test.rs @@ -71,8 +71,8 @@ fn stream_request() -> Value { json!({"model":"test-model","messages":[{"role":"user","content":"Hello"}],"stream":true}) } -/// TS: "should use camelCase metadata key when camelCase provider options are used" (openai-compatible/src/chat/openai-compatible-chat-language-model.test.ts) -/// TS: "should extract detailed token usage when available" (openai-compatible/src/chat/openai-compatible-chat-language-model.test.ts) +/// partial: `should use camelCase metadata key when camelCase provider options are used`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. +/// partial: `should extract detailed token usage when available`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. #[tokio::test] async fn chat_generate_metadata_and_usage() { let usage = json!({"prompt_tokens":20,"completion_tokens":30,"total_tokens":50, @@ -127,7 +127,7 @@ async fn chat_generate_metadata_and_usage() { assert!(result.warnings.is_empty()); } -/// TS: "should stream reasoning content before text deltas" (openai-compatible/src/chat/openai-compatible-chat-language-model.test.ts) +/// partial: `should stream reasoning content before text deltas`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. #[tokio::test] async fn chat_stream_reasoning_before_text() { let delta = |value: Value| { @@ -200,7 +200,7 @@ async fn chat_stream_reasoning_before_text() { ); } -/// TS: "should parse thought signature from extra_content and include in providerMetadata" (openai-compatible/src/chat/openai-compatible-chat-language-model.test.ts) +/// partial: `should parse thought signature from extra_content and include in providerMetadata`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. #[tokio::test] async fn chat_tool_call_preserves_arguments_and_signature() { let fetch = MockFetch::new(vec![Canned::json(&chat_response( @@ -244,7 +244,7 @@ async fn chat_tool_call_preserves_arguments_and_signature() { ); } -/// TS: "should preserve structured error stream parts" (openai-compatible/src/chat/openai-compatible-chat-language-model.test.ts) +/// partial: `should preserve structured error stream parts`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. #[tokio::test] async fn chat_stream_preserves_structured_error() { let fetch = MockFetch::new(vec![stream_response(&[json!({"error":{ @@ -364,8 +364,8 @@ async fn completion_stream_error_infers_no_status() { assert!(!error.is_retryable); } -/// TS: "should extract embedding" (openai-compatible/src/embedding/openai-compatible-embedding-model.test.ts) -/// TS: "should pass the model and the values" (openai-compatible/src/embedding/openai-compatible-embedding-model.test.ts) +/// partial: `should extract embedding`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. +/// partial: `should pass the model and the values`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. #[tokio::test] async fn embedding_request_and_result() { let body = json!({"object":"list","data":[ @@ -406,8 +406,8 @@ fn image_options() -> ImageCallOptions { opts } -/// TS: "should return the raw b64_json content" (openai-compatible/src/image/openai-compatible-image-model.test.ts) -/// TS: "should map the usage object reported by the provider" (openai-compatible/src/image/openai-compatible-image-model.test.ts) +/// partial: `should return the raw b64_json content`; full port in src/openai_compatible/tests/image/image_model.rs. +/// partial: `should map the usage object reported by the provider`; full port in src/openai_compatible/tests/image/image_model.rs. #[tokio::test] async fn image_generate_raw_content_and_usage() { let fetch = MockFetch::new(vec![Canned::json(&json!({"data":[ @@ -442,7 +442,7 @@ async fn image_generate_raw_content_and_usage() { assert!(result.warnings.is_empty()); } -/// TS: "should handle API errors with default error structure" (openai-compatible/src/image/openai-compatible-image-model.test.ts) +/// partial: `should handle API errors with default error structure`; full port in src/openai_compatible/tests/image/image_model.rs. #[tokio::test] async fn image_generate_api_error() { let mut canned = Canned::json(&json!({"error":{"message":"Invalid prompt content", @@ -520,6 +520,9 @@ fn multi_part_provider( .unwrap() } +/// partial: `should pass multi-part tool content when supported`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. +/// partial: `should convert multi-part tool content when supported`; full port in src/openai_compatible/tests/chat/convert_to_chat_messages.rs. +/// partial: `should stringify multi-part tool content by default`; full port in src/openai_compatible/tests/chat/convert_to_chat_messages.rs. /// Upstream: @ai-sdk/openai-compatible 3.0.62 /// src/chat/convert-to-openai-compatible-chat-messages.ts:303-306 sends a /// `content` tool output as an array of content parts From c4771a2cb3597cb4eccfc2212c337e03013e145e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:18:18 +0000 Subject: [PATCH 4/5] refactor: move the OpenAI-compatible files to the upstream layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split src/openai_compatible/ into one Rust file per file of @ai-sdk/openai-compatible 3.0.62 (RFC-0046 §4.2), with the upstream chat/, completion/, embedding/, image/ and utils/ subdirectories: provider.rs (settings, create_openai_compatible, build, the provider and getCommonModelConfig's CompatModelConfig), error.rs, and under chat/ the model, options, usage, message conversion, finish reason, metadata extractor, tool preparation and API types. Function bodies change only in paths and visibility; no public path changes, and the crate-internal callers import from openai_compatible's root instead of the removed config module. Fold tests/openai_compatible_factory_test.rs, tests/openai_compatible_upstream_e2e_test.rs and the inline unit tests of chat.rs, config.rs and convert.rs into src/openai_compatible/tests/ without changing assertions. The factory test that sets OPENAI_API_KEY now runs #[serial], since it shares the lib test binary with the default-providers test that sets the same variable. Point layout.toml at the new paths, and let boundary rule 4b skip the vendor tests, whose recorded fixtures use a provider named groq. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0116HPMoqaLfFr1TyqDq1hrW --- aimux-providers/layout.toml | 46 +- aimux-providers/src/decision_support.rs | 2 +- .../{types.rs => chat/api_types.rs} | 1 - .../{chat.rs => chat/chat_language_model.rs} | 599 +++++++----- .../chat/chat_language_model_options.rs | 48 + .../chat/convert_chat_usage.rs | 59 ++ .../chat/convert_to_chat_messages.rs | 379 ++++++++ .../chat/map_finish_reason.rs | 21 + .../chat/metadata_extractor.rs | 24 + .../src/openai_compatible/chat/mod.rs | 20 + .../openai_compatible/chat/prepare_tools.rs | 63 ++ .../completion_language_model.rs} | 144 +-- .../completion/convert_completion_usage.rs | 48 + .../convert_to_completion_prompt.rs | 51 + .../completion/get_response_metadata.rs | 15 + .../src/openai_compatible/completion/mod.rs | 10 + .../src/openai_compatible/config.rs | 427 --------- .../src/openai_compatible/convert.rs | 777 --------------- .../embedding_model.rs} | 69 +- .../embedding/embedding_model_options.rs | 63 ++ .../src/openai_compatible/embedding/mod.rs | 6 + .../src/openai_compatible/error.rs | 31 + .../{image.rs => image/image_model.rs} | 14 +- .../src/openai_compatible/image/mod.rs | 5 + aimux-providers/src/openai_compatible/mod.rs | 343 +------ .../src/openai_compatible/provider.rs | 599 ++++++++++++ .../tests/chat/chat_language_model.rs | 597 +++++++++++- .../tests/chat/convert_to_chat_messages.rs | 2 +- .../tests/chat/prepare_tools.rs | 2 +- .../completion/completion_language_model.rs | 49 +- .../tests/embedding/embedding_model.rs | 48 + .../tests/image/image_model.rs | 87 +- .../src/openai_compatible/tests/mod.rs | 1 + .../src/openai_compatible/tests/provider.rs | 903 +++++++++++++++++- .../src/openai_compatible/tests/support.rs | 61 ++ .../tests/utils/to_camel_case.rs | 25 +- .../src/openai_compatible/utils/mod.rs | 5 + .../openai_compatible/utils/to_camel_case.rs | 19 + aimux-providers/src/preset.rs | 3 +- aimux-providers/src/runtime_decision.rs | 2 +- .../src/vertex/partner_provider.rs | 4 +- .../tests/openai_compatible_factory_test.rs | 893 ----------------- .../openai_compatible_upstream_e2e_test.rs | 599 ------------ scripts/check_provider_boundaries.sh | 4 +- 44 files changed, 3674 insertions(+), 3494 deletions(-) rename aimux-providers/src/openai_compatible/{types.rs => chat/api_types.rs} (99%) rename aimux-providers/src/openai_compatible/{chat.rs => chat/chat_language_model.rs} (64%) create mode 100644 aimux-providers/src/openai_compatible/chat/chat_language_model_options.rs create mode 100644 aimux-providers/src/openai_compatible/chat/convert_chat_usage.rs create mode 100644 aimux-providers/src/openai_compatible/chat/convert_to_chat_messages.rs create mode 100644 aimux-providers/src/openai_compatible/chat/map_finish_reason.rs create mode 100644 aimux-providers/src/openai_compatible/chat/metadata_extractor.rs create mode 100644 aimux-providers/src/openai_compatible/chat/mod.rs create mode 100644 aimux-providers/src/openai_compatible/chat/prepare_tools.rs rename aimux-providers/src/openai_compatible/{completion.rs => completion/completion_language_model.rs} (82%) create mode 100644 aimux-providers/src/openai_compatible/completion/convert_completion_usage.rs create mode 100644 aimux-providers/src/openai_compatible/completion/convert_to_completion_prompt.rs create mode 100644 aimux-providers/src/openai_compatible/completion/get_response_metadata.rs create mode 100644 aimux-providers/src/openai_compatible/completion/mod.rs delete mode 100644 aimux-providers/src/openai_compatible/config.rs delete mode 100644 aimux-providers/src/openai_compatible/convert.rs rename aimux-providers/src/openai_compatible/{embedding.rs => embedding/embedding_model.rs} (67%) create mode 100644 aimux-providers/src/openai_compatible/embedding/embedding_model_options.rs create mode 100644 aimux-providers/src/openai_compatible/embedding/mod.rs create mode 100644 aimux-providers/src/openai_compatible/error.rs rename aimux-providers/src/openai_compatible/{image.rs => image/image_model.rs} (96%) create mode 100644 aimux-providers/src/openai_compatible/image/mod.rs create mode 100644 aimux-providers/src/openai_compatible/provider.rs create mode 100644 aimux-providers/src/openai_compatible/tests/support.rs create mode 100644 aimux-providers/src/openai_compatible/utils/mod.rs create mode 100644 aimux-providers/src/openai_compatible/utils/to_camel_case.rs delete mode 100644 aimux-providers/tests/openai_compatible_factory_test.rs delete mode 100644 aimux-providers/tests/openai_compatible_upstream_e2e_test.rs diff --git a/aimux-providers/layout.toml b/aimux-providers/layout.toml index 7e8398681..cbfd1ad39 100644 --- a/aimux-providers/layout.toml +++ b/aimux-providers/layout.toml @@ -9,140 +9,140 @@ package = "@ai-sdk/openai-compatible" [[openai_compatible.files]] upstream = "src/chat/convert-openai-compatible-chat-usage.ts" -rust = "openai_compatible/chat.rs" +rust = "openai_compatible/chat/convert_chat_usage.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/chat/convert-to-openai-compatible-chat-messages.ts" -rust = "openai_compatible/convert.rs" +rust = "openai_compatible/chat/convert_to_chat_messages.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/chat/get-response-metadata.ts" -rust = "openai_compatible/chat.rs" +rust = "openai_compatible/chat/chat_language_model.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/chat/map-openai-compatible-finish-reason.ts" -rust = "openai_compatible/convert.rs" +rust = "openai_compatible/chat/map_finish_reason.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/chat/openai-compatible-api-types.ts" -rust = "openai_compatible/types.rs" +rust = "openai_compatible/chat/api_types.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/chat/openai-compatible-chat-language-model-options.ts" -rust = "openai_compatible/config.rs" +rust = "openai_compatible/chat/chat_language_model_options.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/chat/openai-compatible-chat-language-model.ts" -rust = "openai_compatible/chat.rs" +rust = "openai_compatible/chat/chat_language_model.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/chat/openai-compatible-metadata-extractor.ts" -rust = "openai_compatible/config.rs" +rust = "openai_compatible/chat/metadata_extractor.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/chat/openai-compatible-prepare-tools.ts" -rust = "openai_compatible/convert.rs" +rust = "openai_compatible/chat/prepare_tools.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/completion/convert-openai-compatible-completion-usage.ts" -rust = "openai_compatible/completion.rs" +rust = "openai_compatible/completion/convert_completion_usage.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/completion/convert-to-openai-compatible-completion-prompt.ts" -rust = "openai_compatible/completion.rs" +rust = "openai_compatible/completion/convert_to_completion_prompt.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/completion/get-response-metadata.ts" -rust = "openai_compatible/completion.rs" +rust = "openai_compatible/completion/get_response_metadata.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/completion/map-openai-compatible-finish-reason.ts" -rust = "openai_compatible/convert.rs" +rust = "openai_compatible/chat/map_finish_reason.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/completion/openai-compatible-completion-language-model-options.ts" -rust = "openai_compatible/completion.rs" +rust = "openai_compatible/completion/completion_language_model.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/completion/openai-compatible-completion-language-model.ts" -rust = "openai_compatible/completion.rs" +rust = "openai_compatible/completion/completion_language_model.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/embedding/openai-compatible-embedding-model-options.ts" -rust = "openai_compatible/embedding.rs" +rust = "openai_compatible/embedding/embedding_model_options.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/embedding/openai-compatible-embedding-model.ts" -rust = "openai_compatible/embedding.rs" +rust = "openai_compatible/embedding/embedding_model.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/image/openai-compatible-image-model-options.ts" -rust = "openai_compatible/image.rs" +rust = "openai_compatible/image/image_model.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/image/openai-compatible-image-model.ts" -rust = "openai_compatible/image.rs" +rust = "openai_compatible/image/image_model.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/image/openai-compatible-image-settings.ts" -rust = "openai_compatible/image.rs" +rust = "openai_compatible/image/image_model.rs" status = "mapped" [openai_compatible.files.tests] @@ -163,14 +163,14 @@ status = "mapped" [[openai_compatible.files]] upstream = "src/openai-compatible-error.ts" -rust = "openai_compatible/config.rs" +rust = "openai_compatible/error.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/openai-compatible-provider.ts" -rust = "openai_compatible/mod.rs" +rust = "openai_compatible/provider.rs" status = "mapped" [openai_compatible.files.tests] @@ -181,7 +181,7 @@ ts-only = [ [[openai_compatible.files]] upstream = "src/utils/to-camel-case.ts" -rust = "openai_compatible/convert.rs" +rust = "openai_compatible/utils/to_camel_case.rs" status = "mapped" [openai_compatible.files.tests] diff --git a/aimux-providers/src/decision_support.rs b/aimux-providers/src/decision_support.rs index 8a37aa446..2da675aec 100644 --- a/aimux-providers/src/decision_support.rs +++ b/aimux-providers/src/decision_support.rs @@ -76,7 +76,7 @@ pub(crate) fn take_images( } use crate::openai::config::OpenAIModelConfig; -use crate::openai_compatible::config::CompatModelConfig; +use crate::openai_compatible::CompatModelConfig; /// The connection a decision adapter borrows from the provider that made it: /// native OpenAI, or an OpenAI-compatible preset (a local runtime, diff --git a/aimux-providers/src/openai_compatible/types.rs b/aimux-providers/src/openai_compatible/chat/api_types.rs similarity index 99% rename from aimux-providers/src/openai_compatible/types.rs rename to aimux-providers/src/openai_compatible/chat/api_types.rs index 6e182cebf..23dae3b8e 100644 --- a/aimux-providers/src/openai_compatible/types.rs +++ b/aimux-providers/src/openai_compatible/chat/api_types.rs @@ -8,7 +8,6 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; // ── Non-streaming response ── - #[derive(Debug, Deserialize)] pub(crate) struct ChatCompletionResponse { #[serde(default)] diff --git a/aimux-providers/src/openai_compatible/chat.rs b/aimux-providers/src/openai_compatible/chat/chat_language_model.rs similarity index 64% rename from aimux-providers/src/openai_compatible/chat.rs rename to aimux-providers/src/openai_compatible/chat/chat_language_model.rs index e58702804..bae0b6633 100644 --- a/aimux-providers/src/openai_compatible/chat.rs +++ b/aimux-providers/src/openai_compatible/chat/chat_language_model.rs @@ -1,38 +1,154 @@ -//! The OpenAI-compatible chat language model (`{name}.chat`). +//! The OpenAI-compatible chat language model (`{name}.chat`, +//! `openai-compatible-chat-language-model.ts`). //! -//! The Rust form of `OpenAICompatibleChatLanguageModel`. Request construction -//! lives in [`super::convert`]; vendor differences arrive through the -//! [`ChatSettings`](super::config::ChatSettings) of the model's config. +//! The Rust form of `OpenAICompatibleChatLanguageModel`, with its config +//! ([`ChatSettings`]) and `getArgs` ([`build_request_body`]). Vendor differences +//! arrive as [`ChatSettings`] data; the functions a caller or a vendor package +//! passes (`supportedUrls`, `convertUsage`, `errorStructure`, +//! `metadataExtractor`) are kept as given. +//! +//! The body is built in the AI SDK's order (model, `user`, sampling settings, +//! `response_format`, `stop`, `seed`, the pass-through fields of the provider's +//! own options namespace, then `reasoning_effort`, `verbosity`, `messages`, +//! `tools`, `tool_choice`). -use aimux_core::tool::RawToolCall; use std::collections::{HashMap, HashSet}; - -use async_trait::async_trait; -use serde_json::{Map, Value, json}; +use std::sync::Arc; use aimux_core::error::AiMuxError; use aimux_core::language_model::{LanguageModel, SupportedUrls}; -use aimux_core::options::CallOptions; +use aimux_core::options::{CallOptions, ResponseFormat}; use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source, StreamResult}; use aimux_core::shared::{ RequestInfo, ResponseInfo, SharedProviderMetadata, StreamResponseInfo, provider_namespace, }; use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReason, InputTokenUsage, OutputTokenUsage, ResponseMetadata, Usage}; +use aimux_core::tool::RawToolCall; +use aimux_core::types::{FinishReason, ResponseMetadata, Usage, Warning}; use aimux_provider_utils::{Transformer, generate_id, pipe_through}; +use async_trait::async_trait; +use futures::future::BoxFuture; +use serde_json::{Map, Value, json}; -use super::config::{ChatSettings, CompatModelConfig, StreamMetadataExtractor}; -use super::convert::{ChatBodySpec, RequestBodyResult, build_request_body, parse_finish_reason}; -use super::types::{ChatCompletionResponse, StreamChunk, UsageResponse}; +use crate::openai_compatible::CompatModelConfig; +use crate::openai_compatible::error::{ErrorStructure, default_error_structure}; +use crate::openai_compatible::utils::to_camel_case; use crate::stream::{ ChatToolCalls, Controller, Emit, FINISH_ERROR, FINISH_OTHER, StreamContext, ToolCallDelta, }; +use super::api_types::{ChatCompletionResponse, StreamChunk, UsageResponse}; +use super::chat_language_model_options::{SCHEMA_KEYS, option_string, validate_options}; +use super::convert_chat_usage::usage_from_raw; +use super::convert_to_chat_messages::{MessageSpec, convert_messages}; +use super::map_finish_reason::parse_finish_reason; +use super::metadata_extractor::{MetadataExtractor, StreamMetadataExtractor}; +use super::prepare_tools::prepare_function_tools; + +/// Converts the raw optional token usage of a chat response. +pub type ConvertUsage = Arc) -> Usage + Send + Sync>; + +/// Returns the URLs supported by chat models, read each time a chat model's +/// supported URLs are: `supportedUrls?: () => LanguageModelV4['supportedUrls']` +/// (openai-compatible@3.0.62 `openai-compatible-chat-language-model.ts:84`, +/// `:161-163`), whose value may be a `PromiseLike`. +pub type SupportedUrlsFn = Arc BoxFuture<'static, SupportedUrls> + Send + Sync>; + +/// The chat settings of a compatible endpoint: the AI SDK's +/// `includeUsage` / `supportsStructuredOutputs` / +/// `supportsMultiPartToolContent` / `supportedUrls` / `convertUsage` / +/// `metadataExtractor`, plus the preset capability data of RFC-0036 section +/// 5. +#[derive(Clone)] +pub(crate) struct ChatSettings { + pub include_usage: bool, + pub supports_structured_outputs: bool, + /// When set, only these chat models take structured outputs, whatever + /// `supports_structured_outputs` says. Ids compare exactly; an entry + /// ending in `*` matches every id with that prefix. A preset's + /// `profile.structured_output_models`, the vendor package's per-model + /// `supportsStructuredOutputs`. + pub structured_output_models: Option<&'static [String]>, + /// `supportsMultiPartToolContent`: a `content` tool output is sent as an + /// array of content parts instead of a JSON string. + pub supports_multi_part_tool_content: bool, + /// URL patterns supported by chat models, evaluated when requested. + /// `None`: none. + pub supported_urls: Option, + /// The vendor's or the caller's usage accounting. `None`: the generic + /// OpenAI-shaped usage (`convertOpenAICompatibleChatUsage`). + pub convert_usage: Option, + pub metadata_extractor: Option>, + /// Send `top_k`. The AI SDK baseline never does (it warns and drops it). + pub supports_top_k: bool, + /// Send `tools` / `tool_choice`. + pub supports_tools: bool, + /// Send `response_format`. + pub supports_response_format: bool, + /// The only `max-tokens` key the vendor accepts: `"max_tokens"` or + /// `"max_completion_tokens"`. + pub max_tokens_key: Option<&'static str>, + /// Streaming usage rides in `chunk[key].usage` instead of `chunk.usage`. + pub stream_usage_key: Option, + pub error_structure: ErrorStructure, +} + +impl Default for ChatSettings { + /// The AI SDK baseline. + fn default() -> Self { + Self { + include_usage: false, + supports_structured_outputs: false, + structured_output_models: None, + supports_multi_part_tool_content: false, + supported_urls: None, + convert_usage: None, + metadata_extractor: None, + supports_top_k: false, + supports_tools: true, + supports_response_format: true, + max_tokens_key: None, + stream_usage_key: None, + error_structure: Arc::new(default_error_structure), + } + } +} + +impl ChatSettings { + /// These settings for the chat model `model_id`. + pub(crate) fn for_model(mut self, model_id: &str) -> Self { + if let Some(models) = self.structured_output_models { + self.supports_structured_outputs = models.iter().any(|model| { + model + .strip_suffix('*') + .map_or(model == model_id, |prefix| model_id.starts_with(prefix)) + }); + } + self + } + + /// The URL patterns chat models fetch themselves. + pub(crate) async fn supported_urls(&self) -> SupportedUrls { + match &self.supported_urls { + Some(supported_urls) => supported_urls().await, + None => SupportedUrls::default(), + } + } + + /// The core usage of a chat response's raw `usage` object. + pub(crate) fn convert_usage(&self, raw: Option<&Value>) -> Usage { + match &self.convert_usage { + Some(convert) => convert(raw), + None => usage_from_raw(raw), + } + } +} + /// An OpenAI-compatible chat model. Holds no HTTP client: the request helpers /// of `aimux-provider-utils` use the injected or process-default transport. pub struct OpenAICompatibleChatModel { model_id: String, - pub(super) config: CompatModelConfig, + pub(in crate::openai_compatible) config: CompatModelConfig, } impl OpenAICompatibleChatModel { @@ -66,59 +182,240 @@ impl OpenAICompatibleChatModel { } } -// ── Usage ──────────────────────────────────────────────────────────────────── +/// What the converter needs to know about the provider it builds for. +pub(crate) struct ChatBodySpec<'a> { + /// First segment of the provider string: the providerOptions namespace. + pub provider_options_name: &'a str, + pub chat: &'a ChatSettings, +} -/// Core usage from a parsed OpenAI-shaped `usage` object. -/// -/// `raw` is the provider's original object, kept verbatim in `Usage.raw`. -/// Cache reads come from `prompt_tokens_details`, as in the upstream converter. -pub(crate) fn convert_usage(usage: &UsageResponse, raw: Option<&Value>) -> Usage { - let prompt_tokens = usage.prompt_tokens.unwrap_or(0); - let completion_tokens = usage.completion_tokens.unwrap_or(0); - - let cached = usage - .prompt_tokens_details - .as_ref() - .and_then(|d| d.cached_tokens) - .unwrap_or(0); - - // Saturating: some servers report more cached or reasoning tokens than - // prompt or completion tokens. - let no_cache = prompt_tokens.saturating_sub(cached); - let reasoning_tokens = usage - .completion_tokens_details +/// A request body and what building it produced. +#[derive(Debug, Clone)] +pub(crate) struct RequestBodyResult { + /// The JSON body, before the provider's `transform_request_body`. + pub body: Value, + /// Warnings raised while building it. + pub warnings: Vec, + /// The key provider metadata is reported under. + pub metadata_key: String, +} + +fn namespace<'a>(options: &'a CallOptions, key: &str) -> Option<&'a Map> { + options + .provider_options .as_ref() - .and_then(|d| d.reasoning_tokens) - .unwrap_or(0); - - Usage { - input_tokens: InputTokenUsage { - total: Some(prompt_tokens), - no_cache: Some(no_cache), - cache_read: Some(cached), - cache_write: None, - }, - output_tokens: OutputTokenUsage { - total: Some(completion_tokens), - text: Some(completion_tokens.saturating_sub(reasoning_tokens)), - reasoning: Some(reasoning_tokens), - }, - raw: raw.and_then(Value::as_object).cloned(), + .and_then(|all| all.get(key)) +} + +/// The key provider metadata is reported under (`resolveProviderOptionsKey`): +/// the camelCase name when supplied, otherwise the raw provider name. +fn resolve_metadata_key(name: &str, options: &CallOptions) -> String { + let camel = to_camel_case(name); + if camel != name && namespace(options, &camel).is_some() { + camel + } else { + name.to_string() } } -/// Usage from a raw `usage` value. -pub(crate) fn usage_from_raw(raw: Option<&Value>) -> Usage { - let Some(raw) = raw.filter(|value| !value.is_null()) else { - return Usage::default(); - }; - match serde_json::from_value::(raw.clone()) { - Ok(parsed) => convert_usage(&parsed, Some(raw)), - Err(_) => Usage { - raw: raw.as_object().cloned(), - ..Usage::default() +/// Build the request body (`getArgs`). +/// +/// # Errors +/// +/// `InvalidArgument` for a malformed provider option or a prompt part that has +/// no wire form. +pub(crate) fn build_request_body( + model_id: &str, + options: &CallOptions, + stream: bool, + spec: &ChatBodySpec<'_>, +) -> Result { + let name = spec.provider_options_name; + let camel = to_camel_case(name); + let chat = spec.chat; + let mut warnings = Vec::new(); + + // Resolved provider options: the generic namespace, then the provider's + // own, then its camelCase form; later wins per key. + let mut merged = Map::new(); + if let Some(deprecated) = namespace(options, "openai-compatible") { + validate_options(deprecated, "openai-compatible")?; + warnings.push(Warning::Deprecated { + setting: "providerOptions key 'openai-compatible'".to_string(), + message: "Use 'openaiCompatible' instead.".to_string(), + }); + merged.extend(deprecated.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + for key in ["openaiCompatible", name, camel.as_str()] { + if let Some(object) = namespace(options, key) { + validate_options(object, key)?; + merged.extend(object.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + } + if camel != name && namespace(options, name).is_some() { + warnings.push(Warning::Deprecated { + setting: format!("providerOptions key '{name}'"), + message: format!("Use '{camel}' instead."), + }); + } + + let user = option_string(&merged, name, "user")?; + let reasoning_effort = option_string(&merged, name, "reasoningEffort")?.or_else(|| { + options + .reasoning + .filter(|effort| effort.is_custom()) + .map(|effort| effort.to_string()) + }); + let verbosity = option_string(&merged, name, "textVerbosity")?; + let strict_json_schema = merged + .get("strictJsonSchema") + .and_then(Value::as_bool) + .unwrap_or(true); + + let metadata_key = resolve_metadata_key(name, options); + let mut body = Map::new(); + body.insert("model".into(), json!(model_id)); + if let Some(user) = user { + body.insert("user".into(), json!(user)); + } + if let Some(max_tokens) = options.max_output_tokens { + body.insert( + chat.max_tokens_key.unwrap_or("max_tokens").into(), + json!(max_tokens), + ); + } + for (key, value) in [ + ("temperature", options.temperature), + ("top_p", options.top_p), + ("frequency_penalty", options.frequency_penalty), + ("presence_penalty", options.presence_penalty), + ] { + if let Some(value) = value { + body.insert(key.into(), json!(value)); + } + } + if let Some(top_k) = options.top_k { + if chat.supports_top_k { + body.insert("top_k".into(), json!(top_k)); + } else { + warnings.push(Warning::Unsupported { + feature: "topK".to_string(), + details: None, + }); + } + } + + match &options.response_format { + Some(ResponseFormat::Json { + schema, + name: format_name, + description, + }) if chat.supports_response_format => match schema { + Some(schema) if chat.supports_structured_outputs => { + let mut json_schema = Map::new(); + json_schema.insert("schema".into(), schema.clone()); + json_schema.insert("strict".into(), json!(strict_json_schema)); + json_schema.insert( + "name".into(), + json!(format_name.clone().unwrap_or_else(|| "response".into())), + ); + if let Some(description) = description { + json_schema.insert("description".into(), json!(description)); + } + body.insert( + "response_format".into(), + json!({ "type": "json_schema", "json_schema": json_schema }), + ); + } + Some(_) => { + warnings.push(Warning::Unsupported { + feature: "responseFormat".to_string(), + details: Some( + "JSON response format schema is only supported with structuredOutputs" + .to_string(), + ), + }); + body.insert("response_format".into(), json!({ "type": "json_object" })); + } + None => { + body.insert("response_format".into(), json!({ "type": "json_object" })); + } + }, + Some(ResponseFormat::Json { .. }) => warnings.push(Warning::Unsupported { + feature: "responseFormat".to_string(), + details: Some("response_format is not supported by this provider".to_string()), + }), + Some(ResponseFormat::Text) | None => {} + } + + if let Some(stop) = &options.stop_sequences { + body.insert("stop".into(), json!(stop)); + } + if let Some(seed) = options.seed { + body.insert("seed".into(), json!(seed)); + } + + // Fields of the provider's own namespace that the schema does not know go + // to the body as given (the generic `openaiCompatible` namespace is + // schema-only: unknown fields there are dropped). + for key in [name, camel.as_str()] { + if let Some(object) = namespace(options, key) { + for (field, value) in object { + if !SCHEMA_KEYS.contains(&field.as_str()) { + body.insert(field.clone(), value.clone()); + } + } + } + } + + if let Some(effort) = reasoning_effort { + body.insert("reasoning_effort".into(), json!(effort)); + } + if let Some(verbosity) = verbosity { + body.insert("verbosity".into(), json!(verbosity)); + } + + let messages = convert_messages( + &options.prompt, + &MessageSpec { + metadata_key: &metadata_key, + supports_multi_part_tool_content: chat.supports_multi_part_tool_content, }, + )?; + body.insert("messages".into(), Value::Array(messages)); + + if chat.supports_tools { + let prepared = prepare_function_tools(options); + warnings.extend(prepared.warnings); + if let Some(tools) = prepared.tools { + body.insert("tools".into(), Value::Array(tools)); + } + if let Some(choice) = prepared.tool_choice { + body.insert("tool_choice".into(), choice); + } + } else if options + .tools + .as_ref() + .is_some_and(|tools| !tools.is_empty()) + { + warnings.push(Warning::Unsupported { + feature: "tools".to_string(), + details: Some("tools are not supported by this provider".to_string()), + }); } + + if stream { + body.insert("stream".into(), json!(true)); + if chat.include_usage { + body.insert("stream_options".into(), json!({ "include_usage": true })); + } + } + + Ok(RequestBodyResult { + body: Value::Object(body), + warnings, + metadata_key, + }) } fn prediction_tokens(usage: Option<&UsageResponse>, metadata: &mut Map) { @@ -145,7 +442,7 @@ fn thought_signature(extra_content: Option<&Value>) -> Option { /// (openai-compatible-chat-language-model.ts:588-595, /// openai-compatible-completion-language-model.ts:320-325): no status is /// read from its `code`, numeric or not, so nothing makes it retryable. -pub(super) fn stream_error( +pub(in crate::openai_compatible) fn stream_error( name: &str, error: &Value, url: &str, @@ -174,8 +471,6 @@ pub(super) fn stream_error( ) } -// ── LanguageModel ──────────────────────────────────────────────────────────── - #[async_trait] impl LanguageModel for OpenAICompatibleChatModel { /// `"{name}.chat"`. @@ -362,6 +657,7 @@ impl LanguageModel for OpenAICompatibleChatModel { } const TEXT_ID: &str = "txt-0"; + const REASONING_ID: &str = "reasoning-0"; /// The `TransformStream` of [`OpenAICompatibleChatModel::do_stream`]: turns @@ -713,180 +1009,3 @@ fn content_parts(content: Option<&Value>) -> Vec { }) .collect() } - -/// One `GET {base_url}/models` exchange for discovery: no retry, no -/// recording. -pub(crate) async fn list_models_once( - config: &CompatModelConfig, -) -> Result, AiMuxError> { - #[derive(serde::Deserialize)] - struct ModelsList { - #[serde(default)] - data: Vec, - } - #[derive(serde::Deserialize)] - struct ModelEntry { - id: String, - #[serde(default)] - owned_by: Option, - #[serde(default)] - created: Option, - } - - let headers = config.request_headers(None).await?; - let (url, origin) = config.url("/models")?; - let resp = aimux_provider_utils::get_from_api( - config.with_transport( - aimux_provider_utils::HttpRequest { - url, - headers: headers.list, - ..Default::default() - }, - origin, - headers.fetch_user_agent, - ), - aimux_provider_utils::create_json_response_handler::(), - config.failed_response_handler(), - ) - .await?; - Ok(resp - .value - .data - .into_iter() - .map(|m| aimux_core::model_catalogue::RuntimeModel { - id: m.id, - owned_by: m.owned_by, - created: m.created, - }) - .collect()) -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use serde_json::{Value, json}; - use wiremock::matchers::{method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - use aimux_core::error::AiMuxError; - use aimux_core::language_model::LanguageModel; - use aimux_core::language_model_message::LanguageModelMessage; - use aimux_core::options::CallOptions; - use aimux_provider_utils::ProviderErrorParts; - - use crate::openai_compatible::config::{BaseUrl, ChatSettings}; - use crate::openai_compatible::{Assembly, OpenAICompatibleProvider}; - use crate::shared::Credential; - - fn hello() -> CallOptions { - CallOptions::new(vec![LanguageModelMessage::user_text("hi")]) - } - - fn provider(base_url: BaseUrl, chat: ChatSettings) -> OpenAICompatibleProvider { - OpenAICompatibleProvider::assemble(Assembly { - name: "acme".to_string(), - base_url, - credential: Credential::None, - fixed_headers: Vec::new(), - headers: None, - query_params: None, - fetch: None, - transform_request_body: None, - chat, - package: crate::shared::aisdk_pins::OPENAI_COMPATIBLE, - }) - .unwrap() - } - - async fn serve(server: &MockServer, response: ResponseTemplate) { - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(response) - .mount(server) - .await; - } - - #[tokio::test] - async fn an_error_structure_decides_the_message_and_code() { - let server = MockServer::start().await; - serve( - &server, - ResponseTemplate::new(400).set_body_json(json!({"detail": "bad prompt", "kind": 42})), - ) - .await; - let chat = ChatSettings { - error_structure: Arc::new(|data: &Value| ProviderErrorParts { - message: data["detail"].as_str().unwrap_or_default().to_string(), - provider_code: data["kind"].as_u64().map(|k| k.to_string()), - }), - ..ChatSettings::default() - }; - let error = provider(BaseUrl::Fixed(server.uri()), chat) - .chat("m") - .do_generate(&hello()) - .await - .unwrap_err(); - match error { - AiMuxError::ApiCall(detail) => { - assert_eq!(detail.message, "bad prompt"); - assert_eq!(detail.provider_code.as_deref(), Some("42")); - } - other => panic!("expected ApiCall, got {other:?}"), - } - } - - /// A preset base URL is data the request resolves: creating the provider - /// and the model reads nothing, a request that cannot resolve it fails, - /// and the next request resolves it again. - #[tokio::test] - #[serial_test::serial] - async fn a_lazy_base_url_is_evaluated_per_request_and_its_error_fails_the_request() { - use crate::preset::{AuthMode, ParamSpec, PresetDescriptor}; - - const VAR: &str = "AIMUX_TEST_COMPAT_LAZY_BASE_URL"; - let server = MockServer::start().await; - serve( - &server, - ResponseTemplate::new(200).set_body_json(json!({ - "id": "c", "model": "m", - "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}] - })), - ) - .await; - // SAFETY: the test is `#[serial]` and no other test reads `VAR`. - unsafe { std::env::remove_var(VAR) }; - let descriptor: &'static PresetDescriptor = Box::leak(Box::new(PresetDescriptor { - name: "acme".to_string(), - display: "Acme".to_string(), - base_url: "http://{host}/v1".to_string(), - env_var: String::new(), - auth: AuthMode::None, - base_url_env: Some(VAR.to_string()), - max_tokens_key: None, - convert_usage: None, - params: vec![ParamSpec { - name: "host".to_string(), - env: Vec::new(), - default: None, - derive: None, - }], - user_agent: ("openai-compatible", "3.0.62"), - structured_output_models: None, - })); - let lazy = BaseUrl::Preset { - descriptor, - params: Arc::new(std::collections::HashMap::new()), - }; - let provider = provider(lazy, ChatSettings::default()); - let model = provider.chat("m"); - let error = model.do_generate(&hello()).await.unwrap_err(); - assert!(matches!(error, AiMuxError::InvalidArgument(_)), "{error:?}"); - // SAFETY: as above. - unsafe { std::env::set_var(VAR, server.uri()) }; - let result = model.do_generate(&hello()).await; - // SAFETY: as above. - unsafe { std::env::remove_var(VAR) }; - result.expect("resolved on the next request"); - } -} diff --git a/aimux-providers/src/openai_compatible/chat/chat_language_model_options.rs b/aimux-providers/src/openai_compatible/chat/chat_language_model_options.rs new file mode 100644 index 000000000..bb361c81b --- /dev/null +++ b/aimux-providers/src/openai_compatible/chat/chat_language_model_options.rs @@ -0,0 +1,48 @@ +//! The provider options of the chat model +//! (`openai-compatible-chat-language-model-options.ts`): the keys of +//! `openaiCompatibleLanguageModelChatOptions` and their checks. + +use aimux_core::error::AiMuxError; +use serde_json::{Map, Value}; + +/// Option keys of the generic compatible schema +/// (`openaiCompatibleLanguageModelChatOptions`): consumed by the model, never +/// passed through. +pub(super) const SCHEMA_KEYS: [&str; 4] = [ + "user", + "reasoningEffort", + "textVerbosity", + "strictJsonSchema", +]; + +pub(super) fn option_string( + merged: &Map, + namespace_name: &str, + key: &str, +) -> Result, AiMuxError> { + match merged.get(key) { + None => Ok(None), + Some(Value::String(value)) => Ok(Some(value.clone())), + Some(_) => Err(AiMuxError::InvalidArgument(format!( + "invalid provider options for \"{namespace_name}\": `{key}` must be a string" + ))), + } +} + +pub(super) fn validate_options( + options: &Map, + namespace_name: &str, +) -> Result<(), AiMuxError> { + for key in ["user", "reasoningEffort", "textVerbosity"] { + option_string(options, namespace_name, key)?; + } + if options + .get("strictJsonSchema") + .is_some_and(|value| !value.is_boolean()) + { + return Err(AiMuxError::InvalidArgument(format!( + "invalid provider options for \"{namespace_name}\": `strictJsonSchema` must be a boolean" + ))); + } + Ok(()) +} diff --git a/aimux-providers/src/openai_compatible/chat/convert_chat_usage.rs b/aimux-providers/src/openai_compatible/chat/convert_chat_usage.rs new file mode 100644 index 000000000..f92574907 --- /dev/null +++ b/aimux-providers/src/openai_compatible/chat/convert_chat_usage.rs @@ -0,0 +1,59 @@ +//! The token usage of a chat response (`convert-openai-compatible-chat-usage.ts`). + +use aimux_core::types::{InputTokenUsage, OutputTokenUsage, Usage}; +use serde_json::Value; + +use super::api_types::UsageResponse; + +/// Core usage from a parsed OpenAI-shaped `usage` object. +/// +/// `raw` is the provider's original object, kept verbatim in `Usage.raw`. +/// Cache reads come from `prompt_tokens_details`, as in the upstream converter. +pub(crate) fn convert_usage(usage: &UsageResponse, raw: Option<&Value>) -> Usage { + let prompt_tokens = usage.prompt_tokens.unwrap_or(0); + let completion_tokens = usage.completion_tokens.unwrap_or(0); + + let cached = usage + .prompt_tokens_details + .as_ref() + .and_then(|d| d.cached_tokens) + .unwrap_or(0); + + // Saturating: some servers report more cached or reasoning tokens than + // prompt or completion tokens. + let no_cache = prompt_tokens.saturating_sub(cached); + let reasoning_tokens = usage + .completion_tokens_details + .as_ref() + .and_then(|d| d.reasoning_tokens) + .unwrap_or(0); + + Usage { + input_tokens: InputTokenUsage { + total: Some(prompt_tokens), + no_cache: Some(no_cache), + cache_read: Some(cached), + cache_write: None, + }, + output_tokens: OutputTokenUsage { + total: Some(completion_tokens), + text: Some(completion_tokens.saturating_sub(reasoning_tokens)), + reasoning: Some(reasoning_tokens), + }, + raw: raw.and_then(Value::as_object).cloned(), + } +} + +/// Usage from a raw `usage` value. +pub(crate) fn usage_from_raw(raw: Option<&Value>) -> Usage { + let Some(raw) = raw.filter(|value| !value.is_null()) else { + return Usage::default(); + }; + match serde_json::from_value::(raw.clone()) { + Ok(parsed) => convert_usage(&parsed, Some(raw)), + Err(_) => Usage { + raw: raw.as_object().cloned(), + ..Usage::default() + }, + } +} diff --git a/aimux-providers/src/openai_compatible/chat/convert_to_chat_messages.rs b/aimux-providers/src/openai_compatible/chat/convert_to_chat_messages.rs new file mode 100644 index 000000000..39ed1d765 --- /dev/null +++ b/aimux-providers/src/openai_compatible/chat/convert_to_chat_messages.rs @@ -0,0 +1,379 @@ +//! The prompt to the chat-completions `messages` +//! (`convert-to-openai-compatible-chat-messages.ts`). + +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::shared::{FileBytes, FileData, SharedProviderOptions}; +use aimux_provider_utils::{get_top_level_media_type, resolve_full_media_type}; +use base64::Engine; +use serde_json::{Map, Value, json}; + +pub(crate) struct MessageSpec<'a> { + pub metadata_key: &'a str, + /// Send a `content` tool output as content parts, not a JSON string. + pub supports_multi_part_tool_content: bool, +} + +/// The generic `openaiCompatible` metadata of a message or part +/// (`getOpenAIMetadata`). +fn wire_metadata(provider_options: Option<&SharedProviderOptions>) -> Map { + provider_options + .and_then(|options| options.get("openaiCompatible")) + .cloned() + .unwrap_or_default() +} + +fn with_metadata(mut object: Value, metadata: Map) -> Value { + if let Some(map) = object.as_object_mut() { + map.extend(metadata); + } + object +} + +/// `messages` for the body. +/// +/// # Errors +/// +/// `InvalidArgument` when a part has no wire form (an unsupported media type, +/// an audio or PDF part given by URL). +pub(crate) fn convert_messages( + prompt: &LanguageModelPrompt, + spec: &MessageSpec<'_>, +) -> Result, AiMuxError> { + let mut out = Vec::new(); + for message in prompt { + out.extend(convert_message(message, spec)?); + } + Ok(out) +} + +fn convert_message( + message: &LanguageModelMessage, + spec: &MessageSpec<'_>, +) -> Result, AiMuxError> { + match message { + LanguageModelMessage::System { + content, + provider_options, + } => Ok(vec![with_metadata( + json!({ "role": "system", "content": content }), + wire_metadata(provider_options.as_ref()), + )]), + LanguageModelMessage::User { + content, + provider_options, + } => Ok(vec![convert_user_message( + content, + wire_metadata(provider_options.as_ref()), + )?]), + LanguageModelMessage::Assistant { + content, + provider_options, + } => Ok(vec![convert_assistant_message( + content, + spec, + wire_metadata(provider_options.as_ref()), + )]), + LanguageModelMessage::Tool { content, .. } => content + .iter() + .filter_map(|part| match part { + ToolPart::ToolResult(ToolResultPart { + tool_call_id, + output, + provider_options, + .. + }) => Some((tool_call_id, output, provider_options)), + _ => None, + }) + .map(|(tool_call_id, output, provider_options)| { + // convert-to-openai-compatible-chat-messages.ts:284-321: a + // `content` output is an array of content parts only when + // the provider supports multi-part tool content. + let content = match output { + ToolResultOutput::Content { value } + if spec.supports_multi_part_tool_content => + { + Value::Array( + value + .iter() + .map(convert_tool_content_part) + .collect::>()?, + ) + } + output => crate::openai::convert::tool_result_to_content(output), + }; + Ok(with_metadata( + json!({ + "role": "tool", + "tool_call_id": tool_call_id, + "content": content, + }), + wire_metadata(provider_options.as_ref()), + )) + }) + .collect(), + } +} + +/// One part of a multi-part tool result (`convertToolContentPart`, +/// convert-to-openai-compatible-chat-messages.ts:162-176): a text or file part +/// is converted like a user content part; a custom part has no wire form. +fn convert_tool_content_part(part: &ToolResultContent) -> Result { + match part { + ToolResultContent::Text(text) => Ok(convert_text_part(text)), + ToolResultContent::File(file) => convert_file_part(file), + ToolResultContent::Custom { .. } => Err(AiMuxError::UnsupportedFunctionality( + "custom tool content parts".to_string(), + )), + } +} + +fn convert_user_message( + content: &[UserPart], + message_metadata: Map, +) -> Result { + if let [ + UserPart::Text(TextPart { + text, + provider_options, + }), + ] = content + { + let metadata = wire_metadata(provider_options.as_ref()); + return Ok(with_metadata( + json!({ "role": "user", "content": text }), + metadata, + )); + } + let parts = content + .iter() + .map(convert_user_part) + .collect::, _>>()?; + Ok(with_metadata( + json!({ "role": "user", "content": parts }), + message_metadata, + )) +} + +fn convert_user_part(part: &UserPart) -> Result { + match part { + UserPart::Text(text) => Ok(convert_text_part(text)), + UserPart::File(file) => convert_file_part(file), + } +} + +/// A text content part (`convertToOpenAICompatibleContentPart`). +fn convert_text_part(part: &TextPart) -> Value { + with_metadata( + json!({ "type": "text", "text": part.text }), + wire_metadata(part.provider_options.as_ref()), + ) +} + +/// A file content part (`convertToOpenAICompatibleContentPart`). +fn convert_file_part(file: &FilePart) -> Result { + let wire = match &file.data { + FileData::Data { + data: FileBytes::Binary(data), + } => file_part( + file, + FileSource::Base64(&base64::engine::general_purpose::STANDARD.encode(data)), + )?, + FileData::Data { + data: FileBytes::Base64(data), + } => file_part(file, FileSource::Base64(data))?, + FileData::Url { url, .. } => file_part(file, FileSource::Url(url))?, + FileData::Reference { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "file parts with provider references".to_string(), + )); + } + FileData::Text { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "text file parts".to_string(), + )); + } + }; + Ok(with_metadata( + wire, + wire_metadata(file.provider_options.as_ref()), + )) +} + +enum FileSource<'a> { + Base64(&'a str), + Url(&'a str), +} + +fn file_part(part: &FilePart, source: FileSource<'_>) -> Result { + let media_type = part.media_type.as_str(); + let filename = part.filename.as_deref(); + let unsupported = |what: String| AiMuxError::UnsupportedFunctionality(what); + + match get_top_level_media_type(media_type) { + kind @ ("image" | "video") => { + let url = match source { + FileSource::Url(url) => url.to_string(), + FileSource::Base64(b64) => { + format!("data:{};base64,{}", resolve_full_media_type(part)?, b64) + } + }; + let kind = format!("{kind}_url"); + Ok(json!({ "type": kind, kind: { "url": url } })) + } + "audio" => { + let FileSource::Base64(b64) = source else { + return Err(unsupported("audio file parts with URLs".to_string())); + }; + let full_media_type = resolve_full_media_type(part)?; + let format = match full_media_type.as_str() { + "audio/wav" => "wav", + "audio/mp3" | "audio/mpeg" => "mp3", + other => { + return Err(unsupported(format!("audio media type {other}"))); + } + }; + Ok(json!({ "type": "input_audio", "input_audio": { "data": b64, "format": format } })) + } + "application" => { + let FileSource::Base64(b64) = source else { + return Err(unsupported("PDF file parts with URLs".to_string())); + }; + let full_media_type = resolve_full_media_type(part)?; + if full_media_type != "application/pdf" { + return Err(unsupported(format!( + "file part media type {full_media_type}" + ))); + } + let filename = filename.unwrap_or("document.pdf"); + Ok(json!({ + "type": "file", + "file": { + "filename": filename, + "file_data": format!("data:application/pdf;base64,{b64}"), + } + })) + } + "text" => { + let b64 = match source { + FileSource::Url(url) => return Ok(json!({ "type": "text", "text": url })), + FileSource::Base64(b64) => b64, + }; + let bytes = base64::engine::general_purpose::STANDARD + .decode(b64) + .map_err(|e| unsupported(format!("text file part is not valid base64: {e}")))?; + let text = String::from_utf8_lossy(&bytes); + Ok(json!({ "type": "text", "text": text })) + } + _ => Err(unsupported(format!("file part media type {media_type}"))), + } +} + +fn convert_assistant_message( + content: &[AssistantPart], + spec: &MessageSpec<'_>, + message_metadata: Map, +) -> Value { + let key = spec.metadata_key; + let mut text = String::new(); + let mut reasoning = String::new(); + let mut tool_calls = Vec::new(); + let metadata = message_metadata; + for part in content { + match part { + AssistantPart::Text(TextPart { text: t, .. }) => text.push_str(t), + AssistantPart::Reasoning(ReasoningPart { text: t, .. }) => reasoning.push_str(t), + AssistantPart::ToolCall(ToolCallPart { + tool_call_id, + tool_name, + input, + provider_options, + .. + }) => { + let arguments = input.to_string(); + let mut call = json!({ + "id": tool_call_id, + "type": "function", + "function": { "name": tool_name, "arguments": arguments }, + }); + call = with_metadata(call, wire_metadata(provider_options.as_ref())); + let signature = provider_options.as_ref().and_then(|options| { + options + .get(key) + .and_then(|options| options.get("thoughtSignature")) + .filter(|value| !value.is_null()) + .or_else(|| { + options + .get("google") + .and_then(|options| options.get("thoughtSignature")) + }) + }); + let signature = signature.and_then(|value| match value { + Value::Null | Value::Bool(false) => None, + Value::String(text) if text.is_empty() => None, + Value::Number(number) if number.as_f64() == Some(0.0) => None, + Value::String(text) => Some(text.clone()), + other => Some(signature_string(other)), + }); + if let Some(signature) = signature { + call["extra_content"] = json!({ "google": { "thought_signature": signature } }); + } + tool_calls.push(call); + } + // Provider-executed results and media have no assistant-content + // form on this wire. + _ => {} + } + } + let mut wire = Map::new(); + wire.insert("role".into(), json!("assistant")); + wire.insert( + "content".into(), + if tool_calls.is_empty() || !text.is_empty() { + json!(text) + } else { + Value::Null + }, + ); + if !reasoning.is_empty() { + wire.insert("reasoning_content".into(), json!(reasoning)); + } + if !tool_calls.is_empty() { + wire.insert("tool_calls".into(), Value::Array(tool_calls)); + } + wire.extend(metadata); + Value::Object(wire) +} + +// String(value), as used by the compatible package for thought signatures. +fn signature_string(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + Value::Object(_) => "[object Object]".into(), + Value::Array(values) => values + .iter() + .map(|value| { + if value.is_null() { + String::new() + } else { + signature_string(value) + } + }) + .collect::>() + .join(","), + Value::Number(value) => value.as_f64().map_or_else( + || value.to_string(), + |value| { + if value == 0.0 { + "0".into() + } else { + value.to_string() + } + }, + ), + _ => value.to_string(), + } +} diff --git a/aimux-providers/src/openai_compatible/chat/map_finish_reason.rs b/aimux-providers/src/openai_compatible/chat/map_finish_reason.rs new file mode 100644 index 000000000..53040a47b --- /dev/null +++ b/aimux-providers/src/openai_compatible/chat/map_finish_reason.rs @@ -0,0 +1,21 @@ +//! The finish reason of a chat or completion response +//! (`map-openai-compatible-finish-reason.ts`; the completion package's copy is +//! the same function). + +use aimux_core::types::{FinishReason, FinishReasonUnified}; + +/// Map an OpenAI finish reason string to the unified one, keeping the raw. +#[must_use] +pub(crate) fn parse_finish_reason(raw: &str) -> FinishReason { + let unified = match raw { + "stop" => FinishReasonUnified::Stop, + "length" => FinishReasonUnified::Length, + "tool_calls" | "function_call" => FinishReasonUnified::ToolCalls, + "content_filter" => FinishReasonUnified::ContentFilter, + _ => FinishReasonUnified::Other, + }; + FinishReason { + unified, + raw: Some(raw.to_string()), + } +} diff --git a/aimux-providers/src/openai_compatible/chat/metadata_extractor.rs b/aimux-providers/src/openai_compatible/chat/metadata_extractor.rs new file mode 100644 index 000000000..8f3501cae --- /dev/null +++ b/aimux-providers/src/openai_compatible/chat/metadata_extractor.rs @@ -0,0 +1,24 @@ +//! Vendor metadata of chat responses (`openai-compatible-metadata-extractor.ts`). + +use aimux_core::types::ProviderMetadata; +use serde_json::Value; + +/// Captures vendor-specific metadata from responses (the AI SDK's +/// `MetadataExtractor`). The returned map is merged into `provider_metadata` +/// next to the namespace entry. +pub trait MetadataExtractor: Send + Sync { + /// Metadata of a non-streaming response body. + fn extract_metadata(&self, parsed_body: &Value) -> Option; + + /// A fresh extractor for one streaming response. + fn create_stream_extractor(&self) -> Box; +} + +/// The per-stream half of [`MetadataExtractor`]. +pub trait StreamMetadataExtractor: Send { + /// Called with every parsed chunk (including the one carrying usage). + fn process_chunk(&mut self, parsed_chunk: &Value); + + /// Metadata merged into the finish part's `provider_metadata`. + fn build_metadata(&self) -> Option; +} diff --git a/aimux-providers/src/openai_compatible/chat/mod.rs b/aimux-providers/src/openai_compatible/chat/mod.rs new file mode 100644 index 000000000..452c6eecb --- /dev/null +++ b/aimux-providers/src/openai_compatible/chat/mod.rs @@ -0,0 +1,20 @@ +//! The chat-completions model (`src/chat/`). + +mod api_types; +pub(super) mod chat_language_model; +mod chat_language_model_options; +mod convert_chat_usage; +pub(super) mod convert_to_chat_messages; +mod map_finish_reason; +mod metadata_extractor; +pub(super) mod prepare_tools; + +pub use chat_language_model::{ConvertUsage, OpenAICompatibleChatModel, SupportedUrlsFn}; +pub use metadata_extractor::{MetadataExtractor, StreamMetadataExtractor}; + +pub(crate) use chat_language_model::ChatSettings; +#[cfg(test)] +pub(crate) use chat_language_model::chat_test_stream; +pub(super) use chat_language_model::stream_error; +pub(crate) use convert_chat_usage::usage_from_raw; +pub(super) use map_finish_reason::parse_finish_reason; diff --git a/aimux-providers/src/openai_compatible/chat/prepare_tools.rs b/aimux-providers/src/openai_compatible/chat/prepare_tools.rs new file mode 100644 index 000000000..e692f1874 --- /dev/null +++ b/aimux-providers/src/openai_compatible/chat/prepare_tools.rs @@ -0,0 +1,63 @@ +//! `tools` / `tool_choice` of the chat body (`openai-compatible-prepare-tools.ts`). + +use aimux_core::options::{CallOptions, LanguageModelV4ToolChoice}; +use aimux_core::tool::Tool; +use aimux_core::types::Warning; +use serde_json::{Map, Value, json}; + +/// `tools` / `tool_choice` ready for the body, with the warnings raised while +/// preparing them. +#[derive(Debug, Clone, Default)] +pub(crate) struct PreparedTools { + pub tools: Option>, + pub tool_choice: Option, + pub warnings: Vec, +} + +/// The wire form of a function tool. +pub(crate) fn function_tool_value(tool: &aimux_core::tool::FunctionTool) -> Value { + let mut function = Map::new(); + function.insert("name".into(), json!(tool.name)); + if let Some(description) = &tool.description { + function.insert("description".into(), json!(description)); + } + function.insert("parameters".into(), tool.input_schema.clone()); + if let Some(strict) = tool.strict { + function.insert("strict".into(), json!(strict)); + } + json!({ "type": "function", "function": function }) +} + +/// The wire form of the tool choice. +pub(crate) fn tool_choice_value(choice: &LanguageModelV4ToolChoice) -> Value { + match choice { + LanguageModelV4ToolChoice::Auto => json!("auto"), + LanguageModelV4ToolChoice::None => json!("none"), + LanguageModelV4ToolChoice::Required => json!("required"), + LanguageModelV4ToolChoice::Tool { tool_name } => { + json!({ "type": "function", "function": { "name": tool_name } }) + } + } +} + +/// Function tools only; a provider-defined tool is unsupported by the generic +/// endpoint and warned about (the AI SDK's `prepareTools`). +pub(crate) fn prepare_function_tools(options: &CallOptions) -> PreparedTools { + let mut prepared = PreparedTools::default(); + let Some(tools) = options.tools.as_ref().filter(|tools| !tools.is_empty()) else { + return prepared; + }; + let mut wire = Vec::new(); + for tool in tools { + match tool { + Tool::Function(function) => wire.push(function_tool_value(function)), + Tool::Provider(provider) => prepared.warnings.push(Warning::Unsupported { + feature: format!("provider-defined tool {}", provider.id), + details: None, + }), + } + } + prepared.tools = Some(wire); + prepared.tool_choice = options.tool_choice.as_ref().map(tool_choice_value); + prepared +} diff --git a/aimux-providers/src/openai_compatible/completion.rs b/aimux-providers/src/openai_compatible/completion/completion_language_model.rs similarity index 82% rename from aimux-providers/src/openai_compatible/completion.rs rename to aimux-providers/src/openai_compatible/completion/completion_language_model.rs index b0b14c60a..e7b5696d3 100644 --- a/aimux-providers/src/openai_compatible/completion.rs +++ b/aimux-providers/src/openai_compatible/completion/completion_language_model.rs @@ -1,31 +1,32 @@ -//! Text-completion endpoint shared by the native and compatible packages. - -use async_trait::async_trait; -use futures::StreamExt; -use serde::Deserialize; -use serde_json::{Map, Value, json}; +//! The text-completion model (`{name}.completion`, +//! `openai-compatible-completion-language-model.ts`), shared by the native and +//! the compatible packages. use aimux_core::AiMuxError; use aimux_core::language_model::{LanguageModel, SupportedUrls}; -use aimux_core::language_model_message::{AssistantPart, LanguageModelMessage, UserPart}; use aimux_core::options::{CallOptions, ResponseFormat}; -use aimux_core::result::StreamResult; -use aimux_core::result::{GenerateContent, GenerateResult}; +use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; use aimux_core::shared::{RequestInfo, ResponseInfo, StreamResponseInfo, provider_namespace}; use aimux_core::stream_part::StreamPart; -use aimux_core::types::{ - FinishReason, InputTokenUsage, OutputTokenUsage, ResponseMetadata, Usage, Warning, -}; +use aimux_core::types::{FinishReason, Warning}; use aimux_provider_utils::{HttpRequest, Transformer, pipe_through}; +use async_trait::async_trait; +use futures::StreamExt; +use serde::Deserialize; +use serde_json::{Map, Value, json}; +use crate::openai::config::OpenAIModelConfig; +use crate::openai_compatible::CompatModelConfig; +use crate::openai_compatible::chat::parse_finish_reason; +use crate::openai_compatible::utils::to_camel_case; use crate::stream::{Controller, Emit, FINISH_ERROR, FINISH_OTHER, StreamContext}; -use super::config::CompatModelConfig; -use super::convert::{parse_finish_reason, to_camel_case}; -use crate::openai::config::OpenAIModelConfig; +use super::convert_completion_usage::usage; +use super::convert_to_completion_prompt::completion_prompt; +use super::get_response_metadata::metadata; #[derive(Clone)] -pub(super) enum CompletionConfig { +pub(in crate::openai_compatible) enum CompletionConfig { Compatible(CompatModelConfig), Native(OpenAIModelConfig), } @@ -34,7 +35,7 @@ pub(super) enum CompletionConfig { #[derive(Clone)] pub struct OpenAICompatibleCompletionModel { model_id: String, - pub(super) config: CompletionConfig, + pub(in crate::openai_compatible) config: CompletionConfig, } impl OpenAICompatibleCompletionModel { @@ -203,57 +204,11 @@ impl OpenAICompatibleCompletionModel { } } -fn completion_prompt(options: &CallOptions) -> Result { - let mut text = String::new(); - for (index, message) in options.prompt.iter().enumerate() { - match message { - LanguageModelMessage::System { content, .. } if index == 0 => { - text.push_str(content); - text.push_str("\n\n"); - } - LanguageModelMessage::System { .. } => { - return Err(AiMuxError::InvalidPrompt( - "Unexpected system message in prompt".into(), - )); - } - LanguageModelMessage::User { content, .. } => { - text.push_str("user:\n"); - for part in content { - if let UserPart::Text(part) = part { - text.push_str(&part.text); - } - } - text.push_str("\n\n"); - } - LanguageModelMessage::Assistant { content, .. } => { - text.push_str("assistant:\n"); - for part in content { - match part { - AssistantPart::Text(part) => text.push_str(&part.text), - AssistantPart::ToolCall(_) => { - return Err(AiMuxError::UnsupportedFunctionality( - "tool-call messages".into(), - )); - } - _ => {} - } - } - text.push_str("\n\n"); - } - LanguageModelMessage::Tool { .. } => { - return Err(AiMuxError::UnsupportedFunctionality("tool messages".into())); - } - } - } - text.push_str("assistant:\n"); - Ok(text) -} - #[derive(Deserialize)] -struct CompletionResponse { - id: Option, - created: Option, - model: Option, +pub(super) struct CompletionResponse { + pub(super) id: Option, + pub(super) created: Option, + pub(super) model: Option, choices: Vec, usage: Option>, } @@ -321,59 +276,6 @@ fn parse_response( Ok(serde_json::from_value(value)?) } -fn metadata(response: &CompletionResponse) -> ResponseMetadata { - ResponseMetadata { - id: response.id.clone(), - timestamp: response - .created - .and_then(|secs| aimux_core::Timestamp::from_unix_seconds(secs as f64)), - model_id: response.model.clone(), - } -} - -fn usage(raw: Option>, native: bool) -> Usage { - let Some(mut raw) = raw else { - return Usage::default(); - }; - if native { - raw.retain(|key, _| { - matches!( - key.as_str(), - "prompt_tokens" | "completion_tokens" | "total_tokens" - ) - }); - } - let input = raw - .get("prompt_tokens") - .and_then(Value::as_u64) - .map(|n| n as u32); - let output = raw - .get("completion_tokens") - .and_then(Value::as_u64) - .map(|n| n as u32); - Usage { - input_tokens: InputTokenUsage { - total: if native { - input - } else { - Some(input.unwrap_or(0)) - }, - no_cache: Some(input.unwrap_or(0)), - ..Default::default() - }, - output_tokens: OutputTokenUsage { - total: if native { - output - } else { - Some(output.unwrap_or(0)) - }, - text: Some(output.unwrap_or(0)), - ..Default::default() - }, - raw: Some(raw), - } -} - #[async_trait] impl LanguageModel for OpenAICompatibleCompletionModel { fn provider(&self) -> &str { @@ -576,7 +478,7 @@ impl Transformer for OpenAICompatibleCompletionStream { self.ctx.request_body.clone(), self.ctx.response_headers.clone(), ), - Some(name) => super::chat::stream_error( + Some(name) => crate::openai_compatible::chat::stream_error( name, error, &self.ctx.url, diff --git a/aimux-providers/src/openai_compatible/completion/convert_completion_usage.rs b/aimux-providers/src/openai_compatible/completion/convert_completion_usage.rs new file mode 100644 index 000000000..7a633d087 --- /dev/null +++ b/aimux-providers/src/openai_compatible/completion/convert_completion_usage.rs @@ -0,0 +1,48 @@ +//! The token usage of a completion response +//! (`convert-openai-compatible-completion-usage.ts`). + +use aimux_core::types::{InputTokenUsage, OutputTokenUsage, Usage}; +use serde_json::{Map, Value}; + +pub(super) fn usage(raw: Option>, native: bool) -> Usage { + let Some(mut raw) = raw else { + return Usage::default(); + }; + if native { + raw.retain(|key, _| { + matches!( + key.as_str(), + "prompt_tokens" | "completion_tokens" | "total_tokens" + ) + }); + } + let input = raw + .get("prompt_tokens") + .and_then(Value::as_u64) + .map(|n| n as u32); + let output = raw + .get("completion_tokens") + .and_then(Value::as_u64) + .map(|n| n as u32); + Usage { + input_tokens: InputTokenUsage { + total: if native { + input + } else { + Some(input.unwrap_or(0)) + }, + no_cache: Some(input.unwrap_or(0)), + ..Default::default() + }, + output_tokens: OutputTokenUsage { + total: if native { + output + } else { + Some(output.unwrap_or(0)) + }, + text: Some(output.unwrap_or(0)), + ..Default::default() + }, + raw: Some(raw), + } +} diff --git a/aimux-providers/src/openai_compatible/completion/convert_to_completion_prompt.rs b/aimux-providers/src/openai_compatible/completion/convert_to_completion_prompt.rs new file mode 100644 index 000000000..e3eb7290e --- /dev/null +++ b/aimux-providers/src/openai_compatible/completion/convert_to_completion_prompt.rs @@ -0,0 +1,51 @@ +//! The prompt of a text completion (`convert-to-openai-compatible-completion-prompt.ts`). + +use aimux_core::AiMuxError; +use aimux_core::language_model_message::{AssistantPart, LanguageModelMessage, UserPart}; +use aimux_core::options::CallOptions; + +pub(super) fn completion_prompt(options: &CallOptions) -> Result { + let mut text = String::new(); + for (index, message) in options.prompt.iter().enumerate() { + match message { + LanguageModelMessage::System { content, .. } if index == 0 => { + text.push_str(content); + text.push_str("\n\n"); + } + LanguageModelMessage::System { .. } => { + return Err(AiMuxError::InvalidPrompt( + "Unexpected system message in prompt".into(), + )); + } + LanguageModelMessage::User { content, .. } => { + text.push_str("user:\n"); + for part in content { + if let UserPart::Text(part) = part { + text.push_str(&part.text); + } + } + text.push_str("\n\n"); + } + LanguageModelMessage::Assistant { content, .. } => { + text.push_str("assistant:\n"); + for part in content { + match part { + AssistantPart::Text(part) => text.push_str(&part.text), + AssistantPart::ToolCall(_) => { + return Err(AiMuxError::UnsupportedFunctionality( + "tool-call messages".into(), + )); + } + _ => {} + } + } + text.push_str("\n\n"); + } + LanguageModelMessage::Tool { .. } => { + return Err(AiMuxError::UnsupportedFunctionality("tool messages".into())); + } + } + } + text.push_str("assistant:\n"); + Ok(text) +} diff --git a/aimux-providers/src/openai_compatible/completion/get_response_metadata.rs b/aimux-providers/src/openai_compatible/completion/get_response_metadata.rs new file mode 100644 index 000000000..c3a88a86c --- /dev/null +++ b/aimux-providers/src/openai_compatible/completion/get_response_metadata.rs @@ -0,0 +1,15 @@ +//! The response metadata of a completion response (`get-response-metadata.ts`). + +use aimux_core::types::ResponseMetadata; + +use super::completion_language_model::CompletionResponse; + +pub(super) fn metadata(response: &CompletionResponse) -> ResponseMetadata { + ResponseMetadata { + id: response.id.clone(), + timestamp: response + .created + .and_then(|secs| aimux_core::Timestamp::from_unix_seconds(secs as f64)), + model_id: response.model.clone(), + } +} diff --git a/aimux-providers/src/openai_compatible/completion/mod.rs b/aimux-providers/src/openai_compatible/completion/mod.rs new file mode 100644 index 000000000..f6f6eccf9 --- /dev/null +++ b/aimux-providers/src/openai_compatible/completion/mod.rs @@ -0,0 +1,10 @@ +//! The text-completion model (`src/completion/`). + +mod completion_language_model; +mod convert_completion_usage; +mod convert_to_completion_prompt; +mod get_response_metadata; + +#[cfg(test)] +pub(in crate::openai_compatible) use completion_language_model::CompletionConfig; +pub use completion_language_model::OpenAICompatibleCompletionModel; diff --git a/aimux-providers/src/openai_compatible/config.rs b/aimux-providers/src/openai_compatible/config.rs deleted file mode 100644 index ac671fb72..000000000 --- a/aimux-providers/src/openai_compatible/config.rs +++ /dev/null @@ -1,427 +0,0 @@ -//! The private per-model configuration of the OpenAI-compatible package. -//! -//! Like the native OpenAI config this is the Rust shape of the object -//! `createOpenAICompatible` hands each model class (`{ provider, url, headers, -//! fetch, ... }`). It has no getters for the credential or the settings that -//! produced it. Where the AI SDK passes `url` and `headers` functions, this -//! config holds data, or a package's own data behind [`ResolveBaseUrl`] / -//! [`ResolveFetchHeaders`]; the functions a caller or a vendor package passes -//! (`supportedUrls`, `convertUsage`, `errorStructure`, `metadataExtractor`, -//! `transformRequestBody`) are kept as given. The shared model never compares -//! a provider name. - -use futures::future::BoxFuture; -use std::collections::HashMap; -use std::sync::Arc; - -use aimux_core::types::{ProviderMetadata, Usage}; -use serde_json::Value; - -use aimux_core::AiMuxError; -use aimux_core::language_model::SupportedUrls; -use aimux_provider_utils::{ - ExchangeContext, Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse, HeaderMapOpt, - HttpRequest, ProviderErrorParts, combine_headers, normalize_headers, -}; - -use crate::preset::PresetDescriptor; -use crate::shared::ProviderHeaders; - -pub use crate::shared::TransformRequestBody; - -/// Converts the raw optional token usage of a chat response. -pub type ConvertUsage = Arc) -> Usage + Send + Sync>; - -/// Returns the URLs supported by chat models, read each time a chat model's -/// supported URLs are: `supportedUrls?: () => LanguageModelV4['supportedUrls']` -/// (openai-compatible@3.0.62 `openai-compatible-chat-language-model.ts:84`, -/// `:161-163`), whose value may be a `PromiseLike`. -pub type SupportedUrlsFn = Arc BoxFuture<'static, SupportedUrls> + Send + Sync>; - -/// Captures vendor-specific metadata from responses (the AI SDK's -/// `MetadataExtractor`). The returned map is merged into `provider_metadata` -/// next to the namespace entry. -pub trait MetadataExtractor: Send + Sync { - /// Metadata of a non-streaming response body. - fn extract_metadata(&self, parsed_body: &Value) -> Option; - - /// A fresh extractor for one streaming response. - fn create_stream_extractor(&self) -> Box; -} - -/// The per-stream half of [`MetadataExtractor`]. -pub trait StreamMetadataExtractor: Send { - /// Called with every parsed chunk (including the one carrying usage). - fn process_chunk(&mut self, parsed_chunk: &Value); - - /// Metadata merged into the finish part's `provider_metadata`. - fn build_metadata(&self) -> Option; -} - -/// A base URL a package builds from settings read when a request is made -/// (a Vertex AI partner sub-provider's project and location). -pub(crate) trait ResolveBaseUrl: std::fmt::Debug + Send + Sync { - /// The base URL of one request. - fn resolve(&self) -> Result; -} - -/// The headers a package adds in its own fetch wrapper -/// ([`CompatModelConfig::fetch_headers`]). -#[async_trait::async_trait] -pub(crate) trait ResolveFetchHeaders: Send + Sync { - /// The headers of one request. - async fn resolve(&self) -> Result; -} - -/// Where the base URL comes from. -/// -/// A plain provider has a [`Fixed`](Self::Fixed) URL. A preset whose URL is -/// read from the environment or expanded from template parameters is -/// [`Preset`](Self::Preset): it is evaluated per request, so creating the -/// provider (and the preset default instances) never reads the environment -/// and never fails. -#[derive(Clone, Debug)] -pub(crate) enum BaseUrl { - Fixed(String), - Preset { - descriptor: &'static PresetDescriptor, - /// The explicit template parameters of the settings. - params: Arc>, - }, - /// A package's own base URL, resolved when a request is made. - Dynamic(Arc), -} - -impl BaseUrl { - pub(crate) fn resolve(&self) -> Result { - match self { - Self::Fixed(url) => Ok(url.clone()), - Self::Preset { descriptor, params } => { - crate::preset::resolve_base_url(descriptor, params) - } - Self::Dynamic(resolver) => resolver.resolve(), - } - } -} - -/// Maps a provider error payload to the message and code of the API error -/// (the AI SDK's `errorStructure`). -pub(crate) type ErrorStructure = Arc ProviderErrorParts + Send + Sync>; - -/// `{ error: { message, type?, param?, code? } }`, the structure nearly every -/// compatible server answers errors with (the AI SDK's default -/// `errorStructure`). -pub(crate) fn default_error_structure(data: &Value) -> ProviderErrorParts { - let error = data.get("error").unwrap_or(data); - ProviderErrorParts { - message: error - .get("message") - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned(), - provider_code: error.get("code").or_else(|| error.get("type")).and_then( - |value| match value { - Value::String(value) => Some(value.clone()), - Value::Number(value) => Some(value.to_string()), - _ => None, - }, - ), - } -} - -/// The chat settings of a compatible endpoint: the AI SDK's -/// `includeUsage` / `supportsStructuredOutputs` / -/// `supportsMultiPartToolContent` / `supportedUrls` / `convertUsage` / -/// `metadataExtractor`, plus the preset capability data of RFC-0036 section -/// 5. -#[derive(Clone)] -pub(crate) struct ChatSettings { - pub include_usage: bool, - pub supports_structured_outputs: bool, - /// When set, only these chat models take structured outputs, whatever - /// `supports_structured_outputs` says. Ids compare exactly; an entry - /// ending in `*` matches every id with that prefix. A preset's - /// `profile.structured_output_models`, the vendor package's per-model - /// `supportsStructuredOutputs`. - pub structured_output_models: Option<&'static [String]>, - /// `supportsMultiPartToolContent`: a `content` tool output is sent as an - /// array of content parts instead of a JSON string. - pub supports_multi_part_tool_content: bool, - /// URL patterns supported by chat models, evaluated when requested. - /// `None`: none. - pub supported_urls: Option, - /// The vendor's or the caller's usage accounting. `None`: the generic - /// OpenAI-shaped usage (`convertOpenAICompatibleChatUsage`). - pub convert_usage: Option, - pub metadata_extractor: Option>, - /// Send `top_k`. The AI SDK baseline never does (it warns and drops it). - pub supports_top_k: bool, - /// Send `tools` / `tool_choice`. - pub supports_tools: bool, - /// Send `response_format`. - pub supports_response_format: bool, - /// The only `max-tokens` key the vendor accepts: `"max_tokens"` or - /// `"max_completion_tokens"`. - pub max_tokens_key: Option<&'static str>, - /// Streaming usage rides in `chunk[key].usage` instead of `chunk.usage`. - pub stream_usage_key: Option, - pub error_structure: ErrorStructure, -} - -impl Default for ChatSettings { - /// The AI SDK baseline. - fn default() -> Self { - Self { - include_usage: false, - supports_structured_outputs: false, - structured_output_models: None, - supports_multi_part_tool_content: false, - supported_urls: None, - convert_usage: None, - metadata_extractor: None, - supports_top_k: false, - supports_tools: true, - supports_response_format: true, - max_tokens_key: None, - stream_usage_key: None, - error_structure: Arc::new(default_error_structure), - } - } -} - -impl ChatSettings { - /// These settings for the chat model `model_id`. - pub(crate) fn for_model(mut self, model_id: &str) -> Self { - if let Some(models) = self.structured_output_models { - self.supports_structured_outputs = models.iter().any(|model| { - model - .strip_suffix('*') - .map_or(model == model_id, |prefix| model_id.starts_with(prefix)) - }); - } - self - } - - /// The URL patterns chat models fetch themselves. - pub(crate) async fn supported_urls(&self) -> SupportedUrls { - match &self.supported_urls { - Some(supported_urls) => supported_urls().await, - None => SupportedUrls::default(), - } - } - - /// The core usage of a chat response's raw `usage` object. - pub(crate) fn convert_usage(&self, raw: Option<&Value>) -> Usage { - match &self.convert_usage { - Some(convert) => convert(raw), - None => super::chat::usage_from_raw(raw), - } - } -} - -/// What a model needs to talk to the API. -#[derive(Clone)] -pub(crate) struct CompatModelConfig { - /// The identity the model reports: `"{name}.{method}"`. - pub provider: String, - pub base_url: BaseUrl, - /// Appended to every request URL, in this order. - pub query_params: Option>>, - /// Provider headers (credential, user headers), produced on every request. - pub headers: ProviderHeaders, - /// Headers layered over the per-call headers, resolved on every request - /// after `headers`: what an AI SDK package that builds on - /// `createOpenAICompatible` adds in its own fetch wrapper (google-vertex's - /// Node MaaS and xAI providers). Their `user-agent` replaces the whole - /// header, the helper layer's part included ([`FetchUserAgent`]). - pub fetch_headers: Option>, - /// Transport; `None` uses the process default, resolved per request. - pub fetch: Option, - pub transform_request_body: Option, - pub chat: ChatSettings, -} - -impl CompatModelConfig { - /// The providerOptions namespace of this provider: the first segment of - /// the provider string. - pub(crate) fn provider_options_name(&self) -> &str { - self.provider.split('.').next().unwrap_or_default().trim() - } - - /// The full URL of an endpoint path (with the query parameters) and the - /// origin credentialed headers may be sent to. - pub(crate) fn url(&self, path: &str) -> Result<(String, String), AiMuxError> { - let base = self.base_url.resolve()?; - let mut url = url::Url::parse(&format!("{base}{path}")).map_err(|e| { - AiMuxError::InvalidArgument(format!("invalid request URL for {}: {e}", self.provider)) - })?; - if let Some(params) = &self.query_params { - // As the AI SDK does: the configured parameters replace whatever - // query the base URL carried. - url.set_query(None); - if !params.is_empty() { - url.query_pairs_mut() - .extend_pairs(params.iter().map(|(k, v)| (k.as_str(), v.as_str()))); - } - } - Ok((url.to_string(), base)) - } - - /// Resolve the provider headers, layer the per-call headers over them, - /// then the fetch headers (case-insensitive, later wins, `None` removes), - /// and return the list that goes on the wire with the fetch headers' - /// `user-agent`. - pub(crate) async fn request_headers( - &self, - call_headers: Option<&HashMap>, - ) -> Result { - let provider = self.headers.resolve().await?; - let call: HeaderMapOpt = call_headers - .map(|headers| { - headers - .iter() - .map(|(name, value)| (name.clone(), Some(value.clone()))) - .collect() - }) - .unwrap_or_default(); - let fetch = match &self.fetch_headers { - Some(headers) => combine_headers(&[&headers.resolve().await?]), - None => HeaderMapOpt::new(), - }; - let fetch_user_agent = fetch.get("user-agent").cloned().flatten(); - Ok(RequestHeaders { - list: normalize_headers(combine_headers(&[&provider, &call, &fetch])), - fetch_user_agent, - }) - } - - /// An exchange for `path` that inherits the operation's cancellation and - /// recording context and carries this config's transport and credential - /// origin. - pub(crate) fn http_request( - &self, - path: &str, - headers: RequestHeaders, - options: &impl ExchangeContext, - ) -> Result { - let (url, origin) = self.url(path)?; - Ok(self.with_transport( - HttpRequest::new(url, headers.list, options), - origin, - headers.fetch_user_agent, - )) - } - - /// Attach the transport and the credential origin to a request. A fetch - /// headers' `user-agent` is sent as the whole header by the transport. - pub(crate) fn with_transport( - &self, - mut request: HttpRequest, - origin: String, - fetch_user_agent: Option, - ) -> HttpRequest { - request.fetch = match fetch_user_agent { - Some(user_agent) => Some(Arc::new(FetchUserAgent { - inner: self.fetch.clone(), - user_agent, - })), - None => self.fetch.clone(), - }; - request.credentialed_origin = Some(origin); - request - } - - /// Run the provider-level body rewrite, when there is one. - pub(crate) fn transform_body(&self, body: Value) -> Value { - match &self.transform_request_body { - Some(transform) => transform(body), - None => body, - } - } - - /// The failed-response handler of this provider's error structure. - pub(crate) fn failed_response_handler( - &self, - ) -> aimux_provider_utils::ResponseHandler { - let structure = self.chat.error_structure.clone(); - aimux_provider_utils::create_json_error_response_handler(move |data| structure(data)) - } -} - -/// The headers of one request ([`CompatModelConfig::request_headers`]). -pub(crate) struct RequestHeaders { - /// What goes on the wire, before the helper layer adds its - /// `ai-sdk-provider-utils` part. - pub list: Vec<(String, String)>, - /// The `user-agent` of the fetch headers, when they set one. - pub fetch_user_agent: Option, -} - -/// The transport of a request whose fetch headers set `user-agent`: it sends -/// that value as the whole header, over the one the helper layer and the -/// package transport built. google-vertex's Node wrappers spread their -/// headers over `init.headers` after `postToApi` added its part -/// (google-vertex-maas-provider-node.ts:43-49, -/// google-vertex-xai-provider-node.ts:41-47), so nothing of it is left. -struct FetchUserAgent { - /// The package transport (`None`: the process default). - inner: Option, - user_agent: String, -} - -#[async_trait::async_trait] -impl Fetch for FetchUserAgent { - async fn fetch(&self, mut request: FetchRequest) -> Result { - let value = reqwest::header::HeaderValue::try_from(self.user_agent.as_str()) - .map_err(|_| FetchError::Other("invalid header value for user-agent".to_string()))?; - request.headers.insert(reqwest::header::USER_AGENT, value); - self.inner - .clone() - .unwrap_or_else(aimux_provider_utils::default_fetch) - .fetch(request) - .await - } -} - -#[cfg(test)] -mod tests { - use super::*; - - static MODELS: std::sync::LazyLock> = - std::sync::LazyLock::new(|| vec!["qwen3.7-plus".to_string(), "kimi-k*".to_string()]); - - fn supports(model_id: &str) -> bool { - ChatSettings { - supports_structured_outputs: true, - structured_output_models: Some(&MODELS), - ..ChatSettings::default() - } - .for_model(model_id) - .supports_structured_outputs - } - - #[test] - fn a_model_list_matches_the_exact_id() { - assert!(supports("qwen3.7-plus")); - assert!(!supports("qwen3.7-plus-2026-09-01")); - assert!(!supports("qwen3.7-plusplus")); - assert!(!supports("qwen3.7")); - } - - #[test] - fn an_entry_ending_in_a_star_matches_a_prefix() { - assert!(supports("kimi-k")); - assert!(supports("kimi-k2.5")); - assert!(!supports("kimi-latest")); - assert!(!supports("kimi")); - } - - #[test] - fn without_a_model_list_the_flag_applies() { - let settings = ChatSettings { - supports_structured_outputs: true, - ..ChatSettings::default() - }; - assert!(settings.for_model("any").supports_structured_outputs); - } -} diff --git a/aimux-providers/src/openai_compatible/convert.rs b/aimux-providers/src/openai_compatible/convert.rs deleted file mode 100644 index c16fc641b..000000000 --- a/aimux-providers/src/openai_compatible/convert.rs +++ /dev/null @@ -1,777 +0,0 @@ -//! Call options and prompt to the OpenAI-compatible chat-completions body. -//! -//! The Rust form of `getArgs` and `convertToOpenAICompatibleChatMessages` of -//! `@ai-sdk/openai-compatible`. The body is built in the AI SDK's order (model, -//! `user`, sampling settings, `response_format`, `stop`, `seed`, the -//! pass-through fields of the provider's own options namespace, then -//! `reasoning_effort`, `verbosity`, `messages`, `tools`, `tool_choice`). -//! Compatible endpoint capabilities arrive as [`ChatSettings`] data. - -use aimux_core::shared::SharedProviderOptions; -use base64::Engine; -use serde_json::{Map, Value, json}; - -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, LanguageModelV4ToolChoice, ResponseFormat}; -use aimux_core::shared::{FileBytes, FileData}; -use aimux_core::tool::Tool; -use aimux_core::types::{FinishReason, FinishReasonUnified, Warning}; -use aimux_provider_utils::{get_top_level_media_type, resolve_full_media_type}; - -use super::config::ChatSettings; - -/// Option keys of the generic compatible schema -/// (`openaiCompatibleLanguageModelChatOptions`): consumed by the model, never -/// passed through. -const SCHEMA_KEYS: [&str; 4] = [ - "user", - "reasoningEffort", - "textVerbosity", - "strictJsonSchema", -]; - -/// `tools` / `tool_choice` ready for the body, with the warnings raised while -/// preparing them. -#[derive(Debug, Clone, Default)] -pub(crate) struct PreparedTools { - pub tools: Option>, - pub tool_choice: Option, - pub warnings: Vec, -} - -/// The wire form of a function tool. -pub(crate) fn function_tool_value(tool: &aimux_core::tool::FunctionTool) -> Value { - let mut function = Map::new(); - function.insert("name".into(), json!(tool.name)); - if let Some(description) = &tool.description { - function.insert("description".into(), json!(description)); - } - function.insert("parameters".into(), tool.input_schema.clone()); - if let Some(strict) = tool.strict { - function.insert("strict".into(), json!(strict)); - } - json!({ "type": "function", "function": function }) -} - -/// The wire form of the tool choice. -pub(crate) fn tool_choice_value(choice: &LanguageModelV4ToolChoice) -> Value { - match choice { - LanguageModelV4ToolChoice::Auto => json!("auto"), - LanguageModelV4ToolChoice::None => json!("none"), - LanguageModelV4ToolChoice::Required => json!("required"), - LanguageModelV4ToolChoice::Tool { tool_name } => { - json!({ "type": "function", "function": { "name": tool_name } }) - } - } -} - -/// Function tools only; a provider-defined tool is unsupported by the generic -/// endpoint and warned about (the AI SDK's `prepareTools`). -pub(crate) fn prepare_function_tools(options: &CallOptions) -> PreparedTools { - let mut prepared = PreparedTools::default(); - let Some(tools) = options.tools.as_ref().filter(|tools| !tools.is_empty()) else { - return prepared; - }; - let mut wire = Vec::new(); - for tool in tools { - match tool { - Tool::Function(function) => wire.push(function_tool_value(function)), - Tool::Provider(provider) => prepared.warnings.push(Warning::Unsupported { - feature: format!("provider-defined tool {}", provider.id), - details: None, - }), - } - } - prepared.tools = Some(wire); - prepared.tool_choice = options.tool_choice.as_ref().map(tool_choice_value); - prepared -} - -/// `snake_case` / `kebab-case` provider name to `camelCase` (the AI SDK's -/// `toCamelCase`). -#[must_use] -pub(crate) fn to_camel_case(name: &str) -> String { - let mut out = String::with_capacity(name.len()); - let mut chars = name.chars().peekable(); - while let Some(ch) = chars.next() { - match chars.peek() { - Some(next) if (ch == '_' || ch == '-') && next.is_ascii_lowercase() => { - out.push(next.to_ascii_uppercase()); - chars.next(); - } - _ => out.push(ch), - } - } - out -} - -/// What the converter needs to know about the provider it builds for. -pub(crate) struct ChatBodySpec<'a> { - /// First segment of the provider string: the providerOptions namespace. - pub provider_options_name: &'a str, - pub chat: &'a ChatSettings, -} - -/// A request body and what building it produced. -#[derive(Debug, Clone)] -pub(crate) struct RequestBodyResult { - /// The JSON body, before the provider's `transform_request_body`. - pub body: Value, - /// Warnings raised while building it. - pub warnings: Vec, - /// The key provider metadata is reported under. - pub metadata_key: String, -} - -fn namespace<'a>(options: &'a CallOptions, key: &str) -> Option<&'a Map> { - options - .provider_options - .as_ref() - .and_then(|all| all.get(key)) -} - -/// The key provider metadata is reported under (`resolveProviderOptionsKey`): -/// the camelCase name when supplied, otherwise the raw provider name. -fn resolve_metadata_key(name: &str, options: &CallOptions) -> String { - let camel = to_camel_case(name); - if camel != name && namespace(options, &camel).is_some() { - camel - } else { - name.to_string() - } -} - -fn option_string( - merged: &Map, - namespace_name: &str, - key: &str, -) -> Result, AiMuxError> { - match merged.get(key) { - None => Ok(None), - Some(Value::String(value)) => Ok(Some(value.clone())), - Some(_) => Err(AiMuxError::InvalidArgument(format!( - "invalid provider options for \"{namespace_name}\": `{key}` must be a string" - ))), - } -} - -fn validate_options(options: &Map, namespace_name: &str) -> Result<(), AiMuxError> { - for key in ["user", "reasoningEffort", "textVerbosity"] { - option_string(options, namespace_name, key)?; - } - if options - .get("strictJsonSchema") - .is_some_and(|value| !value.is_boolean()) - { - return Err(AiMuxError::InvalidArgument(format!( - "invalid provider options for \"{namespace_name}\": `strictJsonSchema` must be a boolean" - ))); - } - Ok(()) -} - -/// Build the request body (`getArgs`). -/// -/// # Errors -/// -/// `InvalidArgument` for a malformed provider option or a prompt part that has -/// no wire form. -pub(crate) fn build_request_body( - model_id: &str, - options: &CallOptions, - stream: bool, - spec: &ChatBodySpec<'_>, -) -> Result { - let name = spec.provider_options_name; - let camel = to_camel_case(name); - let chat = spec.chat; - let mut warnings = Vec::new(); - - // Resolved provider options: the generic namespace, then the provider's - // own, then its camelCase form; later wins per key. - let mut merged = Map::new(); - if let Some(deprecated) = namespace(options, "openai-compatible") { - validate_options(deprecated, "openai-compatible")?; - warnings.push(Warning::Deprecated { - setting: "providerOptions key 'openai-compatible'".to_string(), - message: "Use 'openaiCompatible' instead.".to_string(), - }); - merged.extend(deprecated.iter().map(|(k, v)| (k.clone(), v.clone()))); - } - for key in ["openaiCompatible", name, camel.as_str()] { - if let Some(object) = namespace(options, key) { - validate_options(object, key)?; - merged.extend(object.iter().map(|(k, v)| (k.clone(), v.clone()))); - } - } - if camel != name && namespace(options, name).is_some() { - warnings.push(Warning::Deprecated { - setting: format!("providerOptions key '{name}'"), - message: format!("Use '{camel}' instead."), - }); - } - - let user = option_string(&merged, name, "user")?; - let reasoning_effort = option_string(&merged, name, "reasoningEffort")?.or_else(|| { - options - .reasoning - .filter(|effort| effort.is_custom()) - .map(|effort| effort.to_string()) - }); - let verbosity = option_string(&merged, name, "textVerbosity")?; - let strict_json_schema = merged - .get("strictJsonSchema") - .and_then(Value::as_bool) - .unwrap_or(true); - - let metadata_key = resolve_metadata_key(name, options); - let mut body = Map::new(); - body.insert("model".into(), json!(model_id)); - if let Some(user) = user { - body.insert("user".into(), json!(user)); - } - if let Some(max_tokens) = options.max_output_tokens { - body.insert( - chat.max_tokens_key.unwrap_or("max_tokens").into(), - json!(max_tokens), - ); - } - for (key, value) in [ - ("temperature", options.temperature), - ("top_p", options.top_p), - ("frequency_penalty", options.frequency_penalty), - ("presence_penalty", options.presence_penalty), - ] { - if let Some(value) = value { - body.insert(key.into(), json!(value)); - } - } - if let Some(top_k) = options.top_k { - if chat.supports_top_k { - body.insert("top_k".into(), json!(top_k)); - } else { - warnings.push(Warning::Unsupported { - feature: "topK".to_string(), - details: None, - }); - } - } - - match &options.response_format { - Some(ResponseFormat::Json { - schema, - name: format_name, - description, - }) if chat.supports_response_format => match schema { - Some(schema) if chat.supports_structured_outputs => { - let mut json_schema = Map::new(); - json_schema.insert("schema".into(), schema.clone()); - json_schema.insert("strict".into(), json!(strict_json_schema)); - json_schema.insert( - "name".into(), - json!(format_name.clone().unwrap_or_else(|| "response".into())), - ); - if let Some(description) = description { - json_schema.insert("description".into(), json!(description)); - } - body.insert( - "response_format".into(), - json!({ "type": "json_schema", "json_schema": json_schema }), - ); - } - Some(_) => { - warnings.push(Warning::Unsupported { - feature: "responseFormat".to_string(), - details: Some( - "JSON response format schema is only supported with structuredOutputs" - .to_string(), - ), - }); - body.insert("response_format".into(), json!({ "type": "json_object" })); - } - None => { - body.insert("response_format".into(), json!({ "type": "json_object" })); - } - }, - Some(ResponseFormat::Json { .. }) => warnings.push(Warning::Unsupported { - feature: "responseFormat".to_string(), - details: Some("response_format is not supported by this provider".to_string()), - }), - Some(ResponseFormat::Text) | None => {} - } - - if let Some(stop) = &options.stop_sequences { - body.insert("stop".into(), json!(stop)); - } - if let Some(seed) = options.seed { - body.insert("seed".into(), json!(seed)); - } - - // Fields of the provider's own namespace that the schema does not know go - // to the body as given (the generic `openaiCompatible` namespace is - // schema-only: unknown fields there are dropped). - for key in [name, camel.as_str()] { - if let Some(object) = namespace(options, key) { - for (field, value) in object { - if !SCHEMA_KEYS.contains(&field.as_str()) { - body.insert(field.clone(), value.clone()); - } - } - } - } - - if let Some(effort) = reasoning_effort { - body.insert("reasoning_effort".into(), json!(effort)); - } - if let Some(verbosity) = verbosity { - body.insert("verbosity".into(), json!(verbosity)); - } - - let messages = convert_messages( - &options.prompt, - &MessageSpec { - metadata_key: &metadata_key, - supports_multi_part_tool_content: chat.supports_multi_part_tool_content, - }, - )?; - body.insert("messages".into(), Value::Array(messages)); - - if chat.supports_tools { - let prepared = prepare_function_tools(options); - warnings.extend(prepared.warnings); - if let Some(tools) = prepared.tools { - body.insert("tools".into(), Value::Array(tools)); - } - if let Some(choice) = prepared.tool_choice { - body.insert("tool_choice".into(), choice); - } - } else if options - .tools - .as_ref() - .is_some_and(|tools| !tools.is_empty()) - { - warnings.push(Warning::Unsupported { - feature: "tools".to_string(), - details: Some("tools are not supported by this provider".to_string()), - }); - } - - if stream { - body.insert("stream".into(), json!(true)); - if chat.include_usage { - body.insert("stream_options".into(), json!({ "include_usage": true })); - } - } - - Ok(RequestBodyResult { - body: Value::Object(body), - warnings, - metadata_key, - }) -} - -// ── Messages ──────────────────────────────────────────────────────────────── - -pub(crate) struct MessageSpec<'a> { - pub metadata_key: &'a str, - /// Send a `content` tool output as content parts, not a JSON string. - pub supports_multi_part_tool_content: bool, -} - -/// The generic `openaiCompatible` metadata of a message or part -/// (`getOpenAIMetadata`). -fn wire_metadata(provider_options: Option<&SharedProviderOptions>) -> Map { - provider_options - .and_then(|options| options.get("openaiCompatible")) - .cloned() - .unwrap_or_default() -} - -fn with_metadata(mut object: Value, metadata: Map) -> Value { - if let Some(map) = object.as_object_mut() { - map.extend(metadata); - } - object -} - -/// `messages` for the body. -/// -/// # Errors -/// -/// `InvalidArgument` when a part has no wire form (an unsupported media type, -/// an audio or PDF part given by URL). -pub(crate) fn convert_messages( - prompt: &LanguageModelPrompt, - spec: &MessageSpec<'_>, -) -> Result, AiMuxError> { - let mut out = Vec::new(); - for message in prompt { - out.extend(convert_message(message, spec)?); - } - Ok(out) -} - -fn convert_message( - message: &LanguageModelMessage, - spec: &MessageSpec<'_>, -) -> Result, AiMuxError> { - match message { - LanguageModelMessage::System { - content, - provider_options, - } => Ok(vec![with_metadata( - json!({ "role": "system", "content": content }), - wire_metadata(provider_options.as_ref()), - )]), - LanguageModelMessage::User { - content, - provider_options, - } => Ok(vec![convert_user_message( - content, - wire_metadata(provider_options.as_ref()), - )?]), - LanguageModelMessage::Assistant { - content, - provider_options, - } => Ok(vec![convert_assistant_message( - content, - spec, - wire_metadata(provider_options.as_ref()), - )]), - LanguageModelMessage::Tool { content, .. } => content - .iter() - .filter_map(|part| match part { - ToolPart::ToolResult(ToolResultPart { - tool_call_id, - output, - provider_options, - .. - }) => Some((tool_call_id, output, provider_options)), - _ => None, - }) - .map(|(tool_call_id, output, provider_options)| { - // convert-to-openai-compatible-chat-messages.ts:284-321: a - // `content` output is an array of content parts only when - // the provider supports multi-part tool content. - let content = match output { - ToolResultOutput::Content { value } - if spec.supports_multi_part_tool_content => - { - Value::Array( - value - .iter() - .map(convert_tool_content_part) - .collect::>()?, - ) - } - output => crate::openai::convert::tool_result_to_content(output), - }; - Ok(with_metadata( - json!({ - "role": "tool", - "tool_call_id": tool_call_id, - "content": content, - }), - wire_metadata(provider_options.as_ref()), - )) - }) - .collect(), - } -} - -/// One part of a multi-part tool result (`convertToolContentPart`, -/// convert-to-openai-compatible-chat-messages.ts:162-176): a text or file part -/// is converted like a user content part; a custom part has no wire form. -fn convert_tool_content_part(part: &ToolResultContent) -> Result { - match part { - ToolResultContent::Text(text) => Ok(convert_text_part(text)), - ToolResultContent::File(file) => convert_file_part(file), - ToolResultContent::Custom { .. } => Err(AiMuxError::UnsupportedFunctionality( - "custom tool content parts".to_string(), - )), - } -} - -fn convert_user_message( - content: &[UserPart], - message_metadata: Map, -) -> Result { - if let [ - UserPart::Text(TextPart { - text, - provider_options, - }), - ] = content - { - let metadata = wire_metadata(provider_options.as_ref()); - return Ok(with_metadata( - json!({ "role": "user", "content": text }), - metadata, - )); - } - let parts = content - .iter() - .map(convert_user_part) - .collect::, _>>()?; - Ok(with_metadata( - json!({ "role": "user", "content": parts }), - message_metadata, - )) -} - -fn convert_user_part(part: &UserPart) -> Result { - match part { - UserPart::Text(text) => Ok(convert_text_part(text)), - UserPart::File(file) => convert_file_part(file), - } -} - -/// A text content part (`convertToOpenAICompatibleContentPart`). -fn convert_text_part(part: &TextPart) -> Value { - with_metadata( - json!({ "type": "text", "text": part.text }), - wire_metadata(part.provider_options.as_ref()), - ) -} - -/// A file content part (`convertToOpenAICompatibleContentPart`). -fn convert_file_part(file: &FilePart) -> Result { - let wire = match &file.data { - FileData::Data { - data: FileBytes::Binary(data), - } => file_part( - file, - FileSource::Base64(&base64::engine::general_purpose::STANDARD.encode(data)), - )?, - FileData::Data { - data: FileBytes::Base64(data), - } => file_part(file, FileSource::Base64(data))?, - FileData::Url { url, .. } => file_part(file, FileSource::Url(url))?, - FileData::Reference { .. } => { - return Err(AiMuxError::UnsupportedFunctionality( - "file parts with provider references".to_string(), - )); - } - FileData::Text { .. } => { - return Err(AiMuxError::UnsupportedFunctionality( - "text file parts".to_string(), - )); - } - }; - Ok(with_metadata( - wire, - wire_metadata(file.provider_options.as_ref()), - )) -} - -enum FileSource<'a> { - Base64(&'a str), - Url(&'a str), -} - -fn file_part(part: &FilePart, source: FileSource<'_>) -> Result { - let media_type = part.media_type.as_str(); - let filename = part.filename.as_deref(); - let unsupported = |what: String| AiMuxError::UnsupportedFunctionality(what); - - match get_top_level_media_type(media_type) { - kind @ ("image" | "video") => { - let url = match source { - FileSource::Url(url) => url.to_string(), - FileSource::Base64(b64) => { - format!("data:{};base64,{}", resolve_full_media_type(part)?, b64) - } - }; - let kind = format!("{kind}_url"); - Ok(json!({ "type": kind, kind: { "url": url } })) - } - "audio" => { - let FileSource::Base64(b64) = source else { - return Err(unsupported("audio file parts with URLs".to_string())); - }; - let full_media_type = resolve_full_media_type(part)?; - let format = match full_media_type.as_str() { - "audio/wav" => "wav", - "audio/mp3" | "audio/mpeg" => "mp3", - other => { - return Err(unsupported(format!("audio media type {other}"))); - } - }; - Ok(json!({ "type": "input_audio", "input_audio": { "data": b64, "format": format } })) - } - "application" => { - let FileSource::Base64(b64) = source else { - return Err(unsupported("PDF file parts with URLs".to_string())); - }; - let full_media_type = resolve_full_media_type(part)?; - if full_media_type != "application/pdf" { - return Err(unsupported(format!( - "file part media type {full_media_type}" - ))); - } - let filename = filename.unwrap_or("document.pdf"); - Ok(json!({ - "type": "file", - "file": { - "filename": filename, - "file_data": format!("data:application/pdf;base64,{b64}"), - } - })) - } - "text" => { - let b64 = match source { - FileSource::Url(url) => return Ok(json!({ "type": "text", "text": url })), - FileSource::Base64(b64) => b64, - }; - let bytes = base64::engine::general_purpose::STANDARD - .decode(b64) - .map_err(|e| unsupported(format!("text file part is not valid base64: {e}")))?; - let text = String::from_utf8_lossy(&bytes); - Ok(json!({ "type": "text", "text": text })) - } - _ => Err(unsupported(format!("file part media type {media_type}"))), - } -} - -fn convert_assistant_message( - content: &[AssistantPart], - spec: &MessageSpec<'_>, - message_metadata: Map, -) -> Value { - let key = spec.metadata_key; - let mut text = String::new(); - let mut reasoning = String::new(); - let mut tool_calls = Vec::new(); - let metadata = message_metadata; - for part in content { - match part { - AssistantPart::Text(TextPart { text: t, .. }) => text.push_str(t), - AssistantPart::Reasoning(ReasoningPart { text: t, .. }) => reasoning.push_str(t), - AssistantPart::ToolCall(ToolCallPart { - tool_call_id, - tool_name, - input, - provider_options, - .. - }) => { - let arguments = input.to_string(); - let mut call = json!({ - "id": tool_call_id, - "type": "function", - "function": { "name": tool_name, "arguments": arguments }, - }); - call = with_metadata(call, wire_metadata(provider_options.as_ref())); - let signature = provider_options.as_ref().and_then(|options| { - options - .get(key) - .and_then(|options| options.get("thoughtSignature")) - .filter(|value| !value.is_null()) - .or_else(|| { - options - .get("google") - .and_then(|options| options.get("thoughtSignature")) - }) - }); - let signature = signature.and_then(|value| match value { - Value::Null | Value::Bool(false) => None, - Value::String(text) if text.is_empty() => None, - Value::Number(number) if number.as_f64() == Some(0.0) => None, - Value::String(text) => Some(text.clone()), - other => Some(signature_string(other)), - }); - if let Some(signature) = signature { - call["extra_content"] = json!({ "google": { "thought_signature": signature } }); - } - tool_calls.push(call); - } - // Provider-executed results and media have no assistant-content - // form on this wire. - _ => {} - } - } - let mut wire = Map::new(); - wire.insert("role".into(), json!("assistant")); - wire.insert( - "content".into(), - if tool_calls.is_empty() || !text.is_empty() { - json!(text) - } else { - Value::Null - }, - ); - if !reasoning.is_empty() { - wire.insert("reasoning_content".into(), json!(reasoning)); - } - if !tool_calls.is_empty() { - wire.insert("tool_calls".into(), Value::Array(tool_calls)); - } - wire.extend(metadata); - Value::Object(wire) -} - -/// Map an OpenAI finish reason string to the unified one, keeping the raw. -#[must_use] -pub(crate) fn parse_finish_reason(raw: &str) -> FinishReason { - let unified = match raw { - "stop" => FinishReasonUnified::Stop, - "length" => FinishReasonUnified::Length, - "tool_calls" | "function_call" => FinishReasonUnified::ToolCalls, - "content_filter" => FinishReasonUnified::ContentFilter, - _ => FinishReasonUnified::Other, - }; - FinishReason { - unified, - raw: Some(raw.to_string()), - } -} - -// String(value), as used by the compatible package for thought signatures. -fn signature_string(value: &Value) -> String { - match value { - Value::String(value) => value.clone(), - Value::Object(_) => "[object Object]".into(), - Value::Array(values) => values - .iter() - .map(|value| { - if value.is_null() { - String::new() - } else { - signature_string(value) - } - }) - .collect::>() - .join(","), - Value::Number(value) => value.as_f64().map_or_else( - || value.to_string(), - |value| { - if value == 0.0 { - "0".into() - } else { - value.to_string() - } - }, - ), - _ => value.to_string(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn camel_case_follows_the_ai_sdk() { - assert_eq!(to_camel_case("acme"), "acme"); - assert_eq!(to_camel_case("my_provider"), "myProvider"); - assert_eq!(to_camel_case("my-provider"), "myProvider"); - assert_eq!( - to_camel_case("vertex_ai_openai_models"), - "vertexAiOpenaiModels" - ); - assert_eq!(to_camel_case("a_1"), "a_1"); - } -} diff --git a/aimux-providers/src/openai_compatible/embedding.rs b/aimux-providers/src/openai_compatible/embedding/embedding_model.rs similarity index 67% rename from aimux-providers/src/openai_compatible/embedding.rs rename to aimux-providers/src/openai_compatible/embedding/embedding_model.rs index 410202c49..dabba724e 100644 --- a/aimux-providers/src/openai_compatible/embedding.rs +++ b/aimux-providers/src/openai_compatible/embedding/embedding_model.rs @@ -1,26 +1,25 @@ -//! The OpenAI-compatible embedding model (`{name}.embedding`). +//! The OpenAI-compatible embedding model (`{name}.embedding`, +//! `openai-compatible-embedding-model.ts`). //! //! The Rust form of `OpenAICompatibleEmbeddingModel`: `POST {base_url}/embeddings` //! with `encoding_format: "float"`, the `dimensions` and `user` options read //! from the compatible namespaces and then the provider's own. -use async_trait::async_trait; -use serde_json::{Map, Value, json}; - use aimux_core::embedding_model::{ EmbeddingCallOptions, EmbeddingModel, EmbeddingResponse, EmbeddingResult, EmbeddingUsage, }; use aimux_core::error::AiMuxError; +use async_trait::async_trait; +use serde_json::{Map, Value, json}; -use aimux_core::shared::Warning; +use crate::openai_compatible::CompatModelConfig; -use super::config::CompatModelConfig; -use super::convert::to_camel_case; +use super::embedding_model_options::embedding_options; /// An OpenAI-compatible embedding model. pub struct OpenAICompatibleEmbeddingModel { model_id: String, - pub(super) config: CompatModelConfig, + pub(in crate::openai_compatible) config: CompatModelConfig, } impl OpenAICompatibleEmbeddingModel { @@ -29,60 +28,6 @@ impl OpenAICompatibleEmbeddingModel { } } -/// Validate each namespace before merging, as upstream's provider-options schema does. -fn embedding_options( - options: &EmbeddingCallOptions, - name: &str, - warnings: &mut Vec, -) -> Result, AiMuxError> { - let mut merged = Map::new(); - for key in ["openai-compatible", "openaiCompatible", name] { - let Some(object) = options - .provider_options - .as_ref() - .and_then(|all| all.get(key)) - else { - continue; - }; - for (setting, valid) in [ - ("dimensions", Value::is_number as fn(&Value) -> bool), - ("user", Value::is_string), - ] { - if let Some(value) = object.get(setting) { - if !valid(value) { - return Err(AiMuxError::InvalidArgument(format!( - "invalid providerOptions.{key}.{setting}" - ))); - } - merged.insert(setting.to_string(), value.clone()); - } - } - } - if options - .provider_options - .as_ref() - .is_some_and(|all| all.contains_key("openai-compatible")) - { - warnings.push(Warning::Deprecated { - setting: "providerOptions key 'openai-compatible'".into(), - message: "Use 'openaiCompatible' instead.".into(), - }); - } - let camel = to_camel_case(name); - if camel != name - && options - .provider_options - .as_ref() - .is_some_and(|all| all.contains_key(name)) - { - warnings.push(Warning::Deprecated { - setting: format!("providerOptions key '{name}'"), - message: format!("Use '{camel}' instead."), - }); - } - Ok(merged) -} - #[async_trait] impl EmbeddingModel for OpenAICompatibleEmbeddingModel { fn provider(&self) -> &str { diff --git a/aimux-providers/src/openai_compatible/embedding/embedding_model_options.rs b/aimux-providers/src/openai_compatible/embedding/embedding_model_options.rs new file mode 100644 index 000000000..2cde4df13 --- /dev/null +++ b/aimux-providers/src/openai_compatible/embedding/embedding_model_options.rs @@ -0,0 +1,63 @@ +//! The provider options of the embedding model +//! (`openai-compatible-embedding-model-options.ts`). + +use aimux_core::embedding_model::EmbeddingCallOptions; +use aimux_core::error::AiMuxError; +use aimux_core::shared::Warning; +use serde_json::{Map, Value}; + +use crate::openai_compatible::utils::to_camel_case; + +/// Validate each namespace before merging, as upstream's provider-options schema does. +pub(super) fn embedding_options( + options: &EmbeddingCallOptions, + name: &str, + warnings: &mut Vec, +) -> Result, AiMuxError> { + let mut merged = Map::new(); + for key in ["openai-compatible", "openaiCompatible", name] { + let Some(object) = options + .provider_options + .as_ref() + .and_then(|all| all.get(key)) + else { + continue; + }; + for (setting, valid) in [ + ("dimensions", Value::is_number as fn(&Value) -> bool), + ("user", Value::is_string), + ] { + if let Some(value) = object.get(setting) { + if !valid(value) { + return Err(AiMuxError::InvalidArgument(format!( + "invalid providerOptions.{key}.{setting}" + ))); + } + merged.insert(setting.to_string(), value.clone()); + } + } + } + if options + .provider_options + .as_ref() + .is_some_and(|all| all.contains_key("openai-compatible")) + { + warnings.push(Warning::Deprecated { + setting: "providerOptions key 'openai-compatible'".into(), + message: "Use 'openaiCompatible' instead.".into(), + }); + } + let camel = to_camel_case(name); + if camel != name + && options + .provider_options + .as_ref() + .is_some_and(|all| all.contains_key(name)) + { + warnings.push(Warning::Deprecated { + setting: format!("providerOptions key '{name}'"), + message: format!("Use '{camel}' instead."), + }); + } + Ok(merged) +} diff --git a/aimux-providers/src/openai_compatible/embedding/mod.rs b/aimux-providers/src/openai_compatible/embedding/mod.rs new file mode 100644 index 000000000..92be76d6c --- /dev/null +++ b/aimux-providers/src/openai_compatible/embedding/mod.rs @@ -0,0 +1,6 @@ +//! The embedding model (`src/embedding/`). + +mod embedding_model; +mod embedding_model_options; + +pub use embedding_model::OpenAICompatibleEmbeddingModel; diff --git a/aimux-providers/src/openai_compatible/error.rs b/aimux-providers/src/openai_compatible/error.rs new file mode 100644 index 000000000..4aedf1365 --- /dev/null +++ b/aimux-providers/src/openai_compatible/error.rs @@ -0,0 +1,31 @@ +//! The error structure of compatible servers (`openai-compatible-error.ts`). + +use std::sync::Arc; + +use aimux_provider_utils::ProviderErrorParts; +use serde_json::Value; + +/// Maps a provider error payload to the message and code of the API error +/// (the AI SDK's `errorStructure`). +pub(crate) type ErrorStructure = Arc ProviderErrorParts + Send + Sync>; + +/// `{ error: { message, type?, param?, code? } }`, the structure nearly every +/// compatible server answers errors with (the AI SDK's default +/// `errorStructure`). +pub(crate) fn default_error_structure(data: &Value) -> ProviderErrorParts { + let error = data.get("error").unwrap_or(data); + ProviderErrorParts { + message: error + .get("message") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + provider_code: error.get("code").or_else(|| error.get("type")).and_then( + |value| match value { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + _ => None, + }, + ), + } +} diff --git a/aimux-providers/src/openai_compatible/image.rs b/aimux-providers/src/openai_compatible/image/image_model.rs similarity index 96% rename from aimux-providers/src/openai_compatible/image.rs rename to aimux-providers/src/openai_compatible/image/image_model.rs index 0b2618fd3..e267d21ea 100644 --- a/aimux-providers/src/openai_compatible/image.rs +++ b/aimux-providers/src/openai_compatible/image/image_model.rs @@ -1,4 +1,5 @@ -//! The OpenAI-compatible image model (`{name}.image`). +//! The OpenAI-compatible image model (`{name}.image`, +//! `openai-compatible-image-model.ts`). //! //! The Rust form of `OpenAICompatibleImageModel`: //! @@ -7,9 +8,6 @@ //! //! Provider-name options are forwarded to the endpoint as given. -use async_trait::async_trait; -use serde_json::{Map, Value, json}; - use aimux_core::error::AiMuxError; use aimux_core::image_model::{ ImageCallOptions, ImageFile, ImageFileData, ImageModel, ImageOutputs, ImageResponse, @@ -17,14 +15,16 @@ use aimux_core::image_model::{ }; use aimux_core::shared::Warning; use aimux_provider_utils::{HttpBody, MultipartForm}; +use async_trait::async_trait; +use serde_json::{Map, Value, json}; -use super::config::CompatModelConfig; -use super::convert::to_camel_case; +use crate::openai_compatible::CompatModelConfig; +use crate::openai_compatible::utils::to_camel_case; /// An OpenAI-compatible image generation / editing model. pub struct OpenAICompatibleImageModel { model_id: String, - pub(super) config: CompatModelConfig, + pub(in crate::openai_compatible) config: CompatModelConfig, } impl OpenAICompatibleImageModel { diff --git a/aimux-providers/src/openai_compatible/image/mod.rs b/aimux-providers/src/openai_compatible/image/mod.rs new file mode 100644 index 000000000..0a4ab421b --- /dev/null +++ b/aimux-providers/src/openai_compatible/image/mod.rs @@ -0,0 +1,5 @@ +//! The image model (`src/image/`). + +mod image_model; + +pub use image_model::OpenAICompatibleImageModel; diff --git a/aimux-providers/src/openai_compatible/mod.rs b/aimux-providers/src/openai_compatible/mod.rs index 07bac0fae..4e1d9a792 100644 --- a/aimux-providers/src/openai_compatible/mod.rs +++ b/aimux-providers/src/openai_compatible/mod.rs @@ -27,342 +27,29 @@ pub(crate) mod chat; pub mod completion; -pub(crate) mod config; -pub(crate) mod convert; pub mod embedding; +mod error; pub mod image; -mod types; +mod provider; +mod utils; -pub use chat::OpenAICompatibleChatModel; -pub use completion::OpenAICompatibleCompletionModel; -pub use config::{ - ConvertUsage, MetadataExtractor, StreamMetadataExtractor, SupportedUrlsFn, TransformRequestBody, +pub use chat::{ + ConvertUsage, MetadataExtractor, OpenAICompatibleChatModel, StreamMetadataExtractor, + SupportedUrlsFn, }; +pub use completion::OpenAICompatibleCompletionModel; pub use embedding::OpenAICompatibleEmbeddingModel; pub use image::OpenAICompatibleImageModel; +pub use provider::{ + OpenAICompatibleProvider, OpenAICompatibleProviderSettings, create_openai_compatible, +}; -use std::collections::HashMap; -use std::sync::Arc; - -use futures::future::BoxFuture; - -use aimux_core::embedding_model::EmbeddingModel; -use aimux_core::error::AiMuxError; -use aimux_core::image_model::ImageModel; -use aimux_core::language_model::LanguageModel; -use aimux_core::model_catalogue::RuntimeModel; -use aimux_core::provider::{Provider, ProviderDiscovery}; -use aimux_provider_utils::{FetchFunction, HeaderMapOpt, Resolvable, validate_base_url}; - -use crate::shared::aisdk_pins::Package; -use crate::shared::{AuthScheme, Credential, ProviderHeaders}; -use config::{BaseUrl, ChatSettings, CompatModelConfig}; - -/// Settings of [`create_openai_compatible`] (the AI SDK's -/// `OpenAICompatibleProviderSettings`). -#[derive(Clone, Default)] -pub struct OpenAICompatibleProviderSettings { - /// The provider name: the first segment of every model's `provider()` - /// string and the providerOptions namespace. Preserved as supplied. - pub name: String, - /// Base URL for the API calls. Required, `http(s)` with a host; a trailing - /// slash is removed. - pub base_url: String, - /// The API key. `None` or `""` sends no `Authorization` header; - /// a non-empty value is sent as `Bearer `. - pub api_key: Option, - /// Extra headers on every request. A `None` value removes the header, - /// including the `Authorization` one. Per-call headers win over these. - pub headers: Option, - /// Query parameters appended to every request URL (sorted by name). - pub query_params: Option>, - /// The transport: a mock, a signing decorator, a proxy-aware client. - /// `None` uses the process default, resolved per request. - pub fetch: Option, - /// Ask streaming responses to include usage (`stream_options.include_usage`). - pub include_usage: Option, - /// Whether the chat endpoint accepts `json_schema` response formats; when - /// not, a schema degrades to `json_object` with a warning. - pub supports_structured_outputs: Option, - /// Whether chat tool results may carry multi-part content: a `content` - /// tool output is then sent as an array of content parts instead of a - /// JSON string. Default `false`. - pub supports_multi_part_tool_content: Option, - /// Rewrites each chat JSON request body once, after it is serialized and - /// before it is sent. - pub transform_request_body: Option, - /// Extracts metadata from chat responses and streaming chunks. - pub metadata_extractor: Option>, - /// URL patterns supported by chat models, evaluated when requested. - pub supported_urls: Option, - /// Custom chat token accounting. - pub convert_usage: Option, -} - -impl std::fmt::Debug for OpenAICompatibleProviderSettings { - /// Never prints the key or header values. - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("OpenAICompatibleProviderSettings") - .field("name", &self.name) - .field("base_url", &self.base_url) - .field("api_key", &self.api_key.is_some()) - .field( - "headers", - &self.headers.as_ref().map(std::collections::HashMap::len), - ) - .field("query_params", &self.query_params) - .field("fetch", &self.fetch.is_some()) - .field("include_usage", &self.include_usage) - .field( - "supports_structured_outputs", - &self.supports_structured_outputs, - ) - .field( - "supports_multi_part_tool_content", - &self.supports_multi_part_tool_content, - ) - .field( - "transform_request_body", - &self.transform_request_body.is_some(), - ) - .field("metadata_extractor", &self.metadata_extractor.is_some()) - .field("supported_urls", &self.supported_urls.is_some()) - .field("convert_usage", &self.convert_usage.is_some()) - .finish() - } -} - -/// Create an OpenAI-compatible provider. -/// -/// # Errors -/// -/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` -/// URL with a host. The key is not read -/// here. -pub fn create_openai_compatible( - settings: OpenAICompatibleProviderSettings, -) -> Result { - let base_url = validate_base_url(&settings.base_url)?; - let credential = settings - .api_key - .clone() - .filter(|key| !key.is_empty()) - .map_or(Credential::None, |key| { - Credential::Explicit(Resolvable::Value(key)) - }); - let headers = ProviderHeaders::bearer(credential, Vec::new(), settings.headers.clone()); - build( - settings, - BaseUrl::Fixed(base_url), - headers, - None, - crate::shared::aisdk_pins::OPENAI_COMPATIBLE, - ) -} - -/// [`create_openai_compatible`] for a package that builds on it with its own -/// base URL source and provider headers (the AI SDK's sub-providers that -/// call `createOpenAICompatible`). `settings.base_url`, `api_key` and -/// `headers` are not read; the user-agent part of `package` (the -/// openai-compatible version the calling package depends on) is added to -/// `headers`. `fetch_headers` go over the per-call headers, as headers a -/// package adds in its own fetch wrapper do. -pub(crate) fn build( - settings: OpenAICompatibleProviderSettings, - base_url: BaseUrl, - headers: ProviderHeaders, - fetch_headers: Option>, - package: Package, -) -> Result { - let defaults = ChatSettings::default(); - let mut provider = OpenAICompatibleProvider::assemble(Assembly { - name: settings.name, - base_url, - credential: Credential::None, - fixed_headers: Vec::new(), - headers: None, - query_params: settings.query_params, - fetch: settings.fetch, - transform_request_body: settings.transform_request_body, - package, - chat: ChatSettings { - include_usage: settings.include_usage.unwrap_or(false), - supports_structured_outputs: settings.supports_structured_outputs.unwrap_or(false), - supports_multi_part_tool_content: settings - .supports_multi_part_tool_content - .unwrap_or(false), - supported_urls: settings.supported_urls, - convert_usage: settings.convert_usage, - metadata_extractor: settings.metadata_extractor, - ..defaults - }, - })?; - provider.headers = headers.with_user_agent(package.name, package.version); - provider.fetch_headers = fetch_headers; - Ok(provider) -} - -/// Everything a compatible provider is made of. The public factory fills it -/// from the settings; presets fill it from their registry row. -pub(crate) struct Assembly { - pub name: String, - pub base_url: BaseUrl, - pub credential: Credential, - /// Headers sent before the user's (organization, project, ...). - pub fixed_headers: Vec<(String, String)>, - pub headers: Option, - pub query_params: Option>, - pub fetch: Option, - pub transform_request_body: Option, - pub chat: ChatSettings, - /// The package the user-agent names: its `ai-sdk-{name}/{version}` part - /// in the provider headers, and its provider-utils in the transport. - pub package: Package, -} - -/// An OpenAI-compatible provider (the AI SDK's `OpenAICompatibleProvider`). -/// Cheap to take models out of; it holds no HTTP client. -pub struct OpenAICompatibleProvider { - name: String, - base_url: BaseUrl, - query_params: Option>>, - headers: ProviderHeaders, - fetch_headers: Option>, - fetch: Option, - transform_request_body: Option, - chat: ChatSettings, -} - -impl OpenAICompatibleProvider { - pub(crate) fn assemble(assembly: Assembly) -> Result { - let query_params = assembly.query_params.map(|params| { - let mut pairs: Vec<(String, String)> = params.into_iter().collect(); - pairs.sort(); - Arc::new(pairs) - }); - Ok(Self { - name: assembly.name, - base_url: assembly.base_url, - query_params, - headers: ProviderHeaders { - credential: assembly.credential, - scheme: AuthScheme::Bearer, - fixed: assembly.fixed_headers, - user: assembly.headers, - user_agent: Some((assembly.package.name, assembly.package.version)), - }, - fetch_headers: None, - fetch: Some(crate::shared::package_fetch( - assembly.fetch, - assembly.package, - )), - transform_request_body: assembly.transform_request_body, - chat: assembly.chat, - }) - } - - fn model_config(&self, method: &str) -> CompatModelConfig { - CompatModelConfig { - provider: format!("{}.{method}", self.name), - base_url: self.base_url.clone(), - query_params: self.query_params.clone(), - headers: self.headers.clone(), - fetch_headers: self.fetch_headers.clone(), - fetch: self.fetch.clone(), - transform_request_body: if method == "chat" { - self.transform_request_body.clone() - } else { - None - }, - chat: self.chat.clone(), - } - } - - /// A chat-completions model; `provider()` is `"{name}.chat"`. - #[must_use] - pub fn chat(&self, model_id: &str) -> OpenAICompatibleChatModel { - let mut config = self.model_config("chat"); - config.chat = config.chat.for_model(model_id); - OpenAICompatibleChatModel::from_config(model_id.to_string(), config) - } - - /// A text-completion model; `provider()` is `"{name}.completion"`. - #[must_use] - pub fn completion(&self, model_id: &str) -> OpenAICompatibleCompletionModel { - OpenAICompatibleCompletionModel::from_config( - model_id.to_string(), - self.model_config("completion"), - ) - } - - /// An embedding model; `provider()` is `"{name}.embedding"`. - #[must_use] - pub fn embedding(&self, model_id: &str) -> OpenAICompatibleEmbeddingModel { - OpenAICompatibleEmbeddingModel::from_config( - model_id.to_string(), - self.model_config("embedding"), - ) - } - - /// An image model; `provider()` is `"{name}.image"`. - #[must_use] - pub fn image(&self, model_id: &str) -> OpenAICompatibleImageModel { - OpenAICompatibleImageModel::from_config(model_id.to_string(), self.model_config("image")) - } - - /// The provider as a function: the default language model for an id, the - /// chat model. The AI SDK's callable provider. - #[must_use] - pub fn call(&self, model_id: &str) -> Arc { - Arc::new(self.chat(model_id)) - } -} - -impl Provider for OpenAICompatibleProvider { - fn discovery(&self) -> Option<&dyn ProviderDiscovery> { - Some(self) - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(self.call(model_id)) - } - - fn embedding_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Arc::new(self.embedding(model_id))) - } - - fn image_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Arc::new(self.image(model_id))) - } - - /// Native decisions of the official runtimes (Ollama, llama.cpp, LocalAI, - /// Laya, vLLM, SGLang, Cloudflare); any other preset offers none. - fn decision_model( - &self, - model_id: &str, - ) -> Option, AiMuxError>> { - if !crate::runtime_decision::supported(&self.name) { - return None; - } - Some( - crate::runtime_decision::RuntimeDecisionModel::new( - &self.name, - model_id, - self.model_config("decision"), - ) - .map(|model| Arc::new(model) as Arc), - ) - } -} +pub use crate::shared::TransformRequestBody; -impl ProviderDiscovery for OpenAICompatibleProvider { - /// `GET {base_url}/models`: one exchange, no retry. Without a key no - /// `Authorization` header is sent. - fn list_models(&self) -> BoxFuture<'_, Result, AiMuxError>> { - let config = self.model_config("models"); - Box::pin(async move { chat::list_models_once(&config).await }) - } -} +pub(crate) use chat::ChatSettings; +pub(crate) use provider::{ + Assembly, BaseUrl, CompatModelConfig, ResolveBaseUrl, ResolveFetchHeaders, build, +}; #[cfg(test)] mod tests; diff --git a/aimux-providers/src/openai_compatible/provider.rs b/aimux-providers/src/openai_compatible/provider.rs new file mode 100644 index 000000000..97afd6984 --- /dev/null +++ b/aimux-providers/src/openai_compatible/provider.rs @@ -0,0 +1,599 @@ +//! The OpenAI-compatible provider (`openai-compatible-provider.ts`): +//! [`create_openai_compatible`], its settings, and the configuration every +//! model of the provider gets (the AI SDK's `getCommonModelConfig`). +//! +//! Like the native OpenAI config, [`CompatModelConfig`] is the Rust shape of the +//! object `createOpenAICompatible` hands each model class (`{ provider, url, +//! headers, fetch, ... }`). It has no getters for the credential or the settings +//! that produced it. Where the AI SDK passes `url` and `headers` functions, it +//! holds data, or a package's own data behind [`ResolveBaseUrl`] / +//! [`ResolveFetchHeaders`]. The shared model never compares a provider name. + +use std::collections::HashMap; +use std::sync::Arc; + +use aimux_core::embedding_model::EmbeddingModel; +use aimux_core::error::AiMuxError; +use aimux_core::image_model::ImageModel; +use aimux_core::language_model::LanguageModel; +use aimux_core::model_catalogue::RuntimeModel; +use aimux_core::provider::{Provider, ProviderDiscovery}; +use aimux_provider_utils::{ + ExchangeContext, Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse, HeaderMapOpt, + HttpRequest, Resolvable, combine_headers, normalize_headers, validate_base_url, +}; +use futures::future::BoxFuture; +use serde_json::Value; + +use crate::preset::PresetDescriptor; +use crate::shared::aisdk_pins::Package; +use crate::shared::{AuthScheme, Credential, ProviderHeaders}; + +use super::chat::{ChatSettings, ConvertUsage, MetadataExtractor, SupportedUrlsFn}; +use super::{ + OpenAICompatibleChatModel, OpenAICompatibleCompletionModel, OpenAICompatibleEmbeddingModel, + OpenAICompatibleImageModel, TransformRequestBody, +}; + +/// Settings of [`create_openai_compatible`] (the AI SDK's +/// `OpenAICompatibleProviderSettings`). +#[derive(Clone, Default)] +pub struct OpenAICompatibleProviderSettings { + /// The provider name: the first segment of every model's `provider()` + /// string and the providerOptions namespace. Preserved as supplied. + pub name: String, + /// Base URL for the API calls. Required, `http(s)` with a host; a trailing + /// slash is removed. + pub base_url: String, + /// The API key. `None` or `""` sends no `Authorization` header; + /// a non-empty value is sent as `Bearer `. + pub api_key: Option, + /// Extra headers on every request. A `None` value removes the header, + /// including the `Authorization` one. Per-call headers win over these. + pub headers: Option, + /// Query parameters appended to every request URL (sorted by name). + pub query_params: Option>, + /// The transport: a mock, a signing decorator, a proxy-aware client. + /// `None` uses the process default, resolved per request. + pub fetch: Option, + /// Ask streaming responses to include usage (`stream_options.include_usage`). + pub include_usage: Option, + /// Whether the chat endpoint accepts `json_schema` response formats; when + /// not, a schema degrades to `json_object` with a warning. + pub supports_structured_outputs: Option, + /// Whether chat tool results may carry multi-part content: a `content` + /// tool output is then sent as an array of content parts instead of a + /// JSON string. Default `false`. + pub supports_multi_part_tool_content: Option, + /// Rewrites each chat JSON request body once, after it is serialized and + /// before it is sent. + pub transform_request_body: Option, + /// Extracts metadata from chat responses and streaming chunks. + pub metadata_extractor: Option>, + /// URL patterns supported by chat models, evaluated when requested. + pub supported_urls: Option, + /// Custom chat token accounting. + pub convert_usage: Option, +} + +impl std::fmt::Debug for OpenAICompatibleProviderSettings { + /// Never prints the key or header values. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OpenAICompatibleProviderSettings") + .field("name", &self.name) + .field("base_url", &self.base_url) + .field("api_key", &self.api_key.is_some()) + .field( + "headers", + &self.headers.as_ref().map(std::collections::HashMap::len), + ) + .field("query_params", &self.query_params) + .field("fetch", &self.fetch.is_some()) + .field("include_usage", &self.include_usage) + .field( + "supports_structured_outputs", + &self.supports_structured_outputs, + ) + .field( + "supports_multi_part_tool_content", + &self.supports_multi_part_tool_content, + ) + .field( + "transform_request_body", + &self.transform_request_body.is_some(), + ) + .field("metadata_extractor", &self.metadata_extractor.is_some()) + .field("supported_urls", &self.supported_urls.is_some()) + .field("convert_usage", &self.convert_usage.is_some()) + .finish() + } +} + +/// Create an OpenAI-compatible provider. +/// +/// # Errors +/// +/// Returns `AiMuxError::InvalidArgument` when `base_url` is not an `http(s)` +/// URL with a host. The key is not read +/// here. +pub fn create_openai_compatible( + settings: OpenAICompatibleProviderSettings, +) -> Result { + let base_url = validate_base_url(&settings.base_url)?; + let credential = settings + .api_key + .clone() + .filter(|key| !key.is_empty()) + .map_or(Credential::None, |key| { + Credential::Explicit(Resolvable::Value(key)) + }); + let headers = ProviderHeaders::bearer(credential, Vec::new(), settings.headers.clone()); + build( + settings, + BaseUrl::Fixed(base_url), + headers, + None, + crate::shared::aisdk_pins::OPENAI_COMPATIBLE, + ) +} + +/// [`create_openai_compatible`] for a package that builds on it with its own +/// base URL source and provider headers (the AI SDK's sub-providers that +/// call `createOpenAICompatible`). `settings.base_url`, `api_key` and +/// `headers` are not read; the user-agent part of `package` (the +/// openai-compatible version the calling package depends on) is added to +/// `headers`. `fetch_headers` go over the per-call headers, as headers a +/// package adds in its own fetch wrapper do. +pub(crate) fn build( + settings: OpenAICompatibleProviderSettings, + base_url: BaseUrl, + headers: ProviderHeaders, + fetch_headers: Option>, + package: Package, +) -> Result { + let defaults = ChatSettings::default(); + let mut provider = OpenAICompatibleProvider::assemble(Assembly { + name: settings.name, + base_url, + credential: Credential::None, + fixed_headers: Vec::new(), + headers: None, + query_params: settings.query_params, + fetch: settings.fetch, + transform_request_body: settings.transform_request_body, + package, + chat: ChatSettings { + include_usage: settings.include_usage.unwrap_or(false), + supports_structured_outputs: settings.supports_structured_outputs.unwrap_or(false), + supports_multi_part_tool_content: settings + .supports_multi_part_tool_content + .unwrap_or(false), + supported_urls: settings.supported_urls, + convert_usage: settings.convert_usage, + metadata_extractor: settings.metadata_extractor, + ..defaults + }, + })?; + provider.headers = headers.with_user_agent(package.name, package.version); + provider.fetch_headers = fetch_headers; + Ok(provider) +} + +/// Everything a compatible provider is made of. The public factory fills it +/// from the settings; presets fill it from their registry row. +pub(crate) struct Assembly { + pub name: String, + pub base_url: BaseUrl, + pub credential: Credential, + /// Headers sent before the user's (organization, project, ...). + pub fixed_headers: Vec<(String, String)>, + pub headers: Option, + pub query_params: Option>, + pub fetch: Option, + pub transform_request_body: Option, + pub chat: ChatSettings, + /// The package the user-agent names: its `ai-sdk-{name}/{version}` part + /// in the provider headers, and its provider-utils in the transport. + pub package: Package, +} + +/// An OpenAI-compatible provider (the AI SDK's `OpenAICompatibleProvider`). +/// Cheap to take models out of; it holds no HTTP client. +pub struct OpenAICompatibleProvider { + name: String, + base_url: BaseUrl, + query_params: Option>>, + headers: ProviderHeaders, + fetch_headers: Option>, + fetch: Option, + transform_request_body: Option, + chat: ChatSettings, +} + +impl OpenAICompatibleProvider { + pub(crate) fn assemble(assembly: Assembly) -> Result { + let query_params = assembly.query_params.map(|params| { + let mut pairs: Vec<(String, String)> = params.into_iter().collect(); + pairs.sort(); + Arc::new(pairs) + }); + Ok(Self { + name: assembly.name, + base_url: assembly.base_url, + query_params, + headers: ProviderHeaders { + credential: assembly.credential, + scheme: AuthScheme::Bearer, + fixed: assembly.fixed_headers, + user: assembly.headers, + user_agent: Some((assembly.package.name, assembly.package.version)), + }, + fetch_headers: None, + fetch: Some(crate::shared::package_fetch( + assembly.fetch, + assembly.package, + )), + transform_request_body: assembly.transform_request_body, + chat: assembly.chat, + }) + } + + fn model_config(&self, method: &str) -> CompatModelConfig { + CompatModelConfig { + provider: format!("{}.{method}", self.name), + base_url: self.base_url.clone(), + query_params: self.query_params.clone(), + headers: self.headers.clone(), + fetch_headers: self.fetch_headers.clone(), + fetch: self.fetch.clone(), + transform_request_body: if method == "chat" { + self.transform_request_body.clone() + } else { + None + }, + chat: self.chat.clone(), + } + } + + /// A chat-completions model; `provider()` is `"{name}.chat"`. + #[must_use] + pub fn chat(&self, model_id: &str) -> OpenAICompatibleChatModel { + let mut config = self.model_config("chat"); + config.chat = config.chat.for_model(model_id); + OpenAICompatibleChatModel::from_config(model_id.to_string(), config) + } + + /// A text-completion model; `provider()` is `"{name}.completion"`. + #[must_use] + pub fn completion(&self, model_id: &str) -> OpenAICompatibleCompletionModel { + OpenAICompatibleCompletionModel::from_config( + model_id.to_string(), + self.model_config("completion"), + ) + } + + /// An embedding model; `provider()` is `"{name}.embedding"`. + #[must_use] + pub fn embedding(&self, model_id: &str) -> OpenAICompatibleEmbeddingModel { + OpenAICompatibleEmbeddingModel::from_config( + model_id.to_string(), + self.model_config("embedding"), + ) + } + + /// An image model; `provider()` is `"{name}.image"`. + #[must_use] + pub fn image(&self, model_id: &str) -> OpenAICompatibleImageModel { + OpenAICompatibleImageModel::from_config(model_id.to_string(), self.model_config("image")) + } + + /// The provider as a function: the default language model for an id, the + /// chat model. The AI SDK's callable provider. + #[must_use] + pub fn call(&self, model_id: &str) -> Arc { + Arc::new(self.chat(model_id)) + } +} + +impl Provider for OpenAICompatibleProvider { + fn discovery(&self) -> Option<&dyn ProviderDiscovery> { + Some(self) + } + + fn language_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(self.call(model_id)) + } + + fn embedding_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.embedding(model_id))) + } + + fn image_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.image(model_id))) + } + + /// Native decisions of the official runtimes (Ollama, llama.cpp, LocalAI, + /// Laya, vLLM, SGLang, Cloudflare); any other preset offers none. + fn decision_model( + &self, + model_id: &str, + ) -> Option, AiMuxError>> { + if !crate::runtime_decision::supported(&self.name) { + return None; + } + Some( + crate::runtime_decision::RuntimeDecisionModel::new( + &self.name, + model_id, + self.model_config("decision"), + ) + .map(|model| Arc::new(model) as Arc), + ) + } +} + +impl ProviderDiscovery for OpenAICompatibleProvider { + /// `GET {base_url}/models`: one exchange, no retry. Without a key no + /// `Authorization` header is sent. + fn list_models(&self) -> BoxFuture<'_, Result, AiMuxError>> { + let config = self.model_config("models"); + Box::pin(async move { list_models_once(&config).await }) + } +} + +/// A base URL a package builds from settings read when a request is made +/// (a Vertex AI partner sub-provider's project and location). +pub(crate) trait ResolveBaseUrl: std::fmt::Debug + Send + Sync { + /// The base URL of one request. + fn resolve(&self) -> Result; +} + +/// The headers a package adds in its own fetch wrapper +/// ([`CompatModelConfig::fetch_headers`]). +#[async_trait::async_trait] +pub(crate) trait ResolveFetchHeaders: Send + Sync { + /// The headers of one request. + async fn resolve(&self) -> Result; +} + +/// Where the base URL comes from. +/// +/// A plain provider has a [`Fixed`](Self::Fixed) URL. A preset whose URL is +/// read from the environment or expanded from template parameters is +/// [`Preset`](Self::Preset): it is evaluated per request, so creating the +/// provider (and the preset default instances) never reads the environment +/// and never fails. +#[derive(Clone, Debug)] +pub(crate) enum BaseUrl { + Fixed(String), + Preset { + descriptor: &'static PresetDescriptor, + /// The explicit template parameters of the settings. + params: Arc>, + }, + /// A package's own base URL, resolved when a request is made. + Dynamic(Arc), +} + +impl BaseUrl { + pub(crate) fn resolve(&self) -> Result { + match self { + Self::Fixed(url) => Ok(url.clone()), + Self::Preset { descriptor, params } => { + crate::preset::resolve_base_url(descriptor, params) + } + Self::Dynamic(resolver) => resolver.resolve(), + } + } +} + +/// What a model needs to talk to the API. +#[derive(Clone)] +pub(crate) struct CompatModelConfig { + /// The identity the model reports: `"{name}.{method}"`. + pub provider: String, + pub base_url: BaseUrl, + /// Appended to every request URL, in this order. + pub query_params: Option>>, + /// Provider headers (credential, user headers), produced on every request. + pub headers: ProviderHeaders, + /// Headers layered over the per-call headers, resolved on every request + /// after `headers`: what an AI SDK package that builds on + /// `createOpenAICompatible` adds in its own fetch wrapper (google-vertex's + /// Node MaaS and xAI providers). Their `user-agent` replaces the whole + /// header, the helper layer's part included ([`FetchUserAgent`]). + pub fetch_headers: Option>, + /// Transport; `None` uses the process default, resolved per request. + pub fetch: Option, + pub transform_request_body: Option, + pub chat: ChatSettings, +} + +impl CompatModelConfig { + /// The providerOptions namespace of this provider: the first segment of + /// the provider string. + pub(crate) fn provider_options_name(&self) -> &str { + self.provider.split('.').next().unwrap_or_default().trim() + } + + /// The full URL of an endpoint path (with the query parameters) and the + /// origin credentialed headers may be sent to. + pub(crate) fn url(&self, path: &str) -> Result<(String, String), AiMuxError> { + let base = self.base_url.resolve()?; + let mut url = url::Url::parse(&format!("{base}{path}")).map_err(|e| { + AiMuxError::InvalidArgument(format!("invalid request URL for {}: {e}", self.provider)) + })?; + if let Some(params) = &self.query_params { + // As the AI SDK does: the configured parameters replace whatever + // query the base URL carried. + url.set_query(None); + if !params.is_empty() { + url.query_pairs_mut() + .extend_pairs(params.iter().map(|(k, v)| (k.as_str(), v.as_str()))); + } + } + Ok((url.to_string(), base)) + } + + /// Resolve the provider headers, layer the per-call headers over them, + /// then the fetch headers (case-insensitive, later wins, `None` removes), + /// and return the list that goes on the wire with the fetch headers' + /// `user-agent`. + pub(crate) async fn request_headers( + &self, + call_headers: Option<&HashMap>, + ) -> Result { + let provider = self.headers.resolve().await?; + let call: HeaderMapOpt = call_headers + .map(|headers| { + headers + .iter() + .map(|(name, value)| (name.clone(), Some(value.clone()))) + .collect() + }) + .unwrap_or_default(); + let fetch = match &self.fetch_headers { + Some(headers) => combine_headers(&[&headers.resolve().await?]), + None => HeaderMapOpt::new(), + }; + let fetch_user_agent = fetch.get("user-agent").cloned().flatten(); + Ok(RequestHeaders { + list: normalize_headers(combine_headers(&[&provider, &call, &fetch])), + fetch_user_agent, + }) + } + + /// An exchange for `path` that inherits the operation's cancellation and + /// recording context and carries this config's transport and credential + /// origin. + pub(crate) fn http_request( + &self, + path: &str, + headers: RequestHeaders, + options: &impl ExchangeContext, + ) -> Result { + let (url, origin) = self.url(path)?; + Ok(self.with_transport( + HttpRequest::new(url, headers.list, options), + origin, + headers.fetch_user_agent, + )) + } + + /// Attach the transport and the credential origin to a request. A fetch + /// headers' `user-agent` is sent as the whole header by the transport. + pub(crate) fn with_transport( + &self, + mut request: HttpRequest, + origin: String, + fetch_user_agent: Option, + ) -> HttpRequest { + request.fetch = match fetch_user_agent { + Some(user_agent) => Some(Arc::new(FetchUserAgent { + inner: self.fetch.clone(), + user_agent, + })), + None => self.fetch.clone(), + }; + request.credentialed_origin = Some(origin); + request + } + + /// Run the provider-level body rewrite, when there is one. + pub(crate) fn transform_body(&self, body: Value) -> Value { + match &self.transform_request_body { + Some(transform) => transform(body), + None => body, + } + } + + /// The failed-response handler of this provider's error structure. + pub(crate) fn failed_response_handler( + &self, + ) -> aimux_provider_utils::ResponseHandler { + let structure = self.chat.error_structure.clone(); + aimux_provider_utils::create_json_error_response_handler(move |data| structure(data)) + } +} + +/// The headers of one request ([`CompatModelConfig::request_headers`]). +pub(crate) struct RequestHeaders { + /// What goes on the wire, before the helper layer adds its + /// `ai-sdk-provider-utils` part. + pub list: Vec<(String, String)>, + /// The `user-agent` of the fetch headers, when they set one. + pub fetch_user_agent: Option, +} + +/// The transport of a request whose fetch headers set `user-agent`: it sends +/// that value as the whole header, over the one the helper layer and the +/// package transport built. google-vertex's Node wrappers spread their +/// headers over `init.headers` after `postToApi` added its part +/// (google-vertex-maas-provider-node.ts:43-49, +/// google-vertex-xai-provider-node.ts:41-47), so nothing of it is left. +struct FetchUserAgent { + /// The package transport (`None`: the process default). + inner: Option, + user_agent: String, +} + +#[async_trait::async_trait] +impl Fetch for FetchUserAgent { + async fn fetch(&self, mut request: FetchRequest) -> Result { + let value = reqwest::header::HeaderValue::try_from(self.user_agent.as_str()) + .map_err(|_| FetchError::Other("invalid header value for user-agent".to_string()))?; + request.headers.insert(reqwest::header::USER_AGENT, value); + self.inner + .clone() + .unwrap_or_else(aimux_provider_utils::default_fetch) + .fetch(request) + .await + } +} + +/// One `GET {base_url}/models` exchange for discovery: no retry, no +/// recording. +pub(crate) async fn list_models_once( + config: &CompatModelConfig, +) -> Result, AiMuxError> { + #[derive(serde::Deserialize)] + struct ModelsList { + #[serde(default)] + data: Vec, + } + #[derive(serde::Deserialize)] + struct ModelEntry { + id: String, + #[serde(default)] + owned_by: Option, + #[serde(default)] + created: Option, + } + + let headers = config.request_headers(None).await?; + let (url, origin) = config.url("/models")?; + let resp = aimux_provider_utils::get_from_api( + config.with_transport( + aimux_provider_utils::HttpRequest { + url, + headers: headers.list, + ..Default::default() + }, + origin, + headers.fetch_user_agent, + ), + aimux_provider_utils::create_json_response_handler::(), + config.failed_response_handler(), + ) + .await?; + Ok(resp + .value + .data + .into_iter() + .map(|m| aimux_core::model_catalogue::RuntimeModel { + id: m.id, + owned_by: m.owned_by, + created: m.created, + }) + .collect()) +} diff --git a/aimux-providers/src/openai_compatible/tests/chat/chat_language_model.rs b/aimux-providers/src/openai_compatible/tests/chat/chat_language_model.rs index 503212a53..9a3e9564d 100644 --- a/aimux-providers/src/openai_compatible/tests/chat/chat_language_model.rs +++ b/aimux-providers/src/openai_compatible/tests/chat/chat_language_model.rs @@ -13,7 +13,7 @@ use aimux_core::shared::{FileBytes, FileData, provider_namespace}; use aimux_core::stream_part::StreamPart; use aimux_core::types::ProviderMetadata; -use crate::openai_compatible::config::{BaseUrl, ChatSettings, CompatModelConfig}; +use crate::openai_compatible::{BaseUrl, ChatSettings, CompatModelConfig}; use crate::openai_compatible::{ MetadataExtractor, OpenAICompatibleChatModel, OpenAICompatibleProvider, OpenAICompatibleProviderSettings, StreamMetadataExtractor, create_openai_compatible, @@ -4549,3 +4549,598 @@ mod transform_request_body { assert_eq!(request_body.get("custom_field"), None); } } + +// ── Older aimux tests (previously the unit tests of `openai_compatible/config.rs`) ── + +mod chat_settings { + use crate::openai_compatible::ChatSettings; + + static MODELS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| vec!["qwen3.7-plus".to_string(), "kimi-k*".to_string()]); + + fn supports(model_id: &str) -> bool { + ChatSettings { + supports_structured_outputs: true, + structured_output_models: Some(&MODELS), + ..ChatSettings::default() + } + .for_model(model_id) + .supports_structured_outputs + } + + #[test] + fn a_model_list_matches_the_exact_id() { + assert!(supports("qwen3.7-plus")); + assert!(!supports("qwen3.7-plus-2026-09-01")); + assert!(!supports("qwen3.7-plusplus")); + assert!(!supports("qwen3.7")); + } + + #[test] + fn an_entry_ending_in_a_star_matches_a_prefix() { + assert!(supports("kimi-k")); + assert!(supports("kimi-k2.5")); + assert!(!supports("kimi-latest")); + assert!(!supports("kimi")); + } + + #[test] + fn without_a_model_list_the_flag_applies() { + let settings = ChatSettings { + supports_structured_outputs: true, + ..ChatSettings::default() + }; + assert!(settings.for_model("any").supports_structured_outputs); + } +} + +// ── Older aimux tests (previously the unit tests of `openai_compatible/chat.rs`) ── + +mod error_structure_and_base_url { + use std::sync::Arc; + + use serde_json::{Value, json}; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + use aimux_core::error::AiMuxError; + use aimux_core::language_model::LanguageModel; + use aimux_core::language_model_message::LanguageModelMessage; + use aimux_core::options::CallOptions; + use aimux_provider_utils::ProviderErrorParts; + + use crate::openai_compatible::{Assembly, BaseUrl, ChatSettings, OpenAICompatibleProvider}; + use crate::shared::Credential; + + fn hello() -> CallOptions { + CallOptions::new(vec![LanguageModelMessage::user_text("hi")]) + } + + fn provider(base_url: BaseUrl, chat: ChatSettings) -> OpenAICompatibleProvider { + OpenAICompatibleProvider::assemble(Assembly { + name: "acme".to_string(), + base_url, + credential: Credential::None, + fixed_headers: Vec::new(), + headers: None, + query_params: None, + fetch: None, + transform_request_body: None, + chat, + package: crate::shared::aisdk_pins::OPENAI_COMPATIBLE, + }) + .unwrap() + } + + async fn serve(server: &MockServer, response: ResponseTemplate) { + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(response) + .mount(server) + .await; + } + + #[tokio::test] + async fn an_error_structure_decides_the_message_and_code() { + let server = MockServer::start().await; + serve( + &server, + ResponseTemplate::new(400).set_body_json(json!({"detail": "bad prompt", "kind": 42})), + ) + .await; + let chat = ChatSettings { + error_structure: Arc::new(|data: &Value| ProviderErrorParts { + message: data["detail"].as_str().unwrap_or_default().to_string(), + provider_code: data["kind"].as_u64().map(|k| k.to_string()), + }), + ..ChatSettings::default() + }; + let error = provider(BaseUrl::Fixed(server.uri()), chat) + .chat("m") + .do_generate(&hello()) + .await + .unwrap_err(); + match error { + AiMuxError::ApiCall(detail) => { + assert_eq!(detail.message, "bad prompt"); + assert_eq!(detail.provider_code.as_deref(), Some("42")); + } + other => panic!("expected ApiCall, got {other:?}"), + } + } + + /// A preset base URL is data the request resolves: creating the provider + /// and the model reads nothing, a request that cannot resolve it fails, + /// and the next request resolves it again. + #[tokio::test] + #[serial_test::serial] + async fn a_lazy_base_url_is_evaluated_per_request_and_its_error_fails_the_request() { + use crate::preset::{AuthMode, ParamSpec, PresetDescriptor}; + + const VAR: &str = "AIMUX_TEST_COMPAT_LAZY_BASE_URL"; + let server = MockServer::start().await; + serve( + &server, + ResponseTemplate::new(200).set_body_json(json!({ + "id": "c", "model": "m", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}] + })), + ) + .await; + // SAFETY: the test is `#[serial]` and no other test reads `VAR`. + unsafe { std::env::remove_var(VAR) }; + let descriptor: &'static PresetDescriptor = Box::leak(Box::new(PresetDescriptor { + name: "acme".to_string(), + display: "Acme".to_string(), + base_url: "http://{host}/v1".to_string(), + env_var: String::new(), + auth: AuthMode::None, + base_url_env: Some(VAR.to_string()), + max_tokens_key: None, + convert_usage: None, + params: vec![ParamSpec { + name: "host".to_string(), + env: Vec::new(), + default: None, + derive: None, + }], + user_agent: ("openai-compatible", "3.0.62"), + structured_output_models: None, + })); + let lazy = BaseUrl::Preset { + descriptor, + params: Arc::new(std::collections::HashMap::new()), + }; + let provider = provider(lazy, ChatSettings::default()); + let model = provider.chat("m"); + let error = model.do_generate(&hello()).await.unwrap_err(); + assert!(matches!(error, AiMuxError::InvalidArgument(_)), "{error:?}"); + // SAFETY: as above. + unsafe { std::env::set_var(VAR, server.uri()) }; + let result = model.do_generate(&hello()).await; + // SAFETY: as above. + unsafe { std::env::remove_var(VAR) }; + result.expect("resolved on the next request"); + } +} + +// ── Older ports (previously `tests/openai_compatible_upstream_e2e_test.rs`) ── +// +// End-to-end subset of the pinned openai-compatible upstream tests. + +mod upstream_e2e { + use std::sync::Arc; + + use aimux_core::AiMuxError; + use aimux_core::language_model::LanguageModel; + use aimux_core::language_model_message::LanguageModelMessage; + use aimux_core::options::CallOptions; + use aimux_core::result::GenerateContent; + use aimux_core::shared::provider_namespace; + use aimux_core::stream_part::StreamPart; + use aimux_core::tool::FunctionTool; + use aimux_core::types::FinishReasonUnified; + use futures::StreamExt; + use serde_json::{Value, json}; + + use crate::openai_compatible::tests::support::*; + use crate::openai_compatible::{ + OpenAICompatibleProvider, OpenAICompatibleProviderSettings, create_openai_compatible, + }; + use crate::test_support::mock_fetch::{Canned, MockFetch}; + + /// partial: `should use camelCase metadata key when camelCase provider options are used`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. + /// partial: `should extract detailed token usage when available`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. + #[tokio::test] + async fn chat_generate_metadata_and_usage() { + let usage = json!({"prompt_tokens":20,"completion_tokens":30,"total_tokens":50, + "prompt_tokens_details":{"cached_tokens":5}, + "completion_tokens_details":{"reasoning_tokens":10,"accepted_prediction_tokens":15,"rejected_prediction_tokens":5}}); + let fetch = MockFetch::new(vec![Canned::json(&chat_response( + json!({"content":"Hello!"}), + usage.clone(), + ))]); + let mut opts = options(); + opts.provider_options = Some( + provider_namespace("testProvider", json!({"reasoningEffort":"high"})) + .expect("provider metadata must be an object"), + ); + let result = provider(&fetch) + .chat("test-model") + .do_generate(&opts) + .await + .unwrap(); + let expected = json!({"model":"test-model","messages":[{"role":"user","content":"Hello"}],"reasoning_effort":"high"}); + assert_request(&fetch, "/chat/completions", expected.clone()); + assert_eq!(result.request.unwrap().body, Some(expected)); + assert_eq!(result.content.len(), 1); + assert!( + matches!(&result.content[0], GenerateContent::Text { text, .. } if text == "Hello!") + ); + assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); + assert_eq!(result.finish_reason.raw.as_deref(), Some("stop")); + assert_eq!(result.usage.input_tokens.total, Some(20)); + assert_eq!(result.usage.input_tokens.no_cache, Some(15)); + assert_eq!(result.usage.input_tokens.cache_read, Some(5)); + assert_eq!(result.usage.input_tokens.cache_write, None); + assert_eq!(result.usage.output_tokens.total, Some(30)); + assert_eq!(result.usage.output_tokens.text, Some(20)); + assert_eq!(result.usage.output_tokens.reasoning, Some(10)); + assert_eq!(result.usage.raw, usage.as_object().cloned()); + let metadata = result.provider_metadata.unwrap(); + assert_eq!( + metadata, + provider_namespace( + "testProvider", + json!({"acceptedPredictionTokens":15,"rejectedPredictionTokens":5}) + ) + .expect("provider metadata must be an object") + ); + assert!(!metadata.contains_key("test-provider")); + let response = result.response.unwrap(); + assert_eq!(response.id.as_deref(), Some("chatcmpl-test")); + assert_eq!(response.model_id.as_deref(), Some("test-model")); + assert_eq!( + response.headers.unwrap()["content-type"], + "application/json" + ); + assert!(result.warnings.is_empty()); + } + + /// partial: `should stream reasoning content before text deltas`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. + #[tokio::test] + async fn chat_stream_reasoning_before_text() { + let delta = |value: Value| { + json!({"id":"chatcmpl-test","created":1711357598, + "model":"test-model","choices":[{"index":0,"delta":value,"finish_reason":null}]}) + }; + let fetch = MockFetch::new(vec![stream_response(&[ + delta(json!({"role":"assistant","content":"","reasoning_content":"Let me think"})), + delta(json!({"content":"","reasoning_content":" about this"})), + delta(json!({"content":"Here's"})), + delta(json!({"content":" my response"})), + json!({"choices":[{"index":0,"delta":{},"finish_reason":"stop"}], + "usage":{"prompt_tokens":18,"completion_tokens":439}}), + ])]); + let result = provider(&fetch) + .chat("test-model") + .do_stream(&options()) + .await + .unwrap(); + assert_request(&fetch, "/chat/completions", stream_request()); + assert_eq!(result.request.unwrap().body, Some(stream_request())); + let parts: Vec<_> = result.stream.map(|part| part.unwrap()).collect().await; + assert_eq!(parts.len(), 11); + assert!(matches!(&parts[0], StreamPart::StreamStart { warnings } if warnings.is_empty())); + assert!( + matches!(&parts[1], StreamPart::ResponseMetadata(metadata) if metadata.id.as_deref() == Some("chatcmpl-test") && metadata.model_id.as_deref() == Some("test-model")) + ); + assert!(matches!(&parts[2], StreamPart::ReasoningStart { id, .. } if id == "reasoning-0")); + assert!(matches!(&parts[5], StreamPart::ReasoningEnd { id, .. } if id == "reasoning-0")); + assert!(matches!(&parts[6], StreamPart::TextStart { id, .. } if id == "txt-0")); + assert!(matches!(&parts[9], StreamPart::TextEnd { id, .. } if id == "txt-0")); + let deltas: Vec<_> = parts + .iter() + .filter_map(|part| match part { + StreamPart::ReasoningDelta { id, delta, .. } => { + Some(("reasoning", id.as_str(), delta.as_str())) + } + StreamPart::TextDelta { id, delta, .. } => { + Some(("text", id.as_str(), delta.as_str())) + } + _ => None, + }) + .collect(); + assert_eq!( + deltas, + vec![ + ("reasoning", "reasoning-0", "Let me think"), + ("reasoning", "reasoning-0", " about this"), + ("text", "txt-0", "Here's"), + ("text", "txt-0", " my response") + ] + ); + let StreamPart::Finish { + finish_reason, + usage, + provider_metadata, + } = &parts[10] + else { + panic!("expected finish"); + }; + assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); + assert_eq!(usage.input_tokens.total, Some(18)); + assert_eq!(usage.input_tokens.no_cache, Some(18)); + assert_eq!(usage.input_tokens.cache_read, Some(0)); + assert_eq!(usage.output_tokens.total, Some(439)); + assert_eq!(usage.output_tokens.text, Some(439)); + assert_eq!(usage.output_tokens.reasoning, Some(0)); + assert_eq!( + provider_metadata.as_ref().unwrap(), + &provider_namespace("test-provider", json!({})) + .expect("provider metadata must be an object") + ); + } + + /// partial: `should parse thought signature from extra_content and include in providerMetadata`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. + #[tokio::test] + async fn chat_tool_call_preserves_arguments_and_signature() { + let fetch = MockFetch::new(vec![Canned::json(&chat_response( + json!({"tool_calls":[{ + "id":"function-call-1","type":"function", + "function":{"name":"check_flight","arguments":"{\"flight\":\"AA100\"}"}, + "extra_content":{"google":{"thought_signature":""}}}]}), + Value::Null, + ))]); + let schema = json!({"type":"object","properties":{"flight":{"type":"string"}}, + "required":["flight"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}); + let mut opts = options(); + opts.tools = Some(vec![ + FunctionTool::new("check_flight", schema.clone()).into(), + ]); + let result = provider(&fetch) + .chat("test-model") + .do_generate(&opts) + .await + .unwrap(); + assert_request( + &fetch, + "/chat/completions", + json!({"model":"test-model", + "messages":[{"role":"user","content":"Hello"}], + "tools":[{"type":"function","function":{"name":"check_flight","parameters":schema}}]}), + ); + assert_eq!(result.content.len(), 1); + assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); + assert_eq!(result.finish_reason.raw.as_deref(), Some("stop")); + let GenerateContent::ToolCall(call) = &result.content[0] else { + panic!("expected tool call"); + }; + assert_eq!(call.tool_call_id, "function-call-1"); + assert_eq!(call.tool_name, "check_flight"); + assert_eq!(call.input, "{\"flight\":\"AA100\"}"); + assert_eq!( + call.provider_metadata.as_ref().unwrap(), + &provider_namespace("test-provider", json!({"thoughtSignature":""})) + .expect("provider metadata must be an object") + ); + } + + /// partial: `should preserve structured error stream parts`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. + #[tokio::test] + async fn chat_stream_preserves_structured_error() { + let fetch = MockFetch::new(vec![stream_response(&[json!({"error":{ + "message":"Context length exceeded","code":"CONTEXT_LENGTH_EXCEEDED"}})])]); + let result = provider(&fetch) + .chat("test-model") + .do_stream(&options()) + .await + .unwrap(); + assert_request(&fetch, "/chat/completions", stream_request()); + let parts: Vec<_> = result.stream.map(|part| part.unwrap()).collect().await; + assert_eq!(parts.len(), 3); + assert!(matches!(&parts[0], StreamPart::StreamStart { warnings } if warnings.is_empty())); + let StreamPart::Error { + error: AiMuxError::ApiCall(error), + } = &parts[1] + else { + panic!("expected structured API error"); + }; + assert_eq!(error.message, "Context length exceeded"); + assert_eq!( + error.provider_code.as_deref(), + Some("CONTEXT_LENGTH_EXCEEDED") + ); + let StreamPart::Finish { + finish_reason, + usage, + provider_metadata, + } = &parts[2] + else { + panic!("expected finish"); + }; + assert_eq!(finish_reason.unified, FinishReasonUnified::Error); + assert_eq!(finish_reason.raw, None); + assert_eq!(usage.input_tokens.total, None); + assert_eq!(usage.output_tokens.total, None); + assert_eq!(usage.raw, None); + assert_eq!( + provider_metadata.as_ref().unwrap(), + &provider_namespace("test-provider", json!({})) + .expect("provider metadata must be an object") + ); + } + + /// Upstream: @ai-sdk/openai-compatible 3.0.62 + /// src/chat/openai-compatible-chat-language-model.ts:588-595 passes an error + /// chunk's `error` on as it is: even a numeric `code` that looks like an HTTP + /// status is no status, so the error is not retryable. + #[tokio::test] + async fn chat_stream_error_takes_no_status_from_a_numeric_code() { + for (code, provider_code) in [ + (json!(429), "429"), + (json!(503), "503"), + (json!("429"), "429"), + ] { + let payload = json!({"message": "Slow down", "code": code}); + let fetch = MockFetch::new(vec![stream_response(&[json!({ "error": payload })])]); + let parts: Vec<_> = provider(&fetch) + .chat("test-model") + .do_stream(&options()) + .await + .unwrap() + .stream + .map(|part| part.unwrap()) + .collect() + .await; + let error = parts + .iter() + .find_map(|part| match part { + StreamPart::Error { + error: AiMuxError::ApiCall(error), + } => Some(error), + _ => None, + }) + .expect("expected an API error part"); + assert_eq!(error.message, "Slow down", "{code}"); + assert_eq!( + error.provider_code.as_deref(), + Some(provider_code), + "{code}" + ); + assert_eq!(error.status_code, None, "{code}"); + assert!(!error.is_retryable, "{code}"); + assert_eq!(error.data.as_ref(), Some(&payload), "{code}"); + } + } + + /// A tool result whose output is `content` (a text part and a base64 image + /// part), as the AI SDK passes it to the provider. + fn multi_part_tool_prompt( + extra: Option, + ) -> CallOptions { + use aimux_core::language_model_message::{ + FilePart, TextPart, ToolPart, ToolResultContent, ToolResultOutput, ToolResultPart, + }; + use aimux_core::shared::{FileBytes, FileData}; + let mut value = vec![ + ToolResultContent::Text(TextPart { + text: "The weather is sunny".into(), + provider_options: None, + }), + ToolResultContent::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64("AAECAw==".into()), + }, + media_type: "image/png".into(), + filename: None, + provider_options: None, + }), + ]; + value.extend(extra); + CallOptions::new(vec![ + LanguageModelMessage::user_text("Weather?"), + LanguageModelMessage::Tool { + content: vec![ToolPart::ToolResult(ToolResultPart { + tool_call_id: "call-1".into(), + tool_name: "weather".into(), + output: ToolResultOutput::Content { value }, + provider_options: None, + })], + provider_options: None, + }, + ]) + } + + fn multi_part_provider( + fetch: &Arc, + supported: Option, + ) -> OpenAICompatibleProvider { + create_openai_compatible(OpenAICompatibleProviderSettings { + name: "test-provider".into(), + base_url: BASE_URL.into(), + fetch: Some(fetch.transport()), + supports_multi_part_tool_content: supported, + ..Default::default() + }) + .unwrap() + } + + /// partial: `should pass multi-part tool content when supported`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. + /// partial: `should convert multi-part tool content when supported`; full port in src/openai_compatible/tests/chat/convert_to_chat_messages.rs. + /// partial: `should stringify multi-part tool content by default`; full port in src/openai_compatible/tests/chat/convert_to_chat_messages.rs. + /// Upstream: @ai-sdk/openai-compatible 3.0.62 + /// src/chat/convert-to-openai-compatible-chat-messages.ts:303-306 sends a + /// `content` tool output as an array of content parts + /// (`convertToolContentPart`, :162-176) when `supportsMultiPartToolContent` + /// (openai-compatible-provider.ts:94-100, :183) is set, and as + /// `JSON.stringify(output.value)` otherwise (the default, `false`). + #[tokio::test] + async fn chat_multi_part_tool_content_follows_the_setting() { + let response = || { + Canned::json(&chat_response( + json!({"role":"assistant","content":"ok"}), + json!({"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}), + )) + }; + let fetch = MockFetch::new(vec![response(), response(), response()]); + let tool_message = |index: usize| fetch.seen()[index].json_body()["messages"][1].clone(); + + multi_part_provider(&fetch, Some(true)) + .chat("test-model") + .do_generate(&multi_part_tool_prompt(None)) + .await + .unwrap(); + assert_eq!( + tool_message(0), + json!({ + "role": "tool", + "tool_call_id": "call-1", + "content": [ + {"type": "text", "text": "The weather is sunny"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAECAw=="}} + ] + }) + ); + + for (index, supported) in [(1, None), (2, Some(false))] { + multi_part_provider(&fetch, supported) + .chat("test-model") + .do_generate(&multi_part_tool_prompt(None)) + .await + .unwrap(); + let message = tool_message(index); + let content: Value = + serde_json::from_str(message["content"].as_str().expect("a JSON string")).unwrap(); + assert_eq!( + content, + json!([ + {"type": "text", "text": "The weather is sunny"}, + {"type": "file", "mediaType": "image/png", "data": {"type": "data", "data": "AAECAw=="}} + ]), + "{supported:?}" + ); + } + } + + /// Upstream: convert-to-openai-compatible-chat-messages.ts:166-170 rejects a + /// custom part in multi-part tool content. + #[tokio::test] + async fn chat_multi_part_tool_content_rejects_a_custom_part() { + let fetch = MockFetch::new(Vec::new()); + let error = multi_part_provider(&fetch, Some(true)) + .chat("test-model") + .do_generate(&multi_part_tool_prompt(Some( + aimux_core::language_model_message::ToolResultContent::Custom { + provider_options: None, + }, + ))) + .await + .unwrap_err(); + assert!( + matches!(&error, AiMuxError::UnsupportedFunctionality(what) if what == "custom tool content parts"), + "{error:?}" + ); + assert!(fetch.seen().is_empty()); + } +} diff --git a/aimux-providers/src/openai_compatible/tests/chat/convert_to_chat_messages.rs b/aimux-providers/src/openai_compatible/tests/chat/convert_to_chat_messages.rs index 85419b9d2..43f421748 100644 --- a/aimux-providers/src/openai_compatible/tests/chat/convert_to_chat_messages.rs +++ b/aimux-providers/src/openai_compatible/tests/chat/convert_to_chat_messages.rs @@ -4,7 +4,7 @@ use aimux_core::error::AiMuxError; use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt, UserPart}; use aimux_core::shared::{FileBytes, FileData}; -use crate::openai_compatible::convert::{MessageSpec, convert_messages}; +use crate::openai_compatible::chat::convert_to_chat_messages::{MessageSpec, convert_messages}; /// A prompt in its AI SDK JSON form. fn prompt(value: Value) -> LanguageModelPrompt { diff --git a/aimux-providers/src/openai_compatible/tests/chat/prepare_tools.rs b/aimux-providers/src/openai_compatible/tests/chat/prepare_tools.rs index ddc522b8a..8d2f9f148 100644 --- a/aimux-providers/src/openai_compatible/tests/chat/prepare_tools.rs +++ b/aimux-providers/src/openai_compatible/tests/chat/prepare_tools.rs @@ -2,7 +2,7 @@ use serde_json::{Map, Value, json}; use aimux_core::options::CallOptions; -use crate::openai_compatible::convert::{PreparedTools, prepare_function_tools}; +use crate::openai_compatible::chat::prepare_tools::{PreparedTools, prepare_function_tools}; /// `prepareTools({ tools, toolChoice })`: the Rust form reads both from the /// call options, given here in their AI SDK JSON form. diff --git a/aimux-providers/src/openai_compatible/tests/completion/completion_language_model.rs b/aimux-providers/src/openai_compatible/tests/completion/completion_language_model.rs index e6f5d3780..744854ce8 100644 --- a/aimux-providers/src/openai_compatible/tests/completion/completion_language_model.rs +++ b/aimux-providers/src/openai_compatible/tests/completion/completion_language_model.rs @@ -15,7 +15,7 @@ use aimux_core::result::StreamResult; use aimux_core::stream_part::StreamPart; use crate::openai_compatible::completion::{CompletionConfig, OpenAICompatibleCompletionModel}; -use crate::openai_compatible::config::{BaseUrl, ChatSettings, CompatModelConfig}; +use crate::openai_compatible::{BaseUrl, ChatSettings, CompatModelConfig}; use crate::openai_compatible::{ OpenAICompatibleProvider, OpenAICompatibleProviderSettings, create_openai_compatible, }; @@ -920,3 +920,50 @@ mod do_stream { } } } + +// ── Older ports (previously `tests/openai_compatible_upstream_e2e_test.rs`) ── +// +// End-to-end subset of the pinned openai-compatible upstream tests. + +mod upstream_e2e { + use aimux_core::AiMuxError; + use aimux_core::language_model::LanguageModel; + use aimux_core::stream_part::StreamPart; + use futures::StreamExt; + use serde_json::json; + + use crate::openai_compatible::tests::support::*; + use crate::test_support::mock_fetch::MockFetch; + + /// Upstream: @ai-sdk/openai-compatible 3.0.62 + /// src/completion/openai-compatible-completion-language-model.ts:320-324 + /// passes an error chunk on as it is (`error: value.error`): unlike + /// @ai-sdk/openai, no status is inferred from its `code`/`type`. + #[tokio::test] + async fn completion_stream_error_infers_no_status() { + let fetch = MockFetch::new(vec![stream_response(&[json!({"error":{ + "message":"Slow down","type":"requests","code":"rate_limit_exceeded"}})])]); + let parts: Vec<_> = provider(&fetch) + .completion("test-model") + .do_stream(&options()) + .await + .unwrap() + .stream + .map(|part| part.unwrap()) + .collect() + .await; + let error = parts + .iter() + .find_map(|part| match part { + StreamPart::Error { + error: AiMuxError::ApiCall(error), + } => Some(error), + _ => None, + }) + .expect("expected an API error part"); + assert_eq!(error.message, "Slow down"); + assert_eq!(error.provider_code.as_deref(), Some("rate_limit_exceeded")); + assert_eq!(error.status_code, None); + assert!(!error.is_retryable); + } +} diff --git a/aimux-providers/src/openai_compatible/tests/embedding/embedding_model.rs b/aimux-providers/src/openai_compatible/tests/embedding/embedding_model.rs index e7e521d34..500bf833b 100644 --- a/aimux-providers/src/openai_compatible/tests/embedding/embedding_model.rs +++ b/aimux-providers/src/openai_compatible/tests/embedding/embedding_model.rs @@ -360,3 +360,51 @@ mod do_embed { ); } } + +// ── Older ports (previously `tests/openai_compatible_upstream_e2e_test.rs`) ── +// +// End-to-end subset of the pinned openai-compatible upstream tests. + +mod upstream_e2e { + use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; + use serde_json::json; + + use crate::openai_compatible::tests::support::*; + use crate::test_support::mock_fetch::{Canned, MockFetch}; + + /// partial: `should extract embedding`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. + /// partial: `should pass the model and the values`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. + #[tokio::test] + async fn embedding_request_and_result() { + let body = json!({"object":"list","data":[ + {"object":"embedding","index":0,"embedding":[0.1,0.2,0.3,0.4,0.5]}, + {"object":"embedding","index":1,"embedding":[0.6,0.7,0.8,0.9,1.0]}], + "model":"test-embedding","usage":{"prompt_tokens":8,"total_tokens":8}}); + let fetch = MockFetch::new(vec![Canned::json(&body)]); + let mut opts = EmbeddingCallOptions::new("sunny day at the beach"); + opts.values.push("rainy day in the city".into()); + let result = provider(&fetch) + .embedding("test-embedding") + .do_embed(&opts) + .await + .unwrap(); + assert_request( + &fetch, + "/embeddings", + json!({"model":"test-embedding", + "input":["sunny day at the beach","rainy day in the city"],"encoding_format":"float"}), + ); + assert_eq!( + result.embeddings, + vec![vec![0.1, 0.2, 0.3, 0.4, 0.5], vec![0.6, 0.7, 0.8, 0.9, 1.0]] + ); + assert_eq!(result.usage.unwrap().tokens, 8); + let response = result.response.unwrap(); + assert_eq!(response.body, Some(body)); + assert_eq!( + response.headers.unwrap()["content-type"], + "application/json" + ); + assert!(result.warnings.is_empty()); + } +} diff --git a/aimux-providers/src/openai_compatible/tests/image/image_model.rs b/aimux-providers/src/openai_compatible/tests/image/image_model.rs index 8f5e2cab1..ec1131fc8 100644 --- a/aimux-providers/src/openai_compatible/tests/image/image_model.rs +++ b/aimux-providers/src/openai_compatible/tests/image/image_model.rs @@ -15,7 +15,8 @@ use aimux_core::shared::{AspectRatio, Size}; use aimux_provider_utils::{HeaderMapOpt, ProviderErrorParts}; use crate::openai_compatible::OpenAICompatibleImageModel; -use crate::openai_compatible::config::{BaseUrl, ChatSettings, CompatModelConfig, ErrorStructure}; +use crate::openai_compatible::error::ErrorStructure; +use crate::openai_compatible::{BaseUrl, ChatSettings, CompatModelConfig}; use crate::shared::{Credential, ProviderHeaders}; use crate::test_support::mock_fetch::{Canned, RouteFetch, Seen}; @@ -843,3 +844,87 @@ mod open_ai_compatible_image_model { } } } + +// ── Older ports (previously `tests/openai_compatible_upstream_e2e_test.rs`) ── +// +// End-to-end subset of the pinned openai-compatible upstream tests. + +mod upstream_e2e { + use aimux_core::AiMuxError; + use aimux_core::image_model::{ImageCallOptions, ImageModel, ImageOutputs}; + use aimux_core::shared::Size; + use serde_json::json; + + use crate::openai_compatible::tests::support::*; + use crate::test_support::mock_fetch::{Canned, MockFetch}; + + const IMAGE_PROMPT: &str = "A photorealistic astronaut riding a horse"; + + fn image_options() -> ImageCallOptions { + let mut opts = ImageCallOptions::new(IMAGE_PROMPT); + opts.size = Some(Size::new(1024, 1024)); + opts + } + + /// partial: `should return the raw b64_json content`; full port in src/openai_compatible/tests/image/image_model.rs. + /// partial: `should map the usage object reported by the provider`; full port in src/openai_compatible/tests/image/image_model.rs. + #[tokio::test] + async fn image_generate_raw_content_and_usage() { + let fetch = MockFetch::new(vec![Canned::json(&json!({"data":[ + {"b64_json":"test1234"},{"b64_json":"test5678"}], + "usage":{"input_tokens":12,"output_tokens":4,"total_tokens":16}}))]); + let mut opts = image_options(); + opts.n = 2; + let result = provider(&fetch) + .image("test-image") + .do_generate(&opts) + .await + .unwrap(); + assert_request( + &fetch, + "/images/generations", + json!({"model":"test-image", + "prompt":IMAGE_PROMPT,"n":2,"size":"1024x1024"}), + ); + let ImageOutputs::Base64(images) = result.images else { + panic!("expected base64 images"); + }; + assert_eq!(images, vec!["test1234", "test5678"]); + let usage = result.usage.unwrap(); + assert_eq!(usage.input_tokens, Some(12)); + assert_eq!(usage.output_tokens, Some(4)); + assert_eq!(usage.total_tokens, Some(16)); + assert_eq!(result.response.model_id.as_deref(), Some("test-image")); + assert_eq!( + result.response.headers.unwrap()["content-type"], + "application/json" + ); + assert!(result.warnings.is_empty()); + } + + /// partial: `should handle API errors with default error structure`; full port in src/openai_compatible/tests/image/image_model.rs. + #[tokio::test] + async fn image_generate_api_error() { + let mut canned = Canned::json(&json!({"error":{"message":"Invalid prompt content", + "type":"invalid_request_error","param":null,"code":null}})); + canned.status = 400; + let fetch = MockFetch::new(vec![canned]); + let error = provider(&fetch) + .image("test-image") + .do_generate(&image_options()) + .await + .unwrap_err(); + assert_request( + &fetch, + "/images/generations", + json!({"model":"test-image", + "prompt":IMAGE_PROMPT,"n":1,"size":"1024x1024"}), + ); + let AiMuxError::ApiCall(error) = error else { + panic!("expected API error"); + }; + assert_eq!(error.message, "Invalid prompt content"); + assert_eq!(error.status_code, Some(400)); + assert_eq!(error.url, format!("{BASE_URL}/images/generations")); + } +} diff --git a/aimux-providers/src/openai_compatible/tests/mod.rs b/aimux-providers/src/openai_compatible/tests/mod.rs index 2b4c89c6c..5bedea63b 100644 --- a/aimux-providers/src/openai_compatible/tests/mod.rs +++ b/aimux-providers/src/openai_compatible/tests/mod.rs @@ -3,4 +3,5 @@ mod completion; mod embedding; mod image; mod provider; +mod support; mod utils; diff --git a/aimux-providers/src/openai_compatible/tests/provider.rs b/aimux-providers/src/openai_compatible/tests/provider.rs index 29205aebf..037dd7908 100644 --- a/aimux-providers/src/openai_compatible/tests/provider.rs +++ b/aimux-providers/src/openai_compatible/tests/provider.rs @@ -18,8 +18,8 @@ use aimux_core::language_model_message::LanguageModelMessage; use aimux_core::options::CallOptions; use aimux_core::types::ProviderMetadata; +use crate::openai_compatible::CompatModelConfig; use crate::openai_compatible::completion::{CompletionConfig, OpenAICompatibleCompletionModel}; -use crate::openai_compatible::config::CompatModelConfig; use crate::openai_compatible::{ MetadataExtractor, OpenAICompatibleProvider, OpenAICompatibleProviderSettings, StreamMetadataExtractor, create_openai_compatible, @@ -411,3 +411,904 @@ mod open_ai_compatible_provider { } } } + +// ── Older ports (previously `tests/openai_compatible_factory_test.rs`) ── +// +// `create_openai_compatible` against the recorded AI SDK behavior. +// +// `fixtures/aisdk/openai-compatible/*.json` hold what +// `@ai-sdk/openai-compatible`'s `createOpenAICompatible` sent over a mocked +// fetch for a fixed input: URL, headers, body and the model's `provider` +// string. Each fixture is replayed here through an injected +// [`Fetch`] (the factory's `fetch` setting), so the test also proves the +// transport is the one the settings name. The expected request comes from the +// fixture; the inputs are rebuilt from the fixture's recorded `sdk.input`. +// +// The recorded `authorization` is redacted, so the key the case used is +// substituted back. The user-agent aimux sends is checked against the +// recorded one (package and provider-utils parts). + +mod recorded_fixtures { + use std::collections::{BTreeMap, HashMap, VecDeque}; + use std::path::PathBuf; + use std::sync::{Arc, Mutex}; + + use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; + use aimux_core::language_model::LanguageModel; + use aimux_core::language_model_message::{ + AssistantPart, LanguageModelMessage, LanguageModelPrompt, TextPart, + }; + use aimux_core::options::CallOptions; + use aimux_core::result::{GenerateContent, GenerateResult}; + use aimux_core::stream_part::StreamPart; + use aimux_core::tool::{FunctionTool, LanguageModelV4ToolChoice, Tool}; + use aimux_core::types::FinishReasonUnified; + use aimux_provider_utils::{ + Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse, HeaderMapOpt, + }; + use async_trait::async_trait; + use bytes::Bytes; + use futures::StreamExt; + use reqwest::StatusCode; + use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; + use serde_json::{Value, json}; + + use crate::openai_compatible::{ + OpenAICompatibleProvider, OpenAICompatibleProviderSettings, create_openai_compatible, + }; + + // ── injected transport ─────────────────────────────────────────────────────── + + const KEY: &str = "sk-test-fixture"; + + const ENV_KEY: &str = "sk-env-should-not-be-used"; + + #[derive(Clone)] + struct Canned { + status: u16, + headers: Vec<(String, String)>, + body: Vec, + } + + impl Canned { + fn json(body: &Value) -> Self { + Self { + status: 200, + headers: vec![("content-type".into(), "application/json".into())], + body: serde_json::to_vec(body).unwrap(), + } + } + } + + /// One request as the transport saw it. + #[derive(Clone, Debug)] + struct Seen { + method: String, + url: String, + /// Lower-cased names (the `http` crate normalizes them). + headers: BTreeMap, + body: Vec, + } + + impl Seen { + fn json_body(&self) -> Value { + serde_json::from_slice(&self.body).expect("request body is JSON") + } + } + + #[derive(Default)] + struct MockFetch { + canned: Mutex>, + seen: Mutex>, + } + + impl MockFetch { + fn new(canned: Vec) -> Arc { + Arc::new(Self { + canned: Mutex::new(canned.into()), + seen: Mutex::default(), + }) + } + + fn seen(&self) -> Vec { + self.seen.lock().unwrap().clone() + } + + fn transport(self: &Arc) -> FetchFunction { + self.clone() + } + } + + #[async_trait] + impl Fetch for MockFetch { + async fn fetch(&self, request: FetchRequest) -> Result { + self.seen.lock().unwrap().push(Seen { + method: request.method.to_string(), + url: request.url.to_string(), + headers: request + .headers + .iter() + .map(|(name, value)| { + ( + name.as_str().to_string(), + value.to_str().expect("ASCII header").to_string(), + ) + }) + .collect(), + body: request.body.to_vec(), + }); + let canned = self + .canned + .lock() + .unwrap() + .pop_front() + .ok_or_else(|| FetchError::Other("no canned response left".into()))?; + let mut headers = HeaderMap::new(); + for (name, value) in &canned.headers { + headers.insert( + HeaderName::try_from(name.as_str()).unwrap(), + HeaderValue::try_from(value.as_str()).unwrap(), + ); + } + Ok(FetchResponse::from_bytes( + StatusCode::from_u16(canned.status).unwrap(), + headers, + request.url, + Bytes::from(canned.body), + )) + } + } + + /// The settings every Groq-named fixture was recorded with + /// (`scripts/aisdk-fixtures/cases/openai-compatible.mjs`). + fn groq_settings(mock: &Arc) -> OpenAICompatibleProviderSettings { + OpenAICompatibleProviderSettings { + name: "groq".to_string(), + base_url: "https://api.groq.com/openai/v1".to_string(), + api_key: Some(KEY.to_string()), + fetch: Some(mock.transport()), + ..Default::default() + } + } + + /// Sets (or removes) an environment variable and restores it on drop. Tests + /// that use it are `#[serial]`. + struct EnvVar { + name: &'static str, + saved: Option, + } + + impl EnvVar { + fn set(name: &'static str, value: Option<&str>) -> Self { + let saved = std::env::var(name).ok(); + unsafe { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + Self { name, saved } + } + } + + impl Drop for EnvVar { + fn drop(&mut self) { + unsafe { + match &self.saved { + Some(value) => std::env::set_var(self.name, value), + None => std::env::remove_var(self.name), + } + } + } + } + + // ── fixtures ───────────────────────────────────────────────────────────────── + + /// Every fixture under `fixtures/aisdk/openai/`; the directory listing is + /// compared to this so a new fixture cannot go unreplayed. + const FIXTURES: [&str; 7] = [ + "chat-basic", + "chat-no-api-key", + "chat-provider-options-generic-key-unknown", + "chat-provider-options-namespace", + "chat-query-params-and-headers", + "chat-stream-basic", + "embedding-basic", + ]; + + fn fixtures_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fixtures/aisdk/openai-compatible") + } + + struct Fixture { + name: &'static str, + json: Value, + } + + impl Fixture { + fn load(name: &'static str) -> Self { + assert!(FIXTURES.contains(&name), "unlisted fixture {name}"); + let path = fixtures_dir().join(format!("{name}.json")); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())); + Self { + name, + json: serde_json::from_str(&text).expect("fixture is JSON"), + } + } + + fn provider_string(&self) -> &str { + self.json["model"]["provider"].as_str().unwrap() + } + + fn model_id(&self) -> &str { + self.json["model"]["modelId"].as_str().unwrap() + } + + fn sdk_input(&self) -> &Value { + &self.json["sdk"]["input"] + } + + /// The transport answering with the recorded response (or a plain chat + /// completion for the case that never reached the wire). + fn mock(&self) -> Arc { + let response = &self.json["response"]; + if response.is_null() { + return MockFetch::new(vec![Canned::json(&json!({ + "id": "chatcmpl-unused", "object": "chat.completion", "created": 1, + "model": "gpt-4o", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "x"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} + }))]); + } + let body = match &response["body"] { + Value::String(text) => text.clone().into_bytes(), + other => serde_json::to_vec(other).unwrap(), + }; + MockFetch::new(vec![Canned { + status: response["status"].as_u64().unwrap() as u16, + headers: response["headers"] + .as_object() + .unwrap() + .iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string())) + .collect(), + body, + }]) + } + + /// The recorded request must equal what went out: method, URL, headers + /// (names case-insensitively; user-agent checked against the recording) and body. + /// `authorization` is the value the redacted recording stands for, or + /// `None` when the recording carries no `authorization` header at all. + fn assert_request(&self, seen: &Seen, authorization: Option<&str>) { + let recorded = &self.json["request"]; + assert_eq!( + seen.method, + recorded["method"].as_str().unwrap(), + "{}", + self.name + ); + assert_eq!(seen.url, recorded["url"].as_str().unwrap(), "{}", self.name); + + let mut expected: BTreeMap = recorded["headers"] + .as_object() + .unwrap() + .iter() + .map(|(k, v)| (k.to_ascii_lowercase(), v.as_str().unwrap().to_string())) + .collect(); + // The user-agent is checked against the recording, not overwritten: + // aimux sends `ai-sdk-openai-compatible/ ai-sdk-provider-utils/`; every + // recording carries the same provider-utils part, and a provider-layer + // recording (not made through `ai`) starts with the same package part. + let recorded_ua = expected.remove("user-agent").expect("recorded user-agent"); + let mut sent_headers = seen.headers.clone(); + let sent_ua = sent_headers.remove("user-agent").expect("sent user-agent"); + let (package_ua, utils_ua) = sent_ua + .split_once(' ') + .expect("package and provider-utils user-agent parts"); + assert!( + package_ua.starts_with("ai-sdk-openai-compatible/") + && utils_ua.starts_with("ai-sdk-provider-utils/"), + "{}: sent user-agent {sent_ua:?}", + self.name + ); + assert!( + recorded_ua.contains(&format!(" {utils_ua} ")), + "{}: recorded user-agent {recorded_ua:?} lacks {utils_ua:?}", + self.name + ); + if recorded_ua.starts_with("ai-sdk-openai-compatible/") { + assert!( + recorded_ua.starts_with(&format!("{package_ua} ")), + "{}: recorded user-agent {recorded_ua:?} does not start with {package_ua:?}", + self.name + ); + } + match authorization { + Some(value) => { + assert_eq!( + expected.get("authorization").map(String::as_str), + Some(""), + "{}: the recording redacts the key", + self.name + ); + expected.insert("authorization".to_string(), value.to_string()); + } + None => assert!( + !expected.contains_key("authorization"), + "{}: the recording carries no authorization", + self.name + ), + } + assert_eq!(sent_headers, expected, "{}: headers", self.name); + + assert_eq!(seen.json_body(), recorded["body"], "{}: body", self.name); + } + + /// The call options the recording was made with. + fn call_options(&self) -> CallOptions { + call_options_from(self.sdk_input()) + } + } + + fn message(role: &str, text: &str) -> LanguageModelMessage { + match role { + "system" => LanguageModelMessage::System { + content: text.to_string(), + provider_options: None, + }, + "user" => LanguageModelMessage::user_text(text), + "assistant" => LanguageModelMessage::Assistant { + content: vec![AssistantPart::Text(TextPart { + text: text.to_string(), + provider_options: None, + })], + provider_options: None, + }, + other => unreachable!("fixture has no {other} messages"), + } + } + + /// Rebuild aimux call options from a recorded `sdk.input` (the subset the + /// OpenAI fixtures use). + fn call_options_from(input: &Value) -> CallOptions { + let mut prompt: LanguageModelPrompt = Vec::new(); + if let Some(system) = input.get("system").and_then(Value::as_str) { + prompt.push(message("system", system)); + } + if let Some(text) = input.get("prompt").and_then(Value::as_str) { + prompt.push(message("user", text)); + } + for entry in input + .get("messages") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + prompt.push(message( + entry["role"].as_str().unwrap(), + entry["content"].as_str().unwrap(), + )); + } + let mut options = CallOptions::new(prompt); + options.temperature = input.get("temperature").and_then(Value::as_f64); + options.top_p = input.get("topP").and_then(Value::as_f64); + options.max_output_tokens = input + .get("maxOutputTokens") + .and_then(Value::as_u64) + .map(|v| v as u32); + options.seed = input.get("seed").and_then(Value::as_u64); + options.stop_sequences = input + .get("stopSequences") + .and_then(Value::as_array) + .map(|a| a.iter().map(|v| v.as_str().unwrap().to_string()).collect()); + if let Some(tools) = input.get("tools").and_then(Value::as_object) { + options.tools = Some( + tools + .iter() + .map(|(name, tool)| { + let mut function = + FunctionTool::new(name.clone(), tool["inputSchema"].clone()); + if let Some(description) = tool["description"].as_str() { + function = function.with_description(description); + } + Tool::Function(function) + }) + .collect(), + ); + } + options.tool_choice = match input.get("toolChoice").and_then(Value::as_str) { + Some("required") => Some(LanguageModelV4ToolChoice::Required), + Some("none") => Some(LanguageModelV4ToolChoice::None), + Some("auto") => Some(LanguageModelV4ToolChoice::Auto), + None => options + .tools + .as_ref() + .filter(|tools| !tools.is_empty()) + .map(|_| LanguageModelV4ToolChoice::Auto), + Some(other) => panic!("unmapped toolChoice {other}"), + }; + options.provider_options = input + .get("providerOptions") + .map(|o| serde_json::from_value(o.clone()).unwrap()); + options.headers = input.get("headers").and_then(Value::as_object).map(|h| { + h.iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string())) + .collect() + }); + options + } + + fn generated_text(result: &GenerateResult) -> String { + result + .content + .iter() + .filter_map(|part| match part { + GenerateContent::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect() + } + + /// Result fields every recorded `generateText` case pins. + fn assert_generate_result(fixture: &Fixture, result: &GenerateResult) { + let recorded = &fixture.json["result"]; + assert_eq!( + generated_text(result), + recorded["text"].as_str().unwrap(), + "{}: text", + fixture.name + ); + assert_eq!( + result.response.as_ref().and_then(|r| r.id.as_deref()), + recorded["response"]["id"].as_str(), + "{}: response id", + fixture.name + ); + assert_eq!( + result.response.as_ref().and_then(|r| r.model_id.as_deref()), + recorded["response"]["modelId"].as_str(), + "{}: response model", + fixture.name + ); + assert_eq!( + result.usage.input_tokens.total, + recorded["usage"]["inputTokens"].as_u64().map(|v| v as _), + "{}: input tokens", + fixture.name + ); + assert_eq!( + result.usage.output_tokens.total, + recorded["usage"]["outputTokens"].as_u64().map(|v| v as _), + "{}: output tokens", + fixture.name + ); + let unified = match recorded["finishReason"].as_str().unwrap() { + "stop" => FinishReasonUnified::Stop, + "tool-calls" => FinishReasonUnified::ToolCalls, + other => panic!("unmapped finish reason {other}"), + }; + assert_eq!( + result.finish_reason.unified, unified, + "{}: finish", + fixture.name + ); + if let Some(raw) = recorded["rawFinishReason"].as_str() { + assert_eq!( + result.finish_reason.raw.as_deref(), + Some(raw), + "{}: raw finish", + fixture.name + ); + } + } + + fn bearer(key: &str) -> String { + format!("Bearer {key}") + } + + fn opt_map(entries: &[(&str, Option<&str>)]) -> HeaderMapOpt { + entries + .iter() + .map(|(k, v)| ((*k).to_string(), v.map(str::to_string))) + .collect() + } + + // ── the fixtures ───────────────────────────────────────────────────────────── + + #[test] + fn every_fixture_is_replayed() { + let mut on_disk: Vec = std::fs::read_dir(fixtures_dir()) + .expect("fixtures/aisdk/openai-compatible exists") + .map(|entry| { + let name = entry.unwrap().file_name().into_string().unwrap(); + name.strip_suffix(".json") + .unwrap_or_else(|| panic!("unexpected file {name}")) + .to_string() + }) + .collect(); + on_disk.sort(); + assert_eq!( + on_disk, FIXTURES, + "add a test for the new fixture and list it" + ); + } + + /// Metadata is reported under the provider name, with the namespace entry + /// present even when empty. + fn assert_metadata_key(fixture: &Fixture, result: &GenerateResult, key: &str) { + assert_eq!( + fixture.json["result"]["providerMetadata"], + json!({ key: {} }), + "{}: recorded", + fixture.name + ); + assert_eq!( + serde_json::to_value(result.provider_metadata.as_ref().unwrap()).unwrap(), + json!({ key: {} }), + "{}: metadata key", + fixture.name + ); + } + + /// Replay a `generateText` fixture recorded with the Groq-named provider. + async fn replay_groq_chat(name: &'static str) -> GenerateResult { + let fixture = Fixture::load(name); + let mock = fixture.mock(); + let provider = create_openai_compatible(groq_settings(&mock)).unwrap(); + let model = provider.chat(fixture.model_id()); + assert_eq!(model.provider(), "groq.chat"); + assert_eq!( + model.provider(), + fixture.provider_string(), + "{name}: provider()" + ); + assert_eq!(model.model_id(), fixture.model_id()); + + let result = model.do_generate(&fixture.call_options()).await.unwrap(); + + let seen = mock.seen(); + assert_eq!(seen.len(), 1, "{name}: exactly one request"); + fixture.assert_request(&seen[0], Some(&bearer(KEY))); + assert_generate_result(&fixture, &result); + assert_metadata_key(&fixture, &result, "groq"); + result + } + + #[tokio::test] + async fn chat_basic() { + replay_groq_chat("chat-basic").await; + } + + /// partial: `should include provider-specific options`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. + #[tokio::test] + async fn chat_provider_options_namespace() { + // `groq.foo` passes through at the top level; `openaiCompatible.user` is a + // schema field. + replay_groq_chat("chat-provider-options-namespace").await; + let fixture = Fixture::load("chat-provider-options-namespace"); + assert_eq!( + fixture.json["observations"], + json!({"bodyHasFooAtTopLevel": true, "bodyUser": "u1", "providerIsGroqChat": true}) + ); + } + + #[tokio::test] + async fn unknown_fields_of_the_generic_namespace_are_not_forwarded() { + replay_groq_chat("chat-provider-options-generic-key-unknown").await; + let fixture = Fixture::load("chat-provider-options-generic-key-unknown"); + assert_eq!( + fixture.json["observations"]["bodyHasExtraField"], + json!(false) + ); + } + + /// partial: `should pass headers`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. + /// partial: `should create provider with correct configuration`; full port in src/openai_compatible/tests/provider.rs. + #[tokio::test] + async fn chat_query_params_and_headers() { + let fixture = Fixture::load("chat-query-params-and-headers"); + let mock = fixture.mock(); + // The recording was made with headers { "x-a": "1", "X-B": "2" } and + // queryParams { "api-version": "1" }. + let provider = create_openai_compatible(OpenAICompatibleProviderSettings { + headers: Some(opt_map(&[("x-a", Some("1")), ("X-B", Some("2"))])), + query_params: Some(HashMap::from([( + "api-version".to_string(), + "1".to_string(), + )])), + ..groq_settings(&mock) + }) + .unwrap(); + let model = provider.chat(fixture.model_id()); + + let result = model.do_generate(&fixture.call_options()).await.unwrap(); + + let seen = mock.seen(); + fixture.assert_request(&seen[0], Some(&bearer(KEY))); + assert_generate_result(&fixture, &result); + // Case-insensitive override: the call's `x-b` replaced the provider's `X-B`. + assert_eq!(seen[0].headers["x-b"], "3"); + let sent: Vec<&String> = seen[0] + .headers + .keys() + .filter(|n| n.starts_with("x-")) + .collect(); + assert_eq!(sent, ["x-a", "x-b", "x-c"]); + assert_eq!( + fixture.json["observations"]["headerNamesAsSent"], + json!(["x-a", "x-b", "x-c"]) + ); + } + + /// partial: `should create headers without authorization when no apiKey provided`; full port in src/openai_compatible/tests/provider.rs. + #[tokio::test] + #[serial_test::serial] + async fn chat_without_an_api_key_sends_no_authorization() { + let fixture = Fixture::load("chat-no-api-key"); + let mock = fixture.mock(); + // A key in the environment must not leak in either. + let _env = EnvVar::set("OPENAI_API_KEY", Some(ENV_KEY)); + let provider = create_openai_compatible(OpenAICompatibleProviderSettings { + name: "local".to_string(), + base_url: "http://localhost:1234/v1".to_string(), + fetch: Some(mock.transport()), + ..Default::default() + }) + .expect("no key is needed to create the provider"); + let model = provider.chat(fixture.model_id()); + assert_eq!(model.provider(), "local.chat"); + assert_eq!(model.provider(), fixture.provider_string()); + + let result = model.do_generate(&fixture.call_options()).await.unwrap(); + + let seen = mock.seen(); + assert_eq!(seen.len(), 1); + fixture.assert_request(&seen[0], None); + assert!(!seen[0].headers.contains_key("authorization")); + assert_eq!( + fixture.json["observations"]["hasAuthorizationHeader"], + json!(false) + ); + assert_generate_result(&fixture, &result); + assert_metadata_key(&fixture, &result, "local"); + } + + /// `supportedUrls` is read through the settings function each time, and its + /// value may be async (openai-compatible@3.0.62 + /// `openai-compatible-chat-language-model.ts:84`, `:161-163`); completion + /// models get none (`openai-compatible-provider.ts:189-193`). + #[tokio::test] + async fn chat_supported_urls_come_from_the_async_settings_function() { + let mock = MockFetch::new(Vec::new()); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = calls.clone(); + let provider = create_openai_compatible(OpenAICompatibleProviderSettings { + supported_urls: Some(Arc::new(move || { + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Box::pin(async { + tokio::task::yield_now().await; + aimux_core::language_model::SupportedUrls(HashMap::from([( + "image/*".to_string(), + vec![regex::Regex::new("^https://").unwrap()], + )])) + }) + })), + ..groq_settings(&mock) + }) + .unwrap(); + let chat = provider.chat("m"); + for _ in 0..2 { + let urls = chat.supported_urls().await; + assert_eq!(urls.0.keys().collect::>(), ["image/*"]); + } + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2); + assert!(provider.completion("m").supported_urls().await.is_empty()); + assert!(mock.seen().is_empty()); + } + + #[tokio::test] + async fn chat_stream_basic() { + let fixture = Fixture::load("chat-stream-basic"); + let mock = fixture.mock(); + let model = create_openai_compatible(groq_settings(&mock)) + .unwrap() + .chat(fixture.model_id()); + assert_eq!(model.provider(), fixture.provider_string()); + + let result = model.do_stream(&fixture.call_options()).await.unwrap(); + let parts: Vec = result + .stream + .map(|part| part.expect("no stream error")) + .collect() + .await; + + // Unlike the native OpenAI package, the baseline asks for no + // `stream_options` unless `include_usage` is set: the recorded body has + // none. (The usage chunk is read anyway when the server sends it.) The + // recording's user-agent names the provider package. + assert!( + fixture.json["request"]["headers"]["user-agent"] + .as_str() + .unwrap() + .starts_with("ai-sdk-openai-compatible/"), + "the stream case is a provider-layer recording" + ); + let seen = mock.seen(); + assert_eq!(seen.len(), 1); + fixture.assert_request(&seen[0], Some(&bearer(KEY))); + assert!(seen[0].json_body().get("stream_options").is_none()); + + let text: String = parts + .iter() + .filter_map(|part| match part { + StreamPart::TextDelta { delta, .. } => Some(delta.as_str()), + _ => None, + }) + .collect(); + assert_eq!(text, "Hello world"); + let (reason, usage, metadata) = parts + .iter() + .find_map(|part| match part { + StreamPart::Finish { + finish_reason, + usage, + provider_metadata, + } => Some((finish_reason, usage, provider_metadata)), + _ => None, + }) + .expect("a finish part"); + assert_eq!(reason.unified, FinishReasonUnified::Stop); + assert_eq!(usage.input_tokens.total, Some(12)); + assert_eq!(usage.output_tokens.total, Some(2)); + assert_eq!( + serde_json::to_value(metadata.as_ref().unwrap()).unwrap(), + json!({"groq": {}}) + ); + } + + /// partial: `should extract embedding`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. + /// partial: `should extract usage`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. + /// partial: `should pass the model and the values`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. + #[tokio::test] + async fn embedding_basic() { + let fixture = Fixture::load("embedding-basic"); + let mock = fixture.mock(); + let model = create_openai_compatible(groq_settings(&mock)) + .unwrap() + .embedding(fixture.model_id()); + assert_eq!(model.provider(), "groq.embedding"); + assert_eq!(model.provider(), fixture.provider_string()); + + let values: Vec = fixture.sdk_input()["values"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect(); + let options = EmbeddingCallOptions { + values, + ..EmbeddingCallOptions::new("") + }; + let result = model.do_embed(&options).await.unwrap(); + + fixture.assert_request(&mock.seen()[0], Some(&bearer(KEY))); + let recorded: Vec> = + serde_json::from_value(fixture.json["result"]["embeddings"].clone()).unwrap(); + assert_eq!(result.embeddings, recorded); + assert_eq!( + result.usage.map(|u| u.tokens), + fixture.json["result"]["usage"]["tokens"] + .as_u64() + .map(|v| v as u32) + ); + } + + fn _assert_provider_is_shareable() { + fn shareable() {} + shareable::(); + } + + // ── compat request body key order ──────────────────────────────────────────── + + /// One chat request with `options`; returns the body bytes as sent. + async fn sent_chat_body(options: CallOptions) -> String { + let mock = MockFetch::new(vec![Canned::json(&json!({ + "id": "chatcmpl-order", "object": "chat.completion", "created": 1, + "model": "m", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "x"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} + }))]); + let provider = create_openai_compatible(groq_settings(&mock)).unwrap(); + provider.chat("m").do_generate(&options).await.unwrap(); + let seen = mock.seen(); + assert_eq!(seen.len(), 1); + String::from_utf8(seen[0].body.clone()).unwrap() + } + + /// Byte offset of top-level key `key` in a compact JSON object. + fn key_at(body: &str, key: &str) -> usize { + body.find(&format!("\"{key}\":")) + .unwrap_or_else(|| panic!("{key} missing in {body}")) + } + + /// A user "hi" prompt with a `weather` tool, `toolChoice: auto` and the given + /// `groq` provider options. + fn tool_call_options(groq: Value) -> CallOptions { + let mut options = CallOptions::new(vec![LanguageModelMessage::user_text("hi")]); + options.temperature = Some(0.5); + options.tools = Some(vec![Tool::Function(FunctionTool::new( + "weather".to_string(), + json!({"type": "object", "properties": {}}), + ))]); + options.tool_choice = Some(LanguageModelV4ToolChoice::Auto); + options.provider_options = Some(serde_json::from_value(json!({ "groq": groq })).unwrap()); + options + } + + /// The body bytes follow openai-compatible@3.0.62 + /// `openai-compatible-chat-language-model.ts:270-325` (the alignment doc's + /// "compat 请求体顺序"): model, user → sampling, response_format, stop, seed + /// → unknown provider-namespace fields → reasoning_effort, verbosity → + /// messages, tools, tool_choice. `serde_json::Value` ignores order, so this + /// asserts on the bytes. Ported from li's fork PR #35 (f9c9653b). + #[tokio::test] + async fn compat_body_keys_follow_the_documented_order() { + let mut options = tool_call_options(json!({ + "zzzUnknown": 1, + "user": "u1", + "reasoningEffort": "low", + "textVerbosity": "low" + })); + options.stop_sequences = Some(vec!["END".to_string()]); + options.seed = Some(7); + let body = sent_chat_body(options).await; + let order = [ + "model", + "user", + "temperature", + "stop", + "seed", + "zzzUnknown", + "reasoning_effort", + "verbosity", + "messages", + "tools", + "tool_choice", + ]; + let at: Vec = order.iter().map(|k| key_at(&body, k)).collect(); + for (i, pair) in at.windows(2).enumerate() { + assert!( + pair[0] < pair[1], + "{} must come before {}: {body}", + order[i], + order[i + 1] + ); + } + } + + /// An unknown field may override earlier fields (sampling), but not the + /// fields assigned after it: `messages` / `tools` / `tool_choice` from the + /// provider namespace are replaced by the real ones (the same spread order). + #[tokio::test] + async fn unknown_fields_override_sampling_but_not_messages_or_tools() { + let body = sent_chat_body(tool_call_options(json!({ + "temperature": 0.9, + "messages": "bogus", + "tools": "bogus", + "tool_choice": "bogus" + }))) + .await; + let sent: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(sent["temperature"], json!(0.9), "{body}"); + assert_eq!(sent["messages"][0]["role"], json!("user"), "{body}"); + assert_eq!( + sent["tools"][0]["function"]["name"], + json!("weather"), + "{body}" + ); + assert_eq!(sent["tool_choice"], json!("auto"), "{body}"); + assert!(!body.contains("bogus"), "{body}"); + } +} diff --git a/aimux-providers/src/openai_compatible/tests/support.rs b/aimux-providers/src/openai_compatible/tests/support.rs new file mode 100644 index 000000000..2dbc96a99 --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/support.rs @@ -0,0 +1,61 @@ +//! The harness of the older end-to-end ports (previously +//! `tests/openai_compatible_upstream_e2e_test.rs`), shared by their parts in +//! `chat/`, `completion/`, `embedding/` and `image/`. + +use std::sync::Arc; + +use aimux_core::language_model_message::LanguageModelMessage; +use aimux_core::options::CallOptions; +use serde_json::{Value, json}; + +use crate::openai_compatible::{ + OpenAICompatibleProvider, OpenAICompatibleProviderSettings, create_openai_compatible, +}; +use crate::test_support::mock_fetch::{Canned, MockFetch}; + +pub(super) const BASE_URL: &str = "https://my.api.com/v1"; + +pub(super) fn provider(fetch: &Arc) -> OpenAICompatibleProvider { + create_openai_compatible(OpenAICompatibleProviderSettings { + name: "test-provider".into(), + base_url: BASE_URL.into(), + fetch: Some(fetch.transport()), + ..Default::default() + }) + .unwrap() +} + +pub(super) fn options() -> CallOptions { + CallOptions::new(vec![LanguageModelMessage::user_text("Hello")]) +} + +pub(super) fn chat_response(message: Value, usage: Value) -> Value { + json!({"id":"chatcmpl-test", "created":1711115037, "model":"test-model", + "choices":[{"index":0,"message":message,"finish_reason":"stop"}], + "usage":usage}) +} + +pub(super) fn stream_response(values: &[Value]) -> Canned { + let mut body = values + .iter() + .map(|value| format!("data: {value}\n\n")) + .collect::(); + body.push_str("data: [DONE]\n\n"); + Canned { + status: 200, + headers: vec![("content-type".into(), "text/event-stream".into())], + body: body.into_bytes(), + } +} + +pub(super) fn assert_request(fetch: &MockFetch, path: &str, body: Value) { + let requests = fetch.seen(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].url, format!("{BASE_URL}{path}")); + assert_eq!(requests[0].json_body(), body); +} + +pub(super) fn stream_request() -> Value { + json!({"model":"test-model","messages":[{"role":"user","content":"Hello"}],"stream":true}) +} diff --git a/aimux-providers/src/openai_compatible/tests/utils/to_camel_case.rs b/aimux-providers/src/openai_compatible/tests/utils/to_camel_case.rs index 9f1f703d2..00a8450ed 100644 --- a/aimux-providers/src/openai_compatible/tests/utils/to_camel_case.rs +++ b/aimux-providers/src/openai_compatible/tests/utils/to_camel_case.rs @@ -3,10 +3,11 @@ use serde_json::{Value, json}; use aimux_core::options::CallOptions; use aimux_core::types::Warning; -use crate::openai_compatible::config::ChatSettings; -use crate::openai_compatible::convert::{ - ChatBodySpec, RequestBodyResult, build_request_body, to_camel_case, +use crate::openai_compatible::ChatSettings; +use crate::openai_compatible::chat::chat_language_model::{ + ChatBodySpec, RequestBodyResult, build_request_body, }; +use crate::openai_compatible::utils::to_camel_case; /// `resolveProviderOptionsKey` and `warnIfDeprecatedProviderOptionsKey` have /// no standalone Rust form: both run inside `build_request_body` (the @@ -214,3 +215,21 @@ mod warn_if_deprecated_provider_options_key { assert_eq!(warnings_json(&warnings), json!([])); } } + +// ── Older aimux tests (previously the unit tests of `openai_compatible/convert.rs`) ── + +mod camel_case { + use crate::openai_compatible::utils::to_camel_case; + + #[test] + fn camel_case_follows_the_ai_sdk() { + assert_eq!(to_camel_case("acme"), "acme"); + assert_eq!(to_camel_case("my_provider"), "myProvider"); + assert_eq!(to_camel_case("my-provider"), "myProvider"); + assert_eq!( + to_camel_case("vertex_ai_openai_models"), + "vertexAiOpenaiModels" + ); + assert_eq!(to_camel_case("a_1"), "a_1"); + } +} diff --git a/aimux-providers/src/openai_compatible/utils/mod.rs b/aimux-providers/src/openai_compatible/utils/mod.rs new file mode 100644 index 000000000..01075a880 --- /dev/null +++ b/aimux-providers/src/openai_compatible/utils/mod.rs @@ -0,0 +1,5 @@ +//! Helpers (`src/utils/`). + +mod to_camel_case; + +pub(crate) use to_camel_case::to_camel_case; diff --git a/aimux-providers/src/openai_compatible/utils/to_camel_case.rs b/aimux-providers/src/openai_compatible/utils/to_camel_case.rs new file mode 100644 index 000000000..5a76c4bf0 --- /dev/null +++ b/aimux-providers/src/openai_compatible/utils/to_camel_case.rs @@ -0,0 +1,19 @@ +//! `toCamelCase` (`to-camel-case.ts`). + +/// `snake_case` / `kebab-case` provider name to `camelCase` (the AI SDK's +/// `toCamelCase`). +#[must_use] +pub(crate) fn to_camel_case(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + let mut chars = name.chars().peekable(); + while let Some(ch) = chars.next() { + match chars.peek() { + Some(next) if (ch == '_' || ch == '-') && next.is_ascii_lowercase() => { + out.push(next.to_ascii_uppercase()); + chars.next(); + } + _ => out.push(ch), + } + } + out +} diff --git a/aimux-providers/src/preset.rs b/aimux-providers/src/preset.rs index 0dd0c1717..dc5a791d3 100644 --- a/aimux-providers/src/preset.rs +++ b/aimux-providers/src/preset.rs @@ -37,9 +37,8 @@ use aimux_core::types::Usage; use aimux_provider_utils::{FetchFunction, HeaderMapOpt, Resolvable, validate_base_url}; use crate::openai_compatible::chat::usage_from_raw; -use crate::openai_compatible::config::{BaseUrl, ChatSettings}; use crate::openai_compatible::{ - Assembly, ConvertUsage, OpenAICompatibleProvider, TransformRequestBody, + Assembly, BaseUrl, ChatSettings, ConvertUsage, OpenAICompatibleProvider, TransformRequestBody, }; use crate::shared::Credential; diff --git a/aimux-providers/src/runtime_decision.rs b/aimux-providers/src/runtime_decision.rs index 120da2009..219a5a197 100644 --- a/aimux-providers/src/runtime_decision.rs +++ b/aimux-providers/src/runtime_decision.rs @@ -11,7 +11,7 @@ use crate::decision_support::{ self as support, DecisionConfig, DecisionImage, DecisionProbabilitySource, candidate_count, invalid, provider_metadata, response_info, }; -use crate::openai_compatible::config::CompatModelConfig; +use crate::openai_compatible::CompatModelConfig; pub(crate) fn supported(provider: &str) -> bool { matches!( diff --git a/aimux-providers/src/vertex/partner_provider.rs b/aimux-providers/src/vertex/partner_provider.rs index b6bd4decb..91abac3fa 100644 --- a/aimux-providers/src/vertex/partner_provider.rs +++ b/aimux-providers/src/vertex/partner_provider.rs @@ -26,9 +26,9 @@ use aimux_provider_utils::{ load_setting, validate_base_url, }; -use crate::openai_compatible::config::{BaseUrl, ResolveBaseUrl, ResolveFetchHeaders}; use crate::openai_compatible::{ - OpenAICompatibleChatModel, OpenAICompatibleProvider, OpenAICompatibleProviderSettings, + BaseUrl, OpenAICompatibleChatModel, OpenAICompatibleProvider, OpenAICompatibleProviderSettings, + ResolveBaseUrl, ResolveFetchHeaders, }; use super::auth::{GoogleAuth, GoogleAuthOptions}; diff --git a/aimux-providers/tests/openai_compatible_factory_test.rs b/aimux-providers/tests/openai_compatible_factory_test.rs deleted file mode 100644 index 44da42f92..000000000 --- a/aimux-providers/tests/openai_compatible_factory_test.rs +++ /dev/null @@ -1,893 +0,0 @@ -//! `create_openai_compatible` against the recorded AI SDK behavior. -//! -//! `fixtures/aisdk/openai-compatible/*.json` hold what -//! `@ai-sdk/openai-compatible`'s `createOpenAICompatible` sent over a mocked -//! fetch for a fixed input: URL, headers, body and the model's `provider` -//! string. Each fixture is replayed here through an injected -//! [`Fetch`] (the factory's `fetch` setting), so the test also proves the -//! transport is the one the settings name. The expected request comes from the -//! fixture; the inputs are rebuilt from the fixture's recorded `sdk.input`. -//! -//! The recorded `authorization` is redacted, so the key the case used is -//! substituted back. The user-agent aimux sends is checked against the -//! recorded one (package and provider-utils parts). - -use std::collections::{BTreeMap, HashMap, VecDeque}; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; - -use async_trait::async_trait; -use bytes::Bytes; -use futures::StreamExt; -use reqwest::StatusCode; -use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; -use serde_json::{Value, json}; - -use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{ - AssistantPart, LanguageModelMessage, LanguageModelPrompt, TextPart, -}; -use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::{FunctionTool, LanguageModelV4ToolChoice, Tool}; -use aimux_core::types::FinishReasonUnified; -use aimux_provider_utils::{ - Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse, HeaderMapOpt, -}; -use aimux_providers::openai_compatible::{ - OpenAICompatibleProvider, OpenAICompatibleProviderSettings, create_openai_compatible, -}; - -// ── injected transport ─────────────────────────────────────────────────────── - -const KEY: &str = "sk-test-fixture"; - -const ENV_KEY: &str = "sk-env-should-not-be-used"; - -#[derive(Clone)] -struct Canned { - status: u16, - headers: Vec<(String, String)>, - body: Vec, -} - -impl Canned { - fn json(body: &Value) -> Self { - Self { - status: 200, - headers: vec![("content-type".into(), "application/json".into())], - body: serde_json::to_vec(body).unwrap(), - } - } -} - -/// One request as the transport saw it. -#[derive(Clone, Debug)] -struct Seen { - method: String, - url: String, - /// Lower-cased names (the `http` crate normalizes them). - headers: BTreeMap, - body: Vec, -} - -impl Seen { - fn json_body(&self) -> Value { - serde_json::from_slice(&self.body).expect("request body is JSON") - } -} - -#[derive(Default)] -struct MockFetch { - canned: Mutex>, - seen: Mutex>, -} - -impl MockFetch { - fn new(canned: Vec) -> Arc { - Arc::new(Self { - canned: Mutex::new(canned.into()), - seen: Mutex::default(), - }) - } - - fn seen(&self) -> Vec { - self.seen.lock().unwrap().clone() - } - - fn transport(self: &Arc) -> FetchFunction { - self.clone() - } -} - -#[async_trait] -impl Fetch for MockFetch { - async fn fetch(&self, request: FetchRequest) -> Result { - self.seen.lock().unwrap().push(Seen { - method: request.method.to_string(), - url: request.url.to_string(), - headers: request - .headers - .iter() - .map(|(name, value)| { - ( - name.as_str().to_string(), - value.to_str().expect("ASCII header").to_string(), - ) - }) - .collect(), - body: request.body.to_vec(), - }); - let canned = self - .canned - .lock() - .unwrap() - .pop_front() - .ok_or_else(|| FetchError::Other("no canned response left".into()))?; - let mut headers = HeaderMap::new(); - for (name, value) in &canned.headers { - headers.insert( - HeaderName::try_from(name.as_str()).unwrap(), - HeaderValue::try_from(value.as_str()).unwrap(), - ); - } - Ok(FetchResponse::from_bytes( - StatusCode::from_u16(canned.status).unwrap(), - headers, - request.url, - Bytes::from(canned.body), - )) - } -} - -/// The settings every Groq-named fixture was recorded with -/// (`scripts/aisdk-fixtures/cases/openai-compatible.mjs`). -fn groq_settings(mock: &Arc) -> OpenAICompatibleProviderSettings { - OpenAICompatibleProviderSettings { - name: "groq".to_string(), - base_url: "https://api.groq.com/openai/v1".to_string(), - api_key: Some(KEY.to_string()), - fetch: Some(mock.transport()), - ..Default::default() - } -} - -/// Sets (or removes) an environment variable and restores it on drop. Tests -/// that use it are `#[serial]`. -struct EnvVar { - name: &'static str, - saved: Option, -} - -impl EnvVar { - fn set(name: &'static str, value: Option<&str>) -> Self { - let saved = std::env::var(name).ok(); - unsafe { - match value { - Some(value) => std::env::set_var(name, value), - None => std::env::remove_var(name), - } - } - Self { name, saved } - } -} - -impl Drop for EnvVar { - fn drop(&mut self) { - unsafe { - match &self.saved { - Some(value) => std::env::set_var(self.name, value), - None => std::env::remove_var(self.name), - } - } - } -} - -// ── fixtures ───────────────────────────────────────────────────────────────── - -/// Every fixture under `fixtures/aisdk/openai/`; the directory listing is -/// compared to this so a new fixture cannot go unreplayed. -const FIXTURES: [&str; 7] = [ - "chat-basic", - "chat-no-api-key", - "chat-provider-options-generic-key-unknown", - "chat-provider-options-namespace", - "chat-query-params-and-headers", - "chat-stream-basic", - "embedding-basic", -]; - -fn fixtures_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fixtures/aisdk/openai-compatible") -} - -struct Fixture { - name: &'static str, - json: Value, -} - -impl Fixture { - fn load(name: &'static str) -> Self { - assert!(FIXTURES.contains(&name), "unlisted fixture {name}"); - let path = fixtures_dir().join(format!("{name}.json")); - let text = std::fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())); - Self { - name, - json: serde_json::from_str(&text).expect("fixture is JSON"), - } - } - - fn provider_string(&self) -> &str { - self.json["model"]["provider"].as_str().unwrap() - } - - fn model_id(&self) -> &str { - self.json["model"]["modelId"].as_str().unwrap() - } - - fn sdk_input(&self) -> &Value { - &self.json["sdk"]["input"] - } - - /// The transport answering with the recorded response (or a plain chat - /// completion for the case that never reached the wire). - fn mock(&self) -> Arc { - let response = &self.json["response"]; - if response.is_null() { - return MockFetch::new(vec![Canned::json(&json!({ - "id": "chatcmpl-unused", "object": "chat.completion", "created": 1, - "model": "gpt-4o", - "choices": [{"index": 0, "message": {"role": "assistant", "content": "x"}, "finish_reason": "stop"}], - "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} - }))]); - } - let body = match &response["body"] { - Value::String(text) => text.clone().into_bytes(), - other => serde_json::to_vec(other).unwrap(), - }; - MockFetch::new(vec![Canned { - status: response["status"].as_u64().unwrap() as u16, - headers: response["headers"] - .as_object() - .unwrap() - .iter() - .map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string())) - .collect(), - body, - }]) - } - - /// The recorded request must equal what went out: method, URL, headers - /// (names case-insensitively; user-agent checked against the recording) and body. - /// `authorization` is the value the redacted recording stands for, or - /// `None` when the recording carries no `authorization` header at all. - fn assert_request(&self, seen: &Seen, authorization: Option<&str>) { - let recorded = &self.json["request"]; - assert_eq!( - seen.method, - recorded["method"].as_str().unwrap(), - "{}", - self.name - ); - assert_eq!(seen.url, recorded["url"].as_str().unwrap(), "{}", self.name); - - let mut expected: BTreeMap = recorded["headers"] - .as_object() - .unwrap() - .iter() - .map(|(k, v)| (k.to_ascii_lowercase(), v.as_str().unwrap().to_string())) - .collect(); - // The user-agent is checked against the recording, not overwritten: - // aimux sends `ai-sdk-openai-compatible/ ai-sdk-provider-utils/`; every - // recording carries the same provider-utils part, and a provider-layer - // recording (not made through `ai`) starts with the same package part. - let recorded_ua = expected.remove("user-agent").expect("recorded user-agent"); - let mut sent_headers = seen.headers.clone(); - let sent_ua = sent_headers.remove("user-agent").expect("sent user-agent"); - let (package_ua, utils_ua) = sent_ua - .split_once(' ') - .expect("package and provider-utils user-agent parts"); - assert!( - package_ua.starts_with("ai-sdk-openai-compatible/") - && utils_ua.starts_with("ai-sdk-provider-utils/"), - "{}: sent user-agent {sent_ua:?}", - self.name - ); - assert!( - recorded_ua.contains(&format!(" {utils_ua} ")), - "{}: recorded user-agent {recorded_ua:?} lacks {utils_ua:?}", - self.name - ); - if recorded_ua.starts_with("ai-sdk-openai-compatible/") { - assert!( - recorded_ua.starts_with(&format!("{package_ua} ")), - "{}: recorded user-agent {recorded_ua:?} does not start with {package_ua:?}", - self.name - ); - } - match authorization { - Some(value) => { - assert_eq!( - expected.get("authorization").map(String::as_str), - Some(""), - "{}: the recording redacts the key", - self.name - ); - expected.insert("authorization".to_string(), value.to_string()); - } - None => assert!( - !expected.contains_key("authorization"), - "{}: the recording carries no authorization", - self.name - ), - } - assert_eq!(sent_headers, expected, "{}: headers", self.name); - - assert_eq!(seen.json_body(), recorded["body"], "{}: body", self.name); - } - - /// The call options the recording was made with. - fn call_options(&self) -> CallOptions { - call_options_from(self.sdk_input()) - } -} - -fn message(role: &str, text: &str) -> LanguageModelMessage { - match role { - "system" => LanguageModelMessage::System { - content: text.to_string(), - provider_options: None, - }, - "user" => LanguageModelMessage::user_text(text), - "assistant" => LanguageModelMessage::Assistant { - content: vec![AssistantPart::Text(TextPart { - text: text.to_string(), - provider_options: None, - })], - provider_options: None, - }, - other => unreachable!("fixture has no {other} messages"), - } -} - -/// Rebuild aimux call options from a recorded `sdk.input` (the subset the -/// OpenAI fixtures use). -fn call_options_from(input: &Value) -> CallOptions { - let mut prompt: LanguageModelPrompt = Vec::new(); - if let Some(system) = input.get("system").and_then(Value::as_str) { - prompt.push(message("system", system)); - } - if let Some(text) = input.get("prompt").and_then(Value::as_str) { - prompt.push(message("user", text)); - } - for entry in input - .get("messages") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - prompt.push(message( - entry["role"].as_str().unwrap(), - entry["content"].as_str().unwrap(), - )); - } - let mut options = CallOptions::new(prompt); - options.temperature = input.get("temperature").and_then(Value::as_f64); - options.top_p = input.get("topP").and_then(Value::as_f64); - options.max_output_tokens = input - .get("maxOutputTokens") - .and_then(Value::as_u64) - .map(|v| v as u32); - options.seed = input.get("seed").and_then(Value::as_u64); - options.stop_sequences = input - .get("stopSequences") - .and_then(Value::as_array) - .map(|a| a.iter().map(|v| v.as_str().unwrap().to_string()).collect()); - if let Some(tools) = input.get("tools").and_then(Value::as_object) { - options.tools = Some( - tools - .iter() - .map(|(name, tool)| { - let mut function = FunctionTool::new(name.clone(), tool["inputSchema"].clone()); - if let Some(description) = tool["description"].as_str() { - function = function.with_description(description); - } - Tool::Function(function) - }) - .collect(), - ); - } - options.tool_choice = match input.get("toolChoice").and_then(Value::as_str) { - Some("required") => Some(LanguageModelV4ToolChoice::Required), - Some("none") => Some(LanguageModelV4ToolChoice::None), - Some("auto") => Some(LanguageModelV4ToolChoice::Auto), - None => options - .tools - .as_ref() - .filter(|tools| !tools.is_empty()) - .map(|_| LanguageModelV4ToolChoice::Auto), - Some(other) => panic!("unmapped toolChoice {other}"), - }; - options.provider_options = input - .get("providerOptions") - .map(|o| serde_json::from_value(o.clone()).unwrap()); - options.headers = input.get("headers").and_then(Value::as_object).map(|h| { - h.iter() - .map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string())) - .collect() - }); - options -} - -fn generated_text(result: &GenerateResult) -> String { - result - .content - .iter() - .filter_map(|part| match part { - GenerateContent::Text { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect() -} - -/// Result fields every recorded `generateText` case pins. -fn assert_generate_result(fixture: &Fixture, result: &GenerateResult) { - let recorded = &fixture.json["result"]; - assert_eq!( - generated_text(result), - recorded["text"].as_str().unwrap(), - "{}: text", - fixture.name - ); - assert_eq!( - result.response.as_ref().and_then(|r| r.id.as_deref()), - recorded["response"]["id"].as_str(), - "{}: response id", - fixture.name - ); - assert_eq!( - result.response.as_ref().and_then(|r| r.model_id.as_deref()), - recorded["response"]["modelId"].as_str(), - "{}: response model", - fixture.name - ); - assert_eq!( - result.usage.input_tokens.total, - recorded["usage"]["inputTokens"].as_u64().map(|v| v as _), - "{}: input tokens", - fixture.name - ); - assert_eq!( - result.usage.output_tokens.total, - recorded["usage"]["outputTokens"].as_u64().map(|v| v as _), - "{}: output tokens", - fixture.name - ); - let unified = match recorded["finishReason"].as_str().unwrap() { - "stop" => FinishReasonUnified::Stop, - "tool-calls" => FinishReasonUnified::ToolCalls, - other => panic!("unmapped finish reason {other}"), - }; - assert_eq!( - result.finish_reason.unified, unified, - "{}: finish", - fixture.name - ); - if let Some(raw) = recorded["rawFinishReason"].as_str() { - assert_eq!( - result.finish_reason.raw.as_deref(), - Some(raw), - "{}: raw finish", - fixture.name - ); - } -} - -fn bearer(key: &str) -> String { - format!("Bearer {key}") -} -fn opt_map(entries: &[(&str, Option<&str>)]) -> HeaderMapOpt { - entries - .iter() - .map(|(k, v)| ((*k).to_string(), v.map(str::to_string))) - .collect() -} - -// ── the fixtures ───────────────────────────────────────────────────────────── - -#[test] -fn every_fixture_is_replayed() { - let mut on_disk: Vec = std::fs::read_dir(fixtures_dir()) - .expect("fixtures/aisdk/openai-compatible exists") - .map(|entry| { - let name = entry.unwrap().file_name().into_string().unwrap(); - name.strip_suffix(".json") - .unwrap_or_else(|| panic!("unexpected file {name}")) - .to_string() - }) - .collect(); - on_disk.sort(); - assert_eq!( - on_disk, FIXTURES, - "add a test for the new fixture and list it" - ); -} - -/// Metadata is reported under the provider name, with the namespace entry -/// present even when empty. -fn assert_metadata_key(fixture: &Fixture, result: &GenerateResult, key: &str) { - assert_eq!( - fixture.json["result"]["providerMetadata"], - json!({ key: {} }), - "{}: recorded", - fixture.name - ); - assert_eq!( - serde_json::to_value(result.provider_metadata.as_ref().unwrap()).unwrap(), - json!({ key: {} }), - "{}: metadata key", - fixture.name - ); -} - -/// Replay a `generateText` fixture recorded with the Groq-named provider. -async fn replay_groq_chat(name: &'static str) -> GenerateResult { - let fixture = Fixture::load(name); - let mock = fixture.mock(); - let provider = create_openai_compatible(groq_settings(&mock)).unwrap(); - let model = provider.chat(fixture.model_id()); - assert_eq!(model.provider(), "groq.chat"); - assert_eq!( - model.provider(), - fixture.provider_string(), - "{name}: provider()" - ); - assert_eq!(model.model_id(), fixture.model_id()); - - let result = model.do_generate(&fixture.call_options()).await.unwrap(); - - let seen = mock.seen(); - assert_eq!(seen.len(), 1, "{name}: exactly one request"); - fixture.assert_request(&seen[0], Some(&bearer(KEY))); - assert_generate_result(&fixture, &result); - assert_metadata_key(&fixture, &result, "groq"); - result -} - -#[tokio::test] -async fn chat_basic() { - replay_groq_chat("chat-basic").await; -} - -/// partial: `should include provider-specific options`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. -#[tokio::test] -async fn chat_provider_options_namespace() { - // `groq.foo` passes through at the top level; `openaiCompatible.user` is a - // schema field. - replay_groq_chat("chat-provider-options-namespace").await; - let fixture = Fixture::load("chat-provider-options-namespace"); - assert_eq!( - fixture.json["observations"], - json!({"bodyHasFooAtTopLevel": true, "bodyUser": "u1", "providerIsGroqChat": true}) - ); -} - -#[tokio::test] -async fn unknown_fields_of_the_generic_namespace_are_not_forwarded() { - replay_groq_chat("chat-provider-options-generic-key-unknown").await; - let fixture = Fixture::load("chat-provider-options-generic-key-unknown"); - assert_eq!( - fixture.json["observations"]["bodyHasExtraField"], - json!(false) - ); -} - -/// partial: `should pass headers`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. -/// partial: `should create provider with correct configuration`; full port in src/openai_compatible/tests/provider.rs. -#[tokio::test] -async fn chat_query_params_and_headers() { - let fixture = Fixture::load("chat-query-params-and-headers"); - let mock = fixture.mock(); - // The recording was made with headers { "x-a": "1", "X-B": "2" } and - // queryParams { "api-version": "1" }. - let provider = create_openai_compatible(OpenAICompatibleProviderSettings { - headers: Some(opt_map(&[("x-a", Some("1")), ("X-B", Some("2"))])), - query_params: Some(HashMap::from([( - "api-version".to_string(), - "1".to_string(), - )])), - ..groq_settings(&mock) - }) - .unwrap(); - let model = provider.chat(fixture.model_id()); - - let result = model.do_generate(&fixture.call_options()).await.unwrap(); - - let seen = mock.seen(); - fixture.assert_request(&seen[0], Some(&bearer(KEY))); - assert_generate_result(&fixture, &result); - // Case-insensitive override: the call's `x-b` replaced the provider's `X-B`. - assert_eq!(seen[0].headers["x-b"], "3"); - let sent: Vec<&String> = seen[0] - .headers - .keys() - .filter(|n| n.starts_with("x-")) - .collect(); - assert_eq!(sent, ["x-a", "x-b", "x-c"]); - assert_eq!( - fixture.json["observations"]["headerNamesAsSent"], - json!(["x-a", "x-b", "x-c"]) - ); -} - -/// partial: `should create headers without authorization when no apiKey provided`; full port in src/openai_compatible/tests/provider.rs. -#[tokio::test] -async fn chat_without_an_api_key_sends_no_authorization() { - let fixture = Fixture::load("chat-no-api-key"); - let mock = fixture.mock(); - // A key in the environment must not leak in either. - let _env = EnvVar::set("OPENAI_API_KEY", Some(ENV_KEY)); - let provider = create_openai_compatible(OpenAICompatibleProviderSettings { - name: "local".to_string(), - base_url: "http://localhost:1234/v1".to_string(), - fetch: Some(mock.transport()), - ..Default::default() - }) - .expect("no key is needed to create the provider"); - let model = provider.chat(fixture.model_id()); - assert_eq!(model.provider(), "local.chat"); - assert_eq!(model.provider(), fixture.provider_string()); - - let result = model.do_generate(&fixture.call_options()).await.unwrap(); - - let seen = mock.seen(); - assert_eq!(seen.len(), 1); - fixture.assert_request(&seen[0], None); - assert!(!seen[0].headers.contains_key("authorization")); - assert_eq!( - fixture.json["observations"]["hasAuthorizationHeader"], - json!(false) - ); - assert_generate_result(&fixture, &result); - assert_metadata_key(&fixture, &result, "local"); -} - -/// `supportedUrls` is read through the settings function each time, and its -/// value may be async (openai-compatible@3.0.62 -/// `openai-compatible-chat-language-model.ts:84`, `:161-163`); completion -/// models get none (`openai-compatible-provider.ts:189-193`). -#[tokio::test] -async fn chat_supported_urls_come_from_the_async_settings_function() { - let mock = MockFetch::new(Vec::new()); - let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let counter = calls.clone(); - let provider = create_openai_compatible(OpenAICompatibleProviderSettings { - supported_urls: Some(Arc::new(move || { - counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Box::pin(async { - tokio::task::yield_now().await; - aimux_core::language_model::SupportedUrls(HashMap::from([( - "image/*".to_string(), - vec![regex::Regex::new("^https://").unwrap()], - )])) - }) - })), - ..groq_settings(&mock) - }) - .unwrap(); - let chat = provider.chat("m"); - for _ in 0..2 { - let urls = chat.supported_urls().await; - assert_eq!(urls.0.keys().collect::>(), ["image/*"]); - } - assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2); - assert!(provider.completion("m").supported_urls().await.is_empty()); - assert!(mock.seen().is_empty()); -} - -#[tokio::test] -async fn chat_stream_basic() { - let fixture = Fixture::load("chat-stream-basic"); - let mock = fixture.mock(); - let model = create_openai_compatible(groq_settings(&mock)) - .unwrap() - .chat(fixture.model_id()); - assert_eq!(model.provider(), fixture.provider_string()); - - let result = model.do_stream(&fixture.call_options()).await.unwrap(); - let parts: Vec = result - .stream - .map(|part| part.expect("no stream error")) - .collect() - .await; - - // Unlike the native OpenAI package, the baseline asks for no - // `stream_options` unless `include_usage` is set: the recorded body has - // none. (The usage chunk is read anyway when the server sends it.) The - // recording's user-agent names the provider package. - assert!( - fixture.json["request"]["headers"]["user-agent"] - .as_str() - .unwrap() - .starts_with("ai-sdk-openai-compatible/"), - "the stream case is a provider-layer recording" - ); - let seen = mock.seen(); - assert_eq!(seen.len(), 1); - fixture.assert_request(&seen[0], Some(&bearer(KEY))); - assert!(seen[0].json_body().get("stream_options").is_none()); - - let text: String = parts - .iter() - .filter_map(|part| match part { - StreamPart::TextDelta { delta, .. } => Some(delta.as_str()), - _ => None, - }) - .collect(); - assert_eq!(text, "Hello world"); - let (reason, usage, metadata) = parts - .iter() - .find_map(|part| match part { - StreamPart::Finish { - finish_reason, - usage, - provider_metadata, - } => Some((finish_reason, usage, provider_metadata)), - _ => None, - }) - .expect("a finish part"); - assert_eq!(reason.unified, FinishReasonUnified::Stop); - assert_eq!(usage.input_tokens.total, Some(12)); - assert_eq!(usage.output_tokens.total, Some(2)); - assert_eq!( - serde_json::to_value(metadata.as_ref().unwrap()).unwrap(), - json!({"groq": {}}) - ); -} - -/// partial: `should extract embedding`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. -/// partial: `should extract usage`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. -/// partial: `should pass the model and the values`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. -#[tokio::test] -async fn embedding_basic() { - let fixture = Fixture::load("embedding-basic"); - let mock = fixture.mock(); - let model = create_openai_compatible(groq_settings(&mock)) - .unwrap() - .embedding(fixture.model_id()); - assert_eq!(model.provider(), "groq.embedding"); - assert_eq!(model.provider(), fixture.provider_string()); - - let values: Vec = fixture.sdk_input()["values"] - .as_array() - .unwrap() - .iter() - .map(|v| v.as_str().unwrap().to_string()) - .collect(); - let options = EmbeddingCallOptions { - values, - ..EmbeddingCallOptions::new("") - }; - let result = model.do_embed(&options).await.unwrap(); - - fixture.assert_request(&mock.seen()[0], Some(&bearer(KEY))); - let recorded: Vec> = - serde_json::from_value(fixture.json["result"]["embeddings"].clone()).unwrap(); - assert_eq!(result.embeddings, recorded); - assert_eq!( - result.usage.map(|u| u.tokens), - fixture.json["result"]["usage"]["tokens"] - .as_u64() - .map(|v| v as u32) - ); -} - -fn _assert_provider_is_shareable() { - fn shareable() {} - shareable::(); -} - -// ── compat request body key order ──────────────────────────────────────────── - -/// One chat request with `options`; returns the body bytes as sent. -async fn sent_chat_body(options: CallOptions) -> String { - let mock = MockFetch::new(vec![Canned::json(&json!({ - "id": "chatcmpl-order", "object": "chat.completion", "created": 1, - "model": "m", - "choices": [{"index": 0, "message": {"role": "assistant", "content": "x"}, "finish_reason": "stop"}], - "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} - }))]); - let provider = create_openai_compatible(groq_settings(&mock)).unwrap(); - provider.chat("m").do_generate(&options).await.unwrap(); - let seen = mock.seen(); - assert_eq!(seen.len(), 1); - String::from_utf8(seen[0].body.clone()).unwrap() -} - -/// Byte offset of top-level key `key` in a compact JSON object. -fn key_at(body: &str, key: &str) -> usize { - body.find(&format!("\"{key}\":")) - .unwrap_or_else(|| panic!("{key} missing in {body}")) -} - -/// A user "hi" prompt with a `weather` tool, `toolChoice: auto` and the given -/// `groq` provider options. -fn tool_call_options(groq: Value) -> CallOptions { - let mut options = CallOptions::new(vec![LanguageModelMessage::user_text("hi")]); - options.temperature = Some(0.5); - options.tools = Some(vec![Tool::Function(FunctionTool::new( - "weather".to_string(), - json!({"type": "object", "properties": {}}), - ))]); - options.tool_choice = Some(LanguageModelV4ToolChoice::Auto); - options.provider_options = Some(serde_json::from_value(json!({ "groq": groq })).unwrap()); - options -} - -/// The body bytes follow openai-compatible@3.0.62 -/// `openai-compatible-chat-language-model.ts:270-325` (the alignment doc's -/// "compat 请求体顺序"): model, user → sampling, response_format, stop, seed -/// → unknown provider-namespace fields → reasoning_effort, verbosity → -/// messages, tools, tool_choice. `serde_json::Value` ignores order, so this -/// asserts on the bytes. Ported from li's fork PR #35 (f9c9653b). -#[tokio::test] -async fn compat_body_keys_follow_the_documented_order() { - let mut options = tool_call_options(json!({ - "zzzUnknown": 1, - "user": "u1", - "reasoningEffort": "low", - "textVerbosity": "low" - })); - options.stop_sequences = Some(vec!["END".to_string()]); - options.seed = Some(7); - let body = sent_chat_body(options).await; - let order = [ - "model", - "user", - "temperature", - "stop", - "seed", - "zzzUnknown", - "reasoning_effort", - "verbosity", - "messages", - "tools", - "tool_choice", - ]; - let at: Vec = order.iter().map(|k| key_at(&body, k)).collect(); - for (i, pair) in at.windows(2).enumerate() { - assert!( - pair[0] < pair[1], - "{} must come before {}: {body}", - order[i], - order[i + 1] - ); - } -} - -/// An unknown field may override earlier fields (sampling), but not the -/// fields assigned after it: `messages` / `tools` / `tool_choice` from the -/// provider namespace are replaced by the real ones (the same spread order). -#[tokio::test] -async fn unknown_fields_override_sampling_but_not_messages_or_tools() { - let body = sent_chat_body(tool_call_options(json!({ - "temperature": 0.9, - "messages": "bogus", - "tools": "bogus", - "tool_choice": "bogus" - }))) - .await; - let sent: Value = serde_json::from_str(&body).unwrap(); - assert_eq!(sent["temperature"], json!(0.9), "{body}"); - assert_eq!(sent["messages"][0]["role"], json!("user"), "{body}"); - assert_eq!( - sent["tools"][0]["function"]["name"], - json!("weather"), - "{body}" - ); - assert_eq!(sent["tool_choice"], json!("auto"), "{body}"); - assert!(!body.contains("bogus"), "{body}"); -} diff --git a/aimux-providers/tests/openai_compatible_upstream_e2e_test.rs b/aimux-providers/tests/openai_compatible_upstream_e2e_test.rs deleted file mode 100644 index 2a3ccaa0a..000000000 --- a/aimux-providers/tests/openai_compatible_upstream_e2e_test.rs +++ /dev/null @@ -1,599 +0,0 @@ -//! End-to-end subset of the pinned openai-compatible upstream tests. - -#[path = "common/mock_fetch.rs"] -mod mock_fetch; - -use std::sync::Arc; - -use aimux_core::AiMuxError; -use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; -use aimux_core::image_model::{ImageCallOptions, ImageModel, ImageOutputs}; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::LanguageModelMessage; -use aimux_core::options::CallOptions; -use aimux_core::result::GenerateContent; -use aimux_core::shared::{Size, provider_namespace}; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::FunctionTool; -use aimux_core::types::FinishReasonUnified; -use aimux_providers::openai_compatible::{ - OpenAICompatibleProvider, OpenAICompatibleProviderSettings, create_openai_compatible, -}; -use futures::StreamExt; -use mock_fetch::{Canned, MockFetch}; -use serde_json::{Value, json}; - -const BASE_URL: &str = "https://my.api.com/v1"; -const IMAGE_PROMPT: &str = "A photorealistic astronaut riding a horse"; - -fn provider(fetch: &Arc) -> OpenAICompatibleProvider { - create_openai_compatible(OpenAICompatibleProviderSettings { - name: "test-provider".into(), - base_url: BASE_URL.into(), - fetch: Some(fetch.transport()), - ..Default::default() - }) - .unwrap() -} - -fn options() -> CallOptions { - CallOptions::new(vec![LanguageModelMessage::user_text("Hello")]) -} - -fn chat_response(message: Value, usage: Value) -> Value { - json!({"id":"chatcmpl-test", "created":1711115037, "model":"test-model", - "choices":[{"index":0,"message":message,"finish_reason":"stop"}], - "usage":usage}) -} - -fn stream_response(values: &[Value]) -> Canned { - let mut body = values - .iter() - .map(|value| format!("data: {value}\n\n")) - .collect::(); - body.push_str("data: [DONE]\n\n"); - Canned { - status: 200, - headers: vec![("content-type".into(), "text/event-stream".into())], - body: body.into_bytes(), - } -} - -fn assert_request(fetch: &MockFetch, path: &str, body: Value) { - let requests = fetch.seen(); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].method, "POST"); - assert_eq!(requests[0].url, format!("{BASE_URL}{path}")); - assert_eq!(requests[0].json_body(), body); -} - -fn stream_request() -> Value { - json!({"model":"test-model","messages":[{"role":"user","content":"Hello"}],"stream":true}) -} - -/// partial: `should use camelCase metadata key when camelCase provider options are used`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. -/// partial: `should extract detailed token usage when available`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. -#[tokio::test] -async fn chat_generate_metadata_and_usage() { - let usage = json!({"prompt_tokens":20,"completion_tokens":30,"total_tokens":50, - "prompt_tokens_details":{"cached_tokens":5}, - "completion_tokens_details":{"reasoning_tokens":10,"accepted_prediction_tokens":15,"rejected_prediction_tokens":5}}); - let fetch = MockFetch::new(vec![Canned::json(&chat_response( - json!({"content":"Hello!"}), - usage.clone(), - ))]); - let mut opts = options(); - opts.provider_options = Some( - provider_namespace("testProvider", json!({"reasoningEffort":"high"})) - .expect("provider metadata must be an object"), - ); - let result = provider(&fetch) - .chat("test-model") - .do_generate(&opts) - .await - .unwrap(); - let expected = json!({"model":"test-model","messages":[{"role":"user","content":"Hello"}],"reasoning_effort":"high"}); - assert_request(&fetch, "/chat/completions", expected.clone()); - assert_eq!(result.request.unwrap().body, Some(expected)); - assert_eq!(result.content.len(), 1); - assert!(matches!(&result.content[0], GenerateContent::Text { text, .. } if text == "Hello!")); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.finish_reason.raw.as_deref(), Some("stop")); - assert_eq!(result.usage.input_tokens.total, Some(20)); - assert_eq!(result.usage.input_tokens.no_cache, Some(15)); - assert_eq!(result.usage.input_tokens.cache_read, Some(5)); - assert_eq!(result.usage.input_tokens.cache_write, None); - assert_eq!(result.usage.output_tokens.total, Some(30)); - assert_eq!(result.usage.output_tokens.text, Some(20)); - assert_eq!(result.usage.output_tokens.reasoning, Some(10)); - assert_eq!(result.usage.raw, usage.as_object().cloned()); - let metadata = result.provider_metadata.unwrap(); - assert_eq!( - metadata, - provider_namespace( - "testProvider", - json!({"acceptedPredictionTokens":15,"rejectedPredictionTokens":5}) - ) - .expect("provider metadata must be an object") - ); - assert!(!metadata.contains_key("test-provider")); - let response = result.response.unwrap(); - assert_eq!(response.id.as_deref(), Some("chatcmpl-test")); - assert_eq!(response.model_id.as_deref(), Some("test-model")); - assert_eq!( - response.headers.unwrap()["content-type"], - "application/json" - ); - assert!(result.warnings.is_empty()); -} - -/// partial: `should stream reasoning content before text deltas`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. -#[tokio::test] -async fn chat_stream_reasoning_before_text() { - let delta = |value: Value| { - json!({"id":"chatcmpl-test","created":1711357598, - "model":"test-model","choices":[{"index":0,"delta":value,"finish_reason":null}]}) - }; - let fetch = MockFetch::new(vec![stream_response(&[ - delta(json!({"role":"assistant","content":"","reasoning_content":"Let me think"})), - delta(json!({"content":"","reasoning_content":" about this"})), - delta(json!({"content":"Here's"})), - delta(json!({"content":" my response"})), - json!({"choices":[{"index":0,"delta":{},"finish_reason":"stop"}], - "usage":{"prompt_tokens":18,"completion_tokens":439}}), - ])]); - let result = provider(&fetch) - .chat("test-model") - .do_stream(&options()) - .await - .unwrap(); - assert_request(&fetch, "/chat/completions", stream_request()); - assert_eq!(result.request.unwrap().body, Some(stream_request())); - let parts: Vec<_> = result.stream.map(|part| part.unwrap()).collect().await; - assert_eq!(parts.len(), 11); - assert!(matches!(&parts[0], StreamPart::StreamStart { warnings } if warnings.is_empty())); - assert!( - matches!(&parts[1], StreamPart::ResponseMetadata(metadata) if metadata.id.as_deref() == Some("chatcmpl-test") && metadata.model_id.as_deref() == Some("test-model")) - ); - assert!(matches!(&parts[2], StreamPart::ReasoningStart { id, .. } if id == "reasoning-0")); - assert!(matches!(&parts[5], StreamPart::ReasoningEnd { id, .. } if id == "reasoning-0")); - assert!(matches!(&parts[6], StreamPart::TextStart { id, .. } if id == "txt-0")); - assert!(matches!(&parts[9], StreamPart::TextEnd { id, .. } if id == "txt-0")); - let deltas: Vec<_> = parts - .iter() - .filter_map(|part| match part { - StreamPart::ReasoningDelta { id, delta, .. } => { - Some(("reasoning", id.as_str(), delta.as_str())) - } - StreamPart::TextDelta { id, delta, .. } => Some(("text", id.as_str(), delta.as_str())), - _ => None, - }) - .collect(); - assert_eq!( - deltas, - vec![ - ("reasoning", "reasoning-0", "Let me think"), - ("reasoning", "reasoning-0", " about this"), - ("text", "txt-0", "Here's"), - ("text", "txt-0", " my response") - ] - ); - let StreamPart::Finish { - finish_reason, - usage, - provider_metadata, - } = &parts[10] - else { - panic!("expected finish"); - }; - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(usage.input_tokens.total, Some(18)); - assert_eq!(usage.input_tokens.no_cache, Some(18)); - assert_eq!(usage.input_tokens.cache_read, Some(0)); - assert_eq!(usage.output_tokens.total, Some(439)); - assert_eq!(usage.output_tokens.text, Some(439)); - assert_eq!(usage.output_tokens.reasoning, Some(0)); - assert_eq!( - provider_metadata.as_ref().unwrap(), - &provider_namespace("test-provider", json!({})) - .expect("provider metadata must be an object") - ); -} - -/// partial: `should parse thought signature from extra_content and include in providerMetadata`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. -#[tokio::test] -async fn chat_tool_call_preserves_arguments_and_signature() { - let fetch = MockFetch::new(vec![Canned::json(&chat_response( - json!({"tool_calls":[{ - "id":"function-call-1","type":"function", - "function":{"name":"check_flight","arguments":"{\"flight\":\"AA100\"}"}, - "extra_content":{"google":{"thought_signature":""}}}]}), - Value::Null, - ))]); - let schema = json!({"type":"object","properties":{"flight":{"type":"string"}}, - "required":["flight"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}); - let mut opts = options(); - opts.tools = Some(vec![ - FunctionTool::new("check_flight", schema.clone()).into(), - ]); - let result = provider(&fetch) - .chat("test-model") - .do_generate(&opts) - .await - .unwrap(); - assert_request( - &fetch, - "/chat/completions", - json!({"model":"test-model", - "messages":[{"role":"user","content":"Hello"}], - "tools":[{"type":"function","function":{"name":"check_flight","parameters":schema}}]}), - ); - assert_eq!(result.content.len(), 1); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.finish_reason.raw.as_deref(), Some("stop")); - let GenerateContent::ToolCall(call) = &result.content[0] else { - panic!("expected tool call"); - }; - assert_eq!(call.tool_call_id, "function-call-1"); - assert_eq!(call.tool_name, "check_flight"); - assert_eq!(call.input, "{\"flight\":\"AA100\"}"); - assert_eq!( - call.provider_metadata.as_ref().unwrap(), - &provider_namespace("test-provider", json!({"thoughtSignature":""})) - .expect("provider metadata must be an object") - ); -} - -/// partial: `should preserve structured error stream parts`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. -#[tokio::test] -async fn chat_stream_preserves_structured_error() { - let fetch = MockFetch::new(vec![stream_response(&[json!({"error":{ - "message":"Context length exceeded","code":"CONTEXT_LENGTH_EXCEEDED"}})])]); - let result = provider(&fetch) - .chat("test-model") - .do_stream(&options()) - .await - .unwrap(); - assert_request(&fetch, "/chat/completions", stream_request()); - let parts: Vec<_> = result.stream.map(|part| part.unwrap()).collect().await; - assert_eq!(parts.len(), 3); - assert!(matches!(&parts[0], StreamPart::StreamStart { warnings } if warnings.is_empty())); - let StreamPart::Error { - error: AiMuxError::ApiCall(error), - } = &parts[1] - else { - panic!("expected structured API error"); - }; - assert_eq!(error.message, "Context length exceeded"); - assert_eq!( - error.provider_code.as_deref(), - Some("CONTEXT_LENGTH_EXCEEDED") - ); - let StreamPart::Finish { - finish_reason, - usage, - provider_metadata, - } = &parts[2] - else { - panic!("expected finish"); - }; - assert_eq!(finish_reason.unified, FinishReasonUnified::Error); - assert_eq!(finish_reason.raw, None); - assert_eq!(usage.input_tokens.total, None); - assert_eq!(usage.output_tokens.total, None); - assert_eq!(usage.raw, None); - assert_eq!( - provider_metadata.as_ref().unwrap(), - &provider_namespace("test-provider", json!({})) - .expect("provider metadata must be an object") - ); -} - -/// Upstream: @ai-sdk/openai-compatible 3.0.62 -/// src/chat/openai-compatible-chat-language-model.ts:588-595 passes an error -/// chunk's `error` on as it is: even a numeric `code` that looks like an HTTP -/// status is no status, so the error is not retryable. -#[tokio::test] -async fn chat_stream_error_takes_no_status_from_a_numeric_code() { - for (code, provider_code) in [ - (json!(429), "429"), - (json!(503), "503"), - (json!("429"), "429"), - ] { - let payload = json!({"message": "Slow down", "code": code}); - let fetch = MockFetch::new(vec![stream_response(&[json!({ "error": payload })])]); - let parts: Vec<_> = provider(&fetch) - .chat("test-model") - .do_stream(&options()) - .await - .unwrap() - .stream - .map(|part| part.unwrap()) - .collect() - .await; - let error = parts - .iter() - .find_map(|part| match part { - StreamPart::Error { - error: AiMuxError::ApiCall(error), - } => Some(error), - _ => None, - }) - .expect("expected an API error part"); - assert_eq!(error.message, "Slow down", "{code}"); - assert_eq!( - error.provider_code.as_deref(), - Some(provider_code), - "{code}" - ); - assert_eq!(error.status_code, None, "{code}"); - assert!(!error.is_retryable, "{code}"); - assert_eq!(error.data.as_ref(), Some(&payload), "{code}"); - } -} - -/// Upstream: @ai-sdk/openai-compatible 3.0.62 -/// src/completion/openai-compatible-completion-language-model.ts:320-324 -/// passes an error chunk on as it is (`error: value.error`): unlike -/// @ai-sdk/openai, no status is inferred from its `code`/`type`. -#[tokio::test] -async fn completion_stream_error_infers_no_status() { - let fetch = MockFetch::new(vec![stream_response(&[json!({"error":{ - "message":"Slow down","type":"requests","code":"rate_limit_exceeded"}})])]); - let parts: Vec<_> = provider(&fetch) - .completion("test-model") - .do_stream(&options()) - .await - .unwrap() - .stream - .map(|part| part.unwrap()) - .collect() - .await; - let error = parts - .iter() - .find_map(|part| match part { - StreamPart::Error { - error: AiMuxError::ApiCall(error), - } => Some(error), - _ => None, - }) - .expect("expected an API error part"); - assert_eq!(error.message, "Slow down"); - assert_eq!(error.provider_code.as_deref(), Some("rate_limit_exceeded")); - assert_eq!(error.status_code, None); - assert!(!error.is_retryable); -} - -/// partial: `should extract embedding`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. -/// partial: `should pass the model and the values`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. -#[tokio::test] -async fn embedding_request_and_result() { - let body = json!({"object":"list","data":[ - {"object":"embedding","index":0,"embedding":[0.1,0.2,0.3,0.4,0.5]}, - {"object":"embedding","index":1,"embedding":[0.6,0.7,0.8,0.9,1.0]}], - "model":"test-embedding","usage":{"prompt_tokens":8,"total_tokens":8}}); - let fetch = MockFetch::new(vec![Canned::json(&body)]); - let mut opts = EmbeddingCallOptions::new("sunny day at the beach"); - opts.values.push("rainy day in the city".into()); - let result = provider(&fetch) - .embedding("test-embedding") - .do_embed(&opts) - .await - .unwrap(); - assert_request( - &fetch, - "/embeddings", - json!({"model":"test-embedding", - "input":["sunny day at the beach","rainy day in the city"],"encoding_format":"float"}), - ); - assert_eq!( - result.embeddings, - vec![vec![0.1, 0.2, 0.3, 0.4, 0.5], vec![0.6, 0.7, 0.8, 0.9, 1.0]] - ); - assert_eq!(result.usage.unwrap().tokens, 8); - let response = result.response.unwrap(); - assert_eq!(response.body, Some(body)); - assert_eq!( - response.headers.unwrap()["content-type"], - "application/json" - ); - assert!(result.warnings.is_empty()); -} - -fn image_options() -> ImageCallOptions { - let mut opts = ImageCallOptions::new(IMAGE_PROMPT); - opts.size = Some(Size::new(1024, 1024)); - opts -} - -/// partial: `should return the raw b64_json content`; full port in src/openai_compatible/tests/image/image_model.rs. -/// partial: `should map the usage object reported by the provider`; full port in src/openai_compatible/tests/image/image_model.rs. -#[tokio::test] -async fn image_generate_raw_content_and_usage() { - let fetch = MockFetch::new(vec![Canned::json(&json!({"data":[ - {"b64_json":"test1234"},{"b64_json":"test5678"}], - "usage":{"input_tokens":12,"output_tokens":4,"total_tokens":16}}))]); - let mut opts = image_options(); - opts.n = 2; - let result = provider(&fetch) - .image("test-image") - .do_generate(&opts) - .await - .unwrap(); - assert_request( - &fetch, - "/images/generations", - json!({"model":"test-image", - "prompt":IMAGE_PROMPT,"n":2,"size":"1024x1024"}), - ); - let ImageOutputs::Base64(images) = result.images else { - panic!("expected base64 images"); - }; - assert_eq!(images, vec!["test1234", "test5678"]); - let usage = result.usage.unwrap(); - assert_eq!(usage.input_tokens, Some(12)); - assert_eq!(usage.output_tokens, Some(4)); - assert_eq!(usage.total_tokens, Some(16)); - assert_eq!(result.response.model_id.as_deref(), Some("test-image")); - assert_eq!( - result.response.headers.unwrap()["content-type"], - "application/json" - ); - assert!(result.warnings.is_empty()); -} - -/// partial: `should handle API errors with default error structure`; full port in src/openai_compatible/tests/image/image_model.rs. -#[tokio::test] -async fn image_generate_api_error() { - let mut canned = Canned::json(&json!({"error":{"message":"Invalid prompt content", - "type":"invalid_request_error","param":null,"code":null}})); - canned.status = 400; - let fetch = MockFetch::new(vec![canned]); - let error = provider(&fetch) - .image("test-image") - .do_generate(&image_options()) - .await - .unwrap_err(); - assert_request( - &fetch, - "/images/generations", - json!({"model":"test-image", - "prompt":IMAGE_PROMPT,"n":1,"size":"1024x1024"}), - ); - let AiMuxError::ApiCall(error) = error else { - panic!("expected API error"); - }; - assert_eq!(error.message, "Invalid prompt content"); - assert_eq!(error.status_code, Some(400)); - assert_eq!(error.url, format!("{BASE_URL}/images/generations")); -} - -/// A tool result whose output is `content` (a text part and a base64 image -/// part), as the AI SDK passes it to the provider. -fn multi_part_tool_prompt( - extra: Option, -) -> CallOptions { - use aimux_core::language_model_message::{ - FilePart, TextPart, ToolPart, ToolResultContent, ToolResultOutput, ToolResultPart, - }; - use aimux_core::shared::{FileBytes, FileData}; - let mut value = vec![ - ToolResultContent::Text(TextPart { - text: "The weather is sunny".into(), - provider_options: None, - }), - ToolResultContent::File(FilePart { - data: FileData::Data { - data: FileBytes::Base64("AAECAw==".into()), - }, - media_type: "image/png".into(), - filename: None, - provider_options: None, - }), - ]; - value.extend(extra); - CallOptions::new(vec![ - LanguageModelMessage::user_text("Weather?"), - LanguageModelMessage::Tool { - content: vec![ToolPart::ToolResult(ToolResultPart { - tool_call_id: "call-1".into(), - tool_name: "weather".into(), - output: ToolResultOutput::Content { value }, - provider_options: None, - })], - provider_options: None, - }, - ]) -} - -fn multi_part_provider( - fetch: &Arc, - supported: Option, -) -> OpenAICompatibleProvider { - create_openai_compatible(OpenAICompatibleProviderSettings { - name: "test-provider".into(), - base_url: BASE_URL.into(), - fetch: Some(fetch.transport()), - supports_multi_part_tool_content: supported, - ..Default::default() - }) - .unwrap() -} - -/// partial: `should pass multi-part tool content when supported`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. -/// partial: `should convert multi-part tool content when supported`; full port in src/openai_compatible/tests/chat/convert_to_chat_messages.rs. -/// partial: `should stringify multi-part tool content by default`; full port in src/openai_compatible/tests/chat/convert_to_chat_messages.rs. -/// Upstream: @ai-sdk/openai-compatible 3.0.62 -/// src/chat/convert-to-openai-compatible-chat-messages.ts:303-306 sends a -/// `content` tool output as an array of content parts -/// (`convertToolContentPart`, :162-176) when `supportsMultiPartToolContent` -/// (openai-compatible-provider.ts:94-100, :183) is set, and as -/// `JSON.stringify(output.value)` otherwise (the default, `false`). -#[tokio::test] -async fn chat_multi_part_tool_content_follows_the_setting() { - let response = || { - Canned::json(&chat_response( - json!({"role":"assistant","content":"ok"}), - json!({"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}), - )) - }; - let fetch = MockFetch::new(vec![response(), response(), response()]); - let tool_message = |index: usize| fetch.seen()[index].json_body()["messages"][1].clone(); - - multi_part_provider(&fetch, Some(true)) - .chat("test-model") - .do_generate(&multi_part_tool_prompt(None)) - .await - .unwrap(); - assert_eq!( - tool_message(0), - json!({ - "role": "tool", - "tool_call_id": "call-1", - "content": [ - {"type": "text", "text": "The weather is sunny"}, - {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAECAw=="}} - ] - }) - ); - - for (index, supported) in [(1, None), (2, Some(false))] { - multi_part_provider(&fetch, supported) - .chat("test-model") - .do_generate(&multi_part_tool_prompt(None)) - .await - .unwrap(); - let message = tool_message(index); - let content: Value = - serde_json::from_str(message["content"].as_str().expect("a JSON string")).unwrap(); - assert_eq!( - content, - json!([ - {"type": "text", "text": "The weather is sunny"}, - {"type": "file", "mediaType": "image/png", "data": {"type": "data", "data": "AAECAw=="}} - ]), - "{supported:?}" - ); - } -} - -/// Upstream: convert-to-openai-compatible-chat-messages.ts:166-170 rejects a -/// custom part in multi-part tool content. -#[tokio::test] -async fn chat_multi_part_tool_content_rejects_a_custom_part() { - let fetch = MockFetch::new(Vec::new()); - let error = multi_part_provider(&fetch, Some(true)) - .chat("test-model") - .do_generate(&multi_part_tool_prompt(Some( - aimux_core::language_model_message::ToolResultContent::Custom { - provider_options: None, - }, - ))) - .await - .unwrap_err(); - assert!( - matches!(&error, AiMuxError::UnsupportedFunctionality(what) if what == "custom tool content parts"), - "{error:?}" - ); - assert!(fetch.seen().is_empty()); -} diff --git a/scripts/check_provider_boundaries.sh b/scripts/check_provider_boundaries.sh index 72b71d382..8c327850b 100755 --- a/scripts/check_provider_boundaries.sh +++ b/scripts/check_provider_boundaries.sh @@ -143,8 +143,10 @@ rule4_hits="$( violation 'provider-name comparison in a shared protocol package (aimux-providers/src/{openai,openai_compatible,anthropic,google,bedrock,cohere})' "$rule4_hits" # `"groq"` / `"deepseek"` literals belong to their own packages and the registry. +# The vendor tests under `tests/` may name them: the openai-compatible fixtures +# were recorded with a provider named `groq`. rule4b_hits="$( - grep -rnE '"(groq|deepseek)"' \ + grep -rnE '"(groq|deepseek)"' --exclude-dir=tests \ aimux-providers/src/openai aimux-providers/src/openai_compatible --include='*.rs' || true )" violation 'groq / deepseek literal in the shared OpenAI or compatible package' "$rule4b_hits" From e41d394f6c719d2424c3d010974e12f59e61c271 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 08:36:12 +0000 Subject: [PATCH 5/5] ci(boundaries): skip only the two vendor test files that name groq or deepseek Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0116HPMoqaLfFr1TyqDq1hrW --- scripts/check_provider_boundaries.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/check_provider_boundaries.sh b/scripts/check_provider_boundaries.sh index 8c327850b..2f4bfc3b1 100755 --- a/scripts/check_provider_boundaries.sh +++ b/scripts/check_provider_boundaries.sh @@ -143,11 +143,14 @@ rule4_hits="$( violation 'provider-name comparison in a shared protocol package (aimux-providers/src/{openai,openai_compatible,anthropic,google,bedrock,cohere})' "$rule4_hits" # `"groq"` / `"deepseek"` literals belong to their own packages and the registry. -# The vendor tests under `tests/` may name them: the openai-compatible fixtures -# were recorded with a provider named `groq`. +# Two vendor test files may name them: the OpenAI evaluation test checks that +# another preset has no decision model, and the openai-compatible fixtures were +# recorded with a provider named `groq`. rule4b_hits="$( - grep -rnE '"(groq|deepseek)"' --exclude-dir=tests \ - aimux-providers/src/openai aimux-providers/src/openai_compatible --include='*.rs' || true + { grep -rnE '"(groq|deepseek)"' \ + aimux-providers/src/openai aimux-providers/src/openai_compatible --include='*.rs' || true; } | + grep -vE '^aimux-providers/src/(openai/tests/evaluation|openai_compatible/tests/provider)\.rs:' || + true )" violation 'groq / deepseek literal in the shared OpenAI or compatible package' "$rule4b_hits"