diff --git a/CHANGELOG.md b/CHANGELOG.md index b3fca1a68..1265be885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1175,6 +1175,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 -- --check` and `python3 scripts/check_dto_vectors.py ` guard the files and the codecs. +- OpenAI's sub-modules follow the `@ai-sdk/openai` files (RFC-0046 §4.2): + `openai::convert`, `openai::model`, `openai::responses::convert`, + `openai::responses::responses_convert` and `openai::responses::types` are no + longer public. `openai::model::OpenAIModel` is `openai::OpenAIModel`, as the + crate root already exports it. The Responses types of `responses::convert` + and `responses::types` are at `openai::responses`: + `openai::responses::{PreparedResponsesTools, parse_usage, ResponsesUsage, + ResponsesInputTokensDetails, ResponsesOutputTokensDetails}`, next to the + functions it already exported. The Chat Completions helpers of `convert` + (`SystemMessageMode`, `ToolWarning`, `PreparedTools`, `prepare_tools`, + `convert_prompt_to_openai_messages_with_mode_fallible`, `RequestBodyResult`, + `build_request_body_with_warnings`, `parse_finish_reason`) and the shared + Responses parser of `responses_convert` (`ResponsesEventStream`, + `build_responses_generate_result`, `build_responses_event_stream`) become + crate-internal, with no public replacement; `build_request_body` and + `build_header_list`, which nothing in the workspace called, are removed. + `openai::completion`, `openai::decision`, `openai::embedding`, + `openai::files`, `openai::image`, `openai::responses`, `openai::speech`, + `openai::tools` and `openai::transcription` keep their paths. + - `aimux_providers::cohere` follows the upstream `@ai-sdk/cohere` file layout (RFC-0046). The sub-modules `cohere::convert`, `cohere::embedding` and `cohere::reranking` are no longer public; use `CohereEmbeddingModel` and diff --git a/aimux-providers/layout.toml b/aimux-providers/layout.toml index cf9917593..b4ce587cb 100644 --- a/aimux-providers/layout.toml +++ b/aimux-providers/layout.toml @@ -1569,133 +1569,133 @@ package = "@ai-sdk/openai" [[openai.files]] upstream = "src/chat/convert-openai-chat-usage.ts" -rust = "openai/model.rs" +rust = "openai/chat/convert_chat_usage.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/chat/convert-to-openai-chat-messages.ts" -rust = "openai/convert.rs" +rust = "openai/chat/convert_to_chat_messages.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/chat/get-response-metadata.ts" -rust = "openai/model.rs" +rust = "openai/chat/chat_language_model.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/chat/map-openai-finish-reason.ts" -rust = "openai/convert.rs" +rust = "openai/chat/map_finish_reason.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/chat/openai-chat-api.ts" -rust = "openai/types.rs" +rust = "openai/chat/chat_api.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/chat/openai-chat-language-model-options.ts" -rust = "openai/options.rs" +rust = "openai/chat/chat_language_model_options.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/chat/openai-chat-language-model.ts" -rust = "openai/model.rs" +rust = "openai/chat/chat_language_model.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/chat/openai-chat-prepare-tools.ts" -rust = "openai/convert.rs" +rust = "openai/chat/chat_prepare_tools.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/chat/openai-chat-prompt.ts" -rust = "openai/convert.rs" +rust = "openai/chat/convert_to_chat_messages.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/completion/convert-openai-completion-usage.ts" -rust = "openai/completion.rs" +rust = "openai/completion/completion_language_model.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/completion/convert-to-openai-completion-prompt.ts" -rust = "openai/completion.rs" +rust = "openai/completion/completion_language_model.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/completion/get-response-metadata.ts" -rust = "openai/completion.rs" +rust = "openai/completion/completion_language_model.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/completion/map-openai-finish-reason.ts" -rust = "openai/completion.rs" +rust = "openai/completion/completion_language_model.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/completion/openai-completion-api.ts" -rust = "openai/completion.rs" +rust = "openai/completion/completion_language_model.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/completion/openai-completion-language-model-options.ts" -rust = "openai/completion.rs" +rust = "openai/completion/completion_language_model.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/completion/openai-completion-language-model.ts" -rust = "openai/completion.rs" +rust = "openai/completion/completion_language_model.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/embedding/openai-embedding-api.ts" -rust = "openai/embedding.rs" +rust = "openai/embedding/embedding_api.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/embedding/openai-embedding-model-options.ts" -rust = "openai/embedding.rs" +rust = "openai/embedding/embedding_model_options.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/embedding/openai-embedding-model.ts" -rust = "openai/embedding.rs" +rust = "openai/embedding/embedding_model.rs" status = "mapped" [openai.files.tests] @@ -1705,21 +1705,21 @@ sub-feature = [ [[openai.files]] upstream = "src/files/openai-files-api.ts" -rust = "openai/files.rs" +rust = "openai/files/files_api.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/files/openai-files-options.ts" -rust = "openai/files.rs" +rust = "openai/files/files_options.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/files/openai-files.ts" -rust = "openai/files.rs" +rust = "openai/files/files.rs" status = "mapped" [openai.files.tests] @@ -1741,21 +1741,21 @@ sub-feature = [ [[openai.files]] upstream = "src/image/openai-image-api.ts" -rust = "openai/image.rs" +rust = "openai/image/image_model.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/image/openai-image-model-options.ts" -rust = "openai/image.rs" +rust = "openai/image/image_model_options.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/image/openai-image-model.ts" -rust = "openai/image.rs" +rust = "openai/image/image_model.rs" status = "mapped" [openai.files.tests] @@ -1803,7 +1803,7 @@ reason = "no OpenAI live (realtime voice) model; aimux has realtime transcriptio [[openai.files]] upstream = "src/normalize-openai-json-schema.ts" -rust = "openai/convert.rs" +rust = "openai/normalize_json_schema.rs" status = "mapped" [openai.files.tests] @@ -1823,28 +1823,28 @@ status = "mapped" [[openai.files]] upstream = "src/openai-error.ts" -rust = "openai/mod.rs" +rust = "openai/error.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/openai-language-model-capabilities.ts" -rust = "openai/convert_common.rs" +rust = "openai/language_model_capabilities.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/openai-provider.ts" -rust = "openai/mod.rs" +rust = "openai/provider.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/openai-stream-error.ts" -rust = "openai/mod.rs" +rust = "openai/stream_error.rs" status = "mapped" [openai.files.tests] @@ -1897,14 +1897,14 @@ reason = "no OpenAI realtime speech-to-speech model; aimux has realtime transcri [[openai.files]] upstream = "src/responses/convert-openai-responses-usage.ts" -rust = "openai/responses/convert.rs" +rust = "openai/responses/convert_responses_usage.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/responses/convert-to-openai-responses-input.ts" -rust = "openai/responses/convert.rs" +rust = "openai/responses/convert_to_responses_input.rs" status = "mapped" [openai.files.tests] @@ -1928,14 +1928,14 @@ reason = "no expansion of the internal `parallel` function-call wrapper (expandP [[openai.files]] upstream = "src/responses/map-openai-responses-finish-reason.ts" -rust = "openai/responses/convert.rs" +rust = "openai/responses/map_responses_finish_reason.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/responses/openai-responses-api.ts" -rust = "openai/responses/types.rs" +rust = "openai/responses/responses_api.rs" status = "mapped" [openai.files.tests] @@ -1952,14 +1952,14 @@ ts-only = [ [[openai.files]] upstream = "src/responses/openai-responses-language-model-options.ts" -rust = "openai/responses/mod.rs" +rust = "openai/responses/responses_language_model_options.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/responses/openai-responses-language-model.ts" -rust = "openai/responses/mod.rs" +rust = "openai/responses/responses_language_model.rs" status = "mapped" [openai.files.tests] @@ -1973,7 +1973,7 @@ sub-feature = [ [[openai.files]] upstream = "src/responses/openai-responses-prepare-tools.ts" -rust = "openai/responses/convert.rs" +rust = "openai/responses/responses_prepare_tools.rs" status = "mapped" [openai.files.tests] @@ -1983,7 +1983,7 @@ sub-feature = [ [[openai.files]] upstream = "src/responses/openai-responses-provider-metadata.ts" -rust = "openai/responses/provider_events.rs" +rust = "openai/responses/responses_language_model.rs" status = "mapped" [openai.files.tests] @@ -2014,133 +2014,133 @@ reason = "no speech translation model (#275)" [[openai.files]] upstream = "src/speech/openai-speech-api.ts" -rust = "openai/speech.rs" +rust = "openai/speech/speech_model.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/speech/openai-speech-model-options.ts" -rust = "openai/speech.rs" +rust = "openai/speech/speech_model.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/speech/openai-speech-model.ts" -rust = "openai/speech.rs" +rust = "openai/speech/speech_model.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/tool/apply-patch.ts" -rust = "openai/tools.rs" +rust = "openai/tool/apply_patch.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/tool/code-interpreter.ts" -rust = "openai/tools.rs" +rust = "openai/tool/code_interpreter.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/tool/computer.ts" -rust = "openai/tools.rs" +rust = "openai/tool/computer.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/tool/custom.ts" -rust = "openai/tools.rs" +rust = "openai/tool/custom.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/tool/file-search.ts" -rust = "openai/tools.rs" +rust = "openai/tool/file_search.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/tool/image-generation.ts" -rust = "openai/tools.rs" +rust = "openai/tool/image_generation.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/tool/local-shell.ts" -rust = "openai/tools.rs" +rust = "openai/tool/local_shell.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/tool/mcp.ts" -rust = "openai/tools.rs" +rust = "openai/tool/mcp.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/tool/programmatic-tool-calling.ts" -rust = "openai/tools.rs" +rust = "openai/tool/programmatic_tool_calling.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/tool/shell.ts" -rust = "openai/tools.rs" +rust = "openai/tool/shell.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/tool/tool-search.ts" -rust = "openai/tools.rs" +rust = "openai/tool/tool_search.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/tool/web-search-preview.ts" -rust = "openai/tools.rs" +rust = "openai/tool/web_search_preview.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/tool/web-search.ts" -rust = "openai/tools.rs" +rust = "openai/tool/web_search.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/transcription/openai-transcription-api.ts" -rust = "openai/transcription.rs" +rust = "openai/transcription/transcription_api.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/transcription/openai-transcription-model-options.ts" -rust = "openai/transcription.rs" +rust = "openai/transcription/transcription_model_options.rs" status = "mapped" [openai.files.tests] [[openai.files]] upstream = "src/transcription/openai-transcription-model.ts" -rust = "openai/transcription.rs" +rust = "openai/transcription/transcription_model.rs" status = "mapped" [openai.files.tests] @@ -2155,3 +2155,13 @@ ts-only = [ upstream = "src/version.ts" status = "not-applicable" reason = "VERSION is the package version string; aimux pins it in shared/aisdk_pins.rs, which the user-agent tests read" + +[[openai.files]] +rust = "openai/decision.rs" +status = "aimux" +reason = "the native Decisions API; upstream decides through the Responses model" + +[[openai.files]] +rust = "openai/responses/tool_args.rs" +status = "aimux" +reason = "the provider tool argument schemas of tool/*.ts, validated in one place" diff --git a/aimux-providers/src/openai/types.rs b/aimux-providers/src/openai/chat/chat_api.rs similarity index 98% rename from aimux-providers/src/openai/types.rs rename to aimux-providers/src/openai/chat/chat_api.rs index a23cd415d..64dde4662 100644 --- a/aimux-providers/src/openai/types.rs +++ b/aimux-providers/src/openai/chat/chat_api.rs @@ -1,4 +1,4 @@ -//! OpenAI API request/response types. +//! Chat Completions response and chunk types (openai-chat-api.ts). #![allow(dead_code)] diff --git a/aimux-providers/src/openai/model.rs b/aimux-providers/src/openai/chat/chat_language_model.rs similarity index 55% rename from aimux-providers/src/openai/model.rs rename to aimux-providers/src/openai/chat/chat_language_model.rs index 5e8cbb81c..2121ead64 100644 --- a/aimux-providers/src/openai/model.rs +++ b/aimux-providers/src/openai/chat/chat_language_model.rs @@ -1,4 +1,5 @@ -//! OpenAI language model — implements `LanguageModel` trait. +//! The Chat Completions language model (openai-chat-language-model.ts): +//! request building, generation and streaming. //! //! The HTTP request/response handling lives in the crate-internal functions //! `execute_generate` and `execute_stream`, which take a prepared request (URL, @@ -7,32 +8,37 @@ //! speak the OpenAI chat-completions wire format (notably Azure OpenAI) reuse //! the conversion + streaming logic while supplying their own URL and auth. -use aimux_core::tool::RawToolCall; - -use async_trait::async_trait; -use futures::StreamExt; -use serde_json::{Value, json}; - use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; -use aimux_core::options::CallOptions; +use aimux_core::language_model_message::LanguageModelMessage; +use aimux_core::options::{CallOptions, ResponseFormat}; use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source, StreamResult}; -use aimux_core::shared::provider_namespace; +use aimux_core::shared::{SharedProviderOptions, provider_namespace}; use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReason, ResponseMetadata, Usage}; - +use aimux_core::tool::{FunctionTool, RawToolCall, Tool}; +use aimux_core::types::{FinishReason, ReasoningEffort, ResponseMetadata, Usage, Warning}; use aimux_provider_utils::{HttpRequest, Transformer, pipe_through}; +use async_trait::async_trait; +use futures::StreamExt; +use serde_json::{Value, json}; +use crate::openai::chat::chat_api::{ChatCompletionResponse, StreamChunk, UsageResponse}; +use crate::openai::chat::chat_language_model_options::{ + openai_option, parse_chat_provider_options, +}; +use crate::openai::chat::chat_prepare_tools::prepare_tools; +use crate::openai::chat::convert_chat_usage::convert_usage; +use crate::openai::chat::convert_to_chat_messages::convert_prompt_to_openai_messages_with_mode_fallible; +use crate::openai::chat::map_finish_reason::parse_finish_reason; +use crate::openai::config::{OpenAIModelConfig, TransformRequestBody}; +use crate::openai::language_model_capabilities::{ + ModelCapabilities, SystemMessageMode, get_model_capabilities, +}; +use crate::openai::normalize_json_schema::normalize_json_schema; use crate::stream::{ ChatToolCalls, Controller, Emit, FINISH_ERROR, FINISH_OTHER, StreamContext, ToolCallDelta, }; -use super::config::{OpenAIModelConfig, TransformRequestBody}; -use super::convert::{ - RequestBodyResult, build_request_body_with_chat_options, parse_finish_reason, -}; -use super::types::{ChatCompletionResponse, StreamChunk, UsageResponse}; - /// An OpenAI chat-completions language model. /// /// Does **not** hold an HTTP client — the `aimux-provider-utils` API helpers use the @@ -78,75 +84,6 @@ fn logprobs_content(logprobs: &Value) -> Option { ) } -// ── Usage conversion ───────────────────────────────────────────────────────── - -/// Convert an OpenAI `UsageResponse` into the core `Usage` type. -/// -/// Mirrors the TS `convertOpenAIChatUsage`: -/// - `input.total = prompt_tokens` -/// - `input.noCache = prompt_tokens - cached_tokens - cache_write_tokens` -/// - `input.cacheRead = cached_tokens` -/// - `input.cacheWrite = cache_write_tokens` -/// -/// `usage_raw` is the provider's original `usage` JSON object, preserved -/// verbatim in `Usage.raw` (M10, RFC-0016). Vendor-specific fields not part -/// of `UsageResponse` (e.g. prompt-cache hit counters) survive only -/// through this raw value. -pub(super) fn convert_usage(usage: &UsageResponse, usage_raw: Option<&Value>) -> Usage { - let prompt_tokens = usage.prompt_tokens.unwrap_or(0); - let completion_tokens = usage.completion_tokens.unwrap_or(0); - - // Cache-read tokens: only the nested `prompt_tokens_details.cached_tokens` - // (upstream convert-openai-chat-usage.ts:28). A top-level `cached_tokens` - // (Moonshot's wire format) stays in `usage.raw`; Moonshot is an - // OpenAI-compatible preset with its own usage converter. - let cached = usage - .prompt_tokens_details - .as_ref() - .and_then(|d| d.cached_tokens) - .unwrap_or(0); - - let cache_write = usage - .prompt_tokens_details - .as_ref() - .and_then(|d| d.cache_write_tokens); - - // Use saturating subtraction: some OpenAI-compatible servers (e.g. vLLM - // serving Qwen3 reasoning models via Doubleword) report - // `reasoning_tokens > completion_tokens`, and cached + cache-write tokens - // can exceed `prompt_tokens`. Saturating to 0 avoids a panic on these - // real-world responses; the non-underflow path is unchanged. - let no_cache = prompt_tokens - .saturating_sub(cached) - .saturating_sub(cache_write.unwrap_or(0)); - - // Reasoning tokens from completion_tokens_details. - let reasoning_tokens = usage - .completion_tokens_details - .as_ref() - .and_then(|d| d.reasoning_tokens) - .unwrap_or(0); - let text_tokens = completion_tokens.saturating_sub(reasoning_tokens); - - Usage { - input_tokens: aimux_core::types::InputTokenUsage { - total: Some(prompt_tokens), - no_cache: Some(no_cache), - cache_read: Some(cached), - cache_write, - }, - output_tokens: aimux_core::types::OutputTokenUsage { - total: Some(completion_tokens), - text: Some(text_tokens), - reasoning: Some(reasoning_tokens), - }, - // M10 (RFC-0016): keep the provider's original usage JSON verbatim — - // vendor-specific fields (e.g. a top-level `cached_tokens`, prompt-cache - // `prompt_cache_hit_tokens`) are otherwise lost for audit/billing. - raw: usage_raw.and_then(|value| value.as_object().cloned()), - } -} - #[async_trait] impl LanguageModel for OpenAIModel { /// Provider identity for recording/routing: `"{name}.chat"` (`"openai.chat"` @@ -223,7 +160,7 @@ pub(crate) async fn execute_generate( model_id: &str, options: &CallOptions, transform_request_body: Option<&TransformRequestBody>, - parse_chat_options: super::options::ChatOptionsParser, + parse_chat_options: crate::openai::chat::chat_language_model_options::ChatOptionsParser, ) -> Result { let request_result = build_request_body_with_chat_options(model_id, options, false, parse_chat_options)?; @@ -236,7 +173,7 @@ pub(crate) async fn execute_generate( http, body.clone(), aimux_provider_utils::create_json_response_handler::(), - super::openai_failed_response_handler(), + crate::openai::error::openai_failed_response_handler(), ) .await?; @@ -378,7 +315,7 @@ pub(crate) async fn execute_stream( model_id: &str, options: &CallOptions, transform_request_body: Option<&TransformRequestBody>, - parse_chat_options: super::options::ChatOptionsParser, + parse_chat_options: crate::openai::chat::chat_language_model_options::ChatOptionsParser, ) -> Result { let request_result = build_request_body_with_chat_options(model_id, options, true, parse_chat_options)?; @@ -395,7 +332,7 @@ pub(crate) async fn execute_stream( http, body.clone(), aimux_provider_utils::create_event_source_response_handler::(), - super::openai_failed_response_handler(), + crate::openai::error::openai_failed_response_handler(), ) .await?; @@ -415,7 +352,7 @@ pub(crate) async fn execute_stream( if let Some(Ok(ref event)) = first_event && let Some(err_obj) = event.get("error") { - return Err(super::openai_stream_error( + return Err(crate::openai::stream_error::openai_stream_error( err_obj, &endpoint, body.clone(), @@ -441,12 +378,13 @@ pub(crate) async fn execute_stream( } const TEXT_ID: &str = "0"; + const REASONING_ID: &str = "reasoning-0"; /// The `TransformStream` of the Chat Completions stream ([`execute_stream`]): /// turns the parsed SSE events into stream parts. #[derive(Default)] -struct OpenAIChatStream { +pub(crate) struct OpenAIChatStream { ctx: StreamContext, /// Finalized on flush. tool_calls: ChatToolCalls, @@ -510,7 +448,7 @@ impl Transformer for OpenAIChatStream { // Check for mid-stream error. if let Some(err_obj) = parsed.get("error") { controller.enqueue(StreamPart::Error { - error: super::openai_stream_error( + error: crate::openai::stream_error::openai_stream_error( err_obj, &self.ctx.url, self.ctx.request_body.clone(), @@ -687,174 +625,490 @@ impl Transformer for OpenAIChatStream { } } -// ── Model listing (RFC-0027) ───────────────────────────────────────────────── +/// Result of building a request body, including warnings. +#[derive(Debug, Clone)] +pub struct RequestBodyResult { + pub body: Value, + pub warnings: Vec, +} + +/// Resolve the effective reasoning effort — direct passthrough (v3: no built-in +/// vendor normalization). `providerOptions.reasoningEffort` wins over top-level +/// `reasoning`; custom top-level levels map verbatim to `reasoning_effort`. +fn resolve_reasoning_effort( + provider_opts: &Option, + reasoning: &Option, +) -> Option { + openai_option(provider_opts, "reasoningEffort") + .map(|v| { + v.as_str() + .map(std::string::ToString::to_string) + .unwrap_or(v.to_string()) + }) + .or_else(|| { + if reasoning.is_some_and(ReasoningEffort::is_custom) { + reasoning.map(|r| r.to_string()) + } else { + None + } + }) +} + +fn resolve_is_reasoning_model( + provider_opts: &Option, + caps: &ModelCapabilities, +) -> bool { + openai_option(provider_opts, "forceReasoning") + .map(|v| v.as_bool().unwrap_or(false)) + .unwrap_or(caps.is_reasoning_model) +} + +fn resolve_system_message_mode( + provider_opts: &Option, + is_reasoning_model: bool, + caps: &ModelCapabilities, +) -> SystemMessageMode { + openai_option(provider_opts, "systemMessageMode") + .and_then(|v| v.as_str().map(std::string::ToString::to_string)) + .map(|s| match s.as_str() { + "developer" => SystemMessageMode::Developer, + "remove" => SystemMessageMode::Remove, + _ => SystemMessageMode::System, + }) + .unwrap_or(if is_reasoning_model { + SystemMessageMode::Developer + } else { + caps.system_message_mode + }) +} + +/// Insert `max_tokens` / `max_completion_tokens`: reasoning models take the +/// latter, the rest the former; an explicit `maxCompletionTokens` option is +/// always sent as `max_completion_tokens`. +fn apply_max_tokens( + body: &mut Value, + options: &CallOptions, + provider_opts: &Option, + is_reasoning_model: bool, +) { + let max_completion_tokens_opt = openai_option(provider_opts, "maxCompletionTokens"); + + if let Some(max_tokens) = options.max_output_tokens { + let key = if is_reasoning_model { + "max_completion_tokens" + } else { + "max_tokens" + }; + body[key] = json!(max_tokens); + } + if let Some(mct) = max_completion_tokens_opt { + body["max_completion_tokens"] = json!(mct); + } +} + +/// Sampling parameters that survive the reasoning-model / search-preview +/// capability filtering. +#[derive(Default)] +struct SamplingParams { + temperature: Option, + top_p: Option, + frequency_penalty: Option, + presence_penalty: Option, +} + +/// Remove unsupported sampling settings for reasoning models and the search +/// preview models, pushing a compatibility warning for each removal. +fn strip_sampling_params( + options: &CallOptions, + model_id: &str, + caps: &ModelCapabilities, + is_reasoning_model: bool, + resolved_reasoning_effort: &Option, + warnings: &mut Vec, +) -> SamplingParams { + let mut params = SamplingParams { + temperature: options.temperature, + top_p: options.top_p, + frequency_penalty: options.frequency_penalty, + presence_penalty: options.presence_penalty, + }; + + if is_reasoning_model { + let allow_non_reasoning = resolved_reasoning_effort.as_deref() == Some("none") + && caps.supports_non_reasoning_parameters; + + if !allow_non_reasoning { + if params.temperature.is_some() { + params.temperature = None; + warnings.push(Warning::Unsupported { + feature: "temperature".to_string(), + details: Some("temperature is not supported for reasoning models".to_string()), + }); + } + if params.top_p.is_some() { + params.top_p = None; + warnings.push(Warning::Unsupported { + feature: "topP".to_string(), + details: Some("topP is not supported for reasoning models".to_string()), + }); + } + } + + if params.frequency_penalty.is_some() { + params.frequency_penalty = None; + warnings.push(Warning::Unsupported { + feature: "frequencyPenalty".to_string(), + details: Some("frequencyPenalty is not supported for reasoning models".to_string()), + }); + } + if params.presence_penalty.is_some() { + params.presence_penalty = None; + warnings.push(Warning::Unsupported { + feature: "presencePenalty".to_string(), + details: Some("presencePenalty is not supported for reasoning models".to_string()), + }); + } + } else if (model_id.starts_with("gpt-4o-search-preview") + || model_id.starts_with("gpt-4o-mini-search-preview")) + && params.temperature.is_some() + { + params.temperature = None; + warnings.push(Warning::Unsupported { + feature: "temperature".to_string(), + details: Some( + "temperature is not supported for the search preview models and has been removed." + .to_string(), + ), + }); + } + + params +} + +/// Write the surviving sampling params (plus stop/seed) into the body. +fn insert_sampling_params(body: &mut Value, params: &SamplingParams, options: &CallOptions) { + if let Some(temp) = params.temperature { + body["temperature"] = json!(temp); + } + if let Some(tp) = params.top_p { + body["top_p"] = json!(tp); + } + if let Some(fp) = params.frequency_penalty { + body["frequency_penalty"] = json!(fp); + } + if let Some(pp) = params.presence_penalty { + body["presence_penalty"] = json!(pp); + } + if let Some(ref stop) = options.stop_sequences { + body["stop"] = json!(stop); + } + if let Some(seed) = options.seed { + body["seed"] = json!(seed); + } +} + +/// `response_format` → body: a schema is sent as `json_schema` (strict), a +/// bare JSON request as `json_object`. +fn apply_response_format( + body: &mut Value, + options: &CallOptions, + warnings: &mut Vec, +) -> Result<(), AiMuxError> { + let Some(ref rf) = options.response_format else { + return Ok(()); + }; + match rf { + ResponseFormat::Text => {} + ResponseFormat::Json { + schema, + name, + description, + } => { + if let Some(schema) = schema { + let mut schema_obj = json!({}); + schema_obj["schema"] = normalize_json_schema(schema, warnings)?; + schema_obj["name"] = json!(name.clone().unwrap_or_else(|| "response".to_string())); + if let Some(d) = description { + schema_obj["description"] = json!(d); + } + schema_obj["strict"] = openai_option(&options.provider_options, "strictJsonSchema") + .unwrap_or(json!(true)); + body["response_format"] = json!({ + "type": "json_schema", + "json_schema": schema_obj, + }); + } else { + body["response_format"] = json!({ "type": "json_object" }); + } + } + } + Ok(()) +} + +/// Pass through the simple provider-specific options that map 1:1 to a body +/// field. +fn apply_provider_option_passthrough( + body: &mut Value, + provider_opts: &Option, +) { + let mut set = |key: &str, body_key: &str| { + if let Some(val) = openai_option(provider_opts, key) { + body[body_key] = val; + } + }; + set("logitBias", "logit_bias"); + set("user", "user"); + set("parallelToolCalls", "parallel_tool_calls"); + set("textVerbosity", "verbosity"); + set("store", "store"); + set("metadata", "metadata"); + set("prediction", "prediction"); + set("promptCacheKey", "prompt_cache_key"); + set("promptCacheRetention", "prompt_cache_retention"); + set("promptCacheOptions", "prompt_cache_options"); + set("safetyIdentifier", "safety_identifier"); + if let Some(logprobs) = openai_option(provider_opts, "logprobs") + && (logprobs == true || logprobs.is_number()) + { + body["logprobs"] = json!(true); + body["top_logprobs"] = if logprobs.is_number() { + logprobs + } else { + json!(0) + }; + } +} -/// OpenAI-compatible `/models` response shape. -#[derive(serde::Deserialize)] -struct ModelsListResponse { - #[serde(default)] - data: Vec, +/// `service_tier` with model-capability validation. +fn apply_service_tier( + body: &mut Value, + provider_opts: &Option, + caps: &ModelCapabilities, + warnings: &mut Vec, +) { + let service_tier = openai_option(provider_opts, "serviceTier") + .and_then(|v| v.as_str().map(std::string::ToString::to_string)); + if let Some(ref st) = service_tier { + match st.as_str() { + "flex" => { + if caps.supports_flex_processing { + body["service_tier"] = json!(st); + } else { + warnings.push(Warning::Unsupported { + feature: "serviceTier".to_string(), + details: Some( + "flex processing is only available for o3, o4-mini, and gpt-5 models" + .to_string(), + ), + }); + } + } + "priority" | "fast" => { + if caps.supports_priority_processing { + body["service_tier"] = json!(st); + } else { + warnings.push(Warning::Unsupported { + feature: "serviceTier".to_string(), + details: Some( + "priority processing is only available for supported models (gpt-4, gpt-5, gpt-5-mini, o3, o4-mini) and requires Enterprise access. gpt-5-nano is not supported".to_string(), + ), + }); + } + } + _ => { + body["service_tier"] = json!(st); + } + } + } } -#[derive(serde::Deserialize)] -struct ModelEntry { - id: String, - #[serde(default)] - owned_by: Option, - #[serde(default)] - created: Option, + +/// Function tools → `tools` / `tool_choice`. +fn apply_tools( + body: &mut Value, + options: &CallOptions, + warnings: &mut Vec, +) -> Result<(), AiMuxError> { + let function_tools: Option> = options.tools.as_ref().map(|tools| { + tools + .iter() + .filter_map(|t| match t { + Tool::Function(ft) => Some(ft.clone()), + Tool::Provider(_) => None, + }) + .collect() + }); + + let mut function_tools = function_tools; + for tool in function_tools.iter_mut().flatten() { + tool.input_schema = normalize_json_schema(&tool.input_schema, warnings)?; + } + for tool in options.tools.iter().flatten() { + if matches!(tool, Tool::Provider(_)) { + warnings.push(Warning::Unsupported { + feature: "tool type: provider".into(), + details: None, + }); + } + } + if options + .tools + .as_ref() + .is_some_and(|tools| !tools.is_empty()) + && function_tools.as_ref().is_some_and(Vec::is_empty) + { + body["tools"] = json!([]); + } + let prepared = prepare_tools(&function_tools, options.tool_choice.as_ref()); + if let Some(tools) = prepared.tools { + body["tools"] = json!(tools); + if let Some(tc) = prepared.tool_choice { + body["tool_choice"] = tc; + } + } + Ok(()) } -/// One `GET {base_url}/models` exchange (OpenAI-compatible): no retry, no -/// recording. Discovery is not a Core operation and the AI SDK has no -/// equivalent, so a failure is reported to the caller as it happened. +/// Convert `CallOptions` to an OpenAI request body, returning warnings. +/// +/// Conversion errors propagate to the caller (fail-fast, issue H2): the old +/// behaviour of silently returning `body: null` sent empty requests upstream +/// and made conversion failures invisible. /// /// # Errors /// -/// Returns the header-resolution error (a missing key is `LoadApiKey`), -/// `ApiCall` for HTTP/transport failures and `JsonParse` when the body does -/// not deserialize into the models list. -pub(crate) async fn list_models_once( - config: &OpenAIModelConfig, -) -> Result, AiMuxError> { - let headers = config.request_headers(None).await?; - let resp = aimux_provider_utils::get_from_api( - config.with_transport(HttpRequest { - url: config.url("/models")?, - headers, - ..Default::default() - }), - aimux_provider_utils::create_json_response_handler(), - super::openai_failed_response_handler(), +/// Propagates conversion errors, fail-fast: e.g. `InvalidArgument` for an +/// unconvertible prompt part or an invalid option combination. +#[cfg(test)] +pub(crate) fn build_request_body_with_warnings( + model_id: &str, + options: &CallOptions, + stream: bool, +) -> Result { + build_request_body_with_chat_options( + model_id, + options, + stream, + crate::openai::chat::chat_language_model_options::parse_chat_options, ) - .await?; - let parsed: ModelsListResponse = resp.value; - - Ok(parsed - .data - .into_iter() - .map(|m| aimux_core::model_catalogue::RuntimeModel { - id: m.id, - owned_by: m.owned_by, - created: m.created, - }) - .collect()) } -/// The Chat Completions stream driven chunk by chunk. -#[cfg(test)] -mod stream_tests { - use serde_json::json; - - use super::*; - use crate::stream::testing::run; - - fn delta(delta: Value) -> Result { - Ok(json!({ "choices": [{ "delta": delta }] })) - } - - fn tool_calls(tool_calls: Value) -> Result { - delta(json!({ "tool_calls": tool_calls })) - } - - #[test] - fn interleaved_tool_calls_stream_as_they_arrive_and_end_on_flush_in_index_order() { - let parts = run( - OpenAIChatStream::default(), - [ - tool_calls(json!([{ - "index": 1, "id": "call_b", "type": "function", - "function": { "name": "time", "arguments": "{" } - }])), - tool_calls(json!([{ - "index": 0, "id": "call_a", "type": "function", - "function": { "name": "weather", "arguments": "{\"city\":" } - }])), - tool_calls(json!([ - { "index": 1, "function": { "arguments": "}" } }, - { "index": 0, "function": { "arguments": "\"Paris\"}" } } - ])), - Ok(json!({ "choices": [{ "delta": {}, "finish_reason": "tool_calls" }] })), - ], - ); - assert_eq!( - parts, - [ - "stream-start", - "tool-input-start call_b time", - "tool-input-delta call_b {", - "tool-input-start call_a weather", - "tool-input-delta call_a {\"city\":", - "tool-input-delta call_b }", - "tool-input-delta call_a \"Paris\"}", - "tool-input-end call_a", - "tool-call call_a weather {\"city\":\"Paris\"}", - "tool-input-end call_b", - "tool-call call_b time {}", - "finish ToolCalls", - ] - ); - } - - /// partial: `should forward error stream parts after output has started`; full port in src/openai/tests/chat/chat_language_model.rs. - #[test] - fn an_error_frame_is_an_error_part_and_the_stream_finishes_with_error() { - let parts = run( - OpenAIChatStream::default(), - [ - delta(json!({ "content": "Hi" })), - Ok(json!({ "error": { "message": "overloaded", "type": "server_error" } })), - ], - ); - assert_eq!(parts.len(), 6, "{parts:?}"); - assert_eq!( - parts[..3], - ["stream-start", "text-start 0", "text-delta 0 Hi"] - ); - assert!(parts[3].starts_with("error ") && parts[3].contains("overloaded")); - assert_eq!(parts[4..], ["text-end 0", "finish Error"]); - } - - #[test] - fn flush_closes_open_blocks_and_finishes_with_other_without_a_finish_reason() { - let parts = run( - OpenAIChatStream::default(), - [ - delta(json!({ "reasoning_content": "Hmm" })), - delta(json!({ "content": "Hi" })), - ], - ); - assert_eq!( - parts, - [ - "stream-start", - "reasoning-start reasoning-0", - "reasoning-delta reasoning-0 Hmm", - "reasoning-end reasoning-0", - "text-start 0", - "text-delta 0 Hi", - "text-end 0", - "finish Other", - ] - ); - } - - #[test] - fn a_tool_call_of_another_type_ends_the_stream_without_flush() { - let parts = run( - OpenAIChatStream::default(), - [ - tool_calls(json!([{ - "index": 0, "id": "call_a", "type": "custom", - "function": { "name": "weather", "arguments": "{}" } - }])), - delta(json!({ "content": "ignored" })), - ], - ); - assert_eq!(parts.len(), 2, "{parts:?}"); - assert_eq!(parts[0], "stream-start"); - assert!( - parts[1].starts_with("err ") && parts[1].contains("Expected 'function' type."), - "{parts:?}" - ); +pub(crate) fn build_request_body_with_chat_options( + model_id: &str, + options: &CallOptions, + stream: bool, + parse: crate::openai::chat::chat_language_model_options::ChatOptionsParser, +) -> Result { + let mut warnings: Vec = Vec::new(); + let caps = get_model_capabilities(model_id); + let parsed_provider_opts = parse_chat_provider_options( + options.provider_options.as_ref(), + crate::openai::chat::chat_language_model_options::NAMESPACE, + parse, + )? + .map(|parsed| { + aimux_core::shared::NamespaceMap::from([( + crate::openai::chat::chat_language_model_options::NAMESPACE.to_string(), + parsed, + )]) + }); + let provider_opts = &parsed_provider_opts; + + let mut resolved_reasoning_effort = resolve_reasoning_effort(provider_opts, &options.reasoning); + if let (Some(effort), Some(supported)) = + (&resolved_reasoning_effort, caps.supported_reasoning_efforts) + && !supported.contains(&effort.as_str()) + { + warnings.push(Warning::Unsupported { + feature: "reasoningEffort".into(), + details: Some(format!( + "{model_id} only supports the following reasoning efforts: {}", + supported.join(", ") + )), + }); + resolved_reasoning_effort = None; } + let is_reasoning_model = resolve_is_reasoning_model(provider_opts, &caps); + let system_message_mode = resolve_system_message_mode(provider_opts, is_reasoning_model, &caps); + + // OpenAI has no top_k. + if options.top_k.is_some() { + warnings.push(Warning::Unsupported { + feature: "topK".to_string(), + details: None, + }); + } + + if system_message_mode == SystemMessageMode::Remove { + for _ in options + .prompt + .iter() + .filter(|m| matches!(m, LanguageModelMessage::System { .. })) + { + warnings.push(Warning::Other { + message: "system messages are removed for this model".into(), + }); + } + } + let messages = + convert_prompt_to_openai_messages_with_mode_fallible(&options.prompt, system_message_mode)?; + + let mut body = json!({ + "model": model_id, + "messages": messages, + }); + + if stream { + body["stream"] = json!(true); + body["stream_options"] = json!({ "include_usage": true }); + } + + apply_max_tokens(&mut body, options, provider_opts, is_reasoning_model); + + let sampling = strip_sampling_params( + options, + model_id, + &caps, + is_reasoning_model, + &resolved_reasoning_effort, + &mut warnings, + ); + insert_sampling_params(&mut body, &sampling, options); + + apply_response_format(&mut body, options, &mut warnings)?; + apply_provider_option_passthrough(&mut body, provider_opts); + + if caps.supported_reasoning_efforts.is_some() && body.get("prompt_cache_retention").is_some() { + body.as_object_mut() + .unwrap() + .remove("prompt_cache_retention"); + warnings.push(Warning::Unsupported { feature: "promptCacheRetention".into(), details: Some("promptCacheRetention is not supported by GPT-6 and later models; use promptCacheOptions instead".into()) }); + } + if is_reasoning_model { + let allow_logprobs = resolved_reasoning_effort.as_deref() == Some("none") + && caps.supports_non_reasoning_parameters; + for (key, feature) in [ + ("logprobs", "logprobs"), + ("logit_bias", "logitBias"), + ("top_logprobs", "topLogprobs"), + ] { + if key == "logprobs" && allow_logprobs { + continue; + } + if body.as_object_mut().unwrap().remove(key).is_some() { + warnings.push(Warning::Other { + message: format!("{feature} is not supported for reasoning models"), + }); + } + } + } + + // Provider reasoning takes precedence over the standardized setting. + if let Some(ref effort) = resolved_reasoning_effort { + body["reasoning_effort"] = json!(effort); + } + + apply_service_tier(&mut body, provider_opts, &caps, &mut warnings); + apply_tools(&mut body, options, &mut warnings)?; + + Ok(RequestBodyResult { body, warnings }) } diff --git a/aimux-providers/src/openai/options.rs b/aimux-providers/src/openai/chat/chat_language_model_options.rs similarity index 82% rename from aimux-providers/src/openai/options.rs rename to aimux-providers/src/openai/chat/chat_language_model_options.rs index 9de515682..302710da3 100644 --- a/aimux-providers/src/openai/options.rs +++ b/aimux-providers/src/openai/chat/chat_language_model_options.rs @@ -1,11 +1,10 @@ -//! Chat option schema mirrored from `openai-chat-language-model-options.ts`. +//! Chat option schema mirrored from `openai-chat-language-model-options.ts`, +//! with the shared provider-options readers. use aimux_core::error::AiMuxError; -use aimux_core::shared::JsonObject; +use aimux_core::shared::{JsonObject, SharedProviderOptions}; use serde_json::Value; -use super::convert::parse_option_fields; - pub(crate) const NAMESPACE: &str = "openai"; pub(crate) type ChatOptionsParser = fn(&JsonObject) -> Result; @@ -146,3 +145,47 @@ pub(crate) fn parse_chat_options(options: &JsonObject) -> Result Option>, +) -> Result { + let mut parsed = JsonObject::new(); + for (key, value) in options { + if let Some(result) = parse(key, value) { + parsed.insert( + key.clone(), + result.map_err(|()| { + AiMuxError::invalid_argument(format!("invalid {provider} provider options")) + })?, + ); + } + } + Ok(parsed) +} + +/// Parse a namespace with the option schema supplied by its package. +pub(crate) fn parse_chat_provider_options( + options: Option<&SharedProviderOptions>, + provider: &str, + parse: impl FnOnce(&JsonObject) -> Result, +) -> Result, AiMuxError> { + options + .and_then(|options| options.get(provider)) + .map(parse) + .transpose() +} + +/// Get a value from provider_options.openai.. +pub(crate) fn openai_option(options: &Option, key: &str) -> Option { + options + .as_ref() + .and_then(|m| m.get("openai")) + .and_then(|o| o.get(key)) + .cloned() +} diff --git a/aimux-providers/src/openai/chat/chat_prepare_tools.rs b/aimux-providers/src/openai/chat/chat_prepare_tools.rs new file mode 100644 index 000000000..e812b5788 --- /dev/null +++ b/aimux-providers/src/openai/chat/chat_prepare_tools.rs @@ -0,0 +1,78 @@ +//! Tool preparation for the Chat Completions API (openai-chat-prepare-tools.ts). + +use aimux_core::options::LanguageModelV4ToolChoice; +use aimux_core::tool::FunctionTool; +use serde::Serialize; +use serde_json::{Value, json}; + +/// A warning emitted while preparing tools (mirrors the V4 `SharedV4Warning` +/// `unsupported` shape used by the TS `prepareChatTools`). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ToolWarning { + #[serde(rename = "type")] + pub warning_type: String, + pub feature: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +/// The result of preparing tools for an OpenAI request body. +#[derive(Debug, Clone, PartialEq)] +pub struct PreparedTools { + pub tools: Option>, + pub tool_choice: Option, + pub tool_warnings: Vec, +} + +/// Prepare `FunctionTool`s into the OpenAI `tools` / `tool_choice` JSON shape. +#[must_use] +pub fn prepare_tools( + tools: &Option>, + tool_choice: Option<&LanguageModelV4ToolChoice>, +) -> PreparedTools { + let non_empty = tools.as_ref().filter(|&t| !t.is_empty()); + + let tool_warnings: Vec = Vec::new(); + + let tools_opt = match non_empty { + None => None, + Some(tools) => { + let openai_tools: Vec = tools + .iter() + .map(|t| { + let mut func = json!({ + "name": t.name, + "parameters": t.input_schema, + }); + if let Some(ref desc) = t.description { + func["description"] = json!(desc); + } + if let Some(strict) = t.strict { + func["strict"] = json!(strict); + } + json!({ "type": "function", "function": func }) + }) + .collect(); + Some(openai_tools) + } + }; + + let tool_choice_opt = match (&tools_opt, tool_choice) { + (None, _) => None, + (Some(_), None) => None, + (Some(_), Some(tc)) => match tc { + LanguageModelV4ToolChoice::Auto => Some(json!("auto")), + LanguageModelV4ToolChoice::None => Some(json!("none")), + LanguageModelV4ToolChoice::Required => Some(json!("required")), + LanguageModelV4ToolChoice::Tool { tool_name } => { + Some(json!({ "type": "function", "function": { "name": tool_name } })) + } + }, + }; + + PreparedTools { + tools: tools_opt, + tool_choice: tool_choice_opt, + tool_warnings, + } +} diff --git a/aimux-providers/src/openai/chat/convert_chat_usage.rs b/aimux-providers/src/openai/chat/convert_chat_usage.rs new file mode 100644 index 000000000..96e0c1172 --- /dev/null +++ b/aimux-providers/src/openai/chat/convert_chat_usage.rs @@ -0,0 +1,73 @@ +//! Convert Chat Completions usage to the core usage (convert-openai-chat-usage.ts). + +use aimux_core::types::Usage; +use serde_json::Value; + +use crate::openai::chat::chat_api::UsageResponse; + +/// Convert an OpenAI `UsageResponse` into the core `Usage` type. +/// +/// Mirrors the TS `convertOpenAIChatUsage`: +/// - `input.total = prompt_tokens` +/// - `input.noCache = prompt_tokens - cached_tokens - cache_write_tokens` +/// - `input.cacheRead = cached_tokens` +/// - `input.cacheWrite = cache_write_tokens` +/// +/// `usage_raw` is the provider's original `usage` JSON object, preserved +/// verbatim in `Usage.raw` (M10, RFC-0016). Vendor-specific fields not part +/// of `UsageResponse` (e.g. prompt-cache hit counters) survive only +/// through this raw value. +pub(crate) fn convert_usage(usage: &UsageResponse, usage_raw: Option<&Value>) -> Usage { + let prompt_tokens = usage.prompt_tokens.unwrap_or(0); + let completion_tokens = usage.completion_tokens.unwrap_or(0); + + // Cache-read tokens: only the nested `prompt_tokens_details.cached_tokens` + // (upstream convert-openai-chat-usage.ts:28). A top-level `cached_tokens` + // (Moonshot's wire format) stays in `usage.raw`; Moonshot is an + // OpenAI-compatible preset with its own usage converter. + let cached = usage + .prompt_tokens_details + .as_ref() + .and_then(|d| d.cached_tokens) + .unwrap_or(0); + + let cache_write = usage + .prompt_tokens_details + .as_ref() + .and_then(|d| d.cache_write_tokens); + + // Use saturating subtraction: some OpenAI-compatible servers (e.g. vLLM + // serving Qwen3 reasoning models via Doubleword) report + // `reasoning_tokens > completion_tokens`, and cached + cache-write tokens + // can exceed `prompt_tokens`. Saturating to 0 avoids a panic on these + // real-world responses; the non-underflow path is unchanged. + let no_cache = prompt_tokens + .saturating_sub(cached) + .saturating_sub(cache_write.unwrap_or(0)); + + // Reasoning tokens from completion_tokens_details. + let reasoning_tokens = usage + .completion_tokens_details + .as_ref() + .and_then(|d| d.reasoning_tokens) + .unwrap_or(0); + let text_tokens = completion_tokens.saturating_sub(reasoning_tokens); + + Usage { + input_tokens: aimux_core::types::InputTokenUsage { + total: Some(prompt_tokens), + no_cache: Some(no_cache), + cache_read: Some(cached), + cache_write, + }, + output_tokens: aimux_core::types::OutputTokenUsage { + total: Some(completion_tokens), + text: Some(text_tokens), + reasoning: Some(reasoning_tokens), + }, + // M10 (RFC-0016): keep the provider's original usage JSON verbatim — + // vendor-specific fields (e.g. a top-level `cached_tokens`, prompt-cache + // `prompt_cache_hit_tokens`) are otherwise lost for audit/billing. + raw: usage_raw.and_then(|value| value.as_object().cloned()), + } +} diff --git a/aimux-providers/src/openai/chat/convert_to_chat_messages.rs b/aimux-providers/src/openai/chat/convert_to_chat_messages.rs new file mode 100644 index 000000000..5aa5c030a --- /dev/null +++ b/aimux-providers/src/openai/chat/convert_to_chat_messages.rs @@ -0,0 +1,418 @@ +//! Conversion of a `LanguageModelPrompt` to Chat Completions messages +//! (convert-to-openai-chat-messages.ts). + +use aimux_core::error::AiMuxError; +use aimux_core::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, TextPart, ToolPart, + ToolResultContent, ToolResultOutput, UserPart, +}; +use aimux_core::shared::{FileBytes, FileData, SharedProviderOptions}; +use aimux_provider_utils::{get_top_level_media_type, resolve_full_media_type}; +use serde_json::{Value, json}; + +use crate::openai::language_model_capabilities::SystemMessageMode; + +/// Convert a `LanguageModelPrompt` to OpenAI `messages` array with a system +/// message mode. +/// +/// # Errors +/// +/// Returns `AiMuxError::invalid_argument` when a message part cannot be +/// converted (e.g. an unsupported file part shape or a missing provider +/// reference). +pub fn convert_prompt_to_openai_messages_with_mode_fallible( + prompt: &LanguageModelPrompt, + system_message_mode: SystemMessageMode, +) -> Result, AiMuxError> { + let mut result = Vec::new(); + for msg in prompt { + result.extend(convert_message_to_openai(msg, system_message_mode)?); + } + Ok(result) +} + +/// Get the prompt cache breakpoint from provider options. +fn get_prompt_cache_breakpoint(provider_options: &Option) -> Option { + provider_options + .as_ref() + .and_then(|po| po.get("openai")) + .and_then(|o| o.get("promptCacheBreakpoint")) + .cloned() +} + +/// Get imageDetail from provider options. +fn get_image_detail(provider_options: &Option) -> Option { + provider_options + .as_ref() + .and_then(|po| po.get("openai")) + .and_then(|o| o.get("imageDetail")) + .cloned() +} + +/// Resolve a provider reference, throwing if the provider is not found. +pub(crate) fn resolve_provider_reference( + reference: &aimux_core::shared::SharedProviderReference, + provider: &str, +) -> Result { + reference.get(provider).cloned().ok_or_else(|| { + let mut available: Vec<&str> = reference.keys().map(String::as_str).collect(); + available.sort_unstable(); + format!( + "No provider reference found for provider '{}'. Available providers: {}", + provider, + available.join(", ") + ) + }) +} + +/// Convert a file part to the OpenAI format, handling images, audio, and PDF. +fn convert_file_part_to_openai(file: &FilePart, part_index: usize) -> Result { + use base64::Engine; + let FilePart { + data, + media_type, + filename, + provider_options, + } = file; + let prompt_cache_breakpoint = get_prompt_cache_breakpoint(provider_options); + let (data_b64, url) = match data { + FileData::Reference { reference } => { + let file_id = resolve_provider_reference(reference, "openai") + .map_err(AiMuxError::invalid_argument)?; + let mut part = json!({ "type": "file", "file": { "file_id": file_id } }); + if let Some(bpt) = prompt_cache_breakpoint { + part["prompt_cache_breakpoint"] = bpt; + } + return Ok(part); + } + FileData::Text { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "text file parts".into(), + )); + } + FileData::Url { url, .. } => (None, Some(url.as_str())), + FileData::Data { data } => ( + Some(match data { + FileBytes::Binary(bytes) => base64::engine::general_purpose::STANDARD.encode(bytes), + FileBytes::Base64(data) => data.clone(), + }), + None, + ), + }; + let data_b64 = data_b64.as_deref(); + let filename = filename.as_deref(); + + let top_level = get_top_level_media_type(media_type); + + // Image + if top_level == "image" { + let image_url = if let Some(url_str) = url { + json!({ "url": url_str }) + } else if let Some(b64) = data_b64 { + let full_mt = resolve_full_media_type(file)?; + json!({ "url": format!("data:{};base64,{}", full_mt, b64) }) + } else { + return Err(AiMuxError::invalid_argument( + "image part has no data or url", + )); + }; + + let mut image_url_obj = image_url; + if let Some(detail) = get_image_detail(provider_options) { + image_url_obj["detail"] = detail; + } + + let mut part = json!({ + "type": "image_url", + "image_url": image_url_obj, + }); + if let Some(bpt) = prompt_cache_breakpoint { + part["prompt_cache_breakpoint"] = bpt; + } + return Ok(part); + } + + // Audio + if top_level == "audio" { + if url.is_some() { + return Err(AiMuxError::UnsupportedFunctionality( + "audio file parts with URLs".into(), + )); + } + let b64 = data_b64.ok_or_else(|| AiMuxError::invalid_argument("audio part has no data"))?; + let full_mt = resolve_full_media_type(file)?; + let format = match full_mt.as_str() { + "audio/wav" => "wav", + "audio/mp3" | "audio/mpeg" => "mp3", + _ => { + return Err(AiMuxError::UnsupportedFunctionality(format!( + "audio content parts with media type {full_mt}" + ))); + } + }; + let mut part = json!({ + "type": "input_audio", + "input_audio": { "data": b64, "format": format } + }); + if let Some(bpt) = prompt_cache_breakpoint { + part["prompt_cache_breakpoint"] = bpt; + } + return Ok(part); + } + + // PDF / application + let full_mt = resolve_full_media_type(file)?; + + if full_mt != "application/pdf" { + return Err(AiMuxError::UnsupportedFunctionality(format!( + "file part media type {full_mt}" + ))); + } + + if url.is_some() { + return Err(AiMuxError::UnsupportedFunctionality( + "PDF file parts with URLs".into(), + )); + } + + let b64 = data_b64.ok_or_else(|| AiMuxError::invalid_argument("PDF part has no data"))?; + let fname = filename + .map(std::string::ToString::to_string) + .unwrap_or_else(|| format!("part-{part_index}.pdf")); + let mut part = json!({ + "type": "file", + "file": { + "filename": fname, + "file_data": format!("data:application/pdf;base64,{}", b64), + } + }); + if let Some(bpt) = prompt_cache_breakpoint { + part["prompt_cache_breakpoint"] = bpt; + } + Ok(part) +} + +/// Convert a single provider-facing message into one or more OpenAI messages. +fn convert_message_to_openai( + msg: &LanguageModelMessage, + system_message_mode: SystemMessageMode, +) -> Result, AiMuxError> { + let message = match msg { + LanguageModelMessage::System { + content, + provider_options, + } => { + let role = match system_message_mode { + SystemMessageMode::Remove => return Ok(vec![]), + SystemMessageMode::Developer => "developer", + SystemMessageMode::System => "system", + }; + let content = match get_prompt_cache_breakpoint(provider_options) { + None => json!(content), + Some(bpt) => json!([{ + "type": "text", "text": content, "prompt_cache_breakpoint": bpt, + }]), + }; + json!({ "role": role, "content": content }) + } + LanguageModelMessage::Tool { content, .. } => { + return Ok(content + .iter() + .filter_map(|part| { + let ToolPart::ToolResult(result) = part else { return None; }; + let mut content = tool_result_to_content(&result.output); + let breakpoint = tool_result_cache_breakpoint(&result.output).or_else(|| get_prompt_cache_breakpoint(&result.provider_options)); + if let Some(breakpoint) = breakpoint { + content = json!([{ "type": "text", "text": content, "prompt_cache_breakpoint": breakpoint }]); + } + Some(json!({ "role": "tool", "content": content, "tool_call_id": result.tool_call_id })) + }) + .collect()); + } + LanguageModelMessage::User { content, .. } => { + let all_plain_text = content.len() == 1 + && content.iter().all(|part| { + matches!(part, UserPart::Text(text) + if get_prompt_cache_breakpoint(&text.provider_options).is_none()) + }); + let content = if all_plain_text { + json!( + content + .iter() + .filter_map(|part| match part { + UserPart::Text(text) => Some(text.text.as_str()), + UserPart::File(_) => None, + }) + .collect::() + ) + } else { + json!( + content + .iter() + .enumerate() + .map(|(index, part)| match part { + UserPart::Text(text) => Ok(convert_text_part_to_openai(text)), + UserPart::File(file) => convert_file_part_to_openai(file, index), + }) + .collect::, _>>()? + ) + }; + json!({ "role": "user", "content": content }) + } + LanguageModelMessage::Assistant { content, .. } => { + let mut text = String::new(); + let mut text_parts = Vec::new(); + let mut has_cache_breakpoint = false; + let mut tool_calls = Vec::new(); + for part in content { + match part { + AssistantPart::Text(part) => { + text.push_str(&part.text); + has_cache_breakpoint |= + get_prompt_cache_breakpoint(&part.provider_options).is_some(); + text_parts.push(convert_text_part_to_openai(part)); + } + AssistantPart::ToolCall(part) => { + // Upstream `serializeToolCallArguments` (@ai-sdk/openai + // 4.0.49+) replays any non-object input as `{}` on + // purpose; OpenAI function arguments are always objects. + let arguments = if !part.input.is_object() { + "{}".to_string() + } else { + part.input.to_string() + }; + tool_calls.push(json!({ + "type": "function", "id": part.tool_call_id, + "function": { "name": part.tool_name, "arguments": arguments }, + })); + } + _ => {} + } + } + let content = if has_cache_breakpoint { + json!(text_parts) + } else if !tool_calls.is_empty() && text.is_empty() { + Value::Null + } else { + json!(text) + }; + let mut message = json!({ "role": "assistant", "content": content }); + if !tool_calls.is_empty() { + message["tool_calls"] = json!(tool_calls); + } + message + } + }; + Ok(vec![message]) +} + +pub(crate) fn tool_result_to_content(output: &ToolResultOutput) -> Value { + Value::String(match output { + ToolResultOutput::Text { value, .. } | ToolResultOutput::ErrorText { value, .. } => { + value.clone() + } + ToolResultOutput::ExecutionDenied { reason, .. } => reason + .clone() + .unwrap_or_else(|| "Tool call execution denied.".to_string()), + ToolResultOutput::Json { value, .. } | ToolResultOutput::ErrorJson { value, .. } => { + value.to_string() + } + ToolResultOutput::Content { value } => tool_result_content_value(value).to_string(), + }) +} + +pub(crate) fn tool_result_content_value(content: &[ToolResultContent]) -> Value { + json!( + content + .iter() + .map(|part| { + let (mut item, provider_options) = match part { + ToolResultContent::Text(part) => ( + json!({"type":"text", "text":part.text}), + &part.provider_options, + ), + ToolResultContent::File(part) => { + let data = match &part.data { + FileData::Data { data } => { + let data = match data { + FileBytes::Base64(data) => json!(data), + FileBytes::Binary(bytes) => Value::Object( + bytes + .iter() + .enumerate() + .map(|(i, byte)| (i.to_string(), json!(byte))) + .collect(), + ), + }; + json!({"type":"data", "data":data}) + } + FileData::Url { url, original_url } => { + let mut data = json!({"type":"url", "url":url}); + if let Some(original_url) = original_url { + data["originalUrl"] = json!(original_url); + } + data + } + FileData::Reference { reference } => { + json!({"type":"reference", "reference":reference}) + } + FileData::Text { text } => json!({"type":"text", "text":text}), + }; + let mut item = + json!({"type":"file", "data":data, "mediaType":part.media_type}); + if let Some(filename) = &part.filename { + item["filename"] = json!(filename); + } + (item, &part.provider_options) + } + ToolResultContent::Custom { provider_options } => { + (json!({"type":"custom"}), provider_options) + } + }; + if let Some(provider_options) = provider_options { + item["providerOptions"] = json!(provider_options); + } + item + }) + .collect::>() + ) +} + +pub(crate) fn tool_result_cache_breakpoint(output: &ToolResultOutput) -> Option { + match output { + ToolResultOutput::Text { + provider_options, .. + } + | ToolResultOutput::Json { + provider_options, .. + } + | ToolResultOutput::ErrorText { + provider_options, .. + } + | ToolResultOutput::ErrorJson { + provider_options, .. + } + | ToolResultOutput::ExecutionDenied { + provider_options, .. + } => get_prompt_cache_breakpoint(provider_options), + ToolResultOutput::Content { value } => value.iter().find_map(|part| match part { + aimux_core::language_model_message::ToolResultContent::Text(part) => { + get_prompt_cache_breakpoint(&part.provider_options) + } + aimux_core::language_model_message::ToolResultContent::File(part) => { + get_prompt_cache_breakpoint(&part.provider_options) + } + aimux_core::language_model_message::ToolResultContent::Custom { provider_options } => { + get_prompt_cache_breakpoint(provider_options) + } + }), + } +} + +fn convert_text_part_to_openai(part: &TextPart) -> Value { + let mut value = json!({ "type": "text", "text": part.text }); + if let Some(bpt) = get_prompt_cache_breakpoint(&part.provider_options) { + value["prompt_cache_breakpoint"] = bpt; + } + value +} diff --git a/aimux-providers/src/openai/chat/map_finish_reason.rs b/aimux-providers/src/openai/chat/map_finish_reason.rs new file mode 100644 index 000000000..15ff73cc7 --- /dev/null +++ b/aimux-providers/src/openai/chat/map_finish_reason.rs @@ -0,0 +1,21 @@ +//! Map a Chat Completions finish reason to the core finish reason (map-openai-finish-reason.ts). + +use aimux_core::types::{FinishReason, FinishReasonUnified}; + +/// Parse OpenAI finish reason string into `FinishReason`. +#[must_use] +pub fn parse_finish_reason(s: &str) -> FinishReason { + let unified = match s { + "stop" => FinishReasonUnified::Stop, + "length" => FinishReasonUnified::Length, + // Legacy `function_call` is a tool-call stop too + // (upstream map-openai-finish-reason.ts:13-15). + "function_call" | "tool_calls" => FinishReasonUnified::ToolCalls, + "content_filter" => FinishReasonUnified::ContentFilter, + _ => FinishReasonUnified::Other, + }; + FinishReason { + unified, + raw: Some(s.to_string()), + } +} diff --git a/aimux-providers/src/openai/chat/mod.rs b/aimux-providers/src/openai/chat/mod.rs new file mode 100644 index 000000000..1f0d359ec --- /dev/null +++ b/aimux-providers/src/openai/chat/mod.rs @@ -0,0 +1,11 @@ +//! `chat/`: the Chat Completions language model. + +pub(super) mod chat_api; +pub(super) mod chat_language_model; +pub(super) mod chat_language_model_options; +pub(super) mod chat_prepare_tools; +pub(super) mod convert_chat_usage; +pub(super) mod convert_to_chat_messages; +pub(super) mod map_finish_reason; + +pub use chat_language_model::OpenAIModel; diff --git a/aimux-providers/src/openai/completion.rs b/aimux-providers/src/openai/completion/completion_language_model.rs similarity index 53% rename from aimux-providers/src/openai/completion.rs rename to aimux-providers/src/openai/completion/completion_language_model.rs index 042aa3ee8..3419a357e 100644 --- a/aimux-providers/src/openai/completion.rs +++ b/aimux-providers/src/openai/completion/completion_language_model.rs @@ -1,4 +1,5 @@ -//! Native text-completion language model. +//! Native text-completion language model (openai-completion-language-model.ts), +//! served by the OpenAI-compatible completion model. /// The native provider's `/completions` model. pub type OpenAICompletionModel = diff --git a/aimux-providers/src/openai/completion/mod.rs b/aimux-providers/src/openai/completion/mod.rs new file mode 100644 index 000000000..b0f2a6a15 --- /dev/null +++ b/aimux-providers/src/openai/completion/mod.rs @@ -0,0 +1,5 @@ +//! `completion/`: the legacy Completions language model. + +pub(super) mod completion_language_model; + +pub use completion_language_model::OpenAICompletionModel; diff --git a/aimux-providers/src/openai/config.rs b/aimux-providers/src/openai/config.rs index 80c0fa1f1..28296b96d 100644 --- a/aimux-providers/src/openai/config.rs +++ b/aimux-providers/src/openai/config.rs @@ -86,7 +86,7 @@ pub(crate) struct OpenAIModelConfig { /// How the Responses API model reads and writes providerOptions and file /// ids for this host. pub(crate) responses: ResponsesProfile, - pub(crate) chat_options: super::options::ChatOptionsParser, + pub(crate) chat_options: crate::openai::chat::chat_language_model_options::ChatOptionsParser, } impl OpenAIModelConfig { @@ -107,7 +107,7 @@ impl OpenAIModelConfig { supported_urls: SupportedUrls::default(), transform_request_body, responses: ResponsesProfile::default(), - chat_options: super::options::parse_chat_options, + chat_options: crate::openai::chat::chat_language_model_options::parse_chat_options, } } diff --git a/aimux-providers/src/openai/convert.rs b/aimux-providers/src/openai/convert.rs deleted file mode 100644 index 8974f9a0f..000000000 --- a/aimux-providers/src/openai/convert.rs +++ /dev/null @@ -1,1264 +0,0 @@ -//! Conversion between `LanguageModelPrompt` and OpenAI API format. - -use aimux_core::error::AiMuxError; -use aimux_core::language_model_message::{ - AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, TextPart, ToolPart, - ToolResultContent, ToolResultOutput, UserPart, -}; -use aimux_core::options::{CallOptions, LanguageModelV4ToolChoice, ResponseFormat}; -use aimux_core::shared::{FileBytes, FileData, JsonObject, SharedProviderOptions}; -use aimux_core::tool::{FunctionTool, Tool}; -use aimux_core::types::{FinishReason, FinishReasonUnified, ReasoningEffort, Warning}; -use aimux_provider_utils::{get_top_level_media_type, resolve_full_media_type}; -use serde::Serialize; -use serde_json::{Value, json}; - -/// Public capability enum used by the conversion helpers (moved to -/// `convert_common` in M10; re-exported for API compatibility). -pub use super::convert_common::SystemMessageMode; -use super::convert_common::{ModelCapabilities, get_model_capabilities}; - -// ── Model capabilities ────────────────────────────────────────────────────── -// `GptVersion` / `get_gpt_version` / `get_o_series_version` / -// `ModelCapabilities` / `SystemMessageMode` / `get_model_capabilities` live in -// `super::convert_common` and are shared with the Responses converter (M10). - -/// Apply the upstream structured-output compatibility rules only at schema positions. -pub(crate) fn normalize_json_schema( - schema: &Value, - warnings: &mut Vec, -) -> Result { - fn walk(value: &mut Value, removed: &mut [bool; 2], is_root: bool) -> Result<(), AiMuxError> { - let Some(obj) = value.as_object_mut() else { - return Ok(()); - }; - if let Some(names) = obj.get("propertyNames").filter(|v| !v.is_null()) { - if names.get("type").and_then(Value::as_str) != Some("string") { - return Err(AiMuxError::UnsupportedFunctionality( - "JSON Schema propertyNames that does not use a string schema".into(), - )); - } - obj.remove("propertyNames"); - removed[0] = true; - } - if obj - .get("pattern") - .and_then(Value::as_str) - .is_some_and(contains_lookaround) - { - obj.remove("pattern"); - removed[1] = true; - } - for key in [ - "properties", - "patternProperties", - "definitions", - "$defs", - "dependencies", - ] { - if let Some(Value::Object(record)) = obj.get_mut(key) { - for child in record.values_mut().filter(|v| !v.is_array()) { - walk(child, removed, false)?; - } - } - } - for key in [ - "additionalProperties", - "additionalItems", - "items", - "contains", - "not", - "allOf", - "anyOf", - "oneOf", - "if", - "then", - "else", - ] { - if let Some(child) = obj.get_mut(key) { - if let Value::Array(children) = child { - for child in children { - walk(child, removed, false)?; - } - } else { - walk(child, removed, false)?; - } - } - } - // Zod 4 writes recursive references as `allOf: [{ $ref }]`; OpenAI - // has no `allOf`, so a singleton local reference becomes a direct - // `$ref`, expanded at the root where OpenAI requires an object. - let reference = match obj.get("allOf") { - Some(Value::Array(all_of)) if all_of.len() == 1 => all_of[0] - .as_object() - .filter(|item| item.len() == 1) - .and_then(|item| item.get("$ref")) - .and_then(Value::as_str) - .map(str::to_string), - _ => None, - }; - if let Some(reference) = reference { - if !is_root { - obj.remove("allOf"); - obj.insert("$ref".into(), Value::String(reference)); - } else if let Some(Value::Object(referenced)) = local_reference(&reference, obj) { - let mut expanded = referenced.clone(); - obj.remove("allOf"); - expanded.extend(std::mem::take(obj)); - *obj = expanded; - } - } - Ok(()) - } - /// `#/definitions/` or `#/$defs/` in `schema`. - fn local_reference<'a>( - reference: &str, - schema: &'a serde_json::Map, - ) -> Option<&'a Value> { - let (keyword, name) = reference - .strip_prefix("#/definitions/") - .map(|name| ("definitions", name)) - .or_else(|| { - reference - .strip_prefix("#/$defs/") - .map(|name| ("$defs", name)) - })?; - if name.is_empty() { - return None; - } - let name = name.replace("~1", "/").replace("~0", "~"); - schema.get(keyword)?.get(&name) - } - let mut result = schema.clone(); - let mut removed = [false; 2]; - walk(&mut result, &mut removed, true)?; - for (index, feature, details) in [ - ( - 0, - "JSON Schema propertyNames", - "OpenAI does not support JSON Schema propertyNames. It was removed before sending the schema, so OpenAI will not enforce property-name constraints.", - ), - ( - 1, - "JSON Schema pattern with regex lookaround", - "OpenAI does not support regex lookaround in JSON Schema patterns. The pattern was removed before sending the schema, so OpenAI will not enforce that constraint.", - ), - ] { - if removed[index] { - warnings.push(Warning::Compatibility { - feature: feature.into(), - details: Some(details.into()), - }); - } - } - Ok(result) -} - -fn contains_lookaround(pattern: &str) -> bool { - let bytes = pattern.as_bytes(); - let (mut escaped, mut in_class) = (false, false); - for (i, &byte) in bytes.iter().enumerate() { - if escaped { - escaped = false; - continue; - } - match byte { - b'\\' => escaped = true, - b'[' => in_class = true, - b']' => in_class = false, - b'(' if !in_class - && bytes.get(i + 1) == Some(&b'?') - && (matches!(bytes.get(i + 2), Some(b'=' | b'!')) - || (bytes.get(i + 2) == Some(&b'<') - && matches!(bytes.get(i + 3), Some(b'=' | b'!')))) => - { - return true; - } - _ => {} - } - } - false -} - -// ── Prepared tools ────────────────────────────────────────────────────────── - -/// A warning emitted while preparing tools (mirrors the V4 `SharedV4Warning` -/// `unsupported` shape used by the TS `prepareChatTools`). -#[derive(Debug, Clone, PartialEq, Serialize)] -pub struct ToolWarning { - #[serde(rename = "type")] - pub warning_type: String, - pub feature: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub details: Option, -} - -/// The result of preparing tools for an OpenAI request body. -#[derive(Debug, Clone, PartialEq)] -pub struct PreparedTools { - pub tools: Option>, - pub tool_choice: Option, - pub tool_warnings: Vec, -} - -/// Prepare `FunctionTool`s into the OpenAI `tools` / `tool_choice` JSON shape. -#[must_use] -pub fn prepare_tools( - tools: &Option>, - tool_choice: Option<&LanguageModelV4ToolChoice>, -) -> PreparedTools { - let non_empty = tools.as_ref().filter(|&t| !t.is_empty()); - - let tool_warnings: Vec = Vec::new(); - - let tools_opt = match non_empty { - None => None, - Some(tools) => { - let openai_tools: Vec = tools - .iter() - .map(|t| { - let mut func = json!({ - "name": t.name, - "parameters": t.input_schema, - }); - if let Some(ref desc) = t.description { - func["description"] = json!(desc); - } - if let Some(strict) = t.strict { - func["strict"] = json!(strict); - } - json!({ "type": "function", "function": func }) - }) - .collect(); - Some(openai_tools) - } - }; - - let tool_choice_opt = match (&tools_opt, tool_choice) { - (None, _) => None, - (Some(_), None) => None, - (Some(_), Some(tc)) => match tc { - LanguageModelV4ToolChoice::Auto => Some(json!("auto")), - LanguageModelV4ToolChoice::None => Some(json!("none")), - LanguageModelV4ToolChoice::Required => Some(json!("required")), - LanguageModelV4ToolChoice::Tool { tool_name } => { - Some(json!({ "type": "function", "function": { "name": tool_name } })) - } - }, - }; - - PreparedTools { - tools: tools_opt, - tool_choice: tool_choice_opt, - tool_warnings, - } -} - -// ── Message conversion ────────────────────────────────────────────────────── - -/// Convert a `LanguageModelPrompt` to OpenAI `messages` array with a system -/// message mode. -/// -/// # Errors -/// -/// Returns `AiMuxError::invalid_argument` when a message part cannot be -/// converted (e.g. an unsupported file part shape or a missing provider -/// reference). -pub fn convert_prompt_to_openai_messages_with_mode_fallible( - prompt: &LanguageModelPrompt, - system_message_mode: SystemMessageMode, -) -> Result, AiMuxError> { - let mut result = Vec::new(); - for msg in prompt { - result.extend(convert_message_to_openai(msg, system_message_mode)?); - } - Ok(result) -} - -/// Get the prompt cache breakpoint from provider options. -fn get_prompt_cache_breakpoint(provider_options: &Option) -> Option { - provider_options - .as_ref() - .and_then(|po| po.get("openai")) - .and_then(|o| o.get("promptCacheBreakpoint")) - .cloned() -} - -/// Get imageDetail from provider options. -fn get_image_detail(provider_options: &Option) -> Option { - provider_options - .as_ref() - .and_then(|po| po.get("openai")) - .and_then(|o| o.get("imageDetail")) - .cloned() -} - -/// Resolve a provider reference, throwing if the provider is not found. -pub(crate) fn resolve_provider_reference( - reference: &aimux_core::shared::SharedProviderReference, - provider: &str, -) -> Result { - reference.get(provider).cloned().ok_or_else(|| { - let mut available: Vec<&str> = reference.keys().map(String::as_str).collect(); - available.sort_unstable(); - format!( - "No provider reference found for provider '{}'. Available providers: {}", - provider, - available.join(", ") - ) - }) -} - -/// Convert a file part to the OpenAI format, handling images, audio, and PDF. -fn convert_file_part_to_openai(file: &FilePart, part_index: usize) -> Result { - use base64::Engine; - let FilePart { - data, - media_type, - filename, - provider_options, - } = file; - let prompt_cache_breakpoint = get_prompt_cache_breakpoint(provider_options); - let (data_b64, url) = match data { - FileData::Reference { reference } => { - let file_id = resolve_provider_reference(reference, "openai") - .map_err(AiMuxError::invalid_argument)?; - let mut part = json!({ "type": "file", "file": { "file_id": file_id } }); - if let Some(bpt) = prompt_cache_breakpoint { - part["prompt_cache_breakpoint"] = bpt; - } - return Ok(part); - } - FileData::Text { .. } => { - return Err(AiMuxError::UnsupportedFunctionality( - "text file parts".into(), - )); - } - FileData::Url { url, .. } => (None, Some(url.as_str())), - FileData::Data { data } => ( - Some(match data { - FileBytes::Binary(bytes) => base64::engine::general_purpose::STANDARD.encode(bytes), - FileBytes::Base64(data) => data.clone(), - }), - None, - ), - }; - let data_b64 = data_b64.as_deref(); - let filename = filename.as_deref(); - - let top_level = get_top_level_media_type(media_type); - - // Image - if top_level == "image" { - let image_url = if let Some(url_str) = url { - json!({ "url": url_str }) - } else if let Some(b64) = data_b64 { - let full_mt = resolve_full_media_type(file)?; - json!({ "url": format!("data:{};base64,{}", full_mt, b64) }) - } else { - return Err(AiMuxError::invalid_argument( - "image part has no data or url", - )); - }; - - let mut image_url_obj = image_url; - if let Some(detail) = get_image_detail(provider_options) { - image_url_obj["detail"] = detail; - } - - let mut part = json!({ - "type": "image_url", - "image_url": image_url_obj, - }); - if let Some(bpt) = prompt_cache_breakpoint { - part["prompt_cache_breakpoint"] = bpt; - } - return Ok(part); - } - - // Audio - if top_level == "audio" { - if url.is_some() { - return Err(AiMuxError::UnsupportedFunctionality( - "audio file parts with URLs".into(), - )); - } - let b64 = data_b64.ok_or_else(|| AiMuxError::invalid_argument("audio part has no data"))?; - let full_mt = resolve_full_media_type(file)?; - let format = match full_mt.as_str() { - "audio/wav" => "wav", - "audio/mp3" | "audio/mpeg" => "mp3", - _ => { - return Err(AiMuxError::UnsupportedFunctionality(format!( - "audio content parts with media type {full_mt}" - ))); - } - }; - let mut part = json!({ - "type": "input_audio", - "input_audio": { "data": b64, "format": format } - }); - if let Some(bpt) = prompt_cache_breakpoint { - part["prompt_cache_breakpoint"] = bpt; - } - return Ok(part); - } - - // PDF / application - let full_mt = resolve_full_media_type(file)?; - - if full_mt != "application/pdf" { - return Err(AiMuxError::UnsupportedFunctionality(format!( - "file part media type {full_mt}" - ))); - } - - if url.is_some() { - return Err(AiMuxError::UnsupportedFunctionality( - "PDF file parts with URLs".into(), - )); - } - - let b64 = data_b64.ok_or_else(|| AiMuxError::invalid_argument("PDF part has no data"))?; - let fname = filename - .map(std::string::ToString::to_string) - .unwrap_or_else(|| format!("part-{part_index}.pdf")); - let mut part = json!({ - "type": "file", - "file": { - "filename": fname, - "file_data": format!("data:application/pdf;base64,{}", b64), - } - }); - if let Some(bpt) = prompt_cache_breakpoint { - part["prompt_cache_breakpoint"] = bpt; - } - Ok(part) -} - -/// Convert a single provider-facing message into one or more OpenAI messages. -fn convert_message_to_openai( - msg: &LanguageModelMessage, - system_message_mode: SystemMessageMode, -) -> Result, AiMuxError> { - let message = match msg { - LanguageModelMessage::System { - content, - provider_options, - } => { - let role = match system_message_mode { - SystemMessageMode::Remove => return Ok(vec![]), - SystemMessageMode::Developer => "developer", - SystemMessageMode::System => "system", - }; - let content = match get_prompt_cache_breakpoint(provider_options) { - None => json!(content), - Some(bpt) => json!([{ - "type": "text", "text": content, "prompt_cache_breakpoint": bpt, - }]), - }; - json!({ "role": role, "content": content }) - } - LanguageModelMessage::Tool { content, .. } => { - return Ok(content - .iter() - .filter_map(|part| { - let ToolPart::ToolResult(result) = part else { return None; }; - let mut content = tool_result_to_content(&result.output); - let breakpoint = tool_result_cache_breakpoint(&result.output).or_else(|| get_prompt_cache_breakpoint(&result.provider_options)); - if let Some(breakpoint) = breakpoint { - content = json!([{ "type": "text", "text": content, "prompt_cache_breakpoint": breakpoint }]); - } - Some(json!({ "role": "tool", "content": content, "tool_call_id": result.tool_call_id })) - }) - .collect()); - } - LanguageModelMessage::User { content, .. } => { - let all_plain_text = content.len() == 1 - && content.iter().all(|part| { - matches!(part, UserPart::Text(text) - if get_prompt_cache_breakpoint(&text.provider_options).is_none()) - }); - let content = if all_plain_text { - json!( - content - .iter() - .filter_map(|part| match part { - UserPart::Text(text) => Some(text.text.as_str()), - UserPart::File(_) => None, - }) - .collect::() - ) - } else { - json!( - content - .iter() - .enumerate() - .map(|(index, part)| match part { - UserPart::Text(text) => Ok(convert_text_part_to_openai(text)), - UserPart::File(file) => convert_file_part_to_openai(file, index), - }) - .collect::, _>>()? - ) - }; - json!({ "role": "user", "content": content }) - } - LanguageModelMessage::Assistant { content, .. } => { - let mut text = String::new(); - let mut text_parts = Vec::new(); - let mut has_cache_breakpoint = false; - let mut tool_calls = Vec::new(); - for part in content { - match part { - AssistantPart::Text(part) => { - text.push_str(&part.text); - has_cache_breakpoint |= - get_prompt_cache_breakpoint(&part.provider_options).is_some(); - text_parts.push(convert_text_part_to_openai(part)); - } - AssistantPart::ToolCall(part) => { - // Upstream `serializeToolCallArguments` (@ai-sdk/openai - // 4.0.49+) replays any non-object input as `{}` on - // purpose; OpenAI function arguments are always objects. - let arguments = if !part.input.is_object() { - "{}".to_string() - } else { - part.input.to_string() - }; - tool_calls.push(json!({ - "type": "function", "id": part.tool_call_id, - "function": { "name": part.tool_name, "arguments": arguments }, - })); - } - _ => {} - } - } - let content = if has_cache_breakpoint { - json!(text_parts) - } else if !tool_calls.is_empty() && text.is_empty() { - Value::Null - } else { - json!(text) - }; - let mut message = json!({ "role": "assistant", "content": content }); - if !tool_calls.is_empty() { - message["tool_calls"] = json!(tool_calls); - } - message - } - }; - Ok(vec![message]) -} - -pub(crate) fn tool_result_to_content(output: &ToolResultOutput) -> Value { - Value::String(match output { - ToolResultOutput::Text { value, .. } | ToolResultOutput::ErrorText { value, .. } => { - value.clone() - } - ToolResultOutput::ExecutionDenied { reason, .. } => reason - .clone() - .unwrap_or_else(|| "Tool call execution denied.".to_string()), - ToolResultOutput::Json { value, .. } | ToolResultOutput::ErrorJson { value, .. } => { - value.to_string() - } - ToolResultOutput::Content { value } => tool_result_content_value(value).to_string(), - }) -} - -pub(crate) fn tool_result_content_value(content: &[ToolResultContent]) -> Value { - json!( - content - .iter() - .map(|part| { - let (mut item, provider_options) = match part { - ToolResultContent::Text(part) => ( - json!({"type":"text", "text":part.text}), - &part.provider_options, - ), - ToolResultContent::File(part) => { - let data = match &part.data { - FileData::Data { data } => { - let data = match data { - FileBytes::Base64(data) => json!(data), - FileBytes::Binary(bytes) => Value::Object( - bytes - .iter() - .enumerate() - .map(|(i, byte)| (i.to_string(), json!(byte))) - .collect(), - ), - }; - json!({"type":"data", "data":data}) - } - FileData::Url { url, original_url } => { - let mut data = json!({"type":"url", "url":url}); - if let Some(original_url) = original_url { - data["originalUrl"] = json!(original_url); - } - data - } - FileData::Reference { reference } => { - json!({"type":"reference", "reference":reference}) - } - FileData::Text { text } => json!({"type":"text", "text":text}), - }; - let mut item = - json!({"type":"file", "data":data, "mediaType":part.media_type}); - if let Some(filename) = &part.filename { - item["filename"] = json!(filename); - } - (item, &part.provider_options) - } - ToolResultContent::Custom { provider_options } => { - (json!({"type":"custom"}), provider_options) - } - }; - if let Some(provider_options) = provider_options { - item["providerOptions"] = json!(provider_options); - } - item - }) - .collect::>() - ) -} - -pub(crate) fn tool_result_cache_breakpoint(output: &ToolResultOutput) -> Option { - match output { - ToolResultOutput::Text { - provider_options, .. - } - | ToolResultOutput::Json { - provider_options, .. - } - | ToolResultOutput::ErrorText { - provider_options, .. - } - | ToolResultOutput::ErrorJson { - provider_options, .. - } - | ToolResultOutput::ExecutionDenied { - provider_options, .. - } => get_prompt_cache_breakpoint(provider_options), - ToolResultOutput::Content { value } => value.iter().find_map(|part| match part { - aimux_core::language_model_message::ToolResultContent::Text(part) => { - get_prompt_cache_breakpoint(&part.provider_options) - } - aimux_core::language_model_message::ToolResultContent::File(part) => { - get_prompt_cache_breakpoint(&part.provider_options) - } - aimux_core::language_model_message::ToolResultContent::Custom { provider_options } => { - get_prompt_cache_breakpoint(provider_options) - } - }), - } -} - -fn convert_text_part_to_openai(part: &TextPart) -> Value { - let mut value = json!({ "type": "text", "text": part.text }); - if let Some(bpt) = get_prompt_cache_breakpoint(&part.provider_options) { - value["prompt_cache_breakpoint"] = bpt; - } - value -} - -// ── Request body ──────────────────────────────────────────────────────────── - -/// Result of building a request body, including warnings. -#[derive(Debug, Clone)] -pub struct RequestBodyResult { - pub body: Value, - pub warnings: Vec, -} - -/// Parse declared namespace fields, discarding unknown keys like a default z.object. -/// -/// # Errors -/// Returns a parse error when a declared field fails its schema. -pub(crate) fn parse_option_fields( - options: &JsonObject, - provider: &str, - parse: impl Fn(&str, &Value) -> Option>, -) -> Result { - let mut parsed = JsonObject::new(); - for (key, value) in options { - if let Some(result) = parse(key, value) { - parsed.insert( - key.clone(), - result.map_err(|()| { - AiMuxError::invalid_argument(format!("invalid {provider} provider options")) - })?, - ); - } - } - Ok(parsed) -} - -/// Parse a namespace with the option schema supplied by its package. -pub(crate) fn parse_chat_provider_options( - options: Option<&SharedProviderOptions>, - provider: &str, - parse: impl FnOnce(&JsonObject) -> Result, -) -> Result, AiMuxError> { - options - .and_then(|options| options.get(provider)) - .map(parse) - .transpose() -} - -/// Get a value from provider_options.openai.. -fn openai_option(options: &Option, key: &str) -> Option { - options - .as_ref() - .and_then(|m| m.get("openai")) - .and_then(|o| o.get(key)) - .cloned() -} - -/// Convert `CallOptions` to an OpenAI request body (without warnings), or an -/// [`AiMuxError`] describing what failed. -/// -/// # Errors -/// -/// Returns the first conversion error from `build_request_body_with_warnings` -/// (e.g. `InvalidArgument` for an unconvertible prompt part). -pub fn build_request_body( - model_id: &str, - options: &CallOptions, - stream: bool, -) -> Result { - build_request_body_with_warnings(model_id, options, stream).map(|r| r.body) -} - -/// Resolve the effective reasoning effort — direct passthrough (v3: no built-in -/// vendor normalization). `providerOptions.reasoningEffort` wins over top-level -/// `reasoning`; custom top-level levels map verbatim to `reasoning_effort`. -fn resolve_reasoning_effort( - provider_opts: &Option, - reasoning: &Option, -) -> Option { - openai_option(provider_opts, "reasoningEffort") - .map(|v| { - v.as_str() - .map(std::string::ToString::to_string) - .unwrap_or(v.to_string()) - }) - .or_else(|| { - if reasoning.is_some_and(ReasoningEffort::is_custom) { - reasoning.map(|r| r.to_string()) - } else { - None - } - }) -} - -fn resolve_is_reasoning_model( - provider_opts: &Option, - caps: &ModelCapabilities, -) -> bool { - openai_option(provider_opts, "forceReasoning") - .map(|v| v.as_bool().unwrap_or(false)) - .unwrap_or(caps.is_reasoning_model) -} - -fn resolve_system_message_mode( - provider_opts: &Option, - is_reasoning_model: bool, - caps: &ModelCapabilities, -) -> SystemMessageMode { - openai_option(provider_opts, "systemMessageMode") - .and_then(|v| v.as_str().map(std::string::ToString::to_string)) - .map(|s| match s.as_str() { - "developer" => SystemMessageMode::Developer, - "remove" => SystemMessageMode::Remove, - _ => SystemMessageMode::System, - }) - .unwrap_or(if is_reasoning_model { - SystemMessageMode::Developer - } else { - caps.system_message_mode - }) -} - -/// Insert `max_tokens` / `max_completion_tokens`: reasoning models take the -/// latter, the rest the former; an explicit `maxCompletionTokens` option is -/// always sent as `max_completion_tokens`. -fn apply_max_tokens( - body: &mut Value, - options: &CallOptions, - provider_opts: &Option, - is_reasoning_model: bool, -) { - let max_completion_tokens_opt = openai_option(provider_opts, "maxCompletionTokens"); - - if let Some(max_tokens) = options.max_output_tokens { - let key = if is_reasoning_model { - "max_completion_tokens" - } else { - "max_tokens" - }; - body[key] = json!(max_tokens); - } - if let Some(mct) = max_completion_tokens_opt { - body["max_completion_tokens"] = json!(mct); - } -} - -/// Sampling parameters that survive the reasoning-model / search-preview -/// capability filtering. -#[derive(Default)] -struct SamplingParams { - temperature: Option, - top_p: Option, - frequency_penalty: Option, - presence_penalty: Option, -} - -/// Remove unsupported sampling settings for reasoning models and the search -/// preview models, pushing a compatibility warning for each removal. -fn strip_sampling_params( - options: &CallOptions, - model_id: &str, - caps: &ModelCapabilities, - is_reasoning_model: bool, - resolved_reasoning_effort: &Option, - warnings: &mut Vec, -) -> SamplingParams { - let mut params = SamplingParams { - temperature: options.temperature, - top_p: options.top_p, - frequency_penalty: options.frequency_penalty, - presence_penalty: options.presence_penalty, - }; - - if is_reasoning_model { - let allow_non_reasoning = resolved_reasoning_effort.as_deref() == Some("none") - && caps.supports_non_reasoning_parameters; - - if !allow_non_reasoning { - if params.temperature.is_some() { - params.temperature = None; - warnings.push(Warning::Unsupported { - feature: "temperature".to_string(), - details: Some("temperature is not supported for reasoning models".to_string()), - }); - } - if params.top_p.is_some() { - params.top_p = None; - warnings.push(Warning::Unsupported { - feature: "topP".to_string(), - details: Some("topP is not supported for reasoning models".to_string()), - }); - } - } - - if params.frequency_penalty.is_some() { - params.frequency_penalty = None; - warnings.push(Warning::Unsupported { - feature: "frequencyPenalty".to_string(), - details: Some("frequencyPenalty is not supported for reasoning models".to_string()), - }); - } - if params.presence_penalty.is_some() { - params.presence_penalty = None; - warnings.push(Warning::Unsupported { - feature: "presencePenalty".to_string(), - details: Some("presencePenalty is not supported for reasoning models".to_string()), - }); - } - } else if (model_id.starts_with("gpt-4o-search-preview") - || model_id.starts_with("gpt-4o-mini-search-preview")) - && params.temperature.is_some() - { - params.temperature = None; - warnings.push(Warning::Unsupported { - feature: "temperature".to_string(), - details: Some( - "temperature is not supported for the search preview models and has been removed." - .to_string(), - ), - }); - } - - params -} - -/// Write the surviving sampling params (plus stop/seed) into the body. -fn insert_sampling_params(body: &mut Value, params: &SamplingParams, options: &CallOptions) { - if let Some(temp) = params.temperature { - body["temperature"] = json!(temp); - } - if let Some(tp) = params.top_p { - body["top_p"] = json!(tp); - } - if let Some(fp) = params.frequency_penalty { - body["frequency_penalty"] = json!(fp); - } - if let Some(pp) = params.presence_penalty { - body["presence_penalty"] = json!(pp); - } - if let Some(ref stop) = options.stop_sequences { - body["stop"] = json!(stop); - } - if let Some(seed) = options.seed { - body["seed"] = json!(seed); - } -} - -/// `response_format` → body: a schema is sent as `json_schema` (strict), a -/// bare JSON request as `json_object`. -fn apply_response_format( - body: &mut Value, - options: &CallOptions, - warnings: &mut Vec, -) -> Result<(), AiMuxError> { - let Some(ref rf) = options.response_format else { - return Ok(()); - }; - match rf { - ResponseFormat::Text => {} - ResponseFormat::Json { - schema, - name, - description, - } => { - if let Some(schema) = schema { - let mut schema_obj = json!({}); - schema_obj["schema"] = normalize_json_schema(schema, warnings)?; - schema_obj["name"] = json!(name.clone().unwrap_or_else(|| "response".to_string())); - if let Some(d) = description { - schema_obj["description"] = json!(d); - } - schema_obj["strict"] = openai_option(&options.provider_options, "strictJsonSchema") - .unwrap_or(json!(true)); - body["response_format"] = json!({ - "type": "json_schema", - "json_schema": schema_obj, - }); - } else { - body["response_format"] = json!({ "type": "json_object" }); - } - } - } - Ok(()) -} - -/// Pass through the simple provider-specific options that map 1:1 to a body -/// field. -fn apply_provider_option_passthrough( - body: &mut Value, - provider_opts: &Option, -) { - let mut set = |key: &str, body_key: &str| { - if let Some(val) = openai_option(provider_opts, key) { - body[body_key] = val; - } - }; - set("logitBias", "logit_bias"); - set("user", "user"); - set("parallelToolCalls", "parallel_tool_calls"); - set("textVerbosity", "verbosity"); - set("store", "store"); - set("metadata", "metadata"); - set("prediction", "prediction"); - set("promptCacheKey", "prompt_cache_key"); - set("promptCacheRetention", "prompt_cache_retention"); - set("promptCacheOptions", "prompt_cache_options"); - set("safetyIdentifier", "safety_identifier"); - if let Some(logprobs) = openai_option(provider_opts, "logprobs") - && (logprobs == true || logprobs.is_number()) - { - body["logprobs"] = json!(true); - body["top_logprobs"] = if logprobs.is_number() { - logprobs - } else { - json!(0) - }; - } -} - -/// `service_tier` with model-capability validation. -fn apply_service_tier( - body: &mut Value, - provider_opts: &Option, - caps: &ModelCapabilities, - warnings: &mut Vec, -) { - let service_tier = openai_option(provider_opts, "serviceTier") - .and_then(|v| v.as_str().map(std::string::ToString::to_string)); - if let Some(ref st) = service_tier { - match st.as_str() { - "flex" => { - if caps.supports_flex_processing { - body["service_tier"] = json!(st); - } else { - warnings.push(Warning::Unsupported { - feature: "serviceTier".to_string(), - details: Some( - "flex processing is only available for o3, o4-mini, and gpt-5 models" - .to_string(), - ), - }); - } - } - "priority" | "fast" => { - if caps.supports_priority_processing { - body["service_tier"] = json!(st); - } else { - warnings.push(Warning::Unsupported { - feature: "serviceTier".to_string(), - details: Some( - "priority processing is only available for supported models (gpt-4, gpt-5, gpt-5-mini, o3, o4-mini) and requires Enterprise access. gpt-5-nano is not supported".to_string(), - ), - }); - } - } - _ => { - body["service_tier"] = json!(st); - } - } - } -} - -/// Function tools → `tools` / `tool_choice`. -fn apply_tools( - body: &mut Value, - options: &CallOptions, - warnings: &mut Vec, -) -> Result<(), AiMuxError> { - let function_tools: Option> = options.tools.as_ref().map(|tools| { - tools - .iter() - .filter_map(|t| match t { - Tool::Function(ft) => Some(ft.clone()), - Tool::Provider(_) => None, - }) - .collect() - }); - - let mut function_tools = function_tools; - for tool in function_tools.iter_mut().flatten() { - tool.input_schema = normalize_json_schema(&tool.input_schema, warnings)?; - } - for tool in options.tools.iter().flatten() { - if matches!(tool, Tool::Provider(_)) { - warnings.push(Warning::Unsupported { - feature: "tool type: provider".into(), - details: None, - }); - } - } - if options - .tools - .as_ref() - .is_some_and(|tools| !tools.is_empty()) - && function_tools.as_ref().is_some_and(Vec::is_empty) - { - body["tools"] = json!([]); - } - let prepared = prepare_tools(&function_tools, options.tool_choice.as_ref()); - if let Some(tools) = prepared.tools { - body["tools"] = json!(tools); - if let Some(tc) = prepared.tool_choice { - body["tool_choice"] = tc; - } - } - Ok(()) -} - -/// Convert `CallOptions` to an OpenAI request body, returning warnings. -/// -/// Conversion errors propagate to the caller (fail-fast, issue H2): the old -/// behaviour of silently returning `body: null` sent empty requests upstream -/// and made conversion failures invisible. -/// -/// # Errors -/// -/// Propagates conversion errors, fail-fast: e.g. `InvalidArgument` for an -/// unconvertible prompt part or an invalid option combination. -pub fn build_request_body_with_warnings( - model_id: &str, - options: &CallOptions, - stream: bool, -) -> Result { - build_request_body_with_chat_options( - model_id, - options, - stream, - super::options::parse_chat_options, - ) -} - -pub(crate) fn build_request_body_with_chat_options( - model_id: &str, - options: &CallOptions, - stream: bool, - parse: super::options::ChatOptionsParser, -) -> Result { - let mut warnings: Vec = Vec::new(); - let caps = get_model_capabilities(model_id); - let parsed_provider_opts = parse_chat_provider_options( - options.provider_options.as_ref(), - super::options::NAMESPACE, - parse, - )? - .map(|parsed| { - aimux_core::shared::NamespaceMap::from([(super::options::NAMESPACE.to_string(), parsed)]) - }); - let provider_opts = &parsed_provider_opts; - - let mut resolved_reasoning_effort = resolve_reasoning_effort(provider_opts, &options.reasoning); - if let (Some(effort), Some(supported)) = - (&resolved_reasoning_effort, caps.supported_reasoning_efforts) - && !supported.contains(&effort.as_str()) - { - warnings.push(Warning::Unsupported { - feature: "reasoningEffort".into(), - details: Some(format!( - "{model_id} only supports the following reasoning efforts: {}", - supported.join(", ") - )), - }); - resolved_reasoning_effort = None; - } - let is_reasoning_model = resolve_is_reasoning_model(provider_opts, &caps); - let system_message_mode = resolve_system_message_mode(provider_opts, is_reasoning_model, &caps); - - // OpenAI has no top_k. - if options.top_k.is_some() { - warnings.push(Warning::Unsupported { - feature: "topK".to_string(), - details: None, - }); - } - - if system_message_mode == SystemMessageMode::Remove { - for _ in options - .prompt - .iter() - .filter(|m| matches!(m, LanguageModelMessage::System { .. })) - { - warnings.push(Warning::Other { - message: "system messages are removed for this model".into(), - }); - } - } - let messages = - convert_prompt_to_openai_messages_with_mode_fallible(&options.prompt, system_message_mode)?; - - let mut body = json!({ - "model": model_id, - "messages": messages, - }); - - if stream { - body["stream"] = json!(true); - body["stream_options"] = json!({ "include_usage": true }); - } - - apply_max_tokens(&mut body, options, provider_opts, is_reasoning_model); - - let sampling = strip_sampling_params( - options, - model_id, - &caps, - is_reasoning_model, - &resolved_reasoning_effort, - &mut warnings, - ); - insert_sampling_params(&mut body, &sampling, options); - - apply_response_format(&mut body, options, &mut warnings)?; - apply_provider_option_passthrough(&mut body, provider_opts); - - if caps.supported_reasoning_efforts.is_some() && body.get("prompt_cache_retention").is_some() { - body.as_object_mut() - .unwrap() - .remove("prompt_cache_retention"); - warnings.push(Warning::Unsupported { feature: "promptCacheRetention".into(), details: Some("promptCacheRetention is not supported by GPT-6 and later models; use promptCacheOptions instead".into()) }); - } - if is_reasoning_model { - let allow_logprobs = resolved_reasoning_effort.as_deref() == Some("none") - && caps.supports_non_reasoning_parameters; - for (key, feature) in [ - ("logprobs", "logprobs"), - ("logit_bias", "logitBias"), - ("top_logprobs", "topLogprobs"), - ] { - if key == "logprobs" && allow_logprobs { - continue; - } - if body.as_object_mut().unwrap().remove(key).is_some() { - warnings.push(Warning::Other { - message: format!("{feature} is not supported for reasoning models"), - }); - } - } - } - - // Provider reasoning takes precedence over the standardized setting. - if let Some(ref effort) = resolved_reasoning_effort { - body["reasoning_effort"] = json!(effort); - } - - apply_service_tier(&mut body, provider_opts, &caps, &mut warnings); - apply_tools(&mut body, options, &mut warnings)?; - - Ok(RequestBodyResult { body, warnings }) -} - -/// Parse OpenAI finish reason string into `FinishReason`. -#[must_use] -pub fn parse_finish_reason(s: &str) -> FinishReason { - let unified = match s { - "stop" => FinishReasonUnified::Stop, - "length" => FinishReasonUnified::Length, - // Legacy `function_call` is a tool-call stop too - // (upstream map-openai-finish-reason.ts:13-15). - "function_call" | "tool_calls" => FinishReasonUnified::ToolCalls, - "content_filter" => FinishReasonUnified::ContentFilter, - _ => FinishReasonUnified::Other, - }; - FinishReason { - unified, - raw: Some(s.to_string()), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use aimux_core::language_model_message::ToolCallPart; - - fn replayed_arguments(input: Value) -> Value { - let prompt = vec![LanguageModelMessage::Assistant { - content: vec![AssistantPart::ToolCall(ToolCallPart { - tool_call_id: "call-1".to_string(), - tool_name: "echo".to_string(), - input, - provider_executed: None, - provider_options: None, - })], - provider_options: None, - }]; - let messages = convert_prompt_to_openai_messages_with_mode_fallible( - &prompt, - SystemMessageMode::System, - ) - .unwrap(); - messages[0]["tool_calls"][0]["function"]["arguments"].clone() - } - - /// partial: `should stringify arguments to tool calls`; full port in src/openai/tests/chat/convert_to_chat_messages.rs. - /// partial: `should default missing tool call input to an empty object`; full port in src/openai/tests/chat/convert_to_chat_messages.rs. - /// partial: `should normalize malformed tool call input and preserve the tool error`; full port in src/openai/tests/chat/convert_to_chat_messages.rs. - #[test] - fn replayed_tool_arguments_follow_upstream() { - assert_eq!( - replayed_arguments(json!({ "city": "Tokyo" })), - json!(r#"{"city":"Tokyo"}"#) - ); - for input in [json!("Tokyo"), json!(7), json!(["Tokyo"]), Value::Null] { - assert_eq!(replayed_arguments(input), json!("{}")); - } - } -} diff --git a/aimux-providers/src/openai/decision.rs b/aimux-providers/src/openai/decision.rs index 395e59e6a..a482d5a80 100644 --- a/aimux-providers/src/openai/decision.rs +++ b/aimux-providers/src/openai/decision.rs @@ -1,5 +1,5 @@ -//! Native OpenAI Decisions API (an aimux extension; the AI SDK's OpenAI -//! package decides through its Responses model instead). +//! aimux 扩展: the native OpenAI Decisions API (the AI SDK's OpenAI package +//! decides through its Responses model instead). //! //! The V4 call options carry what the API takes beyond them in //! `providerOptions.openai`, camelCase as the OpenAI language models' options @@ -16,11 +16,11 @@ use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; -use super::config::OpenAIModelConfig; use crate::decision_support::{ DecisionConfig, DecisionImage, DecisionProbabilitySource, invalid, provider_metadata, response_info, }; +use crate::openai::config::OpenAIModelConfig; /// The OpenAI Decisions API takes at most this many images per request. const MAX_IMAGES: usize = 128; diff --git a/aimux-providers/src/openai/embedding/embedding_api.rs b/aimux-providers/src/openai/embedding/embedding_api.rs new file mode 100644 index 000000000..7b80af5de --- /dev/null +++ b/aimux-providers/src/openai/embedding/embedding_api.rs @@ -0,0 +1,17 @@ +//! Response shape of the OpenAI embeddings endpoint (openai-embedding-api.ts). + +#[derive(serde::Deserialize)] +pub(crate) struct OpenAIEmbeddingResponse { + pub(crate) data: Vec, + pub(crate) usage: Option, +} + +#[derive(serde::Deserialize)] +pub(crate) struct OpenAIEmbeddingData { + pub(crate) embedding: Vec, +} + +#[derive(serde::Deserialize)] +pub(crate) struct OpenAIEmbeddingUsage { + pub(crate) prompt_tokens: f64, +} diff --git a/aimux-providers/src/openai/embedding.rs b/aimux-providers/src/openai/embedding/embedding_model.rs similarity index 68% rename from aimux-providers/src/openai/embedding.rs rename to aimux-providers/src/openai/embedding/embedding_model.rs index 24af0d561..4f8e19f50 100644 --- a/aimux-providers/src/openai/embedding.rs +++ b/aimux-providers/src/openai/embedding/embedding_model.rs @@ -5,17 +5,17 @@ //! //! Endpoint: `POST {base_url}/embeddings` -use async_trait::async_trait; -use serde_json::{Map, Value, json}; - use aimux_core::ErrorCause; use aimux_core::embedding_model::{ EmbeddingCallOptions, EmbeddingModel, EmbeddingResponse, EmbeddingResult, EmbeddingUsage, }; use aimux_core::error::AiMuxError; -use aimux_core::shared::SharedProviderOptions; +use async_trait::async_trait; +use serde_json::{Map, Value, json}; -use super::config::OpenAIModelConfig; +use crate::openai::config::OpenAIModelConfig; +use crate::openai::embedding::embedding_api::OpenAIEmbeddingResponse; +use crate::openai::embedding::embedding_model_options::parse_openai_provider_options; /// An OpenAI-compatible embedding model. /// @@ -84,7 +84,7 @@ impl EmbeddingModel for OpenAIEmbeddingModel { .http_request(self.config.url("/embeddings")?, headers, options), body, aimux_provider_utils::create_json_response_handler(), - super::openai_failed_response_handler(), + crate::openai::error::openai_failed_response_handler(), ) .await?; @@ -118,52 +118,3 @@ impl EmbeddingModel for OpenAIEmbeddingModel { }) } } - -// ── Provider options parsing ───────────────────────────────────────────────── - -#[derive(serde::Deserialize)] -struct OpenAIEmbeddingResponse { - data: Vec, - usage: Option, -} - -#[derive(serde::Deserialize)] -struct OpenAIEmbeddingData { - embedding: Vec, -} - -#[derive(serde::Deserialize)] -struct OpenAIEmbeddingUsage { - prompt_tokens: f64, -} - -#[derive(Default, serde::Deserialize)] -struct OpenAIEmbeddingProviderOptions { - dimensions: Option, - user: Option, -} - -fn parse_openai_provider_options( - options: Option<&SharedProviderOptions>, -) -> Result { - match options.and_then(|options| options.get("openai")) { - Some(value) => { - if value - .get("dimensions") - .is_some_and(|value| !value.is_number()) - || value.get("user").is_some_and(|value| !value.is_string()) - { - return Err(AiMuxError::invalid_argument( - "invalid openai embedding options", - )); - } - serde_json::from_value(Value::Object(value.clone())).map_err(|error| { - AiMuxError::InvalidArgument { - message: format!("invalid openai embedding options: {error}"), - cause: Some(ErrorCause::source(&error)), - } - }) - } - None => Ok(OpenAIEmbeddingProviderOptions::default()), - } -} diff --git a/aimux-providers/src/openai/embedding/embedding_model_options.rs b/aimux-providers/src/openai/embedding/embedding_model_options.rs new file mode 100644 index 000000000..f90fc6b18 --- /dev/null +++ b/aimux-providers/src/openai/embedding/embedding_model_options.rs @@ -0,0 +1,37 @@ +//! Provider options of the OpenAI embedding model (openai-embedding-model-options.ts). + +use aimux_core::ErrorCause; +use aimux_core::error::AiMuxError; +use aimux_core::shared::SharedProviderOptions; +use serde_json::Value; + +#[derive(Default, serde::Deserialize)] +pub(crate) struct OpenAIEmbeddingProviderOptions { + pub(crate) dimensions: Option, + pub(crate) user: Option, +} + +pub(crate) fn parse_openai_provider_options( + options: Option<&SharedProviderOptions>, +) -> Result { + match options.and_then(|options| options.get("openai")) { + Some(value) => { + if value + .get("dimensions") + .is_some_and(|value| !value.is_number()) + || value.get("user").is_some_and(|value| !value.is_string()) + { + return Err(AiMuxError::invalid_argument( + "invalid openai embedding options", + )); + } + serde_json::from_value(Value::Object(value.clone())).map_err(|error| { + AiMuxError::InvalidArgument { + message: format!("invalid openai embedding options: {error}"), + cause: Some(ErrorCause::source(&error)), + } + }) + } + None => Ok(OpenAIEmbeddingProviderOptions::default()), + } +} diff --git a/aimux-providers/src/openai/embedding/mod.rs b/aimux-providers/src/openai/embedding/mod.rs new file mode 100644 index 000000000..42cf5a062 --- /dev/null +++ b/aimux-providers/src/openai/embedding/mod.rs @@ -0,0 +1,7 @@ +//! `embedding/`: the OpenAI embedding model. + +pub(super) mod embedding_api; +pub(super) mod embedding_model; +pub(super) mod embedding_model_options; + +pub use embedding_model::OpenAIEmbeddingModel; diff --git a/aimux-providers/src/openai/error.rs b/aimux-providers/src/openai/error.rs new file mode 100644 index 000000000..92b683564 --- /dev/null +++ b/aimux-providers/src/openai/error.rs @@ -0,0 +1,26 @@ +//! The OpenAI error response handler (openai-error.ts). + +use aimux_core::error::AiMuxError; +use serde_json::Value; + +pub(crate) fn openai_failed_response_handler() -> aimux_provider_utils::ResponseHandler +{ + aimux_provider_utils::create_json_error_response_handler(|data| { + let error = data.get("error").unwrap_or(data); + aimux_provider_utils::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/files.rs b/aimux-providers/src/openai/files/files.rs similarity index 82% rename from aimux-providers/src/openai/files.rs rename to aimux-providers/src/openai/files/files.rs index 6c3b80436..f47d80db3 100644 --- a/aimux-providers/src/openai/files.rs +++ b/aimux-providers/src/openai/files/files.rs @@ -4,50 +4,17 @@ //! Aligned with Vercel AI SDK `OpenAIFiles` //! (`reference/ai/packages/openai/src/files/openai-files.ts`). -use async_trait::async_trait; -use serde::Deserialize; -use serde_json::json; - use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::files_model::{Files, UploadFileCallOptions, UploadFileData, UploadFileResult}; -use aimux_core::shared::{FileBytes, SharedProviderOptions}; - +use aimux_core::shared::FileBytes; use aimux_provider_utils::HttpBody; +use async_trait::async_trait; +use serde_json::json; -use super::config::OpenAIModelConfig; - -/// OpenAI provider-specific file upload options. -/// -/// Aligned with TS `OpenAIFilesOptions`. -#[derive(Debug, Clone, Default)] -struct OpenAIFilesOptions { - /// The purpose of the file (defaults to `"assistants"`). - purpose: Option, - /// Seconds after which the file expires. - expires_after: Option, -} - -/// Parse provider options for the OpenAI files provider. -fn parse_openai_files_options( - provider_options: Option<&SharedProviderOptions>, -) -> OpenAIFilesOptions { - let mut opts = OpenAIFilesOptions::default(); - if let Some(po) = provider_options - && let Some(openai) = po.get("openai") - { - if let Some(purpose) = openai.get("purpose").and_then(|v| v.as_str()) { - opts.purpose = Some(purpose.to_string()); - } - if let Some(expires_after) = openai - .get("expiresAfter") - .and_then(serde_json::Value::as_u64) - { - opts.expires_after = Some(expires_after); - } - } - opts -} +use crate::openai::config::OpenAIModelConfig; +use crate::openai::files::files_api::OpenAIFilesResponse; +use crate::openai::files::files_options::parse_openai_files_options; /// Convert `UploadFileData` to raw bytes. /// @@ -69,24 +36,6 @@ fn data_to_bytes(data: &UploadFileData) -> Result, AiMuxError> { } } -/// The response from the OpenAI `/files` endpoint. -#[derive(Debug, Deserialize)] -struct OpenAIFilesResponse { - id: String, - #[serde(default)] - bytes: Option, - #[serde(default, rename = "created_at")] - created_at: Option, - #[serde(default)] - filename: Option, - #[serde(default)] - purpose: Option, - #[serde(default)] - status: Option, - #[serde(default, rename = "expires_at")] - expires_at: Option, -} - /// An OpenAI Files interface for uploading files. /// /// Aligned with TS `OpenAIFiles`. Does **not** hold an HTTP client — the `aimux-provider-utils` API helpers @@ -153,7 +102,7 @@ impl Files for OpenAIFiles { self.config.http_request(url, header_list, options), HttpBody::Bytes(body, content_type), aimux_provider_utils::create_json_response_handler(), - super::openai_failed_response_handler(), + crate::openai::error::openai_failed_response_handler(), ) .await?; diff --git a/aimux-providers/src/openai/files/files_api.rs b/aimux-providers/src/openai/files/files_api.rs new file mode 100644 index 000000000..aeb1dcfd1 --- /dev/null +++ b/aimux-providers/src/openai/files/files_api.rs @@ -0,0 +1,21 @@ +//! Response shape of the OpenAI Files endpoint (openai-files-api.ts). + +use serde::Deserialize; + +/// The response from the OpenAI `/files` endpoint. +#[derive(Debug, Deserialize)] +pub(crate) struct OpenAIFilesResponse { + pub(crate) id: String, + #[serde(default)] + pub(crate) bytes: Option, + #[serde(default, rename = "created_at")] + pub(crate) created_at: Option, + #[serde(default)] + pub(crate) filename: Option, + #[serde(default)] + pub(crate) purpose: Option, + #[serde(default)] + pub(crate) status: Option, + #[serde(default, rename = "expires_at")] + pub(crate) expires_at: Option, +} diff --git a/aimux-providers/src/openai/files/files_options.rs b/aimux-providers/src/openai/files/files_options.rs new file mode 100644 index 000000000..bdc99ba82 --- /dev/null +++ b/aimux-providers/src/openai/files/files_options.rs @@ -0,0 +1,35 @@ +//! Provider options of OpenAI Files uploads (openai-files-options.ts). + +use aimux_core::shared::SharedProviderOptions; + +/// OpenAI provider-specific file upload options. +/// +/// Aligned with TS `OpenAIFilesOptions`. +#[derive(Debug, Clone, Default)] +pub(crate) struct OpenAIFilesOptions { + /// The purpose of the file (defaults to `"assistants"`). + pub(crate) purpose: Option, + /// Seconds after which the file expires. + pub(crate) expires_after: Option, +} + +/// Parse provider options for the OpenAI files provider. +pub(crate) fn parse_openai_files_options( + provider_options: Option<&SharedProviderOptions>, +) -> OpenAIFilesOptions { + let mut opts = OpenAIFilesOptions::default(); + if let Some(po) = provider_options + && let Some(openai) = po.get("openai") + { + if let Some(purpose) = openai.get("purpose").and_then(|v| v.as_str()) { + opts.purpose = Some(purpose.to_string()); + } + if let Some(expires_after) = openai + .get("expiresAfter") + .and_then(serde_json::Value::as_u64) + { + opts.expires_after = Some(expires_after); + } + } + opts +} diff --git a/aimux-providers/src/openai/files/mod.rs b/aimux-providers/src/openai/files/mod.rs new file mode 100644 index 000000000..323ad64d8 --- /dev/null +++ b/aimux-providers/src/openai/files/mod.rs @@ -0,0 +1,11 @@ +//! `files/`: OpenAI Files uploads. + +#[allow( + clippy::module_inception, + reason = "upstream keeps files/openai-files.ts in a files/ folder" +)] +pub(super) mod files; +pub(super) mod files_api; +pub(super) mod files_options; + +pub use files::OpenAIFiles; diff --git a/aimux-providers/src/openai/image.rs b/aimux-providers/src/openai/image/image_model.rs similarity index 85% rename from aimux-providers/src/openai/image.rs rename to aimux-providers/src/openai/image/image_model.rs index c8ca6aa37..a0049cda1 100644 --- a/aimux-providers/src/openai/image.rs +++ b/aimux-providers/src/openai/image/image_model.rs @@ -8,9 +8,6 @@ //! //! Both endpoints return the same JSON response shape (`openaiImageResponseSchema`). -use async_trait::async_trait; -use serde_json::{Map, Value, json}; - use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::image_model::{ @@ -18,48 +15,15 @@ use aimux_core::image_model::{ ImageResult, ImageUsage, }; use aimux_core::shared::{SharedProviderMetadata, SharedProviderOptions, Warning}; - use aimux_provider_utils::HttpBody; +use async_trait::async_trait; +use serde_json::{Map, Value, json}; -use super::config::OpenAIModelConfig; - -/// Models that default to `b64_json` response format and therefore do NOT need -/// the explicit `response_format` field. Identified by prefix. -const DEFAULT_RESPONSE_FORMAT_PREFIXES: &[&str] = &["chatgpt-image-", "gpt-image-"]; - -/// Returns `true` if the model ID has a default response format (and thus the -/// `response_format` field should be omitted from the request). -fn has_default_response_format(model_id: &str) -> bool { - DEFAULT_RESPONSE_FORMAT_PREFIXES - .iter() - .any(|prefix| model_id.starts_with(prefix)) -} - -/// Returns the maximum number of images per call for the given model ID. -/// -/// Mirrors TS `getMaxImagesPerCall`: -/// - `dall-e-3` → 1 -/// - `dall-e-2`, `gpt-image-*`, `chatgpt-image-*` → 10 -/// - unknown models starting with `gpt-image-` → 10 -/// - everything else → 1 -fn get_max_images_per_call(model_id: &str) -> u32 { - match model_id { - "dall-e-3" => 1, - "dall-e-2" - | "gpt-image-1" - | "gpt-image-1-mini" - | "gpt-image-1.5" - | "gpt-image-2" - | "chatgpt-image-latest" => 10, - _ => { - if model_id.starts_with("gpt-image-") { - 10 - } else { - 1 - } - } - } -} +use crate::openai::config::OpenAIModelConfig; +use crate::openai::image::image_model_options::{ + EditOptions, GenerationOptions, get_max_images_per_call, has_default_response_format, + parse_edit_provider_options, parse_generation_provider_options, +}; /// An OpenAI-compatible image generation/editing model. pub struct OpenAIImageModel { @@ -169,7 +133,7 @@ impl ImageModel for OpenAIImageModel { .http_request(self.config.url("/images/edits")?, headers, options), HttpBody::Bytes(form_body, content_type), aimux_provider_utils::create_json_response_handler(), - super::openai_failed_response_handler(), + crate::openai::error::openai_failed_response_handler(), ) .await?; @@ -192,7 +156,7 @@ impl ImageModel for OpenAIImageModel { .http_request(self.config.url("/images/generations")?, headers, options), body.clone(), aimux_provider_utils::create_json_response_handler(), - super::openai_failed_response_handler(), + crate::openai::error::openai_failed_response_handler(), ) .await?; @@ -271,56 +235,6 @@ fn validate_image_options(options: &SharedProviderOptions, edit: bool) -> Result Ok(()) } -/// OpenAI generation provider options (camelCase → snake_case mapping). -struct GenerationOptions { - quality: Option, - style: Option, - background: Option, - moderation: Option, - output_format: Option, - output_compression: Option, - user: Option, -} - -/// Parse generation provider options from the `"openai"` key. -fn parse_generation_provider_options( - provider_options: &SharedProviderOptions, -) -> GenerationOptions { - let openai = provider_options.get("openai"); - GenerationOptions { - quality: openai.and_then(|o| o.get("quality")).cloned(), - style: openai.and_then(|o| o.get("style")).cloned(), - background: openai.and_then(|o| o.get("background")).cloned(), - moderation: openai.and_then(|o| o.get("moderation")).cloned(), - output_format: openai.and_then(|o| o.get("outputFormat")).cloned(), - output_compression: openai.and_then(|o| o.get("outputCompression")).cloned(), - user: openai.and_then(|o| o.get("user")).cloned(), - } -} - -/// OpenAI edit provider options. -struct EditOptions { - quality: Option, - background: Option, - output_format: Option, - output_compression: Option, - input_fidelity: Option, - user: Option, -} - -/// Parse edit provider options from the `"openai"` key. -fn parse_edit_provider_options(provider_options: &SharedProviderOptions) -> EditOptions { - let openai = provider_options.get("openai"); - EditOptions { - quality: openai.and_then(|o| o.get("quality")).cloned(), - background: openai.and_then(|o| o.get("background")).cloned(), - output_format: openai.and_then(|o| o.get("outputFormat")).cloned(), - output_compression: openai.and_then(|o| o.get("outputCompression")).cloned(), - input_fidelity: openai.and_then(|o| o.get("inputFidelity")).cloned(), - user: openai.and_then(|o| o.get("user")).cloned(), - } -} - /// Build the JSON body for `/images/generations`. /// /// Fields with `None` values are omitted (matching TS behaviour where diff --git a/aimux-providers/src/openai/image/image_model_options.rs b/aimux-providers/src/openai/image/image_model_options.rs new file mode 100644 index 000000000..3aabdf780 --- /dev/null +++ b/aimux-providers/src/openai/image/image_model_options.rs @@ -0,0 +1,93 @@ +//! Model limits and provider options of the OpenAI image model +//! (openai-image-model-options.ts). + +use aimux_core::shared::SharedProviderOptions; +use serde_json::Value; + +/// Models that default to `b64_json` response format and therefore do NOT need +/// the explicit `response_format` field. Identified by prefix. +const DEFAULT_RESPONSE_FORMAT_PREFIXES: &[&str] = &["chatgpt-image-", "gpt-image-"]; + +/// Returns `true` if the model ID has a default response format (and thus the +/// `response_format` field should be omitted from the request). +pub(crate) fn has_default_response_format(model_id: &str) -> bool { + DEFAULT_RESPONSE_FORMAT_PREFIXES + .iter() + .any(|prefix| model_id.starts_with(prefix)) +} + +/// Returns the maximum number of images per call for the given model ID. +/// +/// Mirrors TS `getMaxImagesPerCall`: +/// - `dall-e-3` → 1 +/// - `dall-e-2`, `gpt-image-*`, `chatgpt-image-*` → 10 +/// - unknown models starting with `gpt-image-` → 10 +/// - everything else → 1 +pub(crate) fn get_max_images_per_call(model_id: &str) -> u32 { + match model_id { + "dall-e-3" => 1, + "dall-e-2" + | "gpt-image-1" + | "gpt-image-1-mini" + | "gpt-image-1.5" + | "gpt-image-2" + | "chatgpt-image-latest" => 10, + _ => { + if model_id.starts_with("gpt-image-") { + 10 + } else { + 1 + } + } + } +} + +/// OpenAI generation provider options (camelCase → snake_case mapping). +pub(crate) struct GenerationOptions { + pub(crate) quality: Option, + pub(crate) style: Option, + pub(crate) background: Option, + pub(crate) moderation: Option, + pub(crate) output_format: Option, + pub(crate) output_compression: Option, + pub(crate) user: Option, +} + +/// Parse generation provider options from the `"openai"` key. +pub(crate) fn parse_generation_provider_options( + provider_options: &SharedProviderOptions, +) -> GenerationOptions { + let openai = provider_options.get("openai"); + GenerationOptions { + quality: openai.and_then(|o| o.get("quality")).cloned(), + style: openai.and_then(|o| o.get("style")).cloned(), + background: openai.and_then(|o| o.get("background")).cloned(), + moderation: openai.and_then(|o| o.get("moderation")).cloned(), + output_format: openai.and_then(|o| o.get("outputFormat")).cloned(), + output_compression: openai.and_then(|o| o.get("outputCompression")).cloned(), + user: openai.and_then(|o| o.get("user")).cloned(), + } +} + +/// OpenAI edit provider options. +pub(crate) struct EditOptions { + pub(crate) quality: Option, + pub(crate) background: Option, + pub(crate) output_format: Option, + pub(crate) output_compression: Option, + pub(crate) input_fidelity: Option, + pub(crate) user: Option, +} + +/// Parse edit provider options from the `"openai"` key. +pub(crate) fn parse_edit_provider_options(provider_options: &SharedProviderOptions) -> EditOptions { + let openai = provider_options.get("openai"); + EditOptions { + quality: openai.and_then(|o| o.get("quality")).cloned(), + background: openai.and_then(|o| o.get("background")).cloned(), + output_format: openai.and_then(|o| o.get("outputFormat")).cloned(), + output_compression: openai.and_then(|o| o.get("outputCompression")).cloned(), + input_fidelity: openai.and_then(|o| o.get("inputFidelity")).cloned(), + user: openai.and_then(|o| o.get("user")).cloned(), + } +} diff --git a/aimux-providers/src/openai/image/mod.rs b/aimux-providers/src/openai/image/mod.rs new file mode 100644 index 000000000..118dc28b2 --- /dev/null +++ b/aimux-providers/src/openai/image/mod.rs @@ -0,0 +1,6 @@ +//! `image/`: the OpenAI image model. + +pub(super) mod image_model; +pub(super) mod image_model_options; + +pub use image_model::OpenAIImageModel; diff --git a/aimux-providers/src/openai/convert_common.rs b/aimux-providers/src/openai/language_model_capabilities.rs similarity index 96% rename from aimux-providers/src/openai/convert_common.rs rename to aimux-providers/src/openai/language_model_capabilities.rs index 12d7f648b..fca3e4b50 100644 --- a/aimux-providers/src/openai/convert_common.rs +++ b/aimux-providers/src/openai/language_model_capabilities.rs @@ -1,6 +1,6 @@ -//! Shared model-capability / version helpers used by both the OpenAI Chat -//! Completions converter (`convert.rs`) and the Responses API converter -//! (`responses/convert.rs`). +//! Model capabilities and version helpers (openai-language-model-capabilities.ts), +//! used by both the Chat Completions model (`chat/`) and the Responses model +//! (`responses/`). //! //! These were previously copy-pasted (~140 lines) between the two modules; //! any change had to be applied twice (issue M10). diff --git a/aimux-providers/src/openai/mod.rs b/aimux-providers/src/openai/mod.rs index d40e192ca..c0a4746c3 100644 --- a/aimux-providers/src/openai/mod.rs +++ b/aimux-providers/src/openai/mod.rs @@ -1,4 +1,6 @@ -//! OpenAI provider. +//! OpenAI provider (`@ai-sdk/openai`). One file per upstream file, named after +//! it without the `openai-` prefix, in the upstream sub-directories; this file +//! is `index.ts`. //! //! [`create_openai`] is the Rust form of the AI SDK's `createOpenAI`: it takes //! [`OpenAIProviderSettings`], validates the base URL, fixes the provider name @@ -10,471 +12,64 @@ //! same wire format are served by [`crate::openai_compatible`] (and the //! registry presets built on it). +mod chat; pub mod completion; pub(crate) mod config; -pub mod convert; -mod convert_common; pub mod decision; pub mod embedding; +mod error; pub mod files; pub mod image; -pub mod model; -pub(crate) mod options; +mod language_model_capabilities; +mod normalize_json_schema; +mod provider; pub mod responses; pub mod speech; +mod stream_error; +mod tool; pub mod tools; pub mod transcription; -mod types; +pub use chat::OpenAIModel; pub use completion::OpenAICompletionModel; pub use decision::OpenAIDecisionModel; pub use embedding::OpenAIEmbeddingModel; pub use files::OpenAIFiles; pub use image::OpenAIImageModel; -pub use model::OpenAIModel; +pub use provider::{OpenAIProvider, OpenAIProviderSettings, create_openai, openai}; pub use responses::OpenAIResponsesModel; pub use speech::OpenAISpeechModel; pub use tools::{OpenAIComputerAction, OpenAIComputerSafetyCheck}; pub use transcription::OpenAITranscriptionModel; -use std::sync::{Arc, OnceLock}; +// Paths the other vendors (Azure, the OpenAI-compatible wrappers, the default +// registry) import. +pub(crate) use error::openai_failed_response_handler; +pub(crate) use provider::{API_KEY_ENV_VAR, DEFAULT_BASE_URL}; +pub(crate) use stream_error::openai_stream_error; -use futures::future::BoxFuture; -use serde_json::Value; - -use crate::shared::ProviderHeaders; -use aimux_core::decision_model::DecisionModel; -use aimux_core::embedding_model::EmbeddingModel; -use aimux_core::error::AiMuxError; -use aimux_core::files_model::Files; -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_core::speech_model::SpeechModel; -use aimux_core::transcription_model::TranscriptionModel; -use aimux_provider_utils::{ - DecisionLanguageModel, FetchFunction, HeaderMapOpt, Resolvable, validate_base_url, -}; - -use crate::shared::Credential; - -use config::OpenAIModelConfig; - -pub(crate) fn openai_failed_response_handler() -> aimux_provider_utils::ResponseHandler -{ - aimux_provider_utils::create_json_error_response_handler(|data| { - let error = data.get("error").unwrap_or(data); - aimux_provider_utils::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 `ApiCallError` of an OpenAI stream error frame, as upstream's -/// `createOpenAIProviderStreamError` builds it (@ai-sdk/openai 4.0.83 -/// src/openai-stream-error.ts:18-37): an HTTP status in `code` is the status, -/// otherwise one is inferred from `code`/`type` (`getStatusCode`, :235-267); -/// `insufficient_quota` is never retryable (:303-315). -pub(crate) fn openai_stream_error( - error: &Value, - url: &str, - request_body_values: Value, - response_headers: std::collections::HashMap, -) -> AiMuxError { - let response_failed = error.get("type").and_then(Value::as_str) == Some("response.failed"); - let payload = if response_failed { - error - .get("response") - .and_then(|response| response.get("error")) - .unwrap_or(error) - } else { - error.get("error").unwrap_or(error) +/// The chat helpers the other vendors' converters reuse. +pub(crate) mod convert { + pub(crate) use super::chat::chat_language_model_options::{ + parse_chat_provider_options, parse_option_fields, }; - let message = payload - .get("message") - .and_then(Value::as_str) - .unwrap_or("OpenAI stream failed before any output was generated") - .to_owned(); - let provider_code = payload.get("code").and_then(|value| match value { - Value::String(value) => Some(value.clone()), - Value::Number(value) => Some(value.to_string()), - _ => None, - }); - // Upstream types a `response.failed` frame as `response.failed`, not by - // its inner error (`parseStreamError`, openai-stream-error.ts:207-217). - let error_type = if response_failed { - "response.failed" - } else { - payload - .get("type") - .and_then(Value::as_str) - .unwrap_or_default() + pub(crate) use super::chat::convert_to_chat_messages::{ + tool_result_content_value, tool_result_to_content, }; - let discriminator = format!( - "{} {error_type}", - provider_code.as_deref().unwrap_or_default() - ) - .to_ascii_lowercase(); - let explicit_status = payload - .get("code") - .and_then(|value| { - value - .as_u64() - .and_then(|value| u16::try_from(value).ok()) - .or_else(|| { - value - .as_str() - .filter(|value| { - value.len() == 3 && value.bytes().all(|byte| byte.is_ascii_digit()) - }) - .and_then(|value| value.parse::().ok()) - }) - }) - .filter(|value| (400..=599).contains(value)); - let status = explicit_status.unwrap_or_else(|| { - if ["insufficient_quota", "rate_limit"] - .iter() - .any(|term| discriminator.contains(term)) - { - 429 - } else if discriminator.contains("authentication") { - 401 - } else if discriminator.contains("permission") { - 403 - } else if discriminator.contains("not_found") { - 404 - } else if ["invalid", "bad_request", "context_length"] - .iter() - .any(|term| discriminator.contains(term)) - { - 400 - } else if discriminator.contains("overload") { - 503 - } else if discriminator.contains("timeout") { - 504 - } else { - 500 - } - }); - let quota = provider_code.as_deref() == Some("insufficient_quota") - || error_type == "insufficient_quota"; - let mut result = aimux_provider_utils::stream_error_api_call( - message, - provider_code, - Some(status), - error, - url, - request_body_values, - response_headers, - ); - if quota && let AiMuxError::ApiCall(details) = &mut result { - details.is_retryable = false; - } - result -} - -pub(crate) const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; -pub(crate) const API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; - -/// Settings of [`create_openai`] (the AI SDK's `OpenAIProviderSettings`). -/// -/// Every field is optional. The base URL (including `OPENAI_BASE_URL`) and -/// name are resolved when the provider is created; `api_key` and `headers` are evaluated -/// on every request. -#[derive(Clone, Default)] -pub struct OpenAIProviderSettings { - /// Base URL for the API calls. `None` reads `OPENAI_BASE_URL` when the - /// provider is created, else `https://api.openai.com/v1`; a trailing - /// slash is removed. An invalid explicit value fails [`create_openai`]; - /// an invalid `OPENAI_BASE_URL` fails every request instead. - pub base_url: Option, - /// The API key. `None` loads `OPENAI_API_KEY` when a request is made and - /// fails that request with `AiMuxError::LoadApiKey` if it is unset. An - /// explicit value is used as given, `""` included: it never falls back to - /// the environment. - pub api_key: Option, - /// Sent as `OpenAI-Organization`. - pub organization: Option, - /// Sent as `OpenAI-Project`. - pub project: Option, - /// Extra headers on every request. A `None` value removes the header, - /// including one of the fixed ones. Per-call headers win over these. - pub headers: Option, - /// The provider name, the prefix of every model's `provider()` string. - /// Default `"openai"`. - pub name: Option, - /// The transport: a mock, a signing decorator, a proxy-aware client. - /// `None` uses the process default, resolved per request. - pub fetch: Option, - /// Custom socket connector for streaming transcription. - #[cfg(feature = "realtime")] - pub web_socket: Option>, -} - -impl std::fmt::Debug for OpenAIProviderSettings { - /// Never prints the key or header values. - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("OpenAIProviderSettings") - .field("base_url", &self.base_url) - .field("api_key", &self.api_key.is_some()) - .field("organization", &self.organization) - .field("project", &self.project) - .field( - "headers", - &self.headers.as_ref().map(std::collections::HashMap::len), - ) - .field("name", &self.name) - .field("fetch", &self.fetch.is_some()) - .finish() - } -} - -/// Create an OpenAI provider. -/// -/// # Errors -/// -/// Returns `AiMuxError::invalid_argument` when the explicit `base_url` is not -/// an `http(s)` URL with a host. That is the only way this fails: the key is -/// loaded per request, and an invalid `OPENAI_BASE_URL` is reported by each -/// request, not here. -pub fn create_openai(settings: OpenAIProviderSettings) -> Result { - let base_url = crate::shared::base_url_setting( - settings.base_url.as_deref(), - "OPENAI_BASE_URL", - DEFAULT_BASE_URL, - validate_base_url, - )?; - let name = settings.name.unwrap_or_else(|| "openai".to_string()); - Ok(OpenAIProvider { - name, - base_url, - headers: ProviderHeaders::bearer( - Credential::explicit_or_env( - settings.api_key.map(Resolvable::Value), - API_KEY_ENV_VAR, - "OpenAI", - ), - [ - ("OpenAI-Organization", settings.organization), - ("OpenAI-Project", settings.project), - ] - .into_iter() - .filter_map(|(name, value)| value.map(|value| (name.to_string(), value))) - .collect(), - settings.headers, - ) - .with_user_agent( - crate::shared::aisdk_pins::OPENAI.name, - crate::shared::aisdk_pins::OPENAI.version, - ), - fetch: Some(crate::shared::package_fetch( - settings.fetch, - crate::shared::aisdk_pins::OPENAI, - )), - #[cfg(feature = "realtime")] - web_socket: settings.web_socket, - tools: tools::OpenAITools, - }) -} - -/// The default provider: `create_openai` with default settings, created on -/// first use. `OPENAI_BASE_URL` is read at creation (an invalid value fails -/// each request, not the creation); the key is loaded per request. -pub fn openai() -> &'static OpenAIProvider { - static DEFAULT: OnceLock = OnceLock::new(); - DEFAULT.get_or_init(|| { - create_openai(OpenAIProviderSettings::default()) - .expect("default settings have no explicit base URL to reject") - }) -} - -/// An OpenAI provider (the AI SDK's `OpenAIProvider`). Cheap to clone the -/// models out of; it holds no HTTP client. -pub struct OpenAIProvider { - name: String, - /// The validated base URL, or the error of an invalid `OPENAI_BASE_URL`, - /// which every request reports. - base_url: Result, - headers: ProviderHeaders, - fetch: Option, - #[cfg(feature = "realtime")] - web_socket: Option>, - /// The provider tools (`openai.tools` in the AI SDK). - pub tools: tools::OpenAITools, -} - -impl OpenAIProvider { - fn model_config(&self, method: &str) -> OpenAIModelConfig { - let mut config = OpenAIModelConfig::fixed( - format!("{}.{method}", self.name), - self.base_url.clone().unwrap_or_default(), - self.headers.clone(), - self.fetch.clone(), - None, - ); - config.supported_urls = config::supported_urls(method); - if method == "responses" { - config.responses.file_id_prefixes = vec!["file-"]; - } - if let Err(error) = &self.base_url { - config.url = config::OpenAIUrl::Invalid(error.clone()); - } - config - } - - /// A chat-completions model; `provider()` is `"{name}.chat"`. - #[must_use] - pub fn chat(&self, model_id: &str) -> OpenAIModel { - OpenAIModel::from_config(model_id.to_string(), self.model_config("chat")) - } - - /// A text-completion model; `provider()` is `"{name}.completion"`. - #[must_use] - pub fn completion(&self, model_id: &str) -> OpenAICompletionModel { - OpenAICompletionModel::from_native_config( - model_id.to_string(), - self.model_config("completion"), - ) - } - - /// A Responses API model; `provider()` is `"{name}.responses"`. - #[must_use] - pub fn responses(&self, model_id: &str) -> OpenAIResponsesModel { - let mut config = self.model_config("responses"); - config.responses.file_id_prefixes = vec!["file-"]; - OpenAIResponsesModel::from_config(model_id.to_string(), config) - } - - /// An embedding model; `provider()` is `"{name}.embedding"`. - #[must_use] - pub fn embedding(&self, model_id: &str) -> OpenAIEmbeddingModel { - OpenAIEmbeddingModel::from_config(model_id.to_string(), self.model_config("embedding")) - } - - /// A native Decisions model (an aimux extension: the AI SDK's OpenAI - /// provider has no native decision endpoint). Its `provider()` is - /// `"openai"`. It is the provider's [`decision_model`](Provider::decision_model). - /// - /// # Errors - /// - /// `InvalidArgument` for an empty model id. - pub fn decision(&self, model_id: &str) -> Result { - OpenAIDecisionModel::from_config(model_id, self.model_config("decision")) - } - - /// Decisions from structured Responses output; `provider()` is - /// `"{name}.evaluation"` (`@ai-sdk/openai@4.0.83 - /// src/openai-provider.ts:355-358`, `evaluationModel`). - #[must_use] - pub fn evaluation(&self, model_id: &str) -> DecisionLanguageModel { - DecisionLanguageModel::new( - Arc::new(self.responses(model_id)), - Some(format!("{}.evaluation", self.name)), - ) - } - - /// An image model; `provider()` is `"{name}.image"`. - #[must_use] - pub fn image(&self, model_id: &str) -> OpenAIImageModel { - OpenAIImageModel::from_config(model_id.to_string(), self.model_config("image")) - } - - /// A speech (TTS) model; `provider()` is `"{name}.speech"`. - #[must_use] - pub fn speech(&self, model_id: &str) -> OpenAISpeechModel { - OpenAISpeechModel::from_config(model_id.to_string(), self.model_config("speech")) - } - - /// A transcription (STT) model; `provider()` is `"{name}.transcription"`. - #[must_use] - pub fn transcription(&self, model_id: &str) -> OpenAITranscriptionModel { - let model = OpenAITranscriptionModel::from_config( - model_id.to_string(), - self.model_config("transcription"), - ); - #[cfg(feature = "realtime")] - let model = model.with_web_socket(self.web_socket.clone()); - model - } - - /// The files interface; `provider()` is `"{name}.files"`. - #[must_use] - pub fn files(&self) -> OpenAIFiles { - OpenAIFiles::from_config(self.model_config("files")) - } - - /// The provider as a function: the default language model for an id. The - /// AI SDK's callable provider; it returns the Responses model, the same - /// model as [`responses`](Self::responses) and - /// [`language_model`](Provider::language_model). - #[must_use] - pub fn call(&self, model_id: &str) -> Arc { - Arc::new(self.responses(model_id)) - } } -impl Provider for OpenAIProvider { - 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))) - } - - /// The native Decisions model; the registry takes it before the - /// evaluation model (`ai@7.0.128 src/registry/provider-registry.ts:447-461`), - /// which stays reachable as [`OpenAIProvider::evaluation`]. - fn decision_model(&self, model_id: &str) -> Option, AiMuxError>> { - Some( - self.decision(model_id) - .map(|model| Arc::new(model) as Arc), - ) - } - - fn transcription_model( - &self, - model_id: &str, - ) -> Option, AiMuxError>> { - Some(Ok(Arc::new(self.transcription(model_id)))) - } - - fn speech_model(&self, model_id: &str) -> Option, AiMuxError>> { - Some(Ok(Arc::new(self.speech(model_id)))) - } - - fn files(&self) -> Option> { - Some(Arc::new(self.files())) - } +/// The model listing and the chat stream test driver the other vendors reuse. +pub(crate) mod model { + #[cfg(test)] + pub(crate) use super::chat::chat_language_model::chat_test_stream; + pub(crate) use super::provider::list_models_once; } -impl ProviderDiscovery for OpenAIProvider { - /// `GET {base_url}/models`: one exchange, no retry. - fn list_models(&self) -> BoxFuture<'_, Result, AiMuxError>> { - let config = self.model_config("models"); - Box::pin(async move { model::list_models_once(&config).await }) - } +/// The chat options parser Azure reuses. +pub(crate) mod options { + pub(crate) use super::chat::chat_language_model_options::{ + ChatOptionsParser, parse_chat_options, + }; } #[cfg(test)] diff --git a/aimux-providers/src/openai/normalize_json_schema.rs b/aimux-providers/src/openai/normalize_json_schema.rs new file mode 100644 index 000000000..1f29d3496 --- /dev/null +++ b/aimux-providers/src/openai/normalize_json_schema.rs @@ -0,0 +1,162 @@ +//! Normalization of a JSON schema for OpenAI strict mode (normalize-openai-json-schema.ts). + +use aimux_core::error::AiMuxError; +use aimux_core::types::Warning; +use serde_json::Value; + +/// Apply the upstream structured-output compatibility rules only at schema positions. +pub(crate) fn normalize_json_schema( + schema: &Value, + warnings: &mut Vec, +) -> Result { + fn walk(value: &mut Value, removed: &mut [bool; 2], is_root: bool) -> Result<(), AiMuxError> { + let Some(obj) = value.as_object_mut() else { + return Ok(()); + }; + if let Some(names) = obj.get("propertyNames").filter(|v| !v.is_null()) { + if names.get("type").and_then(Value::as_str) != Some("string") { + return Err(AiMuxError::UnsupportedFunctionality( + "JSON Schema propertyNames that does not use a string schema".into(), + )); + } + obj.remove("propertyNames"); + removed[0] = true; + } + if obj + .get("pattern") + .and_then(Value::as_str) + .is_some_and(contains_lookaround) + { + obj.remove("pattern"); + removed[1] = true; + } + for key in [ + "properties", + "patternProperties", + "definitions", + "$defs", + "dependencies", + ] { + if let Some(Value::Object(record)) = obj.get_mut(key) { + for child in record.values_mut().filter(|v| !v.is_array()) { + walk(child, removed, false)?; + } + } + } + for key in [ + "additionalProperties", + "additionalItems", + "items", + "contains", + "not", + "allOf", + "anyOf", + "oneOf", + "if", + "then", + "else", + ] { + if let Some(child) = obj.get_mut(key) { + if let Value::Array(children) = child { + for child in children { + walk(child, removed, false)?; + } + } else { + walk(child, removed, false)?; + } + } + } + // Zod 4 writes recursive references as `allOf: [{ $ref }]`; OpenAI + // has no `allOf`, so a singleton local reference becomes a direct + // `$ref`, expanded at the root where OpenAI requires an object. + let reference = match obj.get("allOf") { + Some(Value::Array(all_of)) if all_of.len() == 1 => all_of[0] + .as_object() + .filter(|item| item.len() == 1) + .and_then(|item| item.get("$ref")) + .and_then(Value::as_str) + .map(str::to_string), + _ => None, + }; + if let Some(reference) = reference { + if !is_root { + obj.remove("allOf"); + obj.insert("$ref".into(), Value::String(reference)); + } else if let Some(Value::Object(referenced)) = local_reference(&reference, obj) { + let mut expanded = referenced.clone(); + obj.remove("allOf"); + expanded.extend(std::mem::take(obj)); + *obj = expanded; + } + } + Ok(()) + } + /// `#/definitions/` or `#/$defs/` in `schema`. + fn local_reference<'a>( + reference: &str, + schema: &'a serde_json::Map, + ) -> Option<&'a Value> { + let (keyword, name) = reference + .strip_prefix("#/definitions/") + .map(|name| ("definitions", name)) + .or_else(|| { + reference + .strip_prefix("#/$defs/") + .map(|name| ("$defs", name)) + })?; + if name.is_empty() { + return None; + } + let name = name.replace("~1", "/").replace("~0", "~"); + schema.get(keyword)?.get(&name) + } + let mut result = schema.clone(); + let mut removed = [false; 2]; + walk(&mut result, &mut removed, true)?; + for (index, feature, details) in [ + ( + 0, + "JSON Schema propertyNames", + "OpenAI does not support JSON Schema propertyNames. It was removed before sending the schema, so OpenAI will not enforce property-name constraints.", + ), + ( + 1, + "JSON Schema pattern with regex lookaround", + "OpenAI does not support regex lookaround in JSON Schema patterns. The pattern was removed before sending the schema, so OpenAI will not enforce that constraint.", + ), + ] { + if removed[index] { + warnings.push(Warning::Compatibility { + feature: feature.into(), + details: Some(details.into()), + }); + } + } + Ok(result) +} + +fn contains_lookaround(pattern: &str) -> bool { + let bytes = pattern.as_bytes(); + let (mut escaped, mut in_class) = (false, false); + for (i, &byte) in bytes.iter().enumerate() { + if escaped { + escaped = false; + continue; + } + match byte { + b'\\' => escaped = true, + b'[' => in_class = true, + b']' => in_class = false, + b'(' if !in_class + && bytes.get(i + 1) == Some(&b'?') + && (matches!(bytes.get(i + 2), Some(b'=' | b'!')) + || (bytes.get(i + 2) == Some(&b'<') + && matches!(bytes.get(i + 3), Some(b'=' | b'!')))) => + { + return true; + } + _ => {} + } + } + false +} diff --git a/aimux-providers/src/openai/provider.rs b/aimux-providers/src/openai/provider.rs new file mode 100644 index 000000000..01428f636 --- /dev/null +++ b/aimux-providers/src/openai/provider.rs @@ -0,0 +1,378 @@ +//! The OpenAI provider factory (openai-provider.ts): [`create_openai`], +//! [`openai()`] and [`OpenAIProvider`], with the `/models` listing of +//! provider discovery (aimux, RFC-0027). + +use std::sync::{Arc, OnceLock}; + +use aimux_core::decision_model::DecisionModel; +use aimux_core::embedding_model::EmbeddingModel; +use aimux_core::error::AiMuxError; +use aimux_core::files_model::Files; +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_core::speech_model::SpeechModel; +use aimux_core::transcription_model::TranscriptionModel; +use aimux_provider_utils::{ + DecisionLanguageModel, FetchFunction, HeaderMapOpt, HttpRequest, Resolvable, validate_base_url, +}; +use futures::future::BoxFuture; + +use crate::openai::chat::chat_language_model::OpenAIModel; +use crate::openai::completion::completion_language_model::OpenAICompletionModel; +use crate::openai::config::OpenAIModelConfig; +use crate::openai::decision::OpenAIDecisionModel; +use crate::openai::embedding::embedding_model::OpenAIEmbeddingModel; +use crate::openai::files::files::OpenAIFiles; +use crate::openai::image::image_model::OpenAIImageModel; +use crate::openai::responses::responses_language_model::OpenAIResponsesModel; +use crate::openai::speech::speech_model::OpenAISpeechModel; +use crate::openai::transcription::transcription_model::OpenAITranscriptionModel; +use crate::shared::{Credential, ProviderHeaders}; + +pub(crate) const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; + +pub(crate) const API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; + +/// Settings of [`create_openai`] (the AI SDK's `OpenAIProviderSettings`). +/// +/// Every field is optional. The base URL (including `OPENAI_BASE_URL`) and +/// name are resolved when the provider is created; `api_key` and `headers` are evaluated +/// on every request. +#[derive(Clone, Default)] +pub struct OpenAIProviderSettings { + /// Base URL for the API calls. `None` reads `OPENAI_BASE_URL` when the + /// provider is created, else `https://api.openai.com/v1`; a trailing + /// slash is removed. An invalid explicit value fails [`create_openai`]; + /// an invalid `OPENAI_BASE_URL` fails every request instead. + pub base_url: Option, + /// The API key. `None` loads `OPENAI_API_KEY` when a request is made and + /// fails that request with `AiMuxError::LoadApiKey` if it is unset. An + /// explicit value is used as given, `""` included: it never falls back to + /// the environment. + pub api_key: Option, + /// Sent as `OpenAI-Organization`. + pub organization: Option, + /// Sent as `OpenAI-Project`. + pub project: Option, + /// Extra headers on every request. A `None` value removes the header, + /// including one of the fixed ones. Per-call headers win over these. + pub headers: Option, + /// The provider name, the prefix of every model's `provider()` string. + /// Default `"openai"`. + pub name: Option, + /// The transport: a mock, a signing decorator, a proxy-aware client. + /// `None` uses the process default, resolved per request. + pub fetch: Option, + /// Custom socket connector for streaming transcription. + #[cfg(feature = "realtime")] + pub web_socket: Option>, +} + +impl std::fmt::Debug for OpenAIProviderSettings { + /// Never prints the key or header values. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OpenAIProviderSettings") + .field("base_url", &self.base_url) + .field("api_key", &self.api_key.is_some()) + .field("organization", &self.organization) + .field("project", &self.project) + .field( + "headers", + &self.headers.as_ref().map(std::collections::HashMap::len), + ) + .field("name", &self.name) + .field("fetch", &self.fetch.is_some()) + .finish() + } +} + +/// Create an OpenAI provider. +/// +/// # Errors +/// +/// Returns `AiMuxError::invalid_argument` when the explicit `base_url` is not +/// an `http(s)` URL with a host. That is the only way this fails: the key is +/// loaded per request, and an invalid `OPENAI_BASE_URL` is reported by each +/// request, not here. +pub fn create_openai(settings: OpenAIProviderSettings) -> Result { + let base_url = crate::shared::base_url_setting( + settings.base_url.as_deref(), + "OPENAI_BASE_URL", + DEFAULT_BASE_URL, + validate_base_url, + )?; + let name = settings.name.unwrap_or_else(|| "openai".to_string()); + Ok(OpenAIProvider { + name, + base_url, + headers: ProviderHeaders::bearer( + Credential::explicit_or_env( + settings.api_key.map(Resolvable::Value), + API_KEY_ENV_VAR, + "OpenAI", + ), + [ + ("OpenAI-Organization", settings.organization), + ("OpenAI-Project", settings.project), + ] + .into_iter() + .filter_map(|(name, value)| value.map(|value| (name.to_string(), value))) + .collect(), + settings.headers, + ) + .with_user_agent( + crate::shared::aisdk_pins::OPENAI.name, + crate::shared::aisdk_pins::OPENAI.version, + ), + fetch: Some(crate::shared::package_fetch( + settings.fetch, + crate::shared::aisdk_pins::OPENAI, + )), + #[cfg(feature = "realtime")] + web_socket: settings.web_socket, + tools: super::tools::OpenAITools, + }) +} + +/// The default provider: `create_openai` with default settings, created on +/// first use. `OPENAI_BASE_URL` is read at creation (an invalid value fails +/// each request, not the creation); the key is loaded per request. +pub fn openai() -> &'static OpenAIProvider { + static DEFAULT: OnceLock = OnceLock::new(); + DEFAULT.get_or_init(|| { + create_openai(OpenAIProviderSettings::default()) + .expect("default settings have no explicit base URL to reject") + }) +} + +/// An OpenAI provider (the AI SDK's `OpenAIProvider`). Cheap to clone the +/// models out of; it holds no HTTP client. +pub struct OpenAIProvider { + name: String, + /// The validated base URL, or the error of an invalid `OPENAI_BASE_URL`, + /// which every request reports. + base_url: Result, + headers: ProviderHeaders, + fetch: Option, + #[cfg(feature = "realtime")] + web_socket: Option>, + /// The provider tools (`openai.tools` in the AI SDK). + pub tools: super::tools::OpenAITools, +} + +impl OpenAIProvider { + fn model_config(&self, method: &str) -> OpenAIModelConfig { + let mut config = OpenAIModelConfig::fixed( + format!("{}.{method}", self.name), + self.base_url.clone().unwrap_or_default(), + self.headers.clone(), + self.fetch.clone(), + None, + ); + config.supported_urls = super::config::supported_urls(method); + if method == "responses" { + config.responses.file_id_prefixes = vec!["file-"]; + } + if let Err(error) = &self.base_url { + config.url = super::config::OpenAIUrl::Invalid(error.clone()); + } + config + } + + /// A chat-completions model; `provider()` is `"{name}.chat"`. + #[must_use] + pub fn chat(&self, model_id: &str) -> OpenAIModel { + OpenAIModel::from_config(model_id.to_string(), self.model_config("chat")) + } + + /// A text-completion model; `provider()` is `"{name}.completion"`. + #[must_use] + pub fn completion(&self, model_id: &str) -> OpenAICompletionModel { + OpenAICompletionModel::from_native_config( + model_id.to_string(), + self.model_config("completion"), + ) + } + + /// A Responses API model; `provider()` is `"{name}.responses"`. + #[must_use] + pub fn responses(&self, model_id: &str) -> OpenAIResponsesModel { + let mut config = self.model_config("responses"); + config.responses.file_id_prefixes = vec!["file-"]; + OpenAIResponsesModel::from_config(model_id.to_string(), config) + } + + /// An embedding model; `provider()` is `"{name}.embedding"`. + #[must_use] + pub fn embedding(&self, model_id: &str) -> OpenAIEmbeddingModel { + OpenAIEmbeddingModel::from_config(model_id.to_string(), self.model_config("embedding")) + } + + /// A native Decisions model (an aimux extension: the AI SDK's OpenAI + /// provider has no native decision endpoint). Its `provider()` is + /// `"openai"`. It is the provider's [`decision_model`](Provider::decision_model). + /// + /// # Errors + /// + /// `InvalidArgument` for an empty model id. + pub fn decision(&self, model_id: &str) -> Result { + OpenAIDecisionModel::from_config(model_id, self.model_config("decision")) + } + + /// Decisions from structured Responses output; `provider()` is + /// `"{name}.evaluation"` (`@ai-sdk/openai@4.0.83 + /// src/openai-provider.ts:355-358`, `evaluationModel`). + #[must_use] + pub fn evaluation(&self, model_id: &str) -> DecisionLanguageModel { + DecisionLanguageModel::new( + Arc::new(self.responses(model_id)), + Some(format!("{}.evaluation", self.name)), + ) + } + + /// An image model; `provider()` is `"{name}.image"`. + #[must_use] + pub fn image(&self, model_id: &str) -> OpenAIImageModel { + OpenAIImageModel::from_config(model_id.to_string(), self.model_config("image")) + } + + /// A speech (TTS) model; `provider()` is `"{name}.speech"`. + #[must_use] + pub fn speech(&self, model_id: &str) -> OpenAISpeechModel { + OpenAISpeechModel::from_config(model_id.to_string(), self.model_config("speech")) + } + + /// A transcription (STT) model; `provider()` is `"{name}.transcription"`. + #[must_use] + pub fn transcription(&self, model_id: &str) -> OpenAITranscriptionModel { + let model = OpenAITranscriptionModel::from_config( + model_id.to_string(), + self.model_config("transcription"), + ); + #[cfg(feature = "realtime")] + let model = model.with_web_socket(self.web_socket.clone()); + model + } + + /// The files interface; `provider()` is `"{name}.files"`. + #[must_use] + pub fn files(&self) -> OpenAIFiles { + OpenAIFiles::from_config(self.model_config("files")) + } + + /// The provider as a function: the default language model for an id. The + /// AI SDK's callable provider; it returns the Responses model, the same + /// model as [`responses`](Self::responses) and + /// [`language_model`](Provider::language_model). + #[must_use] + pub fn call(&self, model_id: &str) -> Arc { + Arc::new(self.responses(model_id)) + } +} + +impl Provider for OpenAIProvider { + 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))) + } + + /// The native Decisions model; the registry takes it before the + /// evaluation model (`ai@7.0.128 src/registry/provider-registry.ts:447-461`), + /// which stays reachable as [`OpenAIProvider::evaluation`]. + fn decision_model(&self, model_id: &str) -> Option, AiMuxError>> { + Some( + self.decision(model_id) + .map(|model| Arc::new(model) as Arc), + ) + } + + fn transcription_model( + &self, + model_id: &str, + ) -> Option, AiMuxError>> { + Some(Ok(Arc::new(self.transcription(model_id)))) + } + + fn speech_model(&self, model_id: &str) -> Option, AiMuxError>> { + Some(Ok(Arc::new(self.speech(model_id)))) + } + + fn files(&self) -> Option> { + Some(Arc::new(self.files())) + } +} + +impl ProviderDiscovery for OpenAIProvider { + /// `GET {base_url}/models`: one exchange, no retry. + fn list_models(&self) -> BoxFuture<'_, Result, AiMuxError>> { + let config = self.model_config("models"); + Box::pin(async move { list_models_once(&config).await }) + } +} + +// ── Model listing (RFC-0027) ───────────────────────────────────────────────── + +/// OpenAI-compatible `/models` response shape. +#[derive(serde::Deserialize)] +struct ModelsListResponse { + #[serde(default)] + data: Vec, +} + +#[derive(serde::Deserialize)] +struct ModelEntry { + id: String, + #[serde(default)] + owned_by: Option, + #[serde(default)] + created: Option, +} + +/// One `GET {base_url}/models` exchange (OpenAI-compatible): no retry, no +/// recording. Discovery is not a Core operation and the AI SDK has no +/// equivalent, so a failure is reported to the caller as it happened. +/// +/// # Errors +/// +/// Returns the header-resolution error (a missing key is `LoadApiKey`), +/// `ApiCall` for HTTP/transport failures and `JsonParse` when the body does +/// not deserialize into the models list. +pub(crate) async fn list_models_once( + config: &OpenAIModelConfig, +) -> Result, AiMuxError> { + let headers = config.request_headers(None).await?; + let resp = aimux_provider_utils::get_from_api( + config.with_transport(HttpRequest { + url: config.url("/models")?, + headers, + ..Default::default() + }), + aimux_provider_utils::create_json_response_handler(), + crate::openai::error::openai_failed_response_handler(), + ) + .await?; + let parsed: ModelsListResponse = resp.value; + + Ok(parsed + .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/responses/convert.rs b/aimux-providers/src/openai/responses/convert.rs deleted file mode 100644 index cc00dbc92..000000000 --- a/aimux-providers/src/openai/responses/convert.rs +++ /dev/null @@ -1,2451 +0,0 @@ -//! Conversion between `LanguageModelPrompt` and the OpenAI Responses API -//! `input` format, plus request-body construction, tool preparation, usage -//! conversion, and finish-reason mapping. -//! -//! Mirrors the TS sources: -//! - `convert-to-openai-responses-input.ts` -> [`convert_to_responses_input`] -//! - `openai-responses-language-model.ts` `getArgs` -> -//! [`build_responses_request_body`] -//! - `openai-responses-prepare-tools.ts` -> [`prepare_responses_tools`] -//! - `convert-openai-responses-usage.ts` -> [`convert_responses_usage`] -//! - `map-openai-responses-finish-reason.ts` -> [`map_responses_finish_reason`] - -use aimux_core::AiMuxError; -use aimux_core::ErrorCause; -use serde_json::{Value, json}; - -use aimux_core::language_model_message::{ - AssistantPart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, - ToolCallPart, ToolPart, ToolResultContent, ToolResultOutput, ToolResultPart, UserPart, -}; -use aimux_core::options::{CallOptions, LanguageModelV4ToolChoice, ResponseFormat}; -use aimux_core::shared::{FileBytes, FileData, JsonObject, SharedProviderOptions}; -use aimux_core::tool::{FunctionTool, Tool}; -use aimux_core::types::{FinishReason, FinishReasonUnified, ReasoningEffort, Usage, Warning}; - -use super::tool_args::{validate_tool_args, validate_tool_call, validate_tool_result}; -use super::types::ResponsesUsage; - -// -- Model capabilities ------------------------------------------------------ -// `GptVersion` / `get_gpt_version` / `get_o_series_version` / -// `ModelCapabilities` / `SystemMessageMode` / `get_model_capabilities` live in -// `crate::openai::convert_common` and are shared with the Chat Completions -// converter (issue M10). -use crate::openai::convert_common::{ModelCapabilities, SystemMessageMode, get_model_capabilities}; - -// -- Provider options helper ------------------------------------------------- - -/// The providerOptions keys a Responses model reads, in order of precedence -/// (the first one present wins as a whole), and the key it writes response -/// metadata under. The AI SDK's `OpenAIResponsesLanguageModel` picks -/// `providerOptionsName` from the host: `openai` for the OpenAI API, `azure` -/// for Azure (which still falls back to `openai` when no `azure` options were -/// given). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ResponsesNamespace { - read: &'static [&'static str], - write: &'static str, -} - -impl ResponsesNamespace { - /// The OpenAI API: reads and writes `openai`. - pub const OPENAI: Self = Self::new(&["openai"], "openai"); - - /// A host namespace. `read` is non-empty and in order of precedence; - /// `write` is the metadata key. - #[must_use] - pub const fn new(read: &'static [&'static str], write: &'static str) -> Self { - Self { read, write } - } - - /// The key response metadata is written under. - #[must_use] - pub fn write_key(self) -> &'static str { - self.write - } - - /// The first present options object among the read keys. - fn find(self, provider_options: &SharedProviderOptions) -> Option<&JsonObject> { - self.read.iter().find_map(|key| provider_options.get(*key)) - } - - /// [`find`](Self::find) for `CallOptions::provider_options`. - pub(crate) fn find_in(self, provider_options: &SharedProviderOptions) -> Option<&JsonObject> { - self.read.iter().find_map(|key| provider_options.get(*key)) - } -} - -impl Default for ResponsesNamespace { - fn default() -> Self { - Self::OPENAI - } -} - -/// How a host differs in the Responses API model: its namespace, and the -/// prefixes that mark a file part's data as an uploaded file id instead of -/// content to inline (`fileIdPrefixes` in the AI SDK). -#[derive(Debug, Clone, Default)] -pub(crate) struct ResponsesProfile { - pub(crate) namespace: ResponsesNamespace, - /// Empty: no file part is treated as a file id. - pub(crate) file_id_prefixes: Vec<&'static str>, - pub(crate) explicit_message_item_type: bool, -} - -/// Get a value from the host namespace's options (`openai.` for OpenAI). -fn openai_option( - ns: ResponsesNamespace, - options: &Option, - key: &str, -) -> Option { - options - .as_ref() - .and_then(|m| ns.find_in(m)) - .and_then(|o| o.get(key)) - .cloned() -} - -// -- Input conversion -------------------------------------------------------- - -/// Result of converting a prompt into the Responses API `input` array. -pub struct ResponsesInputResult { - pub input: Vec, - pub warnings: Vec, -} - -/// Convert a `LanguageModelPrompt` into the OpenAI Responses API `input` array. -/// -/// Mirrors the core paths of TS `convertToOpenAIResponsesInput`: -/// - system -> `{ role: "system"|"developer", content: }` -/// - user -> `{ role: "user", content: [{ type: "input_text", text }] }` -/// - assistant text -> `{ role: "assistant", content: [{ type: "output_text", text }] }` -/// (or `{ type: "item_reference", id }` when `store` and an itemId are present) -/// - assistant tool-call -> `{ type: "function_call", call_id, name, arguments }` -/// - tool result -> `{ type: "function_call_output", call_id, output }` -/// -/// `store` defaults to `true` (matching the API default). When -/// `has_previous_response_id` is true, assistant reasoning/function-call items -/// that already carry an `itemId` are skipped (they live in the previous -/// response chain). -/// # Errors -/// Rejects unsupported file data and unresolved file references. -pub fn convert_to_responses_input( - ns: ResponsesNamespace, - prompt: &LanguageModelPrompt, - system_message_mode: SystemMessageMode, - store: bool, - has_previous_response_id: bool, -) -> Result { - convert_responses_input_with_conversation( - ns, - prompt, - system_message_mode, - store, - has_previous_response_id, - false, - None, - ) -} - -fn convert_responses_input_with_conversation( - ns: ResponsesNamespace, - prompt: &LanguageModelPrompt, - system_message_mode: SystemMessageMode, - store: bool, - has_previous_response_id: bool, - has_conversation: bool, - tools: Option<&[Tool]>, -) -> Result { - let mut input: Vec = Vec::new(); - let mut warnings: Vec = Vec::new(); - - let mut processed_approval_ids = std::collections::HashSet::new(); - let mut programmatic_tool_call_ids = std::collections::HashSet::new(); - for msg in prompt { - match msg { - LanguageModelMessage::System { - content, - provider_options, - } => { - if let Some(effort) = - openai_sub_option(ns, provider_options, "reasoningEffortUpdate") - { - input.push(json!({ "type": "configuration_update", "reasoning": { "effort": effort } })); - continue; - } - match system_message_mode { - SystemMessageMode::System => { - input.push(json!({ - "role": "system", - "content": system_content(ns, content, provider_options), - })); - } - SystemMessageMode::Developer => { - input.push(json!({ - "role": "developer", - "content": system_content(ns, content, provider_options), - })); - } - SystemMessageMode::Remove => { - warnings.push(Warning::Other { - message: "system messages are removed for this model".to_string(), - }); - } - } - } - LanguageModelMessage::User { content, .. } => { - let content: Vec = content - .iter() - .enumerate() - .map(|(index, part)| { - let mut value = convert_user_part(ns, part, index)?; - if let Some(breakpoint) = - openai_sub_option(ns, user_part_options(part), "promptCacheBreakpoint") - { - value["prompt_cache_breakpoint"] = breakpoint; - } - Ok(value) - }) - .collect::>()?; - input.push(json!({ "role": "user", "content": content })); - } - LanguageModelMessage::Assistant { content, .. } => { - // Reasoning item id → its index in `input`: the parts of one - // reasoning item become one input item (per assistant - // message, as `reasoningMessages` upstream). - let mut reasoning_items: std::collections::HashMap> = - std::collections::HashMap::new(); - for part in content { - match part { - AssistantPart::Text(TextPart { - text, - provider_options, - }) => { - let id = item_id(ns, provider_options); - if has_conversation && id.is_some() { - continue; - } - if store && let Some(ref id) = id { - input.push(json!({ "type": "item_reference", "id": id })); - continue; - } - let phase = phase_from_provider_options(ns, provider_options); - let mut item = json!({ - "role": "assistant", - "content": text, - }); - if let Some(phase) = phase { - item["phase"] = json!(phase); - } - input.push(item); - } - AssistantPart::ToolCall(ToolCallPart { - tool_call_id, - tool_name, - input: tool_input, - provider_options, - provider_executed, - .. - }) => { - let id = item_id(ns, provider_options); - if openai_sub_option(ns, provider_options, "caller") - .is_some_and(|caller| caller["type"] == "program") - { - programmatic_tool_call_ids.insert(tool_call_id.as_str()); - } - if has_conversation && id.is_some() { - continue; - } - let kind = provider_tool_kind(tools, tool_name); - // Hosted tool search and program calls are replayed - // in full without response storage. - if provider_executed == &Some(true) - && !matches!( - kind, - Some("tool_search" | "programmatic_tool_calling") - ) - { - if store && let Some(id) = &id { - input.push(json!({"type":"item_reference", "id":id})); - } - if store || kind != Some("shell") { - continue; - } - } - if let Some(kind) = kind { - if store - && id.is_some() - && kind != "tool_search" - && kind != "programmatic_tool_calling" - && has_previous_response_id - { - continue; - } - if store && let Some(id) = &id { - input.push(json!({"type":"item_reference", "id":id})); - continue; - } - if let Some(mut item) = provider_tool_call_input( - kind, - tool_name, - tool_call_id, - id.as_deref(), - tool_input, - )? { - if kind == "custom" - && let Some(value) = - openai_sub_option(ns, provider_options, "async") - .filter(|value| !value.is_null()) - { - item["async"] = value; - } - input.push(item); - continue; - } - } - let namespace = namespace_from_provider_options(ns, provider_options); - let mut item = json!({ - "type": "function_call", - "call_id": tool_call_id, - "name": tool_name, - "arguments": serialize_arguments(tool_input), - }); - if let Some(ref namespace) = namespace { - item["namespace"] = json!(namespace); - } - if let Some(value) = openai_sub_option(ns, provider_options, "async") { - item["async"] = value; - } - if let Some(caller) = tool_caller(ns, provider_options) { - item["caller"] = caller; - } - input.push(item); - } - AssistantPart::Reasoning(ReasoningPart { - text, - provider_options, - .. - }) => { - // convert-to-openai-responses-input.ts:1151-1250 (openai@4.0.83). - let reasoning_id = openai_sub_option(ns, provider_options, "itemId") - .and_then(|id| id.as_str().map(str::to_owned)); - let encrypted = openai_sub_option( - ns, - provider_options, - "reasoningEncryptedContent", - ) - .filter(|value| !value.is_null()); - if (has_previous_response_id || has_conversation) - && reasoning_id.is_some() - { - continue; - } - let summary_part = (!text.is_empty()) - .then(|| json!({ "type": "summary_text", "text": text })); - if let Some(rid) = reasoning_id { - if store { - // One item reference, at the first part. - reasoning_items.entry(rid).or_insert_with_key(|rid| { - input.push(json!({ "type": "item_reference", "id": rid })); - None - }); - } else if let Some(&Some(index)) = reasoning_items.get(&rid) { - // Later parts of the same item append their - // summary; the last encrypted content wins. - let item = &mut input[index]; - match summary_part { - Some(summary_part) => { - if let Some(summary) = - item["summary"].as_array_mut() - { - summary.push(summary_part); - } - } - None => warnings.push(Warning::Other { - message: format!( - "Cannot append empty reasoning part to existing reasoning sequence. Skipping reasoning part: {}.", - serde_json::to_string(part).unwrap_or_default() - ), - }), - } - if let Some(enc) = encrypted { - item["encrypted_content"] = enc; - } - } else { - let mut item = json!({ - "type": "reasoning", - "id": rid, - "summary": summary_part.into_iter().collect::>(), - }); - if let Some(enc) = encrypted { - item["encrypted_content"] = enc; - } - reasoning_items.insert(rid, Some(input.len())); - input.push(item); - } - } else if let Some(enc) = encrypted { - input.push(json!({ - "type": "reasoning", - "encrypted_content": enc, - "summary": summary_part.into_iter().collect::>(), - })); - } else { - warnings.push(Warning::Other { - message: format!( - "Non-OpenAI reasoning parts are not supported. Skipping reasoning part: {}.", - serde_json::to_string(part).unwrap_or_default() - ), - }); - } - } - AssistantPart::ToolResult(part) => { - if has_conversation - || matches!(part.output, ToolResultOutput::ExecutionDenied { .. }) - || matches!(&part.output, ToolResultOutput::Json { value, .. } if value.get("type").and_then(Value::as_str) == Some("execution-denied")) - { - continue; - } - let id = item_id(ns, &part.provider_options) - .unwrap_or_else(|| part.tool_call_id.clone()); - let kind = provider_tool_kind(tools, &part.tool_name); - match kind { - Some("shell") => { - if let ToolResultOutput::Json { value, .. } = &part.output - && let Some(item) = provider_tool_result_input("shell", &part.tool_call_id, value)? - { input.push(item); } - } - _ if store => input.push(json!({"type":"item_reference", "id":id})), - Some("tool_search" | "programmatic_tool_calling") => { - if let ToolResultOutput::Json { value, .. } = &part.output { - let kind = kind.unwrap_or_default(); - validate_tool_result(kind, value)?; - input.push(if kind == "tool_search" { json!({"type":"tool_search_output", "id":id, "execution":"server", "call_id":null, "status":"completed", "tools":value["tools"]}) } else { json!({"type":"program_output", "id":id, "call_id":part.tool_call_id, "result":value["result"], "status":value["status"]}) }); - } - } - _ => warnings.push(Warning::Other { message:format!("Results for OpenAI tool {} are not sent to the API when store is false", part.tool_name) }), - } - } - AssistantPart::Custom(part) if part.kind == "openai.compaction" => { - let id = item_id(ns, &part.provider_options); - if has_conversation && id.is_some() { - continue; - } - if let Some(id) = id { - if store { - input.push(json!({"type":"item_reference", "id":id})); - } else { - input.push(json!({"type":"compaction", "id":id, "encrypted_content":openai_sub_option(ns, &part.provider_options, "encryptedContent")})); - } - } - } - _ => {} - } - } - } - LanguageModelMessage::Tool { content, .. } => { - for part in content { - if let ToolPart::ToolApprovalResponse(approval) = part { - if !processed_approval_ids.insert(&approval.approval_id) { - continue; - } - if store && !has_conversation && !has_previous_response_id { - input.push(json!({"type":"item_reference", "id":approval.approval_id})); - } - input.push(json!({"type":"mcp_approval_response", "approval_request_id":approval.approval_id, "approve":approval.approved})); - continue; - } - let ToolPart::ToolResult(ToolResultPart { - tool_call_id, - tool_name, - output, - provider_options, - }) = part - else { - continue; - }; - if let ToolResultOutput::ExecutionDenied { - provider_options, .. - } = output - && openai_sub_option(ns, provider_options, "approvalId") - .and_then(|id| id.as_str().map(|id| !id.is_empty())) - .unwrap_or(false) - { - continue; - } - let kind = provider_tool_kind(tools, tool_name); - if let Some(kind) = kind - && kind != "custom" - && let ToolResultOutput::Json { value, .. } = output - && let Some(item) = provider_tool_result_input(kind, tool_call_id, value)? - { - input.push(item); - continue; - } - let custom = kind == Some("custom"); - if !custom - && matches!(output, ToolResultOutput::ExecutionDenied { .. }) - && (programmatic_tool_call_ids.contains(tool_call_id.as_str()) - || openai_sub_option(ns, provider_options, "caller") - .is_some_and(|caller| caller["type"] == "program")) - { - return Err(AiMuxError::UnsupportedFunctionality( - "execution-denied results for programmatic tool calls".into(), - )); - } - let mut content_value = - convert_tool_result_output(ns, output, custom, &mut warnings)?; - let has_output_schema = tools.is_some_and(|tools| tools.iter().any(|tool| { - matches!(tool, Tool::Function(tool) if tool.name == *tool_name && tool.provider_options.as_ref().and_then(|options| options.get("openai")).is_some_and(|options| options.contains_key("outputSchema"))) - })); - if has_output_schema - && matches!( - output, - ToolResultOutput::Text { .. } - | ToolResultOutput::ErrorText { .. } - | ToolResultOutput::ExecutionDenied { .. } - ) - { - content_value = json!(content_value.to_string()); - } - if !matches!(output, ToolResultOutput::Content { .. }) - && let Some(breakpoint) = scalar_tool_result_cache_breakpoint(ns, output) - .or_else(|| { - openai_sub_option(ns, provider_options, "promptCacheBreakpoint") - }) - { - content_value = json!([{"type":"input_text", "text":content_value, "prompt_cache_breakpoint":breakpoint}]); - } - let mut item = json!({ - "type": if custom { "custom_tool_call_output" } else { "function_call_output" }, - "call_id": tool_call_id, - "output": content_value, - }); - if !custom && let Some(caller) = tool_caller(ns, provider_options) { - item["caller"] = caller; - } - input.push(item); - } - } - } - } - - // When store is false, remove reasoning parts without encrypted content. - if !store - && input.iter().any(|item| { - item.get("type").and_then(|v| v.as_str()) == Some("reasoning") - && item.get("encrypted_content").is_none() - }) - { - warnings.push(Warning::Other { - message: - "Reasoning parts without encrypted content are not supported when store is false. Skipping reasoning parts." - .to_string(), - }); - input.retain(|item| { - item.get("type").and_then(|v| v.as_str()) != Some("reasoning") - || item.get("encrypted_content").is_some() - }); - } - - Ok(ResponsesInputResult { input, warnings }) -} - -/// The `caller` provider option of a tool call or result, on the wire. -fn tool_caller( - ns: ResponsesNamespace, - provider_options: &Option, -) -> Option { - let caller = openai_sub_option(ns, provider_options, "caller")?; - let mut caller = map_tool_fields(&caller, &[("type", "type"), ("callerId", "caller_id")]); - if caller["type"] != "program" - && let Some(object) = caller.as_object_mut() - { - object.remove("caller_id"); - } - Some(caller) -} - -fn provider_tool_kind<'a>(tools: Option<&'a [Tool]>, name: &str) -> Option<&'a str> { - tools?.iter().find_map(|tool| match tool { - Tool::Provider(tool) if tool.name == name => tool.id.as_str().strip_prefix("openai."), - _ => None, - }) -} - -fn provider_tool_call_input( - kind: &str, - name: &str, - call_id: &str, - id: Option<&str>, - args: &Value, -) -> Result, AiMuxError> { - let parsed; - let args = if kind != "custom" && args.is_string() { - parsed = serde_json::from_str(args.as_str().unwrap_or_default()).map_err(|error| { - AiMuxError::InvalidArgument { - message: error.to_string(), - cause: Some(ErrorCause::source(&error)), - } - })?; - &parsed - } else { - args - }; - if kind != "custom" { - validate_tool_call(kind, args)?; - } - let mut value = match kind { - "custom" => { - json!({"type":"custom_tool_call", "call_id":call_id, "name":name, "input":args.as_str().map(str::to_owned).unwrap_or_else(|| args.to_string())}) - } - "local_shell" => { - json!({"type":"local_shell_call", "call_id":call_id, "action": map_tool_fields(&args["action"], &[("command", "command"), ("timeoutMs", "timeout_ms"), ("user", "user"), ("workingDirectory", "working_directory"), ("env", "env")])}) - } - "shell" => { - json!({"type":"shell_call", "call_id":call_id, "status":"completed", "action":map_tool_fields(&args["action"], &[("commands", "commands"), ("timeoutMs", "timeout_ms"), ("maxOutputLength", "max_output_length")])}) - } - "apply_patch" => { - json!({"type":"apply_patch_call", "call_id":args["callId"], "status":"completed", "operation":args["operation"]}) - } - "computer" => { - let mut actions = args - .get("actions") - .and_then(Value::as_array) - .cloned() - .ok_or_else(|| AiMuxError::invalid_argument("computer actions"))?; - for action in &mut actions { - if action["type"] == "scroll" - && let Some(object) = action.as_object_mut() - { - for (key, wire) in [("scrollX", "scroll_x"), ("scrollY", "scroll_y")] { - if let Some(v) = object.remove(key) { - object.insert(wire.into(), v); - } - } - } - } - json!({"type":"computer_call", "call_id":call_id, "status":args["status"], "actions":actions, "pending_safety_checks":args["pendingSafetyChecks"]}) - } - "programmatic_tool_calling" => { - json!({"type":"program", "call_id":call_id, "code":args["code"], "fingerprint":args["fingerprint"]}) - } - "tool_search" => { - let args = if let Some(text) = args.as_str() { - serde_json::from_str(text).map_err(|error| AiMuxError::InvalidArgument { - message: error.to_string(), - cause: Some(ErrorCause::source(&error)), - })? - } else { - args.clone() - }; - let mut value = map_tool_fields(&args, &[("arguments", "arguments")]); - value["type"] = json!("tool_search_call"); - value["status"] = json!("completed"); - value["execution"] = json!(if args.get("call_id").is_some_and(|id| !id.is_null()) { - "client" - } else { - "server" - }); - value["call_id"] = args.get("call_id").cloned().unwrap_or(Value::Null); - value - } - _ => return Ok(None), - }; - if kind == "local_shell" { - value["action"]["type"] = json!("exec"); - } - if let Some(id) = id { - value["id"] = json!(id); - } else if ["tool_search", "programmatic_tool_calling"].contains(&kind) { - value["id"] = json!(call_id); - } - Ok(Some(value)) -} - -fn provider_tool_result_input( - kind: &str, - call_id: &str, - result: &Value, -) -> Result, AiMuxError> { - if !matches!( - kind, - "local_shell" | "shell" | "apply_patch" | "computer" | "tool_search" | "custom" - ) { - return Ok(None); - } - validate_tool_result(kind, result)?; - let mut value = match kind { - "local_shell" => json!({"type":"local_shell_call_output", "output":result["output"]}), - "shell" => { - let mut output = result - .get("output") - .and_then(Value::as_array) - .cloned() - .ok_or_else(|| AiMuxError::invalid_argument("shell output"))?; - for item in &mut output { - if let Some(outcome) = item.get_mut("outcome").and_then(Value::as_object_mut) - && let Some(code) = outcome.remove("exitCode") - { - outcome.insert("exit_code".into(), code); - } - } - json!({"type":"shell_call_output", "output":output}) - } - "apply_patch" => { - json!({"type":"apply_patch_call_output", "status":result["status"], "output":result["output"]}) - } - "computer" => { - json!({"type":"computer_call_output", "output":map_tool_fields(&result["output"], &[("imageUrl", "image_url"), ("fileId", "file_id"), ("detail", "detail")])}) - } - "tool_search" => { - json!({"type":"tool_search_output", "execution":"client", "status":"completed", "tools":result["tools"]}) - } - "custom" => { - json!({"type":"custom_tool_call_output", "output":result.as_str().map(str::to_owned).unwrap_or_else(|| result.to_string())}) - } - _ => return Ok(None), - }; - if kind == "computer" { - value["output"]["type"] = json!("computer_screenshot"); - if let Some(checks) = result.get("acknowledgedSafetyChecks") { - value["acknowledged_safety_checks"] = checks.clone(); - } - } - value["call_id"] = json!(call_id); - Ok(Some(value)) -} - -fn user_part_options(part: &UserPart) -> &Option { - match part { - UserPart::Text(part) => &part.provider_options, - UserPart::File(part) => &part.provider_options, - } -} - -fn system_content( - ns: ResponsesNamespace, - text: &str, - provider_options: &Option, -) -> Value { - match openai_sub_option(ns, provider_options, "promptCacheBreakpoint") { - Some(value) => { - json!([{ "type": "input_text", "text": text, "prompt_cache_breakpoint": value }]) - } - None => json!(text), - } -} - -/// Convert a single user-message content part into the Responses input shape. -fn convert_user_part( - ns: ResponsesNamespace, - part: &UserPart, - index: usize, -) -> Result { - Ok(match part { - UserPart::Text(part) => json!({ "type": "input_text", "text": part.text }), - UserPart::File(file) => match &file.data { - FileData::Data { data } => { - use base64::Engine; - let b64 = match data { - FileBytes::Binary(bytes) => { - base64::engine::general_purpose::STANDARD.encode(bytes) - } - FileBytes::Base64(data) => data.clone(), - }; - inline_file( - ns, - &aimux_provider_utils::resolve_full_media_type(file)?, - file.filename.as_deref(), - &b64, - &file.provider_options, - index, - ) - } - FileData::Reference { reference } => { - let file_id = - crate::openai::convert::resolve_provider_reference(reference, ns.write_key()) - .map_err(AiMuxError::invalid_argument)?; - let mut part = json!({ "type": if file.media_type.split('/').next() == Some("image") { "input_image" } else { "input_file" }, "file_id": file_id }); - if part["type"] == "input_image" - && let Some(detail) = - openai_sub_option(ns, &file.provider_options, "imageDetail") - { - part["detail"] = detail; - } - part - } - FileData::Url { url, .. } => { - if file.media_type.split('/').next() == Some("image") { - let mut part = json!({ "type": "input_image", "image_url": url }); - if let Some(detail) = - openai_sub_option(ns, &file.provider_options, "imageDetail") - { - part["detail"] = detail; - } - part - } else { - json!({ "type": "input_file", "file_url": url }) - } - } - FileData::Text { .. } => { - return Err(AiMuxError::UnsupportedFunctionality( - "text file parts".into(), - )); - } - }, - }) -} - -fn scalar_tool_result_cache_breakpoint( - ns: ResponsesNamespace, - output: &ToolResultOutput, -) -> Option { - let provider_options = match output { - ToolResultOutput::Text { - provider_options, .. - } - | ToolResultOutput::Json { - provider_options, .. - } - | ToolResultOutput::ErrorText { - provider_options, .. - } - | ToolResultOutput::ErrorJson { - provider_options, .. - } - | ToolResultOutput::ExecutionDenied { - provider_options, .. - } => provider_options, - ToolResultOutput::Content { .. } => return None, - }; - openai_sub_option(ns, provider_options, "promptCacheBreakpoint") -} - -fn convert_tool_result_output( - ns: ResponsesNamespace, - output: &ToolResultOutput, - custom: bool, - warnings: &mut Vec, -) -> Result { - let ToolResultOutput::Content { value } = output else { - return Ok(crate::openai::convert::tool_result_to_content(output)); - }; - let mut parts = Vec::new(); - for item in value { - let (mut converted, provider_options) = match item { - ToolResultContent::Text(part) => ( - json!({"type":"input_text", "text":part.text}), - &part.provider_options, - ), - ToolResultContent::File(part) => { - if matches!(part.data, FileData::Text { .. }) - || (custom && matches!(part.data, FileData::Reference { .. })) - { - warnings.push(Warning::Other { - message: format!( - "unsupported {}tool content part type: file with data type: {}", - if custom { "custom " } else { "" }, - if matches!(part.data, FileData::Reference { .. }) { - "reference" - } else { - "text" - } - ), - }); - continue; - } - let mut converted = convert_user_part(ns, &UserPart::File(part.clone()), 0)?; - if converted["type"] == "input_file" && converted.get("file_data").is_some() { - converted["filename"] = json!(part.filename.as_deref().unwrap_or("data")); - } - if converted["type"] == "input_image" - && let Some(detail) = - openai_sub_option(ns, &part.provider_options, "imageDetail") - { - converted["detail"] = detail; - } - (converted, &part.provider_options) - } - ToolResultContent::Custom { .. } => { - warnings.push(Warning::Other { - message: format!( - "unsupported {}tool content part type: custom", - if custom { "custom " } else { "" } - ), - }); - continue; - } - }; - if let Some(breakpoint) = openai_sub_option(ns, provider_options, "promptCacheBreakpoint") { - converted["prompt_cache_breakpoint"] = breakpoint; - } - parts.push(converted); - } - Ok(json!(parts)) -} - -fn inline_file( - ns: ResponsesNamespace, - media_type: &str, - filename: Option<&str>, - data: &str, - provider_options: &Option, - index: usize, -) -> Value { - if media_type.split('/').next() == Some("image") { - let mut part = json!({ "type": "input_image", "image_url": format!("data:{media_type};base64,{data}") }); - if let Some(detail) = openai_sub_option(ns, provider_options, "imageDetail") { - part["detail"] = detail; - } - part - } else { - json!({ "type": "input_file", "filename": filename.map(str::to_owned).unwrap_or_else(|| if media_type == "application/pdf" { format!("part-{index}.pdf") } else { format!("part-{index}") }), "file_data": format!("data:{media_type};base64,{data}") }) - } -} - -/// Serialize tool-call arguments as JSON, including null and string values. -fn serialize_arguments(input: &Value) -> String { - input.to_string() -} - -/// Read the `itemId` from a content part's `providerOptions.openai.itemId`. -fn item_id( - ns: ResponsesNamespace, - provider_options: &Option, -) -> Option { - provider_options - .as_ref() - .and_then(|v| ns.find(v)) - .and_then(|o| o.get("itemId")) - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string) -} - -/// Read the `phase` from a content part's `providerOptions.openai.phase`. -fn phase_from_provider_options( - ns: ResponsesNamespace, - provider_options: &Option, -) -> Option { - provider_options - .as_ref() - .and_then(|v| ns.find(v)) - .and_then(|o| o.get("phase")) - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string) -} - -/// Read the `namespace` from a content part's `providerOptions.openai.namespace`. -fn namespace_from_provider_options( - ns: ResponsesNamespace, - provider_options: &Option, -) -> Option { - provider_options - .as_ref() - .and_then(|v| ns.find(v)) - .and_then(|o| o.get("namespace")) - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string) -} - -/// Read a sub-key from `providerOptions.openai.` on a content part. -fn openai_sub_option( - ns: ResponsesNamespace, - provider_options: &Option, - key: &str, -) -> Option { - provider_options - .as_ref() - .and_then(|v| ns.find(v)) - .and_then(|o| o.get(key)) - .cloned() -} - -// -- Tool preparation -------------------------------------------------------- - -/// The result of preparing tools for a Responses request body. -#[derive(Debug, Clone)] -pub struct PreparedResponsesTools { - pub tools: Option>, - pub tool_choice: Option, - pub tool_warnings: Vec, -} - -/// Prepare the upstream Responses function and provider tool family. -/// -/// # Errors -/// Returns an error for invalid provider tool arguments or conflicting namespaces. -pub fn prepare_responses_tools( - tools: &Option>, - tool_choice: Option<&LanguageModelV4ToolChoice>, -) -> Result { - prepare_responses_tools_for(tools, tool_choice, None, true) -} - -pub(crate) fn prepare_responses_tools_for( - tools: &Option>, - tool_choice: Option<&LanguageModelV4ToolChoice>, - allowed_tools: Option<&Value>, - supports_async: bool, -) -> Result { - let Some(tools) = tools.as_ref().filter(|tools| !tools.is_empty()) else { - return Ok(PreparedResponsesTools { - tools: None, - tool_choice: None, - tool_warnings: Vec::new(), - }); - }; - let mut tool_warnings = Vec::new(); - let mut prepared: Vec = Vec::new(); - for tool in tools { - match tool { - Tool::Function(ft) => { - let mut value = function_tool_to_json(ft); - value["parameters"] = crate::openai::convert::normalize_json_schema( - &ft.input_schema, - &mut tool_warnings, - )?; - let opts = ft - .provider_options - .as_ref() - .and_then(|options| options.get("openai")); - if let Some(opts) = opts { - for (key, wire) in [ - ("async", "async"), - ("deferLoading", "defer_loading"), - ("allowedCallers", "allowed_callers"), - ] { - if let Some(v) = opts.get(key) { - value[wire] = v.clone(); - } - } - if let Some(schema) = opts.get("outputSchema") { - value["output_schema"] = crate::openai::convert::normalize_json_schema( - schema, - &mut tool_warnings, - )?; - } - resolve_async_tool_option( - &mut value, - supports_async, - &ft.name, - &mut tool_warnings, - ); - if let Some(namespace) = opts.get("namespace") { - let name = namespace - .get("name") - .and_then(Value::as_str) - .ok_or_else(|| AiMuxError::invalid_argument("tool namespace name"))?; - let description = namespace - .get("description") - .and_then(Value::as_str) - .ok_or_else(|| { - AiMuxError::invalid_argument("tool namespace description") - })?; - if let Some(group) = prepared - .iter_mut() - .find(|group| group["type"] == "namespace" && group["name"] == name) - { - if group["description"] != description { - return Err(AiMuxError::UnsupportedFunctionality(format!( - "conflicting descriptions for OpenAI tool namespace \"{name}\"" - ))); - } - if let Some(items) = group["tools"].as_array_mut() { - items.push(value); - } - } else { - prepared.push(json!({"type":"namespace", "name":name, "description":description, "tools":[value]})); - } - continue; - } - } - prepared.push(value); - } - Tool::Provider(tool) => { - if let Some(mut value) = provider_tool_to_json(tool)? { - resolve_async_tool_option( - &mut value, - supports_async, - &tool.name, - &mut tool_warnings, - ); - prepared.push(value); - } - } - } - } - let mut tool_choice = tool_choice.map(|choice| match choice { - LanguageModelV4ToolChoice::Auto => json!("auto"), - LanguageModelV4ToolChoice::None => json!("none"), - LanguageModelV4ToolChoice::Required => json!("required"), - LanguageModelV4ToolChoice::Tool { tool_name } => { - let tool = tools.iter().find_map(|tool| match tool { - Tool::Provider(tool) if tool.name == *tool_name => Some(tool), - _ => None, - }); - match tool.map(|tool| tool.id.as_str()) { - Some("openai.custom") => json!({"type":"custom", "name":tool_name}), - Some(id) - if [ - "openai.code_interpreter", - "openai.file_search", - "openai.image_generation", - "openai.web_search_preview", - "openai.web_search", - "openai.mcp", - "openai.apply_patch", - "openai.computer", - "openai.programmatic_tool_calling", - ] - .contains(&id) => - { - json!({"type":id.trim_start_matches("openai.")}) - } - _ => json!({"type":"function", "name":tool_name}), - } - } - }); - if let Some(allowed) = allowed_tools { - tool_choice = Some(prepare_allowed_tools(allowed, tools, &mut tool_warnings)?); - } - Ok(PreparedResponsesTools { - tools: Some(prepared), - tool_choice, - tool_warnings, - }) -} - -fn resolve_async_tool_option( - value: &mut Value, - supported: bool, - name: &str, - warnings: &mut Vec, -) { - if !supported && value.get("async") == Some(&json!(true)) { - if let Some(object) = value.as_object_mut() { - object.remove("async"); - } - warnings.push(Warning::Unsupported { - feature: format!("async tool calling for \"{name}\""), - details: Some("Async tool calling is only supported by GPT-6 and later models.".into()), - }); - } -} - -fn prepare_allowed_tools( - allowed: &Value, - tools: &[Tool], - warnings: &mut Vec, -) -> Result { - let invalid = || AiMuxError::invalid_argument("allowedTools"); - let names = allowed - .get("toolNames") - .and_then(Value::as_array) - .filter(|names| !names.is_empty()) - .ok_or_else(invalid)?; - let mode = allowed - .get("mode") - .map(|mode| { - mode.as_str() - .filter(|mode| ["auto", "required"].contains(mode)) - .ok_or_else(invalid) - }) - .transpose()? - .unwrap_or("auto"); - let mut entries = Vec::new(); - let mut dropped = Vec::new(); - for name in names { - let name = name.as_str().ok_or_else(invalid)?; - let direct = tools.iter().find(|tool| match tool { - Tool::Function(tool) => tool.name == name, - Tool::Provider(tool) => tool.name == name, - }); - let aliases: Vec<_> = tools.iter().filter(|tool| matches!(tool, Tool::Provider(tool) if tool.id.as_str().strip_prefix("openai.") == Some(name) && tool.name != name)).collect(); - let tool = if let Some(tool) = direct { - if !aliases.is_empty() { - warnings.push(Warning::Unsupported { feature:format!("allowedTools entry \"{name}\""), details:Some("this name is both a tool name and the provider tool name of another tool in this request; the tool with this name is allowed".into()) }); - } - Some(tool) - } else { - if aliases.len() > 1 - && aliases.iter().any(|tool| match tool { - Tool::Provider(tool) => tool.id == "openai.custom" || tool.id == "openai.mcp", - _ => false, - }) - { - warnings.push(Warning::Unsupported { feature:format!("allowedTools entry \"{name}\""), details:Some("several tools in this request share this provider tool name; use the tool name from the tools for this request instead".into()) }); - dropped.push(name); - continue; - } - aliases.first().copied() - }; - let entry = match tool { - Some(Tool::Function(tool)) => { - let opts = tool - .provider_options - .as_ref() - .and_then(|options| options.get("openai")); - let reason = if opts.is_some_and(|opts| opts.contains_key("namespace")) { - Some( - "tools inside an OpenAI tool namespace are not visible to tool_choice.allowed_tools", - ) - } else if opts.is_some_and(|opts| opts.get("deferLoading") == Some(&json!(true))) { - Some("deferred tools are not visible to tool_choice.allowed_tools") - } else { - None - }; - if let Some(reason) = reason { - warnings.push(Warning::Unsupported { - feature: format!("allowedTools entry \"{name}\""), - details: Some(format!( - "{reason}; the tool is removed from the allowed tools" - )), - }); - dropped.push(name); - continue; - } - json!({"type":"function", "name":tool.name}) - } - Some(Tool::Provider(tool)) => { - let Some(value) = provider_tool_to_json(tool)? else { - entries.push(json!({"type":"function", "name":name})); - continue; - }; - match value["type"].as_str() { - Some("custom") => json!({"type":"custom", "name":tool.name}), - Some("mcp") => json!({"type":"mcp", "server_label":value["server_label"]}), - Some("tool_search") => { - warnings.push(Warning::Unsupported { feature:format!("allowedTools entry \"{name}\""), details:Some("OpenAI does not support tool_search tools in tool_choice.allowed_tools; the tool is removed from the allowed tools".into()) }); - dropped.push(name); - continue; - } - Some(kind) => json!({"type":kind}), - None => continue, - } - } - None => { - warnings.push(Warning::Unsupported { feature:format!("allowedTools entry \"{name}\""), details:Some("the tool is not part of the tools for this request and is sent as a function tool".into()) }); - json!({"type":"function", "name":name}) - } - }; - entries.push(entry); - } - if entries.is_empty() { - return Err(AiMuxError::UnsupportedFunctionality(format!( - "allowedTools with only tools that cannot be allow-listed ({})", - dropped.join(", ") - ))); - } - Ok(json!({"type":"allowed_tools", "mode":mode, "tools":entries})) -} - -// Only configured fields are sent: absent optional values are omitted, not null. -fn map_tool_fields(value: &Value, fields: &[(&str, &str)]) -> Value { - let mut result = json!({}); - for (name, wire) in fields { - if let Some(value) = value.get(*name) { - result[*wire] = value.clone(); - } - } - result -} - -fn provider_tool_to_json( - tool: &aimux_core::tool::ProviderTool, -) -> Result, AiMuxError> { - let kind = tool.id.as_str().strip_prefix("openai.").unwrap_or(""); - if ![ - "file_search", - "local_shell", - "shell", - "apply_patch", - "computer", - "web_search_preview", - "web_search", - "code_interpreter", - "image_generation", - "mcp", - "custom", - "programmatic_tool_calling", - "tool_search", - ] - .contains(&kind) - { - return Ok(None); - } - if [ - "local_shell", - "apply_patch", - "computer", - "programmatic_tool_calling", - ] - .contains(&kind) - { - return Ok(Some(json!({"type":kind}))); - } - let args = &Value::Object(tool.args.clone()); - validate_tool_args(kind, args)?; - let fields: &[(&str, &str)] = match kind { - "file_search" => &[ - ("vectorStoreIds", "vector_store_ids"), - ("maxNumResults", "max_num_results"), - ("filters", "filters"), - ], - "web_search_preview" | "web_search" => &[ - ("searchContextSize", "search_context_size"), - ("userLocation", "user_location"), - ], - "image_generation" => &[ - ("action", "action"), - ("background", "background"), - ("inputFidelity", "input_fidelity"), - ("model", "model"), - ("moderation", "moderation"), - ("partialImages", "partial_images"), - ("quality", "quality"), - ("outputCompression", "output_compression"), - ("outputFormat", "output_format"), - ("size", "size"), - ], - "mcp" => &[ - ("serverLabel", "server_label"), - ("authorization", "authorization"), - ("connectorId", "connector_id"), - ("headers", "headers"), - ("serverDescription", "server_description"), - ("serverUrl", "server_url"), - ], - "custom" => &[ - ("description", "description"), - ("format", "format"), - ("async", "async"), - ], - "tool_search" => &[ - ("execution", "execution"), - ("description", "description"), - ("parameters", "parameters"), - ], - _ => &[], - }; - let mut value = map_tool_fields(args, fields); - value["type"] = json!(kind); - if let Some(location) = args.get("userLocation") { - value["user_location"] = map_tool_fields( - location, - &[ - ("type", "type"), - ("country", "country"), - ("city", "city"), - ("region", "region"), - ("timezone", "timezone"), - ], - ); - } - match kind { - "file_search" => { - if let Some(ranking) = args.get("ranking") { - value["ranking_options"] = map_tool_fields( - ranking, - &[("ranker", "ranker"), ("scoreThreshold", "score_threshold")], - ); - } - } - "web_search" => { - if let Some(filters) = args.get("filters") { - value["filters"] = map_tool_fields( - filters, - &[ - ("allowedDomains", "allowed_domains"), - ("blockedDomains", "blocked_domains"), - ], - ); - } - if let Some(access) = args.get("externalWebAccess") { - value["external_web_access"] = access.clone(); - } - } - "code_interpreter" => { - value["container"] = match args.get("container") { - Some(container) if container.is_string() => container.clone(), - container => { - let mut value = container - .map(|container| map_tool_fields(container, &[("fileIds", "file_ids")])) - .unwrap_or_else(|| json!({})); - value["type"] = json!("auto"); - value - } - } - } - "image_generation" => { - if let Some(mask) = args.get("inputImageMask") { - value["input_image_mask"] = - map_tool_fields(mask, &[("fileId", "file_id"), ("imageUrl", "image_url")]); - } - } - "custom" => { - value["name"] = json!(tool.name); - if let Some(format) = args.get("format") { - value["format"] = map_tool_fields( - format, - &[ - ("type", "type"), - ("syntax", "syntax"), - ("definition", "definition"), - ], - ); - } - } - "mcp" => { - if let Some(allowed) = args.get("allowedTools") { - value["allowed_tools"] = if allowed.is_array() { - allowed.clone() - } else { - map_tool_fields( - allowed, - &[("readOnly", "read_only"), ("toolNames", "tool_names")], - ) - }; - } - value["require_approval"] = args - .get("requireApproval") - .map(|approval| { - if approval.is_string() { - approval.clone() - } else if let Some(never) = approval.get("never") { - json!({"never":map_tool_fields(never, &[("toolNames", "tool_names")])}) - } else { - json!("never") - } - }) - .unwrap_or_else(|| json!("never")); - } - "shell" => { - if let Some(environment) = args.get("environment") { - value["environment"] = map_shell_environment(environment)?; - } - } - _ => {} - } - Ok(Some(value)) -} - -fn map_shell_environment(environment: &Value) -> Result { - let kind = environment - .get("type") - .and_then(Value::as_str) - .unwrap_or("local"); - let mut value = match kind { - "containerReference" => map_tool_fields(environment, &[("containerId", "container_id")]), - "containerAuto" => map_tool_fields( - environment, - &[("fileIds", "file_ids"), ("memoryLimit", "memory_limit")], - ), - _ => map_tool_fields(environment, &[("skills", "skills")]), - }; - value["type"] = json!(match kind { - "containerReference" => "container_reference", - "containerAuto" => "container_auto", - _ => "local", - }); - if kind == "containerAuto" { - if let Some(policy) = environment.get("networkPolicy") { - value["network_policy"] = map_tool_fields( - policy, - &[ - ("type", "type"), - ("allowedDomains", "allowed_domains"), - ("domainSecrets", "domain_secrets"), - ], - ); - } - if let Some(skills) = environment.get("skills").and_then(Value::as_array) { - value["skills"] = Value::Array(skills.iter().map(|skill| { - if skill["type"] == "skillReference" { - let reference = skill.get("providerReference").and_then(Value::as_object).map(|reference| reference.iter().filter_map(|(provider, id)| Some((provider.clone(), id.as_str()?.to_owned()))).collect()).unwrap_or_default(); - let id = crate::openai::convert::resolve_provider_reference(&reference, "openai").map_err(AiMuxError::invalid_argument)?; - Ok(json!({"type":"skill_reference", "skill_id":id, "version":skill.get("version").cloned().unwrap_or_else(|| json!("latest"))})) - } else { - let mut value = map_tool_fields(skill, &[("name", "name"), ("description", "description")]); - value["type"] = json!("inline"); - value["source"] = map_tool_fields(&skill["source"], &[("type", "type"), ("mediaType", "media_type"), ("data", "data")]); - Ok(value) - } - }).collect::, AiMuxError>>()?); - } - } - Ok(value) -} - -/// Convert a `FunctionTool` into the Responses `function` tool JSON shape. -fn function_tool_to_json(t: &FunctionTool) -> Value { - let mut func = json!({ - "type": "function", - "name": t.name, - "parameters": t.input_schema, - }); - if let Some(ref desc) = t.description { - func["description"] = json!(desc); - } - func["strict"] = json!(t.strict.unwrap_or(false)); - func -} - -// -- Request body ------------------------------------------------------------ - -/// Result of building a Responses request body. -pub struct ResponsesRequestBodyResult { - pub body: Value, - pub warnings: Vec, -} -/// Compatibility warnings for call options the Responses API does not carry. -fn push_unsupported_call_option_warnings(options: &CallOptions, warnings: &mut Vec) { - if options.top_k.is_some() { - warnings.push(Warning::Unsupported { - feature: "topK".to_string(), - details: None, - }); - } - if options.seed.is_some() { - warnings.push(Warning::Unsupported { - feature: "seed".to_string(), - details: None, - }); - } - if options.presence_penalty.is_some() { - warnings.push(Warning::Unsupported { - feature: "presencePenalty".to_string(), - details: None, - }); - } - if options.frequency_penalty.is_some() { - warnings.push(Warning::Unsupported { - feature: "frequencyPenalty".to_string(), - details: None, - }); - } - if options.stop_sequences.is_some() { - warnings.push(Warning::Unsupported { - feature: "stopSequences".to_string(), - details: None, - }); - } -} - -/// Resolve the Responses reasoning config: `reasoningEffort` (provider option -/// wins over top-level `reasoning`), `reasoningSummary` (defaults to "detailed" -/// when an effort other than "none" applies), and whether the model reasons. -fn resolve_responses_reasoning( - ns: ResponsesNamespace, - provider_opts: &Option, - options: &CallOptions, - caps: &ModelCapabilities, -) -> (Option, Option, bool) { - let resolved_reasoning_effort: Option = - openai_option(ns, provider_opts, "reasoningEffort") - .map(|v| { - v.as_str() - .map(std::string::ToString::to_string) - .unwrap_or_else(|| v.to_string()) - }) - .or_else(|| { - if options.reasoning.is_some_and(ReasoningEffort::is_custom) { - options.reasoning.map(|r| r.to_string()) - } else { - None - } - }); - - let resolved_reasoning_summary = match openai_option(ns, provider_opts, "reasoningSummary") { - Some(value) => value.as_str().map(str::to_owned), - None => resolved_reasoning_effort - .as_deref() - .filter(|effort| *effort != "none") - .map(|_| "detailed".to_string()), - }; - - let is_reasoning_model = openai_option(ns, provider_opts, "forceReasoning") - .map(|v| v.as_bool().unwrap_or(false)) - .unwrap_or(caps.is_reasoning_model); - - ( - resolved_reasoning_effort, - resolved_reasoning_summary, - is_reasoning_model, - ) -} - -/// Warn when `conversation` and `previousResponseId` are both set. -fn warn_conversation_conflict( - ns: ResponsesNamespace, - provider_opts: &Option, - warnings: &mut Vec, -) { - let has_conversation = openai_option(ns, provider_opts, "conversation").is_some(); - let has_previous_response_id = openai_option(ns, provider_opts, "previousResponseId").is_some(); - if has_conversation && has_previous_response_id { - warnings.push(Warning::Unsupported { - feature: "conversation".to_string(), - details: Some( - "conversation and previousResponseId cannot be used together".to_string(), - ), - }); - } -} - -fn resolve_responses_system_message_mode( - ns: ResponsesNamespace, - provider_opts: &Option, - is_reasoning_model: bool, - caps: &ModelCapabilities, -) -> SystemMessageMode { - openai_option(ns, provider_opts, "systemMessageMode") - .and_then(|v| v.as_str().map(std::string::ToString::to_string)) - .map(|s| match s.as_str() { - "developer" => SystemMessageMode::Developer, - "remove" => SystemMessageMode::Remove, - _ => SystemMessageMode::System, - }) - .unwrap_or(if is_reasoning_model { - SystemMessageMode::Developer - } else { - caps.system_message_mode - }) -} - -/// temperature / top_p, subject to reasoning-model restrictions. -fn apply_responses_sampling( - ns: ResponsesNamespace, - body: &mut Value, - options: &CallOptions, - caps: &ModelCapabilities, - is_reasoning_model: bool, - resolved_reasoning_effort: &Option, - warnings: &mut Vec, -) { - let provider_opts = &options.provider_options; - let mut temperature = options.temperature; - let mut top_p = options.top_p; - - if is_reasoning_model { - let allow_non_reasoning = resolved_reasoning_effort.as_deref() == Some("none") - && caps.supports_non_reasoning_parameters; - if !allow_non_reasoning { - if temperature.is_some() { - temperature = None; - warnings.push(Warning::Unsupported { - feature: "temperature".to_string(), - details: Some("temperature is not supported for reasoning models".to_string()), - }); - } - if top_p.is_some() { - top_p = None; - warnings.push(Warning::Unsupported { - feature: "topP".to_string(), - details: Some("topP is not supported for reasoning models".to_string()), - }); - } - } - } else { - for key in [ - "reasoningEffort", - "reasoningSummary", - "reasoningMode", - "reasoningContext", - ] { - if openai_option(ns, provider_opts, key).is_some() { - warnings.push(Warning::Unsupported { - feature: key.to_string(), - details: Some(format!("{key} is not supported for non-reasoning models")), - }); - } - } - } - - if let Some(t) = temperature { - body["temperature"] = json!(t); - } - if let Some(p) = top_p { - body["top_p"] = json!(p); - } -} - -/// `text.format` (json_schema / json_object) plus `verbosity`. -fn apply_responses_text_format( - ns: ResponsesNamespace, - body: &mut Value, - options: &CallOptions, - provider_opts: &Option, - warnings: &mut Vec, -) { - if let Some(ref rf) = options.response_format { - match rf { - ResponseFormat::Text => {} - ResponseFormat::Json { - schema, - name, - description, - } => { - let mut text = json!({}); - match schema { - Some(schema) => { - let strict_json = openai_option(ns, provider_opts, "strictJsonSchema") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - text["format"] = json!({ - "type": "json_schema", - "strict": strict_json, - "name": name.clone().unwrap_or_else(|| "response".to_string()), - "schema": crate::openai::convert::normalize_json_schema(schema, warnings).unwrap_or_else(|_| schema.clone()), - }); - if let Some(description) = description { - text["format"]["description"] = json!(description); - } - } - None => { - text["format"] = json!({ "type": "json_object" }); - } - } - body["text"] = text; - } - } - } - - if let Some(verbosity) = openai_option(ns, provider_opts, "textVerbosity") { - let text = body.get_mut("text").and_then(|t| t.as_object_mut()); - match text { - Some(obj) => { - obj.insert("verbosity".to_string(), verbosity); - } - None => { - body["text"] = json!({ "verbosity": verbosity }); - } - } - } -} - -/// The computed `include` list (store=false on reasoning models adds -/// `reasoning.encrypted_content`). -fn resolve_responses_include( - ns: ResponsesNamespace, - provider_opts: &Option, - is_reasoning_model: bool, - tools: Option<&[Tool]>, -) -> Option> { - let mut include: Option> = - openai_option(ns, provider_opts, "include").and_then(|v| v.as_array().cloned()); - - let add_include = |key: &str, inc: &mut Option>| { - let already = inc - .as_ref() - .is_some_and(|arr| arr.iter().any(|v| v.as_str() == Some(key))); - if !already { - match inc { - Some(arr) => arr.push(json!(key)), - None => *inc = Some(vec![json!(key)]), - } - } - }; - - // store defaults to true; only the explicit `false` triggers encrypted_content. - let store_explicit = openai_option(ns, provider_opts, "store").and_then(|v| v.as_bool()); - if store_explicit == Some(false) && is_reasoning_model { - add_include("reasoning.encrypted_content", &mut include); - } - - let top_logprobs = openai_option(ns, provider_opts, "logprobs") - .and_then(|v| if v == true { Some(20) } else { v.as_u64() }); - if top_logprobs.is_some_and(|value| value > 0) { - add_include("message.output_text.logprobs", &mut include); - } - for tool in tools.into_iter().flatten() { - if let Tool::Provider(tool) = tool { - match tool.id.as_str() { - "openai.web_search" | "openai.web_search_preview" - if openai_option(ns, provider_opts, "includeWebSearchSources") - != Some(json!(false)) => - { - add_include("web_search_call.action.sources", &mut include) - } - "openai.code_interpreter" => { - add_include("code_interpreter_call.outputs", &mut include) - } - _ => {} - } - } - } - include -} - -/// Pass-through of the remaining Responses provider options (only sent when -/// set). -fn apply_responses_provider_options( - ns: ResponsesNamespace, - body: &mut Value, - provider_opts: &Option, -) { - let mut set = |key: &str, body_key: &str| { - if let Some(v) = openai_option(ns, provider_opts, key) { - body[body_key] = v; - } - }; - set("conversation", "conversation"); - set("maxToolCalls", "max_tool_calls"); - set("metadata", "metadata"); - set("parallelToolCalls", "parallel_tool_calls"); - set("previousResponseId", "previous_response_id"); - set("user", "user"); - set("instructions", "instructions"); - set("promptCacheKey", "prompt_cache_key"); - set("promptCacheRetention", "prompt_cache_retention"); - set("safetyIdentifier", "safety_identifier"); - set("truncation", "truncation"); - if let Some(Value::Object(options)) = openai_option(ns, provider_opts, "promptCacheOptions") { - body["prompt_cache_options"] = Value::Object( - options - .into_iter() - .filter(|(key, _)| matches!(key.as_str(), "mode" | "ttl")) - .collect(), - ); - } - if let Some(value) = openai_option(ns, provider_opts, "logprobs").and_then(|v| { - if v == true { - Some(json!(20)) - } else { - v.as_u64().map(|n| json!(n)) - } - }) { - body["top_logprobs"] = value; - } - if let Some(values) = - openai_option(ns, provider_opts, "contextManagement").and_then(|v| v.as_array().cloned()) - { - body["context_management"] = json!( - values - .into_iter() - .map(|v| { - let mut entry = json!({ "type": v["type"] }); - if let Some(threshold) = v.get("compactThreshold") { - entry["compact_threshold"] = threshold.clone(); - } - entry - }) - .collect::>() - ); - } -} - -/// `service_tier` with model-capability validation. -fn apply_responses_service_tier( - ns: ResponsesNamespace, - body: &mut Value, - provider_opts: &Option, - caps: &ModelCapabilities, - warnings: &mut Vec, -) { - if let Some(st) = openai_option(ns, provider_opts, "serviceTier") - .and_then(|v| v.as_str().map(std::string::ToString::to_string)) - { - match st.as_str() { - "flex" if !caps.supports_flex_processing => { - warnings.push(Warning::Unsupported { - feature: "serviceTier".to_string(), - details: Some( - "flex processing is only available for o3, o4-mini, and gpt-5 models" - .to_string(), - ), - }); - } - "priority" | "fast" if !caps.supports_priority_processing => { - warnings.push(Warning::Unsupported { - feature: "serviceTier".to_string(), - details: Some("priority processing is only available for supported models (gpt-4, gpt-5, gpt-5-mini, o3, o4-mini) and requires Enterprise access. gpt-5-nano is not supported".to_string()), - }); - } - _ => { - body["service_tier"] = json!(st); - } - } - } -} - -/// `reasoning` block for reasoning models. -fn apply_responses_reasoning_block( - ns: ResponsesNamespace, - body: &mut Value, - provider_opts: &Option, - is_reasoning_model: bool, - resolved_reasoning_effort: &Option, - resolved_reasoning_summary: &Option, -) { - if !is_reasoning_model { - return; - } - let effort = resolved_reasoning_effort.as_ref(); - let summary = resolved_reasoning_summary.as_ref(); - let mode = openai_option(ns, provider_opts, "reasoningMode") - .and_then(|v| v.as_str().map(std::string::ToString::to_string)); - let context = openai_option(ns, provider_opts, "reasoningContext") - .and_then(|v| v.as_str().map(std::string::ToString::to_string)); - - if effort.is_some() || summary.is_some() || mode.is_some() || context.is_some() { - let mut reasoning = json!({}); - if let Some(e) = effort { - reasoning["effort"] = json!(e); - } - if let Some(s) = summary { - reasoning["summary"] = json!(s); - } - if let Some(m) = mode { - reasoning["mode"] = json!(m); - } - if let Some(c) = context { - reasoning["context"] = json!(c); - } - body["reasoning"] = reasoning; - } -} - -/// Build the OpenAI Responses API request body and warnings. -/// -/// # Errors -/// Rejects unsupported prompt parts and invalid tool arguments. -pub fn build_responses_request_body( - model_id: &str, - options: &CallOptions, - stream: bool, -) -> Result { - build_responses_request_body_for(ResponsesNamespace::OPENAI, model_id, options, stream) -} - -/// [`build_responses_request_body`] for a host with its own providerOptions -/// namespace. -/// -/// # Errors -/// Rejects unsupported prompt parts and invalid tool arguments. -pub fn build_responses_request_body_for( - ns: ResponsesNamespace, - model_id: &str, - options: &CallOptions, - stream: bool, -) -> Result { - let mut warnings: Vec = Vec::new(); - let caps = get_model_capabilities(model_id); - let provider_opts = &options.provider_options; - - // -- Warnings for unsupported call options -- - push_unsupported_call_option_warnings(options, &mut warnings); - - // -- Reasoning resolution -- - let (mut resolved_reasoning_effort, mut resolved_reasoning_summary, is_reasoning_model) = - resolve_responses_reasoning(ns, provider_opts, options, &caps); - if let Some(efforts) = caps.supported_reasoning_efforts - && resolved_reasoning_effort - .as_deref() - .is_some_and(|effort| !efforts.contains(&effort)) - { - warnings.push(Warning::Unsupported { - feature: "reasoningEffort".to_string(), - details: Some(format!( - "{model_id} only supports the following reasoning efforts: {}", - efforts.join(", ") - )), - }); - resolved_reasoning_effort = None; - resolved_reasoning_summary = openai_option(ns, provider_opts, "reasoningSummary") - .and_then(|v| v.as_str().map(str::to_owned)); - } - - // -- conversation + previousResponseId conflict -- - warn_conversation_conflict(ns, provider_opts, &mut warnings); - - // -- System message mode -- - let system_message_mode = - resolve_responses_system_message_mode(ns, provider_opts, is_reasoning_model, &caps); - - // -- Input conversion -- - let store_bool = openai_option(ns, provider_opts, "store") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - let has_previous_response_id = openai_option(ns, provider_opts, "previousResponseId").is_some(); - let mut input_result = convert_responses_input_with_conversation( - ns, - &options.prompt, - system_message_mode, - store_bool, - has_previous_response_id, - openai_option(ns, provider_opts, "conversation").is_some(), - options.tools.as_deref(), - )?; - if let Some(effort) = openai_option(ns, provider_opts, "reasoningEffortUpdate") - .and_then(|v| v.as_str().map(str::to_owned)) - { - let reason = if !caps.supports_configuration_update { - Some("reasoningEffortUpdate is only supported by GPT-6 and later models".to_string()) - } else if openai_option(ns, provider_opts, "reasoningMode") == Some(json!("pro")) - || openai_option(ns, provider_opts, "contextManagement").is_some() - || openai_option(ns, provider_opts, "truncation") == Some(json!("auto")) - { - Some("reasoningEffortUpdate requires standard reasoning mode without automatic compaction or automatic truncation".to_string()) - } else { - caps.supported_reasoning_efforts - .filter(|values| !values.contains(&effort.as_str())) - .map(|values| { - format!( - "{model_id} only supports the following reasoning efforts: {}", - values.join(", ") - ) - }) - }; - if let Some(details) = reason { - warnings.push(Warning::Unsupported { - feature: "reasoningEffortUpdate".to_string(), - details: Some(details), - }); - } else if input_result.input.first().is_none_or(|item| { - item["type"] != "configuration_update" || item["reasoning"]["effort"] != effort - }) { - input_result.input.insert( - 0, - json!({ "type": "configuration_update", "reasoning": { "effort": effort } }), - ); - } - } - if openai_option(ns, provider_opts, "compactionTrigger") == Some(json!(true)) { - input_result - .input - .push(json!({ "type": "compaction_trigger" })); - } - warnings.extend(input_result.warnings); - - // -- Base body -- - let mut body = json!({ - "model": model_id, - "input": input_result.input, - }); - - if stream { - body["stream"] = json!(true); - } - - if let Some(max_tokens) = options.max_output_tokens { - body["max_output_tokens"] = json!(max_tokens); - } - - // temperature / top_p (subject to reasoning-model restrictions) - apply_responses_sampling( - ns, - &mut body, - options, - &caps, - is_reasoning_model, - &resolved_reasoning_effort, - &mut warnings, - ); - - // -- Response format (text.format) + verbosity -- - apply_responses_text_format(ns, &mut body, options, provider_opts, &mut warnings); - - // -- include (computed) -- - let include = resolve_responses_include( - ns, - provider_opts, - is_reasoning_model, - options.tools.as_deref(), - ); - if let Some(inc) = include { - body["include"] = json!(inc); - } - - // -- store (only sent when explicitly set) -- - if let Some(s) = openai_option(ns, provider_opts, "store").and_then(|v| v.as_bool()) { - body["store"] = json!(s); - } - - // -- Other provider options (only sent when set) -- - apply_responses_provider_options(ns, &mut body, provider_opts); - if caps.supports_configuration_update && body.get("prompt_cache_retention").is_some() { - body.as_object_mut() - .expect("request body is an object") - .remove("prompt_cache_retention"); - warnings.push(Warning::Unsupported { - feature: "promptCacheRetention".to_string(), - details: Some("promptCacheRetention is not supported by GPT-6 and later models; use promptCacheOptions instead".to_string()), - }); - } - - if is_reasoning_model - && caps.supported_reasoning_efforts.is_some() - && !(resolved_reasoning_effort.as_deref() == Some("none") - && caps.supports_non_reasoning_parameters) - { - let mut removed = body - .as_object_mut() - .expect("request body is an object") - .remove("top_logprobs") - .is_some(); - if let Some(include) = body.get_mut("include").and_then(Value::as_array_mut) { - let len = include.len(); - include.retain(|value| value.as_str() != Some("message.output_text.logprobs")); - removed |= include.len() != len; - } - if body - .get("include") - .and_then(Value::as_array) - .is_some_and(Vec::is_empty) - { - body.as_object_mut() - .expect("request body is an object") - .remove("include"); - } - if removed { - warnings.push(Warning::Unsupported { - feature: "logprobs".to_string(), - details: Some("logprobs is not supported for reasoning models".to_string()), - }); - } - } - - // -- service_tier (with capability validation) -- - apply_responses_service_tier(ns, &mut body, provider_opts, &caps, &mut warnings); - - // -- reasoning block (reasoning models only) -- - apply_responses_reasoning_block( - ns, - &mut body, - provider_opts, - is_reasoning_model, - &resolved_reasoning_effort, - &resolved_reasoning_summary, - ); - - // -- Tools -- - let allowed_tools = openai_option(ns, provider_opts, "allowedTools"); - let prepared = prepare_responses_tools_for( - &options.tools, - options.tool_choice.as_ref(), - allowed_tools.as_ref(), - caps.supports_configuration_update, - )?; - if let Some(tools) = prepared.tools { - body["tools"] = json!(tools); - if let Some(tc) = prepared.tool_choice { - body["tool_choice"] = tc; - } - } - for tw in prepared.tool_warnings { - warnings.push(tw); - } - - Ok(ResponsesRequestBodyResult { body, warnings }) -} - -// -- File id prefixes -------------------------------------------------------- - -/// Apply the host's file-id prefixes (`fileIdPrefixes` in the AI SDK) to a -/// built request body: when a file content part's base64 data starts with one -/// of `prefixes`, it is an uploaded file's id and the part carries a `file_id` -/// field instead of `image_url` / `file_data`. A no-op for no prefixes. -pub(crate) fn apply_file_id_prefixes(body: &mut Value, prefixes: &[&str]) { - if prefixes.is_empty() { - return; - } - if let Some(input) = body.get_mut("input").and_then(|v| v.as_array_mut()) { - for msg in input.iter_mut() { - if let Some(content) = msg.get_mut("content").and_then(|v| v.as_array_mut()) { - for part in content.iter_mut() { - apply_prefix_to_part(part, prefixes); - } - } - } - } -} - -/// Check if a content part's `image_url` or `file_data` contains a data URL -/// whose payload starts with a file-id prefix and, if so, replace it with a -/// `file_id`. -/// -/// For `input_file` parts whose media type is an image, the type is also -/// changed to `input_image`, mirroring the AI SDK, which sends image files as -/// `input_image` with a `file_id`. -fn apply_prefix_to_part(part: &mut Value, prefixes: &[&str]) { - let is_file_id = |data: &str| prefixes.iter().any(|prefix| data.starts_with(prefix)); - // input_image: { type: "input_image", image_url: "data:;base64," } - if part.get("type").and_then(|v| v.as_str()) == Some("input_image") { - let file_id = part - .get("image_url") - .and_then(|v| v.as_str()) - .and_then(extract_base64_data) - .filter(|data| is_file_id(data)) - .map(std::string::ToString::to_string); - if let Some(file_id) = file_id - && let Some(obj) = part.as_object_mut() - { - obj.remove("image_url"); - obj.insert("file_id".to_string(), json!(file_id)); - } - } - - // input_file: { type: "input_file", file_data: "data:;base64," } - if part.get("type").and_then(|v| v.as_str()) == Some("input_file") { - let file_data = part - .get("file_data") - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string); - if let Some(file_data) = file_data { - let media_type = extract_media_type(&file_data).unwrap_or(""); - if let Some(data) = extract_base64_data(&file_data) - && is_file_id(data) - && let Some(obj) = part.as_object_mut() - { - obj.remove("file_data"); - obj.remove("filename"); - obj.insert("file_id".to_string(), json!(data)); - if media_type.starts_with("image/") { - obj.insert("type".to_string(), json!("input_image")); - } - } - } - } -} - -/// Extract the MIME type from a `data:;base64,` URL. -fn extract_media_type(data_url: &str) -> Option<&str> { - let prefix = data_url.strip_prefix("data:")?; - let end = prefix.find(";base64,")?; - Some(&prefix[..end]) -} - -/// Extract the base64 payload from a `data:;base64,` URL. -fn extract_base64_data(data_url: &str) -> Option<&str> { - data_url.split_once(";base64,").map(|(_, rest)| rest) -} - -// -- Usage conversion -------------------------------------------------------- - -/// Convert a Responses API usage into the core `Usage` type. -/// -/// Mirrors TS `convertOpenAIResponsesUsage`: -/// - `input.total = input_tokens` -/// - `input.noCache = input_tokens - cached_tokens - cache_write_tokens` -/// - `input.cacheRead = cached_tokens` -/// - `input.cacheWrite = cache_write_tokens` -/// - `output.total = output_tokens` -/// - `output.text = output_tokens - reasoning_tokens` -/// - `output.reasoning = reasoning_tokens` -/// -/// `raw` is the untouched wire `usage` object; it is stored on `Usage.raw` -/// (RFC-0015 P0-3). Re-serializing the typed struct instead would drop the -/// `orchestration_*` counters and add explicit nulls, so the caller passes the -/// original value through — matching the TS `raw: usage`. -#[must_use] -pub fn convert_responses_usage(usage: Option<&ResponsesUsage>, raw: Option) -> Usage { - let Some(usage) = usage else { - return Usage { - raw: raw.and_then(|value| value.as_object().cloned()), - ..Default::default() - }; - }; - - let input_tokens = usage.input_tokens; - let output_tokens = usage.output_tokens; - - let cached_tokens = usage - .input_tokens_details - .as_ref() - .and_then(|d| d.cached_tokens) - .unwrap_or(0); - let cache_write = usage - .input_tokens_details - .as_ref() - .and_then(|d| d.cache_write_tokens); - let reasoning_tokens = usage - .output_tokens_details - .as_ref() - .and_then(|d| d.reasoning_tokens) - .unwrap_or(0); - - let no_cache = input_tokens - cached_tokens - cache_write.unwrap_or(0); - let text_tokens = output_tokens - reasoning_tokens; - - Usage { - input_tokens: aimux_core::types::InputTokenUsage { - total: Some(input_tokens), - no_cache: Some(no_cache), - cache_read: Some(cached_tokens), - cache_write, - }, - output_tokens: aimux_core::types::OutputTokenUsage { - total: Some(output_tokens), - text: Some(text_tokens), - reasoning: Some(reasoning_tokens), - }, - raw: raw.and_then(|value| value.as_object().cloned()), - } -} - -/// Helper: build a `ResponsesUsage` from a raw JSON `usage` object. -#[must_use] -pub fn parse_usage(raw: &Value) -> Option { - serde_json::from_value(raw.clone()).ok() -} - -// -- Finish reason ----------------------------------------------------------- - -/// Map a Responses API finish reason into the unified `FinishReason`. -/// -/// Mirrors TS `mapOpenAIResponseFinishReason`. When `finish_reason` is -/// `None`/null, the unified reason is `tool-calls` if there were function -/// calls, else `stop`. `"max_output_tokens"` -> `length`, -/// `"content_filter"` -> `content-filter`; otherwise `tool-calls` if there -/// were function calls, else `other`. -#[must_use] -pub fn map_responses_finish_reason( - finish_reason: Option<&str>, - has_function_call: bool, -) -> FinishReason { - let unified = match finish_reason { - None => { - if has_function_call { - FinishReasonUnified::ToolCalls - } else { - FinishReasonUnified::Stop - } - } - Some("max_output_tokens") => FinishReasonUnified::Length, - Some("content_filter") => FinishReasonUnified::ContentFilter, - Some(_) => { - if has_function_call { - FinishReasonUnified::ToolCalls - } else { - FinishReasonUnified::Other - } - } - }; - FinishReason { - unified, - raw: finish_reason.map(std::string::ToString::to_string), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn reasoning(text: &str, options: Value) -> AssistantPart { - AssistantPart::Reasoning(ReasoningPart { - text: text.to_string(), - provider_options: Some(serde_json::from_value(json!({ "openai": options })).unwrap()), - }) - } - - fn convert(store: bool, content: Vec) -> ResponsesInputResult { - let prompt = vec![LanguageModelMessage::Assistant { - content, - provider_options: None, - }]; - convert_to_responses_input( - ResponsesNamespace::OPENAI, - &prompt, - SystemMessageMode::System, - store, - false, - ) - .unwrap() - } - - /// partial: `should merge consecutive parts with same reasoning ID`; full port in src/openai/tests/responses/convert_to_responses_input.rs. - /// Parts of one reasoning item become one input item: the summaries are - /// appended and the last encrypted content wins (openai@4.0.83 - /// `convert-to-openai-responses-input.ts:1167-1220`). - #[test] - fn reasoning_parts_with_one_item_id_are_merged_without_store() { - let result = convert( - false, - vec![ - reasoning( - "first", - json!({ "itemId": "rs_1", "reasoningEncryptedContent": "enc-1" }), - ), - reasoning( - "second", - json!({ "itemId": "rs_1", "reasoningEncryptedContent": "enc-2" }), - ), - reasoning( - "other", - json!({ "itemId": "rs_2", "reasoningEncryptedContent": "enc-3" }), - ), - ], - ); - assert_eq!( - result.input, - vec![ - json!({ - "type": "reasoning", - "id": "rs_1", - "encrypted_content": "enc-2", - "summary": [ - { "type": "summary_text", "text": "first" }, - { "type": "summary_text", "text": "second" }, - ], - }), - json!({ - "type": "reasoning", - "id": "rs_2", - "encrypted_content": "enc-3", - "summary": [{ "type": "summary_text", "text": "other" }], - }), - ] - ); - assert!(result.warnings.is_empty(), "{:?}", result.warnings); - } - - /// partial: `should warn when appending empty text to existing sequence`; full port in src/openai/tests/responses/convert_to_responses_input.rs. - /// An empty part cannot extend an item that already has parts. - #[test] - fn an_empty_reasoning_part_is_not_appended_to_an_item() { - let result = convert( - false, - vec![ - reasoning( - "first", - json!({ "itemId": "rs_1", "reasoningEncryptedContent": "enc-1" }), - ), - reasoning("", json!({ "itemId": "rs_1" })), - ], - ); - assert_eq!( - result.input, - vec![json!({ - "type": "reasoning", - "id": "rs_1", - "encrypted_content": "enc-1", - "summary": [{ "type": "summary_text", "text": "first" }], - })] - ); - assert!(matches!( - result.warnings.as_slice(), - [Warning::Other { message }] - if message.starts_with("Cannot append empty reasoning part to existing reasoning sequence. Skipping reasoning part: ") - )); - } - - /// With `store`, one item reference stands for every part of the item. - #[test] - fn reasoning_parts_with_one_item_id_are_one_reference_with_store() { - let result = convert( - true, - vec![ - reasoning("first", json!({ "itemId": "rs_1" })), - reasoning("second", json!({ "itemId": "rs_1" })), - reasoning("other", json!({ "itemId": "rs_2" })), - ], - ); - assert_eq!( - result.input, - vec![ - json!({ "type": "item_reference", "id": "rs_1" }), - json!({ "type": "item_reference", "id": "rs_2" }), - ] - ); - } -} diff --git a/aimux-providers/src/openai/responses/convert_responses_usage.rs b/aimux-providers/src/openai/responses/convert_responses_usage.rs new file mode 100644 index 000000000..5d803d1ea --- /dev/null +++ b/aimux-providers/src/openai/responses/convert_responses_usage.rs @@ -0,0 +1,73 @@ +//! Convert Responses usage to the core usage (convert-openai-responses-usage.ts). + +use aimux_core::types::Usage; +use serde_json::Value; + +use crate::openai::responses::responses_api::ResponsesUsage; + +/// Convert a Responses API usage into the core `Usage` type. +/// +/// Mirrors TS `convertOpenAIResponsesUsage`: +/// - `input.total = input_tokens` +/// - `input.noCache = input_tokens - cached_tokens - cache_write_tokens` +/// - `input.cacheRead = cached_tokens` +/// - `input.cacheWrite = cache_write_tokens` +/// - `output.total = output_tokens` +/// - `output.text = output_tokens - reasoning_tokens` +/// - `output.reasoning = reasoning_tokens` +/// +/// `raw` is the untouched wire `usage` object; it is stored on `Usage.raw` +/// (RFC-0015 P0-3). Re-serializing the typed struct instead would drop the +/// `orchestration_*` counters and add explicit nulls, so the caller passes the +/// original value through — matching the TS `raw: usage`. +#[must_use] +pub fn convert_responses_usage(usage: Option<&ResponsesUsage>, raw: Option) -> Usage { + let Some(usage) = usage else { + return Usage { + raw: raw.and_then(|value| value.as_object().cloned()), + ..Default::default() + }; + }; + + let input_tokens = usage.input_tokens; + let output_tokens = usage.output_tokens; + + let cached_tokens = usage + .input_tokens_details + .as_ref() + .and_then(|d| d.cached_tokens) + .unwrap_or(0); + let cache_write = usage + .input_tokens_details + .as_ref() + .and_then(|d| d.cache_write_tokens); + let reasoning_tokens = usage + .output_tokens_details + .as_ref() + .and_then(|d| d.reasoning_tokens) + .unwrap_or(0); + + let no_cache = input_tokens - cached_tokens - cache_write.unwrap_or(0); + let text_tokens = output_tokens - reasoning_tokens; + + Usage { + input_tokens: aimux_core::types::InputTokenUsage { + total: Some(input_tokens), + no_cache: Some(no_cache), + cache_read: Some(cached_tokens), + cache_write, + }, + output_tokens: aimux_core::types::OutputTokenUsage { + total: Some(output_tokens), + text: Some(text_tokens), + reasoning: Some(reasoning_tokens), + }, + raw: raw.and_then(|value| value.as_object().cloned()), + } +} + +/// Helper: build a `ResponsesUsage` from a raw JSON `usage` object. +#[must_use] +pub fn parse_usage(raw: &Value) -> Option { + serde_json::from_value(raw.clone()).ok() +} diff --git a/aimux-providers/src/openai/responses/convert_to_responses_input.rs b/aimux-providers/src/openai/responses/convert_to_responses_input.rs new file mode 100644 index 000000000..1125a0306 --- /dev/null +++ b/aimux-providers/src/openai/responses/convert_to_responses_input.rs @@ -0,0 +1,952 @@ +//! Conversion of a `LanguageModelPrompt` to the Responses `input` items +//! (convert-to-openai-responses-input.ts), with the file id prefixes. + +use aimux_core::language_model_message::{ + AssistantPart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, + ToolCallPart, ToolPart, ToolResultContent, ToolResultOutput, ToolResultPart, UserPart, +}; +use aimux_core::shared::{FileBytes, FileData, SharedProviderOptions}; +use aimux_core::tool::Tool; +use aimux_core::types::Warning; +use aimux_core::{AiMuxError, ErrorCause}; +use serde_json::{Value, json}; + +use crate::openai::language_model_capabilities::SystemMessageMode; +use crate::openai::responses::responses_language_model_options::ResponsesNamespace; +use crate::openai::responses::responses_prepare_tools::map_tool_fields; +use crate::openai::responses::tool_args::{validate_tool_call, validate_tool_result}; + +/// Result of converting a prompt into the Responses API `input` array. +pub struct ResponsesInputResult { + pub input: Vec, + pub warnings: Vec, +} + +/// Convert a `LanguageModelPrompt` into the OpenAI Responses API `input` array. +/// +/// Mirrors the core paths of TS `convertToOpenAIResponsesInput`: +/// - system -> `{ role: "system"|"developer", content: }` +/// - user -> `{ role: "user", content: [{ type: "input_text", text }] }` +/// - assistant text -> `{ role: "assistant", content: [{ type: "output_text", text }] }` +/// (or `{ type: "item_reference", id }` when `store` and an itemId are present) +/// - assistant tool-call -> `{ type: "function_call", call_id, name, arguments }` +/// - tool result -> `{ type: "function_call_output", call_id, output }` +/// +/// `store` defaults to `true` (matching the API default). When +/// `has_previous_response_id` is true, assistant reasoning/function-call items +/// that already carry an `itemId` are skipped (they live in the previous +/// response chain). +/// # Errors +/// Rejects unsupported file data and unresolved file references. +pub fn convert_to_responses_input( + ns: ResponsesNamespace, + prompt: &LanguageModelPrompt, + system_message_mode: SystemMessageMode, + store: bool, + has_previous_response_id: bool, +) -> Result { + convert_responses_input_with_conversation( + ns, + prompt, + system_message_mode, + store, + has_previous_response_id, + false, + None, + ) +} + +pub(crate) fn convert_responses_input_with_conversation( + ns: ResponsesNamespace, + prompt: &LanguageModelPrompt, + system_message_mode: SystemMessageMode, + store: bool, + has_previous_response_id: bool, + has_conversation: bool, + tools: Option<&[Tool]>, +) -> Result { + let mut input: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + + let mut processed_approval_ids = std::collections::HashSet::new(); + let mut programmatic_tool_call_ids = std::collections::HashSet::new(); + for msg in prompt { + match msg { + LanguageModelMessage::System { + content, + provider_options, + } => { + if let Some(effort) = + openai_sub_option(ns, provider_options, "reasoningEffortUpdate") + { + input.push(json!({ "type": "configuration_update", "reasoning": { "effort": effort } })); + continue; + } + match system_message_mode { + SystemMessageMode::System => { + input.push(json!({ + "role": "system", + "content": system_content(ns, content, provider_options), + })); + } + SystemMessageMode::Developer => { + input.push(json!({ + "role": "developer", + "content": system_content(ns, content, provider_options), + })); + } + SystemMessageMode::Remove => { + warnings.push(Warning::Other { + message: "system messages are removed for this model".to_string(), + }); + } + } + } + LanguageModelMessage::User { content, .. } => { + let content: Vec = content + .iter() + .enumerate() + .map(|(index, part)| { + let mut value = convert_user_part(ns, part, index)?; + if let Some(breakpoint) = + openai_sub_option(ns, user_part_options(part), "promptCacheBreakpoint") + { + value["prompt_cache_breakpoint"] = breakpoint; + } + Ok(value) + }) + .collect::>()?; + input.push(json!({ "role": "user", "content": content })); + } + LanguageModelMessage::Assistant { content, .. } => { + // Reasoning item id → its index in `input`: the parts of one + // reasoning item become one input item (per assistant + // message, as `reasoningMessages` upstream). + let mut reasoning_items: std::collections::HashMap> = + std::collections::HashMap::new(); + for part in content { + match part { + AssistantPart::Text(TextPart { + text, + provider_options, + }) => { + let id = item_id(ns, provider_options); + if has_conversation && id.is_some() { + continue; + } + if store && let Some(ref id) = id { + input.push(json!({ "type": "item_reference", "id": id })); + continue; + } + let phase = phase_from_provider_options(ns, provider_options); + let mut item = json!({ + "role": "assistant", + "content": text, + }); + if let Some(phase) = phase { + item["phase"] = json!(phase); + } + input.push(item); + } + AssistantPart::ToolCall(ToolCallPart { + tool_call_id, + tool_name, + input: tool_input, + provider_options, + provider_executed, + .. + }) => { + let id = item_id(ns, provider_options); + if openai_sub_option(ns, provider_options, "caller") + .is_some_and(|caller| caller["type"] == "program") + { + programmatic_tool_call_ids.insert(tool_call_id.as_str()); + } + if has_conversation && id.is_some() { + continue; + } + let kind = provider_tool_kind(tools, tool_name); + // Hosted tool search and program calls are replayed + // in full without response storage. + if provider_executed == &Some(true) + && !matches!( + kind, + Some("tool_search" | "programmatic_tool_calling") + ) + { + if store && let Some(id) = &id { + input.push(json!({"type":"item_reference", "id":id})); + } + if store || kind != Some("shell") { + continue; + } + } + if let Some(kind) = kind { + if store + && id.is_some() + && kind != "tool_search" + && kind != "programmatic_tool_calling" + && has_previous_response_id + { + continue; + } + if store && let Some(id) = &id { + input.push(json!({"type":"item_reference", "id":id})); + continue; + } + if let Some(mut item) = provider_tool_call_input( + kind, + tool_name, + tool_call_id, + id.as_deref(), + tool_input, + )? { + if kind == "custom" + && let Some(value) = + openai_sub_option(ns, provider_options, "async") + .filter(|value| !value.is_null()) + { + item["async"] = value; + } + input.push(item); + continue; + } + } + let namespace = namespace_from_provider_options(ns, provider_options); + let mut item = json!({ + "type": "function_call", + "call_id": tool_call_id, + "name": tool_name, + "arguments": serialize_arguments(tool_input), + }); + if let Some(ref namespace) = namespace { + item["namespace"] = json!(namespace); + } + if let Some(value) = openai_sub_option(ns, provider_options, "async") { + item["async"] = value; + } + if let Some(caller) = tool_caller(ns, provider_options) { + item["caller"] = caller; + } + input.push(item); + } + AssistantPart::Reasoning(ReasoningPart { + text, + provider_options, + .. + }) => { + // convert-to-openai-responses-input.ts:1151-1250 (openai@4.0.83). + let reasoning_id = openai_sub_option(ns, provider_options, "itemId") + .and_then(|id| id.as_str().map(str::to_owned)); + let encrypted = openai_sub_option( + ns, + provider_options, + "reasoningEncryptedContent", + ) + .filter(|value| !value.is_null()); + if (has_previous_response_id || has_conversation) + && reasoning_id.is_some() + { + continue; + } + let summary_part = (!text.is_empty()) + .then(|| json!({ "type": "summary_text", "text": text })); + if let Some(rid) = reasoning_id { + if store { + // One item reference, at the first part. + reasoning_items.entry(rid).or_insert_with_key(|rid| { + input.push(json!({ "type": "item_reference", "id": rid })); + None + }); + } else if let Some(&Some(index)) = reasoning_items.get(&rid) { + // Later parts of the same item append their + // summary; the last encrypted content wins. + let item = &mut input[index]; + match summary_part { + Some(summary_part) => { + if let Some(summary) = + item["summary"].as_array_mut() + { + summary.push(summary_part); + } + } + None => warnings.push(Warning::Other { + message: format!( + "Cannot append empty reasoning part to existing reasoning sequence. Skipping reasoning part: {}.", + serde_json::to_string(part).unwrap_or_default() + ), + }), + } + if let Some(enc) = encrypted { + item["encrypted_content"] = enc; + } + } else { + let mut item = json!({ + "type": "reasoning", + "id": rid, + "summary": summary_part.into_iter().collect::>(), + }); + if let Some(enc) = encrypted { + item["encrypted_content"] = enc; + } + reasoning_items.insert(rid, Some(input.len())); + input.push(item); + } + } else if let Some(enc) = encrypted { + input.push(json!({ + "type": "reasoning", + "encrypted_content": enc, + "summary": summary_part.into_iter().collect::>(), + })); + } else { + warnings.push(Warning::Other { + message: format!( + "Non-OpenAI reasoning parts are not supported. Skipping reasoning part: {}.", + serde_json::to_string(part).unwrap_or_default() + ), + }); + } + } + AssistantPart::ToolResult(part) => { + if has_conversation + || matches!(part.output, ToolResultOutput::ExecutionDenied { .. }) + || matches!(&part.output, ToolResultOutput::Json { value, .. } if value.get("type").and_then(Value::as_str) == Some("execution-denied")) + { + continue; + } + let id = item_id(ns, &part.provider_options) + .unwrap_or_else(|| part.tool_call_id.clone()); + let kind = provider_tool_kind(tools, &part.tool_name); + match kind { + Some("shell") => { + if let ToolResultOutput::Json { value, .. } = &part.output + && let Some(item) = provider_tool_result_input("shell", &part.tool_call_id, value)? + { input.push(item); } + } + _ if store => input.push(json!({"type":"item_reference", "id":id})), + Some("tool_search" | "programmatic_tool_calling") => { + if let ToolResultOutput::Json { value, .. } = &part.output { + let kind = kind.unwrap_or_default(); + validate_tool_result(kind, value)?; + input.push(if kind == "tool_search" { json!({"type":"tool_search_output", "id":id, "execution":"server", "call_id":null, "status":"completed", "tools":value["tools"]}) } else { json!({"type":"program_output", "id":id, "call_id":part.tool_call_id, "result":value["result"], "status":value["status"]}) }); + } + } + _ => warnings.push(Warning::Other { message:format!("Results for OpenAI tool {} are not sent to the API when store is false", part.tool_name) }), + } + } + AssistantPart::Custom(part) if part.kind == "openai.compaction" => { + let id = item_id(ns, &part.provider_options); + if has_conversation && id.is_some() { + continue; + } + if let Some(id) = id { + if store { + input.push(json!({"type":"item_reference", "id":id})); + } else { + input.push(json!({"type":"compaction", "id":id, "encrypted_content":openai_sub_option(ns, &part.provider_options, "encryptedContent")})); + } + } + } + _ => {} + } + } + } + LanguageModelMessage::Tool { content, .. } => { + for part in content { + if let ToolPart::ToolApprovalResponse(approval) = part { + if !processed_approval_ids.insert(&approval.approval_id) { + continue; + } + if store && !has_conversation && !has_previous_response_id { + input.push(json!({"type":"item_reference", "id":approval.approval_id})); + } + input.push(json!({"type":"mcp_approval_response", "approval_request_id":approval.approval_id, "approve":approval.approved})); + continue; + } + let ToolPart::ToolResult(ToolResultPart { + tool_call_id, + tool_name, + output, + provider_options, + }) = part + else { + continue; + }; + if let ToolResultOutput::ExecutionDenied { + provider_options, .. + } = output + && openai_sub_option(ns, provider_options, "approvalId") + .and_then(|id| id.as_str().map(|id| !id.is_empty())) + .unwrap_or(false) + { + continue; + } + let kind = provider_tool_kind(tools, tool_name); + if let Some(kind) = kind + && kind != "custom" + && let ToolResultOutput::Json { value, .. } = output + && let Some(item) = provider_tool_result_input(kind, tool_call_id, value)? + { + input.push(item); + continue; + } + let custom = kind == Some("custom"); + if !custom + && matches!(output, ToolResultOutput::ExecutionDenied { .. }) + && (programmatic_tool_call_ids.contains(tool_call_id.as_str()) + || openai_sub_option(ns, provider_options, "caller") + .is_some_and(|caller| caller["type"] == "program")) + { + return Err(AiMuxError::UnsupportedFunctionality( + "execution-denied results for programmatic tool calls".into(), + )); + } + let mut content_value = + convert_tool_result_output(ns, output, custom, &mut warnings)?; + let has_output_schema = tools.is_some_and(|tools| tools.iter().any(|tool| { + matches!(tool, Tool::Function(tool) if tool.name == *tool_name && tool.provider_options.as_ref().and_then(|options| options.get("openai")).is_some_and(|options| options.contains_key("outputSchema"))) + })); + if has_output_schema + && matches!( + output, + ToolResultOutput::Text { .. } + | ToolResultOutput::ErrorText { .. } + | ToolResultOutput::ExecutionDenied { .. } + ) + { + content_value = json!(content_value.to_string()); + } + if !matches!(output, ToolResultOutput::Content { .. }) + && let Some(breakpoint) = scalar_tool_result_cache_breakpoint(ns, output) + .or_else(|| { + openai_sub_option(ns, provider_options, "promptCacheBreakpoint") + }) + { + content_value = json!([{"type":"input_text", "text":content_value, "prompt_cache_breakpoint":breakpoint}]); + } + let mut item = json!({ + "type": if custom { "custom_tool_call_output" } else { "function_call_output" }, + "call_id": tool_call_id, + "output": content_value, + }); + if !custom && let Some(caller) = tool_caller(ns, provider_options) { + item["caller"] = caller; + } + input.push(item); + } + } + } + } + + // When store is false, remove reasoning parts without encrypted content. + if !store + && input.iter().any(|item| { + item.get("type").and_then(|v| v.as_str()) == Some("reasoning") + && item.get("encrypted_content").is_none() + }) + { + warnings.push(Warning::Other { + message: + "Reasoning parts without encrypted content are not supported when store is false. Skipping reasoning parts." + .to_string(), + }); + input.retain(|item| { + item.get("type").and_then(|v| v.as_str()) != Some("reasoning") + || item.get("encrypted_content").is_some() + }); + } + + Ok(ResponsesInputResult { input, warnings }) +} + +/// The `caller` provider option of a tool call or result, on the wire. +fn tool_caller( + ns: ResponsesNamespace, + provider_options: &Option, +) -> Option { + let caller = openai_sub_option(ns, provider_options, "caller")?; + let mut caller = map_tool_fields(&caller, &[("type", "type"), ("callerId", "caller_id")]); + if caller["type"] != "program" + && let Some(object) = caller.as_object_mut() + { + object.remove("caller_id"); + } + Some(caller) +} + +fn provider_tool_kind<'a>(tools: Option<&'a [Tool]>, name: &str) -> Option<&'a str> { + tools?.iter().find_map(|tool| match tool { + Tool::Provider(tool) if tool.name == name => tool.id.as_str().strip_prefix("openai."), + _ => None, + }) +} + +fn provider_tool_call_input( + kind: &str, + name: &str, + call_id: &str, + id: Option<&str>, + args: &Value, +) -> Result, AiMuxError> { + let parsed; + let args = if kind != "custom" && args.is_string() { + parsed = serde_json::from_str(args.as_str().unwrap_or_default()).map_err(|error| { + AiMuxError::InvalidArgument { + message: error.to_string(), + cause: Some(ErrorCause::source(&error)), + } + })?; + &parsed + } else { + args + }; + if kind != "custom" { + validate_tool_call(kind, args)?; + } + let mut value = match kind { + "custom" => { + json!({"type":"custom_tool_call", "call_id":call_id, "name":name, "input":args.as_str().map(str::to_owned).unwrap_or_else(|| args.to_string())}) + } + "local_shell" => { + json!({"type":"local_shell_call", "call_id":call_id, "action": map_tool_fields(&args["action"], &[("command", "command"), ("timeoutMs", "timeout_ms"), ("user", "user"), ("workingDirectory", "working_directory"), ("env", "env")])}) + } + "shell" => { + json!({"type":"shell_call", "call_id":call_id, "status":"completed", "action":map_tool_fields(&args["action"], &[("commands", "commands"), ("timeoutMs", "timeout_ms"), ("maxOutputLength", "max_output_length")])}) + } + "apply_patch" => { + json!({"type":"apply_patch_call", "call_id":args["callId"], "status":"completed", "operation":args["operation"]}) + } + "computer" => { + let mut actions = args + .get("actions") + .and_then(Value::as_array) + .cloned() + .ok_or_else(|| AiMuxError::invalid_argument("computer actions"))?; + for action in &mut actions { + if action["type"] == "scroll" + && let Some(object) = action.as_object_mut() + { + for (key, wire) in [("scrollX", "scroll_x"), ("scrollY", "scroll_y")] { + if let Some(v) = object.remove(key) { + object.insert(wire.into(), v); + } + } + } + } + json!({"type":"computer_call", "call_id":call_id, "status":args["status"], "actions":actions, "pending_safety_checks":args["pendingSafetyChecks"]}) + } + "programmatic_tool_calling" => { + json!({"type":"program", "call_id":call_id, "code":args["code"], "fingerprint":args["fingerprint"]}) + } + "tool_search" => { + let args = if let Some(text) = args.as_str() { + serde_json::from_str(text).map_err(|error| AiMuxError::InvalidArgument { + message: error.to_string(), + cause: Some(ErrorCause::source(&error)), + })? + } else { + args.clone() + }; + let mut value = map_tool_fields(&args, &[("arguments", "arguments")]); + value["type"] = json!("tool_search_call"); + value["status"] = json!("completed"); + value["execution"] = json!(if args.get("call_id").is_some_and(|id| !id.is_null()) { + "client" + } else { + "server" + }); + value["call_id"] = args.get("call_id").cloned().unwrap_or(Value::Null); + value + } + _ => return Ok(None), + }; + if kind == "local_shell" { + value["action"]["type"] = json!("exec"); + } + if let Some(id) = id { + value["id"] = json!(id); + } else if ["tool_search", "programmatic_tool_calling"].contains(&kind) { + value["id"] = json!(call_id); + } + Ok(Some(value)) +} + +fn provider_tool_result_input( + kind: &str, + call_id: &str, + result: &Value, +) -> Result, AiMuxError> { + if !matches!( + kind, + "local_shell" | "shell" | "apply_patch" | "computer" | "tool_search" | "custom" + ) { + return Ok(None); + } + validate_tool_result(kind, result)?; + let mut value = match kind { + "local_shell" => json!({"type":"local_shell_call_output", "output":result["output"]}), + "shell" => { + let mut output = result + .get("output") + .and_then(Value::as_array) + .cloned() + .ok_or_else(|| AiMuxError::invalid_argument("shell output"))?; + for item in &mut output { + if let Some(outcome) = item.get_mut("outcome").and_then(Value::as_object_mut) + && let Some(code) = outcome.remove("exitCode") + { + outcome.insert("exit_code".into(), code); + } + } + json!({"type":"shell_call_output", "output":output}) + } + "apply_patch" => { + json!({"type":"apply_patch_call_output", "status":result["status"], "output":result["output"]}) + } + "computer" => { + json!({"type":"computer_call_output", "output":map_tool_fields(&result["output"], &[("imageUrl", "image_url"), ("fileId", "file_id"), ("detail", "detail")])}) + } + "tool_search" => { + json!({"type":"tool_search_output", "execution":"client", "status":"completed", "tools":result["tools"]}) + } + "custom" => { + json!({"type":"custom_tool_call_output", "output":result.as_str().map(str::to_owned).unwrap_or_else(|| result.to_string())}) + } + _ => return Ok(None), + }; + if kind == "computer" { + value["output"]["type"] = json!("computer_screenshot"); + if let Some(checks) = result.get("acknowledgedSafetyChecks") { + value["acknowledged_safety_checks"] = checks.clone(); + } + } + value["call_id"] = json!(call_id); + Ok(Some(value)) +} + +fn user_part_options(part: &UserPart) -> &Option { + match part { + UserPart::Text(part) => &part.provider_options, + UserPart::File(part) => &part.provider_options, + } +} + +fn system_content( + ns: ResponsesNamespace, + text: &str, + provider_options: &Option, +) -> Value { + match openai_sub_option(ns, provider_options, "promptCacheBreakpoint") { + Some(value) => { + json!([{ "type": "input_text", "text": text, "prompt_cache_breakpoint": value }]) + } + None => json!(text), + } +} + +/// Convert a single user-message content part into the Responses input shape. +fn convert_user_part( + ns: ResponsesNamespace, + part: &UserPart, + index: usize, +) -> Result { + Ok(match part { + UserPart::Text(part) => json!({ "type": "input_text", "text": part.text }), + UserPart::File(file) => match &file.data { + FileData::Data { data } => { + use base64::Engine; + let b64 = match data { + FileBytes::Binary(bytes) => { + base64::engine::general_purpose::STANDARD.encode(bytes) + } + FileBytes::Base64(data) => data.clone(), + }; + inline_file( + ns, + &aimux_provider_utils::resolve_full_media_type(file)?, + file.filename.as_deref(), + &b64, + &file.provider_options, + index, + ) + } + FileData::Reference { reference } => { + let file_id = + crate::openai::chat::convert_to_chat_messages::resolve_provider_reference( + reference, + ns.write_key(), + ) + .map_err(AiMuxError::invalid_argument)?; + let mut part = json!({ "type": if file.media_type.split('/').next() == Some("image") { "input_image" } else { "input_file" }, "file_id": file_id }); + if part["type"] == "input_image" + && let Some(detail) = + openai_sub_option(ns, &file.provider_options, "imageDetail") + { + part["detail"] = detail; + } + part + } + FileData::Url { url, .. } => { + if file.media_type.split('/').next() == Some("image") { + let mut part = json!({ "type": "input_image", "image_url": url }); + if let Some(detail) = + openai_sub_option(ns, &file.provider_options, "imageDetail") + { + part["detail"] = detail; + } + part + } else { + json!({ "type": "input_file", "file_url": url }) + } + } + FileData::Text { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "text file parts".into(), + )); + } + }, + }) +} + +fn scalar_tool_result_cache_breakpoint( + ns: ResponsesNamespace, + output: &ToolResultOutput, +) -> Option { + let provider_options = match output { + ToolResultOutput::Text { + provider_options, .. + } + | ToolResultOutput::Json { + provider_options, .. + } + | ToolResultOutput::ErrorText { + provider_options, .. + } + | ToolResultOutput::ErrorJson { + provider_options, .. + } + | ToolResultOutput::ExecutionDenied { + provider_options, .. + } => provider_options, + ToolResultOutput::Content { .. } => return None, + }; + openai_sub_option(ns, provider_options, "promptCacheBreakpoint") +} + +fn convert_tool_result_output( + ns: ResponsesNamespace, + output: &ToolResultOutput, + custom: bool, + warnings: &mut Vec, +) -> Result { + let ToolResultOutput::Content { value } = output else { + return Ok(crate::openai::chat::convert_to_chat_messages::tool_result_to_content(output)); + }; + let mut parts = Vec::new(); + for item in value { + let (mut converted, provider_options) = match item { + ToolResultContent::Text(part) => ( + json!({"type":"input_text", "text":part.text}), + &part.provider_options, + ), + ToolResultContent::File(part) => { + if matches!(part.data, FileData::Text { .. }) + || (custom && matches!(part.data, FileData::Reference { .. })) + { + warnings.push(Warning::Other { + message: format!( + "unsupported {}tool content part type: file with data type: {}", + if custom { "custom " } else { "" }, + if matches!(part.data, FileData::Reference { .. }) { + "reference" + } else { + "text" + } + ), + }); + continue; + } + let mut converted = convert_user_part(ns, &UserPart::File(part.clone()), 0)?; + if converted["type"] == "input_file" && converted.get("file_data").is_some() { + converted["filename"] = json!(part.filename.as_deref().unwrap_or("data")); + } + if converted["type"] == "input_image" + && let Some(detail) = + openai_sub_option(ns, &part.provider_options, "imageDetail") + { + converted["detail"] = detail; + } + (converted, &part.provider_options) + } + ToolResultContent::Custom { .. } => { + warnings.push(Warning::Other { + message: format!( + "unsupported {}tool content part type: custom", + if custom { "custom " } else { "" } + ), + }); + continue; + } + }; + if let Some(breakpoint) = openai_sub_option(ns, provider_options, "promptCacheBreakpoint") { + converted["prompt_cache_breakpoint"] = breakpoint; + } + parts.push(converted); + } + Ok(json!(parts)) +} + +fn inline_file( + ns: ResponsesNamespace, + media_type: &str, + filename: Option<&str>, + data: &str, + provider_options: &Option, + index: usize, +) -> Value { + if media_type.split('/').next() == Some("image") { + let mut part = json!({ "type": "input_image", "image_url": format!("data:{media_type};base64,{data}") }); + if let Some(detail) = openai_sub_option(ns, provider_options, "imageDetail") { + part["detail"] = detail; + } + part + } else { + json!({ "type": "input_file", "filename": filename.map(str::to_owned).unwrap_or_else(|| if media_type == "application/pdf" { format!("part-{index}.pdf") } else { format!("part-{index}") }), "file_data": format!("data:{media_type};base64,{data}") }) + } +} + +/// Serialize tool-call arguments as JSON, including null and string values. +fn serialize_arguments(input: &Value) -> String { + input.to_string() +} + +/// Read the `itemId` from a content part's `providerOptions.openai.itemId`. +pub(crate) fn item_id( + ns: ResponsesNamespace, + provider_options: &Option, +) -> Option { + provider_options + .as_ref() + .and_then(|v| ns.find(v)) + .and_then(|o| o.get("itemId")) + .and_then(|v| v.as_str()) + .map(std::string::ToString::to_string) +} + +/// Read the `phase` from a content part's `providerOptions.openai.phase`. +fn phase_from_provider_options( + ns: ResponsesNamespace, + provider_options: &Option, +) -> Option { + provider_options + .as_ref() + .and_then(|v| ns.find(v)) + .and_then(|o| o.get("phase")) + .and_then(|v| v.as_str()) + .map(std::string::ToString::to_string) +} + +/// Read the `namespace` from a content part's `providerOptions.openai.namespace`. +fn namespace_from_provider_options( + ns: ResponsesNamespace, + provider_options: &Option, +) -> Option { + provider_options + .as_ref() + .and_then(|v| ns.find(v)) + .and_then(|o| o.get("namespace")) + .and_then(|v| v.as_str()) + .map(std::string::ToString::to_string) +} + +/// Read a sub-key from `providerOptions.openai.` on a content part. +fn openai_sub_option( + ns: ResponsesNamespace, + provider_options: &Option, + key: &str, +) -> Option { + provider_options + .as_ref() + .and_then(|v| ns.find(v)) + .and_then(|o| o.get(key)) + .cloned() +} + +/// Apply the host's file-id prefixes (`fileIdPrefixes` in the AI SDK) to a +/// built request body: when a file content part's base64 data starts with one +/// of `prefixes`, it is an uploaded file's id and the part carries a `file_id` +/// field instead of `image_url` / `file_data`. A no-op for no prefixes. +pub(crate) fn apply_file_id_prefixes(body: &mut Value, prefixes: &[&str]) { + if prefixes.is_empty() { + return; + } + if let Some(input) = body.get_mut("input").and_then(|v| v.as_array_mut()) { + for msg in input.iter_mut() { + if let Some(content) = msg.get_mut("content").and_then(|v| v.as_array_mut()) { + for part in content.iter_mut() { + apply_prefix_to_part(part, prefixes); + } + } + } + } +} + +/// Check if a content part's `image_url` or `file_data` contains a data URL +/// whose payload starts with a file-id prefix and, if so, replace it with a +/// `file_id`. +/// +/// For `input_file` parts whose media type is an image, the type is also +/// changed to `input_image`, mirroring the AI SDK, which sends image files as +/// `input_image` with a `file_id`. +fn apply_prefix_to_part(part: &mut Value, prefixes: &[&str]) { + let is_file_id = |data: &str| prefixes.iter().any(|prefix| data.starts_with(prefix)); + // input_image: { type: "input_image", image_url: "data:;base64," } + if part.get("type").and_then(|v| v.as_str()) == Some("input_image") { + let file_id = part + .get("image_url") + .and_then(|v| v.as_str()) + .and_then(extract_base64_data) + .filter(|data| is_file_id(data)) + .map(std::string::ToString::to_string); + if let Some(file_id) = file_id + && let Some(obj) = part.as_object_mut() + { + obj.remove("image_url"); + obj.insert("file_id".to_string(), json!(file_id)); + } + } + + // input_file: { type: "input_file", file_data: "data:;base64," } + if part.get("type").and_then(|v| v.as_str()) == Some("input_file") { + let file_data = part + .get("file_data") + .and_then(|v| v.as_str()) + .map(std::string::ToString::to_string); + if let Some(file_data) = file_data { + let media_type = extract_media_type(&file_data).unwrap_or(""); + if let Some(data) = extract_base64_data(&file_data) + && is_file_id(data) + && let Some(obj) = part.as_object_mut() + { + obj.remove("file_data"); + obj.remove("filename"); + obj.insert("file_id".to_string(), json!(data)); + if media_type.starts_with("image/") { + obj.insert("type".to_string(), json!("input_image")); + } + } + } + } +} + +/// Extract the MIME type from a `data:;base64,` URL. +fn extract_media_type(data_url: &str) -> Option<&str> { + let prefix = data_url.strip_prefix("data:")?; + let end = prefix.find(";base64,")?; + Some(&prefix[..end]) +} + +/// Extract the base64 payload from a `data:;base64,` URL. +fn extract_base64_data(data_url: &str) -> Option<&str> { + data_url.split_once(";base64,").map(|(_, rest)| rest) +} diff --git a/aimux-providers/src/openai/responses/map_responses_finish_reason.rs b/aimux-providers/src/openai/responses/map_responses_finish_reason.rs new file mode 100644 index 000000000..d1659e924 --- /dev/null +++ b/aimux-providers/src/openai/responses/map_responses_finish_reason.rs @@ -0,0 +1,39 @@ +//! Map a Responses finish reason to the core finish reason (map-openai-responses-finish-reason.ts). + +use aimux_core::types::{FinishReason, FinishReasonUnified}; + +/// Map a Responses API finish reason into the unified `FinishReason`. +/// +/// Mirrors TS `mapOpenAIResponseFinishReason`. When `finish_reason` is +/// `None`/null, the unified reason is `tool-calls` if there were function +/// calls, else `stop`. `"max_output_tokens"` -> `length`, +/// `"content_filter"` -> `content-filter`; otherwise `tool-calls` if there +/// were function calls, else `other`. +#[must_use] +pub fn map_responses_finish_reason( + finish_reason: Option<&str>, + has_function_call: bool, +) -> FinishReason { + let unified = match finish_reason { + None => { + if has_function_call { + FinishReasonUnified::ToolCalls + } else { + FinishReasonUnified::Stop + } + } + Some("max_output_tokens") => FinishReasonUnified::Length, + Some("content_filter") => FinishReasonUnified::ContentFilter, + Some(_) => { + if has_function_call { + FinishReasonUnified::ToolCalls + } else { + FinishReasonUnified::Other + } + } + }; + FinishReason { + unified, + raw: finish_reason.map(std::string::ToString::to_string), + } +} diff --git a/aimux-providers/src/openai/responses/mod.rs b/aimux-providers/src/openai/responses/mod.rs index 3094ac680..5177c767f 100644 --- a/aimux-providers/src/openai/responses/mod.rs +++ b/aimux-providers/src/openai/responses/mod.rs @@ -1,422 +1,31 @@ -//! OpenAI Responses API language model. -//! -//! Implements the [`LanguageModel`] trait against the `/v1/responses` -//! endpoint. The Responses API uses a different request/response format from -//! chat completions: requests carry an `input` array (not `messages`), and -//! streaming events are typed as `response.output_text.delta`, -//! `response.function_call_arguments.delta`, etc. -//! -//! Mirrors the TS `OpenAIResponsesLanguageModel`. The core paths implemented -//! here are: -//! - **Request building**: input array, instructions, store, -//! previous_response_id, reasoning (effort/summary), response_format -//! (json_schema/json_object) — see [`convert::build_responses_request_body`]. -//! - **Non-streaming**: text, function-call, and reasoning output items -- see -//! [`OpenAIResponsesModel::do_generate`]. -//! - **Streaming**: the `response.created -> output_item.added -> -//! output_text.delta -> output_text.done -> output_item.done -> -//! response.completed` main path, plus `function_call_arguments.delta/done` -//! and `reasoning_summary_text.delta` -- see [`OpenAIResponsesModel::do_stream`]. -//! -//! The non-streaming output parser and the streaming SSE event reducer are -//! shared with the Azure OpenAI Responses provider via -//! [`responses_convert`] (RFC-0012 §3.5). - -pub mod convert; -mod provider_events; -pub mod responses_convert; -pub(crate) mod tool_args; -pub mod types; - -pub(crate) use convert::ResponsesProfile; -pub use convert::{ - ResponsesInputResult, ResponsesNamespace, ResponsesRequestBodyResult, - build_responses_request_body, build_responses_request_body_for, convert_responses_usage, - convert_to_responses_input, map_responses_finish_reason, prepare_responses_tools, +//! `responses/`: the OpenAI Responses API language model. + +pub(super) mod convert_responses_usage; +pub(super) mod convert_to_responses_input; +pub(super) mod map_responses_finish_reason; +pub(super) mod responses_api; +pub(super) mod responses_language_model; +pub(super) mod responses_language_model_options; +pub(super) mod responses_prepare_tools; +pub(super) mod tool_args; + +pub use convert_responses_usage::{convert_responses_usage, parse_usage}; +pub use convert_to_responses_input::{ResponsesInputResult, convert_to_responses_input}; +pub use map_responses_finish_reason::map_responses_finish_reason; +pub use responses_api::{ + ResponsesInputTokensDetails, ResponsesOutputTokensDetails, ResponsesUsage, }; - -use std::collections::HashMap; - -use async_trait::async_trait; -use futures::StreamExt; -use serde_json::Value; - -use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateResult, StreamResult}; - -use super::config::OpenAIModelConfig; - -/// An OpenAI Responses API language model. -/// -/// Does **not** hold an HTTP client — the `aimux-provider-utils` API helpers use -/// the process-wide shared `Client` internally (RFC-0009 §4.1). -/// -/// Created with -/// [`OpenAIProvider::responses`](crate::openai::OpenAIProvider::responses). -pub struct OpenAIResponsesModel { - model_id: String, - config: OpenAIModelConfig, -} - -impl OpenAIResponsesModel { - pub(crate) fn from_config(model_id: String, config: OpenAIModelConfig) -> Self { - Self { model_id, config } - } - - /// The request headers for one call: provider headers resolved now, with - /// the per-call headers layered over them. - pub(crate) async fn request_headers( - &self, - call_headers: Option<&HashMap>, - ) -> Result, AiMuxError> { - self.config.request_headers(call_headers).await - } - - fn endpoint(&self) -> Result { - self.config.url("/responses") - } - - /// The request body for one call: built with the host's providerOptions - /// namespace, file-id prefixes applied, then the provider-level rewrite. - fn request_body( - &self, - options: &CallOptions, - stream: bool, - ) -> Result { - let profile = &self.config.responses; - let mut unused_warnings = Vec::new(); - if let Some(aimux_core::options::ResponseFormat::Json { - schema: Some(schema), - .. - }) = &options.response_format - { - super::convert::normalize_json_schema(schema, &mut unused_warnings)?; - } - for tool in options.tools.iter().flatten() { - if let aimux_core::tool::Tool::Function(tool) = tool { - super::convert::normalize_json_schema(&tool.input_schema, &mut unused_warnings)?; - } - } - let pass_through = options - .provider_options - .as_ref() - .and_then(|v| profile.namespace.find_in(v)) - .and_then(|v| v.get("passThroughUnsupportedFiles")) - .and_then(Value::as_bool) - == Some(true); - let caps = super::convert_common::get_model_capabilities(&self.model_id); - let provider_options = options - .provider_options - .as_ref() - .and_then(|v| profile.namespace.find_in(v)); - if provider_options - .and_then(|v| v.get("reasoningEffortUpdate")) - .is_some_and(|v| { - !matches!( - v.as_str(), - Some("none" | "low" | "medium" | "high" | "xhigh" | "max") - ) - }) - { - return Err(AiMuxError::invalid_argument("reasoningEffortUpdate")); - } - let update_incompatible = provider_options.is_some_and(|v| { - v.get("reasoningMode") == Some(&serde_json::json!("pro")) - || v.get("contextManagement").is_some() - || v.get("truncation") == Some(&serde_json::json!("auto")) - }); - for message in &options.prompt { - use aimux_core::language_model_message::{ - AssistantPart, LanguageModelMessage, UserPart, - }; - use aimux_core::shared::FileData; - if let LanguageModelMessage::System { - content, - provider_options, - } = message - && let Some(effort) = provider_options - .as_ref() - .and_then(|v| profile.namespace.find_in(v)) - .and_then(|v| v.get("reasoningEffortUpdate")) - .and_then(Value::as_str) - { - if !matches!(effort, "none" | "low" | "medium" | "high" | "xhigh" | "max") { - return Err(AiMuxError::invalid_argument("reasoningEffortUpdate")); - } - if !content.is_empty() - || !caps.supports_configuration_update - || update_incompatible - || caps - .supported_reasoning_efforts - .is_some_and(|values| !values.contains(&effort)) - { - return Err(AiMuxError::UnsupportedFunctionality( - "Message-level reasoningEffortUpdate".into(), - )); - } - } - let (files, user) = match message { - LanguageModelMessage::User { content, .. } => ( - content - .iter() - .filter_map(|part| match part { - UserPart::File(file) => Some(file), - UserPart::Text(_) => None, - }) - .collect::>(), - true, - ), - LanguageModelMessage::Assistant { content, .. } => ( - content - .iter() - .filter_map(|part| match part { - AssistantPart::File(file) => Some(file), - _ => None, - }) - .collect::>(), - false, - ), - _ => (Vec::new(), false), - }; - for file in files { - match &file.data { - FileData::Text { .. } if user => { - return Err(AiMuxError::UnsupportedFunctionality( - "text file parts".into(), - )); - } - FileData::Reference { reference } => { - crate::openai::convert::resolve_provider_reference( - reference, - profile.namespace.write_key(), - ) - .map_err(AiMuxError::invalid_argument)?; - } - FileData::Data { .. } if user => { - aimux_provider_utils::resolve_full_media_type(file)?; - let media_type = &file.media_type; - if !pass_through - && !media_type.starts_with("image/") - && media_type != "image" - && media_type != "application/pdf" - { - return Err(AiMuxError::UnsupportedFunctionality(format!( - "file part media type {media_type}" - ))); - } - } - _ => {} - } - } - } - let mut result = - build_responses_request_body_for(profile.namespace, &self.model_id, options, stream)?; - convert::apply_file_id_prefixes(&mut result.body, &profile.file_id_prefixes); - if profile.explicit_message_item_type - && let Some(input) = result.body["input"].as_array_mut() - { - for item in input { - if item.get("role").is_some() { - item["type"] = serde_json::json!("message"); - } - } - } - if result.body["input"].as_array().is_some_and(|input| { - input.windows(2).any(|pair| { - pair[0]["type"] == "configuration_update" - && pair[1]["type"] == "configuration_update" - }) - }) { - return Err(AiMuxError::UnsupportedFunctionality( - "Adjacent reasoning effort configuration updates".into(), - )); - } - result.body = self.config.transform_body(result.body); - Ok(result) - } - - /// The provider-metadata key of the host. - fn provider_options_name(&self) -> &str { - self.config.responses.namespace.write_key() - } -} - -#[async_trait] -impl LanguageModel for OpenAIResponsesModel { - fn provider(&self) -> &str { - &self.config.provider - } - - async fn supported_urls(&self) -> aimux_core::language_model::SupportedUrls { - self.config.supported_urls.clone() - } - - fn model_id(&self) -> &str { - &self.model_id - } - - async fn do_generate(&self, options: &CallOptions) -> Result { - let headers = self.request_headers(options.headers.as_ref()).await?; - let request_result = self.request_body(options, false)?; - let body = request_result.body; - let provider_key = self.provider_options_name().to_string(); - - let endpoint = self.endpoint()?; - let resp = aimux_provider_utils::post_json_to_api( - self.config.http_request(endpoint.clone(), headers, options), - body.clone(), - aimux_provider_utils::create_json_response_handler::(), - super::openai_failed_response_handler(), - ) - .await?; - - let response_headers = resp.response_headers; - let raw_body = resp - .raw_value - .as_ref() - .map(ToString::to_string) - .unwrap_or_default(); - let data = resp.value; - - responses_convert::build_responses_generate_result_with_tools( - &data, - &raw_body, - request_result.warnings, - provider_key, - endpoint, - body, - response_headers, - tool_name_mapping(options), - prompt_approval_tool_call_ids(options), - ) - } - - async fn do_stream(&self, options: &CallOptions) -> Result { - let headers = self.request_headers(options.headers.as_ref()).await?; - let request_result = self.request_body(options, false)?; - let body = request_result.body; - let warnings = request_result.warnings; - let provider_key = self.provider_options_name().to_string(); - - // The `store` request option (None by default). Used to decide when - // reasoning summary parts are concluded. - let store_flag = options - .provider_options - .as_ref() - .and_then(|m| self.config.responses.namespace.find_in(m)) - .and_then(|o| o.get("store")) - .and_then(serde_json::Value::as_bool) - == Some(true); - - let endpoint = self.endpoint()?; - let mut stream_body = body.clone(); - stream_body["stream"] = Value::Bool(true); - let resp = aimux_provider_utils::post_json_to_api( - self.config.http_request(endpoint.clone(), headers, options), - stream_body, - aimux_provider_utils::create_event_source_response_handler::(), - super::openai_failed_response_handler(), - ) - .await?; - - let response_headers = resp.response_headers; - - let mut sse_stream = resp.value; - let events_before_output = responses_convert::read_until_output( - &mut sse_stream, - &endpoint, - &body, - &response_headers, - ) - .await?; - let stream = responses_convert::build_responses_event_stream_with_tools( - None, - futures::stream::iter(events_before_output).chain(sse_stream), - provider_key, - warnings, - store_flag, - endpoint, - body.clone(), - response_headers.clone(), - options.include_raw_chunks == Some(true), - tool_name_mapping(options), - prompt_approval_tool_call_ids(options), - )?; - - Ok(StreamResult { - stream, - request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::shared::StreamResponseInfo { - headers: Some(response_headers), - }), - }) - } -} - -/// The provider tools whose names the AI SDK maps back to the caller's tool -/// names (`providerToolNames` in openai-responses-language-model.ts). -const PROVIDER_TOOL_NAMES: &[&str] = &[ - "code_interpreter", - "computer", - "file_search", - "image_generation", - "local_shell", - "shell", - "web_search", - "web_search_preview", - "mcp", - "apply_patch", - "tool_search", - "programmatic_tool_calling", -]; - -fn tool_name_mapping(options: &CallOptions) -> HashMap { - let mut names = HashMap::new(); - for tool in options.tools.iter().flatten() { - if let aimux_core::tool::Tool::Provider(tool) = tool - && let Some(name) = tool.id.as_str().strip_prefix("openai.") - && PROVIDER_TOOL_NAMES.contains(&name) - { - names.insert(name.to_owned(), tool.name.clone()); - } - } - if let Some(name) = options.tools.iter().flatten().find_map(|tool| match tool { - aimux_core::tool::Tool::Provider(tool) - if matches!( - tool.id.as_str(), - "openai.web_search" | "openai.web_search_preview" - ) => - { - Some(tool.name.clone()) - } - _ => None, - }) { - names.insert("web_search".into(), name); - } - names -} - -fn prompt_approval_tool_call_ids(options: &CallOptions) -> HashMap { - use aimux_core::language_model_message::{AssistantPart, LanguageModelMessage}; - - let mut mapping = HashMap::new(); - for message in &options.prompt { - if let LanguageModelMessage::Assistant { content, .. } = message { - for part in content { - if let AssistantPart::ToolCall(call) = part - && let Some(id) = call - .provider_options - .as_ref() - .and_then(|options| options.get("openai")) - .and_then(|options| options.get("approvalRequestId")) - .and_then(Value::as_str) - { - mapping.insert(id.to_owned(), call.tool_call_id.clone()); - } - } - } - } - mapping +pub use responses_language_model::{ + OpenAIResponsesModel, ResponsesRequestBodyResult, build_responses_request_body, + build_responses_request_body_for, +}; +pub use responses_language_model_options::ResponsesNamespace; +pub(crate) use responses_language_model_options::ResponsesProfile; +pub use responses_prepare_tools::{PreparedResponsesTools, prepare_responses_tools}; + +/// The Responses output parser and event stream Codex reuses. +pub(crate) mod responses_convert { + pub(crate) use super::responses_language_model::{ + build_responses_event_stream_with_raw, build_responses_generate_result, + }; } diff --git a/aimux-providers/src/openai/responses/provider_events.rs b/aimux-providers/src/openai/responses/provider_events.rs deleted file mode 100644 index 0d2c3956c..000000000 --- a/aimux-providers/src/openai/responses/provider_events.rs +++ /dev/null @@ -1,379 +0,0 @@ -//! Provider tool output mapping from the pinned Responses implementation. - -use std::collections::HashMap; - -use aimux_core::result::{GenerateContent, RawToolApprovalRequest}; -use aimux_core::shared::provider_namespace; -use aimux_core::tool::{RawToolCall, ToolResult}; -use aimux_core::types::NamespacedKind; -use serde_json::{Value, json}; - -pub(crate) fn provider_tool_content( - part: &Value, - provider_key: &str, - tool_names: &HashMap, - shell_provider_executed: bool, - mcp_tool_call_id: Option<&str>, -) -> Vec { - let kind = part["type"].as_str().unwrap_or_default(); - let item_id = part["id"].as_str().unwrap_or_default(); - let call_id = part["call_id"].as_str().unwrap_or(item_id); - let metadata = || { - Some( - provider_namespace(provider_key, json!({"itemId": item_id})) - .expect("provider metadata must be an object"), - ) - }; - let name = |canonical: &str| { - tool_names - .get(canonical) - .cloned() - .unwrap_or_else(|| canonical.to_string()) - }; - let call = |id: &str, tool_name: String, input: String, executed, dynamic, meta| { - GenerateContent::ToolCall(RawToolCall { - tool_call_id: id.to_string(), - tool_name, - input, - provider_executed: executed, - dynamic, - provider_metadata: meta, - }) - }; - let result = |id: &str, tool_name: String, value: Value, meta| { - GenerateContent::ToolResult(ToolResult { - tool_call_id: id.to_string(), - tool_name, - result: value, - is_error: None, - preliminary: None, - dynamic: None, - provider_metadata: meta, - }) - }; - let pair = |canonical: &str, input: String, output: Value| { - vec![ - call(item_id, name(canonical), input, Some(true), None, None), - result(item_id, name(canonical), output, None), - ] - }; - match kind { - "image_generation_call" => pair( - "image_generation", - "{}".into(), - json!({"result": part["result"]}), - ), - "web_search_call" => pair( - "web_search", - "{}".into(), - web_search_output(&part["action"]), - ), - "file_search_call" => pair( - "file_search", - "{}".into(), - json!({ - "queries": part["queries"], - "results": part["results"].as_array().map(|results| results.iter().map(|value| { - json!({"attributes": value["attributes"], "fileId": value["file_id"], - "filename": value["filename"], "score": value["score"], "text": value["text"]}) - }).collect::>()), - }), - ), - "code_interpreter_call" => pair( - "code_interpreter", - json!({"code": part["code"], "containerId": part["container_id"]}).to_string(), - json!({"outputs": part["outputs"]}), - ), - "computer_call" if part["call_id"].is_null() => pair( - "computer_use", - String::new(), - json!({"type": "computer_use_tool_result", "status": part["status"]}), - ), - "computer_call" => vec![call( - call_id, - name("computer"), - computer_input(part).to_string(), - None, - None, - metadata(), - )], - "local_shell_call" => vec![call( - call_id, - name("local_shell"), - json!({"action": local_shell_action(&part["action"])}).to_string(), - None, - None, - metadata(), - )], - "shell_call" => vec![call( - call_id, - name("shell"), - json!({"action": {"commands": part["action"]["commands"]}}).to_string(), - shell_provider_executed.then_some(true), - None, - metadata(), - )], - "shell_call_output" => vec![result( - call_id, - name("shell"), - json!({"output": part["output"].as_array().map(|items| items.iter().map(|item| { - let outcome = if item["outcome"]["type"] == "exit" { - json!({"type": "exit", "exitCode": item["outcome"]["exit_code"]}) - } else { - json!({"type": "timeout"}) - }; - json!({"stdout": item["stdout"], "stderr": item["stderr"], "outcome": outcome}) - }).collect::>())}), - None, - )], - "apply_patch_call" => vec![call( - call_id, - name("apply_patch"), - json!({"callId": part["call_id"], "operation": apply_patch_operation(&part["operation"])}) - .to_string(), - None, - None, - metadata(), - )], - "program" => vec![call( - call_id, - name("programmatic_tool_calling"), - json!({"code": part["code"], "fingerprint": part["fingerprint"]}).to_string(), - Some(true), - None, - metadata(), - )], - "program_output" => vec![result( - call_id, - name("programmatic_tool_calling"), - json!({"result": part["result"], "status": part["status"]}), - metadata(), - )], - "tool_search_call" => { - let mut input = json!({}); - for field in ["arguments", "call_id"] { - if let Some(value) = part.get(field) { - input[field] = value.clone(); - } - } - vec![call( - call_id, - name("tool_search"), - input.to_string(), - (part["execution"] == "server").then_some(true), - None, - metadata(), - )] - } - "tool_search_output" => vec![result( - call_id, - name("tool_search"), - json!({"tools": part["tools"]}), - metadata(), - )], - "compaction" => vec![GenerateContent::Custom { - kind: NamespacedKind::new("openai.compaction") - .expect("openai.compaction is namespaced"), - provider_metadata: Some( - provider_namespace( - provider_key, - json!({ - "type": "compaction", "itemId": part["id"], - "encryptedContent": part["encrypted_content"], - }), - ) - .expect("provider metadata must be an object"), - ), - }], - "mcp_approval_request" => { - let tool_call_id = aimux_provider_utils::generate_id(); - let approval_id = part - .get("approval_request_id") - .and_then(Value::as_str) - .unwrap_or(item_id) - .to_owned(); - vec![ - call( - &tool_call_id, - format!("mcp.{}", part["name"].as_str().unwrap_or_default()), - part["arguments"].as_str().unwrap_or_default().to_owned(), - Some(true), - Some(true), - None, - ), - GenerateContent::ToolApprovalRequest(RawToolApprovalRequest { - approval_id, - tool_call_id, - provider_metadata: None, - }), - ] - } - "mcp_call" => { - let tool_name = format!("mcp.{}", part["name"].as_str().unwrap_or_default()); - let mut output = json!({"type": "call", "serverLabel": part["server_label"], - "name": part["name"], "arguments": part["arguments"]}); - for field in ["output", "error"] { - if let Some(value) = part.get(field).filter(|value| !value.is_null()) { - output[field] = value.clone(); - } - } - vec![ - call( - mcp_tool_call_id.unwrap_or(item_id), - tool_name.clone(), - part["arguments"].as_str().unwrap_or_default().to_string(), - Some(true), - Some(true), - None, - ), - result( - mcp_tool_call_id.unwrap_or(item_id), - tool_name, - output, - metadata(), - ), - ] - } - _ => Vec::new(), - } -} - -pub(crate) fn web_search_output(action: &Value) -> Value { - match action["type"].as_str() { - Some("search") => { - let mut output = json!({"action": {"type": "search"}}); - for field in ["query", "queries"] { - if let Some(value) = action.get(field).filter(|value| !value.is_null()) { - output["action"][field] = value.clone(); - } - } - if let Some(sources) = action.get("sources").filter(|value| !value.is_null()) { - output["sources"] = sources.clone(); - } - output - } - Some("open_page" | "find_in_page") => { - let kind = if action["type"] == "open_page" { - "openPage" - } else { - "findInPage" - }; - let mut output = json!({"action": {"type": kind}}); - for field in ["url", "pattern"] { - if field == "pattern" && kind == "openPage" { - continue; - } - if let Some(value) = action.get(field) { - output["action"][field] = value.clone(); - } - } - output - } - _ => json!({}), - } -} - -/// A local shell action with its fields in the AI SDK's order: the schema's -/// (`type`, `command`, `timeout_ms`, `user`, `working_directory`, `env`) for a -/// generated call, and the same order under the camel-case names the stream -/// writes (`timeoutMs`, `workingDirectory`). -fn local_shell_action(action: &Value) -> Value { - let Some(object) = action.as_object() else { - return action.clone(); - }; - let mut output = serde_json::Map::new(); - for field in [ - "type", - "command", - "timeout_ms", - "timeoutMs", - "user", - "working_directory", - "workingDirectory", - "env", - ] { - if let Some(value) = object.get(field) { - output.insert(field.to_owned(), value.clone()); - } - } - Value::Object(output) -} - -/// An apply patch operation with the fields of its type in the order of the -/// AI SDK's schema (`type`, `path`, then `diff`), as zod's parse leaves it. -pub(crate) fn apply_patch_operation(operation: &Value) -> Value { - let fields: &[&str] = match operation["type"].as_str() { - Some("create_file" | "update_file") => &["path", "diff"], - Some("delete_file") => &["path"], - _ => return operation.clone(), - }; - let mut output = json!({"type": operation["type"]}); - for field in fields { - if let Some(value) = operation.get(*field) { - output[*field] = value.clone(); - } - } - output -} - -/// Keeps the fields each computer action type has, renaming the scroll -/// offsets and leaving out a null `keys`, as the AI SDK's `mapComputerAction`. -fn computer_action(action: &Value) -> Value { - let fields: &[(&str, &str)] = match action["type"].as_str() { - Some("click") => &[ - ("button", "button"), - ("x", "x"), - ("y", "y"), - ("keys", "keys"), - ], - Some("double_click" | "move") => &[("x", "x"), ("y", "y"), ("keys", "keys")], - Some("drag") => &[("path", "path"), ("keys", "keys")], - Some("keypress") => &[("keys", "keys")], - Some("scroll") => &[ - ("x", "x"), - ("y", "y"), - ("scroll_x", "scrollX"), - ("scroll_y", "scrollY"), - ("keys", "keys"), - ], - Some("type") => &[("text", "text")], - Some("screenshot" | "wait") => &[], - _ => return action.clone(), - }; - let mut output = json!({"type": action["type"]}); - for (from, to) in fields { - if let Some(value) = action.get(*from).filter(|value| !value.is_null()) { - output[*to] = value.clone(); - } - } - output -} - -fn computer_input(part: &Value) -> Value { - let actions = part["actions"].as_array().cloned().unwrap_or_else(|| { - part.get("action") - .filter(|action| !action.is_null()) - .cloned() - .into_iter() - .collect() - }); - let actions: Vec<_> = actions.iter().map(computer_action).collect(); - let checks: Vec<_> = part["pending_safety_checks"] - .as_array() - .map(|checks| { - checks - .iter() - .map(|check| { - let mut result = json!({"id": check["id"]}); - for field in ["code", "message"] { - if let Some(value) = check.get(field).filter(|value| !value.is_null()) { - result[field] = value.clone(); - } - } - result - }) - .collect() - }) - .unwrap_or_default(); - json!({"actions": actions, "pendingSafetyChecks": checks, "status": part["status"]}) -} diff --git a/aimux-providers/src/openai/responses/types.rs b/aimux-providers/src/openai/responses/responses_api.rs similarity index 96% rename from aimux-providers/src/openai/responses/types.rs rename to aimux-providers/src/openai/responses/responses_api.rs index 3e2ffc012..c268559c8 100644 --- a/aimux-providers/src/openai/responses/types.rs +++ b/aimux-providers/src/openai/responses/responses_api.rs @@ -1,4 +1,4 @@ -//! OpenAI Responses API response/streaming types. +//! OpenAI Responses API response/streaming types (openai-responses-api.ts). //! //! The Responses API (`/v1/responses`) uses a completely different wire format //! from chat completions: requests carry an `input` array (not `messages`), diff --git a/aimux-providers/src/openai/responses/responses_convert.rs b/aimux-providers/src/openai/responses/responses_language_model.rs similarity index 62% rename from aimux-providers/src/openai/responses/responses_convert.rs rename to aimux-providers/src/openai/responses/responses_language_model.rs index 4461f46ef..7a3628703 100644 --- a/aimux-providers/src/openai/responses/responses_convert.rs +++ b/aimux-providers/src/openai/responses/responses_language_model.rs @@ -1,39 +1,1097 @@ -//! Shared Responses API framework (RFC-0012 §3.5). +//! The Responses language model (openai-responses-language-model.ts). +//! +//! Implements the [`LanguageModel`] trait against the `/v1/responses` +//! endpoint. The Responses API uses a different request/response format from +//! chat completions: requests carry an `input` array (not `messages`), and +//! streaming events are typed as `response.output_text.delta`, +//! `response.function_call_arguments.delta`, etc. +//! +//! Mirrors the TS `OpenAIResponsesLanguageModel`. The core paths implemented +//! here are: +//! - **Request building**: input array, instructions, store, +//! previous_response_id, reasoning (effort/summary), response_format +//! (json_schema/json_object) — see [`build_responses_request_body`]. +//! - **Non-streaming**: text, function-call, and reasoning output items -- see +//! [`OpenAIResponsesModel::do_generate`]. +//! - **Streaming**: the `response.created -> output_item.added -> +//! output_text.delta -> output_text.done -> output_item.done -> +//! response.completed` main path, plus `function_call_arguments.delta/done` +//! and `reasoning_summary_text.delta` -- see [`OpenAIResponsesModel::do_stream`]. //! //! Vendors whose Responses implementations speak the OpenAI wire format -//! (currently OpenAI and Azure OpenAI) share this module for the parts that are -//! byte-identical across them: -//! - non-streaming output parsing — [`build_responses_generate_result`], -//! - the streaming SSE event reducer — [`build_responses_event_stream`], -//! - common HTTP header list construction — [`build_header_list`]. +//! (currently OpenAI and Azure OpenAI) share the parts that are byte-identical +//! across them (RFC-0012 §3.5): the non-streaming output parsing in +//! [`build_responses_generate_result`] and the streaming SSE event reducer. //! //! Vendors with genuinely different protocols (xAI, HuggingFace, the generic //! `open_responses` provider) keep their own request/streaming logic and reuse //! only the small shared helpers where they are byte-identical. Per the RFC, //! genuinely different streaming loops are **not** force-merged into one -//! function — only the shared framework is extracted. +//! function — only the shared framework is extracted. The provider tool output +//! items map to content parts in `provider_tool_content`. -use aimux_core::tool::RawToolCall; use std::collections::{HashMap, HashSet, VecDeque}; use std::pin::Pin; +use aimux_core::AiMuxError; +use aimux_core::error::ApiCallError; +use aimux_core::language_model::LanguageModel; +use aimux_core::options::{CallOptions, ResponseFormat}; +use aimux_core::result::{ + GenerateContent, GenerateResult, RawToolApprovalRequest, ReasoningOutput, Source, StreamResult, +}; +use aimux_core::shared::{SharedProviderOptions, provider_namespace}; +use aimux_core::stream_part::StreamPart; +use aimux_core::tool::{RawToolCall, Tool, ToolResult}; +use aimux_core::types::{ + FinishReason, FinishReasonUnified, NamespacedKind, ProviderMetadata, ReasoningEffort, + ResponseMetadata, Warning, +}; use aimux_provider_utils::{Transformer, pipe_through}; - -use crate::stream::{Controller, Emit, FINISH_ERROR, FINISH_OTHER, StreamContext}; +use async_trait::async_trait; use futures::{Stream, StreamExt}; use serde_json::{Value, json}; -use aimux_core::error::AiMuxError; -use aimux_core::error::ApiCallError; -use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source}; -use aimux_core::shared::provider_namespace; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::{ - FinishReason, FinishReasonUnified, ProviderMetadata, ResponseMetadata, Warning, +use crate::openai::config::OpenAIModelConfig; +use crate::openai::language_model_capabilities::{ + ModelCapabilities, SystemMessageMode, get_model_capabilities, +}; +use crate::openai::responses::convert_responses_usage::{convert_responses_usage, parse_usage}; +use crate::openai::responses::convert_to_responses_input::{ + apply_file_id_prefixes, convert_responses_input_with_conversation, +}; +use crate::openai::responses::map_responses_finish_reason::map_responses_finish_reason; +use crate::openai::responses::responses_api::ResponsesUsage; +use crate::openai::responses::responses_language_model_options::{ + ResponsesNamespace, openai_option, }; +use crate::openai::responses::responses_prepare_tools::prepare_responses_tools_for; +use crate::stream::{Controller, Emit, FINISH_ERROR, FINISH_OTHER, StreamContext}; + +/// An OpenAI Responses API language model. +/// +/// Does **not** hold an HTTP client — the `aimux-provider-utils` API helpers use +/// the process-wide shared `Client` internally (RFC-0009 §4.1). +/// +/// Created with +/// [`OpenAIProvider::responses`](crate::openai::OpenAIProvider::responses). +pub struct OpenAIResponsesModel { + model_id: String, + config: OpenAIModelConfig, +} + +impl OpenAIResponsesModel { + pub(crate) fn from_config(model_id: String, config: OpenAIModelConfig) -> Self { + Self { model_id, config } + } + + /// The request headers for one call: provider headers resolved now, with + /// the per-call headers layered over them. + pub(crate) async fn request_headers( + &self, + call_headers: Option<&HashMap>, + ) -> Result, AiMuxError> { + self.config.request_headers(call_headers).await + } + + fn endpoint(&self) -> Result { + self.config.url("/responses") + } + + /// The request body for one call: built with the host's providerOptions + /// namespace, file-id prefixes applied, then the provider-level rewrite. + fn request_body( + &self, + options: &CallOptions, + stream: bool, + ) -> Result { + let profile = &self.config.responses; + let mut unused_warnings = Vec::new(); + if let Some(aimux_core::options::ResponseFormat::Json { + schema: Some(schema), + .. + }) = &options.response_format + { + crate::openai::normalize_json_schema::normalize_json_schema( + schema, + &mut unused_warnings, + )?; + } + for tool in options.tools.iter().flatten() { + if let aimux_core::tool::Tool::Function(tool) = tool { + crate::openai::normalize_json_schema::normalize_json_schema( + &tool.input_schema, + &mut unused_warnings, + )?; + } + } + let pass_through = options + .provider_options + .as_ref() + .and_then(|v| profile.namespace.find_in(v)) + .and_then(|v| v.get("passThroughUnsupportedFiles")) + .and_then(Value::as_bool) + == Some(true); + let caps = + crate::openai::language_model_capabilities::get_model_capabilities(&self.model_id); + let provider_options = options + .provider_options + .as_ref() + .and_then(|v| profile.namespace.find_in(v)); + if provider_options + .and_then(|v| v.get("reasoningEffortUpdate")) + .is_some_and(|v| { + !matches!( + v.as_str(), + Some("none" | "low" | "medium" | "high" | "xhigh" | "max") + ) + }) + { + return Err(AiMuxError::invalid_argument("reasoningEffortUpdate")); + } + let update_incompatible = provider_options.is_some_and(|v| { + v.get("reasoningMode") == Some(&serde_json::json!("pro")) + || v.get("contextManagement").is_some() + || v.get("truncation") == Some(&serde_json::json!("auto")) + }); + for message in &options.prompt { + use aimux_core::language_model_message::{ + AssistantPart, LanguageModelMessage, UserPart, + }; + use aimux_core::shared::FileData; + if let LanguageModelMessage::System { + content, + provider_options, + } = message + && let Some(effort) = provider_options + .as_ref() + .and_then(|v| profile.namespace.find_in(v)) + .and_then(|v| v.get("reasoningEffortUpdate")) + .and_then(Value::as_str) + { + if !matches!(effort, "none" | "low" | "medium" | "high" | "xhigh" | "max") { + return Err(AiMuxError::invalid_argument("reasoningEffortUpdate")); + } + if !content.is_empty() + || !caps.supports_configuration_update + || update_incompatible + || caps + .supported_reasoning_efforts + .is_some_and(|values| !values.contains(&effort)) + { + return Err(AiMuxError::UnsupportedFunctionality( + "Message-level reasoningEffortUpdate".into(), + )); + } + } + let (files, user) = match message { + LanguageModelMessage::User { content, .. } => ( + content + .iter() + .filter_map(|part| match part { + UserPart::File(file) => Some(file), + UserPart::Text(_) => None, + }) + .collect::>(), + true, + ), + LanguageModelMessage::Assistant { content, .. } => ( + content + .iter() + .filter_map(|part| match part { + AssistantPart::File(file) => Some(file), + _ => None, + }) + .collect::>(), + false, + ), + _ => (Vec::new(), false), + }; + for file in files { + match &file.data { + FileData::Text { .. } if user => { + return Err(AiMuxError::UnsupportedFunctionality( + "text file parts".into(), + )); + } + FileData::Reference { reference } => { + crate::openai::chat::convert_to_chat_messages::resolve_provider_reference( + reference, + profile.namespace.write_key(), + ) + .map_err(AiMuxError::invalid_argument)?; + } + FileData::Data { .. } if user => { + aimux_provider_utils::resolve_full_media_type(file)?; + let media_type = &file.media_type; + if !pass_through + && !media_type.starts_with("image/") + && media_type != "image" + && media_type != "application/pdf" + { + return Err(AiMuxError::UnsupportedFunctionality(format!( + "file part media type {media_type}" + ))); + } + } + _ => {} + } + } + } + let mut result = + build_responses_request_body_for(profile.namespace, &self.model_id, options, stream)?; + apply_file_id_prefixes(&mut result.body, &profile.file_id_prefixes); + if profile.explicit_message_item_type + && let Some(input) = result.body["input"].as_array_mut() + { + for item in input { + if item.get("role").is_some() { + item["type"] = serde_json::json!("message"); + } + } + } + if result.body["input"].as_array().is_some_and(|input| { + input.windows(2).any(|pair| { + pair[0]["type"] == "configuration_update" + && pair[1]["type"] == "configuration_update" + }) + }) { + return Err(AiMuxError::UnsupportedFunctionality( + "Adjacent reasoning effort configuration updates".into(), + )); + } + result.body = self.config.transform_body(result.body); + Ok(result) + } + + /// The provider-metadata key of the host. + fn provider_options_name(&self) -> &str { + self.config.responses.namespace.write_key() + } +} + +#[async_trait] +impl LanguageModel for OpenAIResponsesModel { + fn provider(&self) -> &str { + &self.config.provider + } + + async fn supported_urls(&self) -> aimux_core::language_model::SupportedUrls { + self.config.supported_urls.clone() + } + + fn model_id(&self) -> &str { + &self.model_id + } + + async fn do_generate(&self, options: &CallOptions) -> Result { + let headers = self.request_headers(options.headers.as_ref()).await?; + let request_result = self.request_body(options, false)?; + let body = request_result.body; + let provider_key = self.provider_options_name().to_string(); + + let endpoint = self.endpoint()?; + let resp = aimux_provider_utils::post_json_to_api( + self.config.http_request(endpoint.clone(), headers, options), + body.clone(), + aimux_provider_utils::create_json_response_handler::(), + crate::openai::error::openai_failed_response_handler(), + ) + .await?; + + let response_headers = resp.response_headers; + let raw_body = resp + .raw_value + .as_ref() + .map(ToString::to_string) + .unwrap_or_default(); + let data = resp.value; + + build_responses_generate_result_with_tools( + &data, + &raw_body, + request_result.warnings, + provider_key, + endpoint, + body, + response_headers, + tool_name_mapping(options), + prompt_approval_tool_call_ids(options), + ) + } + + async fn do_stream(&self, options: &CallOptions) -> Result { + let headers = self.request_headers(options.headers.as_ref()).await?; + let request_result = self.request_body(options, false)?; + let body = request_result.body; + let warnings = request_result.warnings; + let provider_key = self.provider_options_name().to_string(); + + // The `store` request option (None by default). Used to decide when + // reasoning summary parts are concluded. + let store_flag = options + .provider_options + .as_ref() + .and_then(|m| self.config.responses.namespace.find_in(m)) + .and_then(|o| o.get("store")) + .and_then(serde_json::Value::as_bool) + == Some(true); + + let endpoint = self.endpoint()?; + let mut stream_body = body.clone(); + stream_body["stream"] = Value::Bool(true); + let resp = aimux_provider_utils::post_json_to_api( + self.config.http_request(endpoint.clone(), headers, options), + stream_body, + aimux_provider_utils::create_event_source_response_handler::(), + crate::openai::error::openai_failed_response_handler(), + ) + .await?; + + let response_headers = resp.response_headers; + + let mut sse_stream = resp.value; + let events_before_output = + read_until_output(&mut sse_stream, &endpoint, &body, &response_headers).await?; + let stream = build_responses_event_stream_with_tools( + None, + futures::stream::iter(events_before_output).chain(sse_stream), + provider_key, + warnings, + store_flag, + endpoint, + body.clone(), + response_headers.clone(), + options.include_raw_chunks == Some(true), + tool_name_mapping(options), + prompt_approval_tool_call_ids(options), + )?; + + Ok(StreamResult { + stream, + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::shared::StreamResponseInfo { + headers: Some(response_headers), + }), + }) + } +} + +/// The provider tools whose names the AI SDK maps back to the caller's tool +/// names (`providerToolNames` in openai-responses-language-model.ts). +const PROVIDER_TOOL_NAMES: &[&str] = &[ + "code_interpreter", + "computer", + "file_search", + "image_generation", + "local_shell", + "shell", + "web_search", + "web_search_preview", + "mcp", + "apply_patch", + "tool_search", + "programmatic_tool_calling", +]; + +fn tool_name_mapping(options: &CallOptions) -> HashMap { + let mut names = HashMap::new(); + for tool in options.tools.iter().flatten() { + if let aimux_core::tool::Tool::Provider(tool) = tool + && let Some(name) = tool.id.as_str().strip_prefix("openai.") + && PROVIDER_TOOL_NAMES.contains(&name) + { + names.insert(name.to_owned(), tool.name.clone()); + } + } + if let Some(name) = options.tools.iter().flatten().find_map(|tool| match tool { + aimux_core::tool::Tool::Provider(tool) + if matches!( + tool.id.as_str(), + "openai.web_search" | "openai.web_search_preview" + ) => + { + Some(tool.name.clone()) + } + _ => None, + }) { + names.insert("web_search".into(), name); + } + names +} + +fn prompt_approval_tool_call_ids(options: &CallOptions) -> HashMap { + use aimux_core::language_model_message::{AssistantPart, LanguageModelMessage}; + + let mut mapping = HashMap::new(); + for message in &options.prompt { + if let LanguageModelMessage::Assistant { content, .. } = message { + for part in content { + if let AssistantPart::ToolCall(call) = part + && let Some(id) = call + .provider_options + .as_ref() + .and_then(|options| options.get("openai")) + .and_then(|options| options.get("approvalRequestId")) + .and_then(Value::as_str) + { + mapping.insert(id.to_owned(), call.tool_call_id.clone()); + } + } + } + } + mapping +} + +// -- Request body ------------------------------------------------------------ + +/// Result of building a Responses request body. +pub struct ResponsesRequestBodyResult { + pub body: Value, + pub warnings: Vec, +} + +/// Compatibility warnings for call options the Responses API does not carry. +fn push_unsupported_call_option_warnings(options: &CallOptions, warnings: &mut Vec) { + if options.top_k.is_some() { + warnings.push(Warning::Unsupported { + feature: "topK".to_string(), + details: None, + }); + } + if options.seed.is_some() { + warnings.push(Warning::Unsupported { + feature: "seed".to_string(), + details: None, + }); + } + if options.presence_penalty.is_some() { + warnings.push(Warning::Unsupported { + feature: "presencePenalty".to_string(), + details: None, + }); + } + if options.frequency_penalty.is_some() { + warnings.push(Warning::Unsupported { + feature: "frequencyPenalty".to_string(), + details: None, + }); + } + if options.stop_sequences.is_some() { + warnings.push(Warning::Unsupported { + feature: "stopSequences".to_string(), + details: None, + }); + } +} + +/// Resolve the Responses reasoning config: `reasoningEffort` (provider option +/// wins over top-level `reasoning`), `reasoningSummary` (defaults to "detailed" +/// when an effort other than "none" applies), and whether the model reasons. +fn resolve_responses_reasoning( + ns: ResponsesNamespace, + provider_opts: &Option, + options: &CallOptions, + caps: &ModelCapabilities, +) -> (Option, Option, bool) { + let resolved_reasoning_effort: Option = + openai_option(ns, provider_opts, "reasoningEffort") + .map(|v| { + v.as_str() + .map(std::string::ToString::to_string) + .unwrap_or_else(|| v.to_string()) + }) + .or_else(|| { + if options.reasoning.is_some_and(ReasoningEffort::is_custom) { + options.reasoning.map(|r| r.to_string()) + } else { + None + } + }); + + let resolved_reasoning_summary = match openai_option(ns, provider_opts, "reasoningSummary") { + Some(value) => value.as_str().map(str::to_owned), + None => resolved_reasoning_effort + .as_deref() + .filter(|effort| *effort != "none") + .map(|_| "detailed".to_string()), + }; + + let is_reasoning_model = openai_option(ns, provider_opts, "forceReasoning") + .map(|v| v.as_bool().unwrap_or(false)) + .unwrap_or(caps.is_reasoning_model); + + ( + resolved_reasoning_effort, + resolved_reasoning_summary, + is_reasoning_model, + ) +} + +/// Warn when `conversation` and `previousResponseId` are both set. +fn warn_conversation_conflict( + ns: ResponsesNamespace, + provider_opts: &Option, + warnings: &mut Vec, +) { + let has_conversation = openai_option(ns, provider_opts, "conversation").is_some(); + let has_previous_response_id = openai_option(ns, provider_opts, "previousResponseId").is_some(); + if has_conversation && has_previous_response_id { + warnings.push(Warning::Unsupported { + feature: "conversation".to_string(), + details: Some( + "conversation and previousResponseId cannot be used together".to_string(), + ), + }); + } +} + +fn resolve_responses_system_message_mode( + ns: ResponsesNamespace, + provider_opts: &Option, + is_reasoning_model: bool, + caps: &ModelCapabilities, +) -> SystemMessageMode { + openai_option(ns, provider_opts, "systemMessageMode") + .and_then(|v| v.as_str().map(std::string::ToString::to_string)) + .map(|s| match s.as_str() { + "developer" => SystemMessageMode::Developer, + "remove" => SystemMessageMode::Remove, + _ => SystemMessageMode::System, + }) + .unwrap_or(if is_reasoning_model { + SystemMessageMode::Developer + } else { + caps.system_message_mode + }) +} + +/// temperature / top_p, subject to reasoning-model restrictions. +fn apply_responses_sampling( + ns: ResponsesNamespace, + body: &mut Value, + options: &CallOptions, + caps: &ModelCapabilities, + is_reasoning_model: bool, + resolved_reasoning_effort: &Option, + warnings: &mut Vec, +) { + let provider_opts = &options.provider_options; + let mut temperature = options.temperature; + let mut top_p = options.top_p; + + if is_reasoning_model { + let allow_non_reasoning = resolved_reasoning_effort.as_deref() == Some("none") + && caps.supports_non_reasoning_parameters; + if !allow_non_reasoning { + if temperature.is_some() { + temperature = None; + warnings.push(Warning::Unsupported { + feature: "temperature".to_string(), + details: Some("temperature is not supported for reasoning models".to_string()), + }); + } + if top_p.is_some() { + top_p = None; + warnings.push(Warning::Unsupported { + feature: "topP".to_string(), + details: Some("topP is not supported for reasoning models".to_string()), + }); + } + } + } else { + for key in [ + "reasoningEffort", + "reasoningSummary", + "reasoningMode", + "reasoningContext", + ] { + if openai_option(ns, provider_opts, key).is_some() { + warnings.push(Warning::Unsupported { + feature: key.to_string(), + details: Some(format!("{key} is not supported for non-reasoning models")), + }); + } + } + } + + if let Some(t) = temperature { + body["temperature"] = json!(t); + } + if let Some(p) = top_p { + body["top_p"] = json!(p); + } +} + +/// `text.format` (json_schema / json_object) plus `verbosity`. +fn apply_responses_text_format( + ns: ResponsesNamespace, + body: &mut Value, + options: &CallOptions, + provider_opts: &Option, + warnings: &mut Vec, +) { + if let Some(ref rf) = options.response_format { + match rf { + ResponseFormat::Text => {} + ResponseFormat::Json { + schema, + name, + description, + } => { + let mut text = json!({}); + match schema { + Some(schema) => { + let strict_json = openai_option(ns, provider_opts, "strictJsonSchema") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + text["format"] = json!({ + "type": "json_schema", + "strict": strict_json, + "name": name.clone().unwrap_or_else(|| "response".to_string()), + "schema": crate::openai::normalize_json_schema::normalize_json_schema(schema, warnings).unwrap_or_else(|_| schema.clone()), + }); + if let Some(description) = description { + text["format"]["description"] = json!(description); + } + } + None => { + text["format"] = json!({ "type": "json_object" }); + } + } + body["text"] = text; + } + } + } + + if let Some(verbosity) = openai_option(ns, provider_opts, "textVerbosity") { + let text = body.get_mut("text").and_then(|t| t.as_object_mut()); + match text { + Some(obj) => { + obj.insert("verbosity".to_string(), verbosity); + } + None => { + body["text"] = json!({ "verbosity": verbosity }); + } + } + } +} + +/// The computed `include` list (store=false on reasoning models adds +/// `reasoning.encrypted_content`). +fn resolve_responses_include( + ns: ResponsesNamespace, + provider_opts: &Option, + is_reasoning_model: bool, + tools: Option<&[Tool]>, +) -> Option> { + let mut include: Option> = + openai_option(ns, provider_opts, "include").and_then(|v| v.as_array().cloned()); + + let add_include = |key: &str, inc: &mut Option>| { + let already = inc + .as_ref() + .is_some_and(|arr| arr.iter().any(|v| v.as_str() == Some(key))); + if !already { + match inc { + Some(arr) => arr.push(json!(key)), + None => *inc = Some(vec![json!(key)]), + } + } + }; + + // store defaults to true; only the explicit `false` triggers encrypted_content. + let store_explicit = openai_option(ns, provider_opts, "store").and_then(|v| v.as_bool()); + if store_explicit == Some(false) && is_reasoning_model { + add_include("reasoning.encrypted_content", &mut include); + } + + let top_logprobs = openai_option(ns, provider_opts, "logprobs") + .and_then(|v| if v == true { Some(20) } else { v.as_u64() }); + if top_logprobs.is_some_and(|value| value > 0) { + add_include("message.output_text.logprobs", &mut include); + } + for tool in tools.into_iter().flatten() { + if let Tool::Provider(tool) = tool { + match tool.id.as_str() { + "openai.web_search" | "openai.web_search_preview" + if openai_option(ns, provider_opts, "includeWebSearchSources") + != Some(json!(false)) => + { + add_include("web_search_call.action.sources", &mut include) + } + "openai.code_interpreter" => { + add_include("code_interpreter_call.outputs", &mut include) + } + _ => {} + } + } + } + include +} + +/// Pass-through of the remaining Responses provider options (only sent when +/// set). +fn apply_responses_provider_options( + ns: ResponsesNamespace, + body: &mut Value, + provider_opts: &Option, +) { + let mut set = |key: &str, body_key: &str| { + if let Some(v) = openai_option(ns, provider_opts, key) { + body[body_key] = v; + } + }; + set("conversation", "conversation"); + set("maxToolCalls", "max_tool_calls"); + set("metadata", "metadata"); + set("parallelToolCalls", "parallel_tool_calls"); + set("previousResponseId", "previous_response_id"); + set("user", "user"); + set("instructions", "instructions"); + set("promptCacheKey", "prompt_cache_key"); + set("promptCacheRetention", "prompt_cache_retention"); + set("safetyIdentifier", "safety_identifier"); + set("truncation", "truncation"); + if let Some(Value::Object(options)) = openai_option(ns, provider_opts, "promptCacheOptions") { + body["prompt_cache_options"] = Value::Object( + options + .into_iter() + .filter(|(key, _)| matches!(key.as_str(), "mode" | "ttl")) + .collect(), + ); + } + if let Some(value) = openai_option(ns, provider_opts, "logprobs").and_then(|v| { + if v == true { + Some(json!(20)) + } else { + v.as_u64().map(|n| json!(n)) + } + }) { + body["top_logprobs"] = value; + } + if let Some(values) = + openai_option(ns, provider_opts, "contextManagement").and_then(|v| v.as_array().cloned()) + { + body["context_management"] = json!( + values + .into_iter() + .map(|v| { + let mut entry = json!({ "type": v["type"] }); + if let Some(threshold) = v.get("compactThreshold") { + entry["compact_threshold"] = threshold.clone(); + } + entry + }) + .collect::>() + ); + } +} + +/// `service_tier` with model-capability validation. +fn apply_responses_service_tier( + ns: ResponsesNamespace, + body: &mut Value, + provider_opts: &Option, + caps: &ModelCapabilities, + warnings: &mut Vec, +) { + if let Some(st) = openai_option(ns, provider_opts, "serviceTier") + .and_then(|v| v.as_str().map(std::string::ToString::to_string)) + { + match st.as_str() { + "flex" if !caps.supports_flex_processing => { + warnings.push(Warning::Unsupported { + feature: "serviceTier".to_string(), + details: Some( + "flex processing is only available for o3, o4-mini, and gpt-5 models" + .to_string(), + ), + }); + } + "priority" | "fast" if !caps.supports_priority_processing => { + warnings.push(Warning::Unsupported { + feature: "serviceTier".to_string(), + details: Some("priority processing is only available for supported models (gpt-4, gpt-5, gpt-5-mini, o3, o4-mini) and requires Enterprise access. gpt-5-nano is not supported".to_string()), + }); + } + _ => { + body["service_tier"] = json!(st); + } + } + } +} -use super::convert::{convert_responses_usage, map_responses_finish_reason, parse_usage}; -use super::types::ResponsesUsage; +/// `reasoning` block for reasoning models. +fn apply_responses_reasoning_block( + ns: ResponsesNamespace, + body: &mut Value, + provider_opts: &Option, + is_reasoning_model: bool, + resolved_reasoning_effort: &Option, + resolved_reasoning_summary: &Option, +) { + if !is_reasoning_model { + return; + } + let effort = resolved_reasoning_effort.as_ref(); + let summary = resolved_reasoning_summary.as_ref(); + let mode = openai_option(ns, provider_opts, "reasoningMode") + .and_then(|v| v.as_str().map(std::string::ToString::to_string)); + let context = openai_option(ns, provider_opts, "reasoningContext") + .and_then(|v| v.as_str().map(std::string::ToString::to_string)); + + if effort.is_some() || summary.is_some() || mode.is_some() || context.is_some() { + let mut reasoning = json!({}); + if let Some(e) = effort { + reasoning["effort"] = json!(e); + } + if let Some(s) = summary { + reasoning["summary"] = json!(s); + } + if let Some(m) = mode { + reasoning["mode"] = json!(m); + } + if let Some(c) = context { + reasoning["context"] = json!(c); + } + body["reasoning"] = reasoning; + } +} + +/// Build the OpenAI Responses API request body and warnings. +/// +/// # Errors +/// Rejects unsupported prompt parts and invalid tool arguments. +pub fn build_responses_request_body( + model_id: &str, + options: &CallOptions, + stream: bool, +) -> Result { + build_responses_request_body_for(ResponsesNamespace::OPENAI, model_id, options, stream) +} + +/// [`build_responses_request_body`] for a host with its own providerOptions +/// namespace. +/// +/// # Errors +/// Rejects unsupported prompt parts and invalid tool arguments. +pub fn build_responses_request_body_for( + ns: ResponsesNamespace, + model_id: &str, + options: &CallOptions, + stream: bool, +) -> Result { + let mut warnings: Vec = Vec::new(); + let caps = get_model_capabilities(model_id); + let provider_opts = &options.provider_options; + + // -- Warnings for unsupported call options -- + push_unsupported_call_option_warnings(options, &mut warnings); + + // -- Reasoning resolution -- + let (mut resolved_reasoning_effort, mut resolved_reasoning_summary, is_reasoning_model) = + resolve_responses_reasoning(ns, provider_opts, options, &caps); + if let Some(efforts) = caps.supported_reasoning_efforts + && resolved_reasoning_effort + .as_deref() + .is_some_and(|effort| !efforts.contains(&effort)) + { + warnings.push(Warning::Unsupported { + feature: "reasoningEffort".to_string(), + details: Some(format!( + "{model_id} only supports the following reasoning efforts: {}", + efforts.join(", ") + )), + }); + resolved_reasoning_effort = None; + resolved_reasoning_summary = openai_option(ns, provider_opts, "reasoningSummary") + .and_then(|v| v.as_str().map(str::to_owned)); + } + + // -- conversation + previousResponseId conflict -- + warn_conversation_conflict(ns, provider_opts, &mut warnings); + + // -- System message mode -- + let system_message_mode = + resolve_responses_system_message_mode(ns, provider_opts, is_reasoning_model, &caps); + + // -- Input conversion -- + let store_bool = openai_option(ns, provider_opts, "store") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let has_previous_response_id = openai_option(ns, provider_opts, "previousResponseId").is_some(); + let mut input_result = convert_responses_input_with_conversation( + ns, + &options.prompt, + system_message_mode, + store_bool, + has_previous_response_id, + openai_option(ns, provider_opts, "conversation").is_some(), + options.tools.as_deref(), + )?; + if let Some(effort) = openai_option(ns, provider_opts, "reasoningEffortUpdate") + .and_then(|v| v.as_str().map(str::to_owned)) + { + let reason = if !caps.supports_configuration_update { + Some("reasoningEffortUpdate is only supported by GPT-6 and later models".to_string()) + } else if openai_option(ns, provider_opts, "reasoningMode") == Some(json!("pro")) + || openai_option(ns, provider_opts, "contextManagement").is_some() + || openai_option(ns, provider_opts, "truncation") == Some(json!("auto")) + { + Some("reasoningEffortUpdate requires standard reasoning mode without automatic compaction or automatic truncation".to_string()) + } else { + caps.supported_reasoning_efforts + .filter(|values| !values.contains(&effort.as_str())) + .map(|values| { + format!( + "{model_id} only supports the following reasoning efforts: {}", + values.join(", ") + ) + }) + }; + if let Some(details) = reason { + warnings.push(Warning::Unsupported { + feature: "reasoningEffortUpdate".to_string(), + details: Some(details), + }); + } else if input_result.input.first().is_none_or(|item| { + item["type"] != "configuration_update" || item["reasoning"]["effort"] != effort + }) { + input_result.input.insert( + 0, + json!({ "type": "configuration_update", "reasoning": { "effort": effort } }), + ); + } + } + if openai_option(ns, provider_opts, "compactionTrigger") == Some(json!(true)) { + input_result + .input + .push(json!({ "type": "compaction_trigger" })); + } + warnings.extend(input_result.warnings); + + // -- Base body -- + let mut body = json!({ + "model": model_id, + "input": input_result.input, + }); + + if stream { + body["stream"] = json!(true); + } + + if let Some(max_tokens) = options.max_output_tokens { + body["max_output_tokens"] = json!(max_tokens); + } + + // temperature / top_p (subject to reasoning-model restrictions) + apply_responses_sampling( + ns, + &mut body, + options, + &caps, + is_reasoning_model, + &resolved_reasoning_effort, + &mut warnings, + ); + + // -- Response format (text.format) + verbosity -- + apply_responses_text_format(ns, &mut body, options, provider_opts, &mut warnings); + + // -- include (computed) -- + let include = resolve_responses_include( + ns, + provider_opts, + is_reasoning_model, + options.tools.as_deref(), + ); + if let Some(inc) = include { + body["include"] = json!(inc); + } + + // -- store (only sent when explicitly set) -- + if let Some(s) = openai_option(ns, provider_opts, "store").and_then(|v| v.as_bool()) { + body["store"] = json!(s); + } + + // -- Other provider options (only sent when set) -- + apply_responses_provider_options(ns, &mut body, provider_opts); + if caps.supports_configuration_update && body.get("prompt_cache_retention").is_some() { + body.as_object_mut() + .expect("request body is an object") + .remove("prompt_cache_retention"); + warnings.push(Warning::Unsupported { + feature: "promptCacheRetention".to_string(), + details: Some("promptCacheRetention is not supported by GPT-6 and later models; use promptCacheOptions instead".to_string()), + }); + } + + if is_reasoning_model + && caps.supported_reasoning_efforts.is_some() + && !(resolved_reasoning_effort.as_deref() == Some("none") + && caps.supports_non_reasoning_parameters) + { + let mut removed = body + .as_object_mut() + .expect("request body is an object") + .remove("top_logprobs") + .is_some(); + if let Some(include) = body.get_mut("include").and_then(Value::as_array_mut) { + let len = include.len(); + include.retain(|value| value.as_str() != Some("message.output_text.logprobs")); + removed |= include.len() != len; + } + if body + .get("include") + .and_then(Value::as_array) + .is_some_and(Vec::is_empty) + { + body.as_object_mut() + .expect("request body is an object") + .remove("include"); + } + if removed { + warnings.push(Warning::Unsupported { + feature: "logprobs".to_string(), + details: Some("logprobs is not supported for reasoning models".to_string()), + }); + } + } + + // -- service_tier (with capability validation) -- + apply_responses_service_tier(ns, &mut body, provider_opts, &caps, &mut warnings); + + // -- reasoning block (reasoning models only) -- + apply_responses_reasoning_block( + ns, + &mut body, + provider_opts, + is_reasoning_model, + &resolved_reasoning_effort, + &resolved_reasoning_summary, + ); + + // -- Tools -- + let allowed_tools = openai_option(ns, provider_opts, "allowedTools"); + let prepared = prepare_responses_tools_for( + &options.tools, + options.tool_choice.as_ref(), + allowed_tools.as_ref(), + caps.supports_configuration_update, + )?; + if let Some(tools) = prepared.tools { + body["tools"] = json!(tools); + if let Some(tc) = prepared.tool_choice { + body["tool_choice"] = tc; + } + } + for tw in prepared.tool_warnings { + warnings.push(tw); + } + + Ok(ResponsesRequestBodyResult { body, warnings }) +} /// Pinned, boxed stream of model stream parts. /// @@ -42,21 +1100,6 @@ use super::types::ResponsesUsage; /// does not leak a complex type into call sites. pub type ResponsesEventStream = Pin> + Send>>; -/// Build the `Vec<(String, String)>` header list for an `HttpRequest`, appending -/// `Content-Type: application/json`. -/// -/// Byte-identical copies previously lived in the OpenAI, HuggingFace and xAI -/// responses modules; they now route through this single implementation. -#[must_use] -pub fn build_header_list(headers: &HashMap) -> Vec<(String, String)> { - let mut list: Vec<(String, String)> = headers - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - list.push(("Content-Type".to_string(), "application/json".to_string())); - list -} - // -- Non-streaming output parsing -------------------------------------------- /// Parse a non-streaming Responses API JSON body into a [`GenerateResult`]. @@ -317,7 +1360,7 @@ pub(crate) fn build_responses_generate_result_with_tools( _ => { let mut part = part.clone(); pair_tool_search_id(&mut part, &mut hosted_tool_search_ids); - let parts = super::provider_events::provider_tool_content( + let parts = provider_tool_content( &part, &provider_key, &tool_names, @@ -514,7 +1557,7 @@ where } let kind = event["type"].as_str().unwrap_or_default(); if kind == "error" || (kind == "response.failed" && !event["response"]["error"].is_null()) { - return Err(super::super::openai_stream_error( + return Err(crate::openai::stream_error::openai_stream_error( &event, request_url, request_body.clone(), @@ -825,8 +1868,9 @@ fn generate_source_id() -> String { // This reducer is shared by OpenAI, Azure, and Codex. Keeping the wire inputs // explicit is clearer than introducing a second context object used nowhere // else. +#[cfg(test)] #[allow(clippy::too_many_arguments)] -pub fn build_responses_event_stream( +pub(crate) fn build_responses_event_stream( first_event: Option>, sse_stream: S, provider_key: String, @@ -903,7 +1947,7 @@ where if let Some(Ok(ref val)) = first_event { let etype = val.get("type").and_then(|v| v.as_str()).unwrap_or(""); if etype == "error" || etype == "response.failed" { - return Err(super::super::openai_stream_error( + return Err(crate::openai::stream_error::openai_stream_error( val, &request_url, request_body.clone(), @@ -1258,7 +2302,7 @@ impl Transformer for OpenAIResponsesStream { if code { let mut item = item.clone(); item["code"] = parsed["code"].clone(); - for part in super::provider_events::provider_tool_content( + for part in provider_tool_content( &item, provider_key, tool_names, @@ -1614,7 +2658,7 @@ impl Transformer for OpenAIResponsesStream { .or_else(|| prompt_approval_tool_call_ids.get(id)) }) .cloned(); - for part in super::provider_events::provider_tool_content( + for part in provider_tool_content( &item, provider_key, tool_names, @@ -1800,7 +2844,7 @@ impl Transformer for OpenAIResponsesStream { if !*stream_errored && resp_obj.get("error").is_some() { *stream_errored = true; controller.enqueue(StreamPart::Error { - error: super::super::openai_stream_error( + error: crate::openai::stream_error::openai_stream_error( &parsed, url, request_body.clone(), @@ -1819,7 +2863,7 @@ impl Transformer for OpenAIResponsesStream { raw: Some("error".to_string()), }); controller.enqueue(StreamPart::Error { - error: super::super::openai_stream_error( + error: crate::openai::stream_error::openai_stream_error( &parsed, url, request_body.clone(), @@ -1963,7 +3007,7 @@ fn provider_tool_start(item: &Value, names: &HashMap) -> Vec { - let operation = &super::provider_events::apply_patch_operation(&item["operation"]); + let operation = &apply_patch_operation(&item["operation"]); if operation["type"] == "delete_file" { parts.push(StreamPart::ToolInputDelta { id: id.clone(), @@ -2002,3 +3046,375 @@ fn hosted_shell(body: &Value) -> bool { }) }) } + +// -- Provider tool output items ---------------------------------------------- + +pub(crate) fn provider_tool_content( + part: &Value, + provider_key: &str, + tool_names: &HashMap, + shell_provider_executed: bool, + mcp_tool_call_id: Option<&str>, +) -> Vec { + let kind = part["type"].as_str().unwrap_or_default(); + let item_id = part["id"].as_str().unwrap_or_default(); + let call_id = part["call_id"].as_str().unwrap_or(item_id); + let metadata = || { + Some( + provider_namespace(provider_key, json!({"itemId": item_id})) + .expect("provider metadata must be an object"), + ) + }; + let name = |canonical: &str| { + tool_names + .get(canonical) + .cloned() + .unwrap_or_else(|| canonical.to_string()) + }; + let call = |id: &str, tool_name: String, input: String, executed, dynamic, meta| { + GenerateContent::ToolCall(RawToolCall { + tool_call_id: id.to_string(), + tool_name, + input, + provider_executed: executed, + dynamic, + provider_metadata: meta, + }) + }; + let result = |id: &str, tool_name: String, value: Value, meta| { + GenerateContent::ToolResult(ToolResult { + tool_call_id: id.to_string(), + tool_name, + result: value, + is_error: None, + preliminary: None, + dynamic: None, + provider_metadata: meta, + }) + }; + let pair = |canonical: &str, input: String, output: Value| { + vec![ + call(item_id, name(canonical), input, Some(true), None, None), + result(item_id, name(canonical), output, None), + ] + }; + match kind { + "image_generation_call" => pair( + "image_generation", + "{}".into(), + json!({"result": part["result"]}), + ), + "web_search_call" => pair( + "web_search", + "{}".into(), + web_search_output(&part["action"]), + ), + "file_search_call" => pair( + "file_search", + "{}".into(), + json!({ + "queries": part["queries"], + "results": part["results"].as_array().map(|results| results.iter().map(|value| { + json!({"attributes": value["attributes"], "fileId": value["file_id"], + "filename": value["filename"], "score": value["score"], "text": value["text"]}) + }).collect::>()), + }), + ), + "code_interpreter_call" => pair( + "code_interpreter", + json!({"code": part["code"], "containerId": part["container_id"]}).to_string(), + json!({"outputs": part["outputs"]}), + ), + "computer_call" if part["call_id"].is_null() => pair( + "computer_use", + String::new(), + json!({"type": "computer_use_tool_result", "status": part["status"]}), + ), + "computer_call" => vec![call( + call_id, + name("computer"), + computer_input(part).to_string(), + None, + None, + metadata(), + )], + "local_shell_call" => vec![call( + call_id, + name("local_shell"), + json!({"action": local_shell_action(&part["action"])}).to_string(), + None, + None, + metadata(), + )], + "shell_call" => vec![call( + call_id, + name("shell"), + json!({"action": {"commands": part["action"]["commands"]}}).to_string(), + shell_provider_executed.then_some(true), + None, + metadata(), + )], + "shell_call_output" => vec![result( + call_id, + name("shell"), + json!({"output": part["output"].as_array().map(|items| items.iter().map(|item| { + let outcome = if item["outcome"]["type"] == "exit" { + json!({"type": "exit", "exitCode": item["outcome"]["exit_code"]}) + } else { + json!({"type": "timeout"}) + }; + json!({"stdout": item["stdout"], "stderr": item["stderr"], "outcome": outcome}) + }).collect::>())}), + None, + )], + "apply_patch_call" => vec![call( + call_id, + name("apply_patch"), + json!({"callId": part["call_id"], "operation": apply_patch_operation(&part["operation"])}) + .to_string(), + None, + None, + metadata(), + )], + "program" => vec![call( + call_id, + name("programmatic_tool_calling"), + json!({"code": part["code"], "fingerprint": part["fingerprint"]}).to_string(), + Some(true), + None, + metadata(), + )], + "program_output" => vec![result( + call_id, + name("programmatic_tool_calling"), + json!({"result": part["result"], "status": part["status"]}), + metadata(), + )], + "tool_search_call" => { + let mut input = json!({}); + for field in ["arguments", "call_id"] { + if let Some(value) = part.get(field) { + input[field] = value.clone(); + } + } + vec![call( + call_id, + name("tool_search"), + input.to_string(), + (part["execution"] == "server").then_some(true), + None, + metadata(), + )] + } + "tool_search_output" => vec![result( + call_id, + name("tool_search"), + json!({"tools": part["tools"]}), + metadata(), + )], + "compaction" => vec![GenerateContent::Custom { + kind: NamespacedKind::new("openai.compaction") + .expect("openai.compaction is namespaced"), + provider_metadata: Some( + provider_namespace( + provider_key, + json!({ + "type": "compaction", "itemId": part["id"], + "encryptedContent": part["encrypted_content"], + }), + ) + .expect("provider metadata must be an object"), + ), + }], + "mcp_approval_request" => { + let tool_call_id = aimux_provider_utils::generate_id(); + let approval_id = part + .get("approval_request_id") + .and_then(Value::as_str) + .unwrap_or(item_id) + .to_owned(); + vec![ + call( + &tool_call_id, + format!("mcp.{}", part["name"].as_str().unwrap_or_default()), + part["arguments"].as_str().unwrap_or_default().to_owned(), + Some(true), + Some(true), + None, + ), + GenerateContent::ToolApprovalRequest(RawToolApprovalRequest { + approval_id, + tool_call_id, + provider_metadata: None, + }), + ] + } + "mcp_call" => { + let tool_name = format!("mcp.{}", part["name"].as_str().unwrap_or_default()); + let mut output = json!({"type": "call", "serverLabel": part["server_label"], + "name": part["name"], "arguments": part["arguments"]}); + for field in ["output", "error"] { + if let Some(value) = part.get(field).filter(|value| !value.is_null()) { + output[field] = value.clone(); + } + } + vec![ + call( + mcp_tool_call_id.unwrap_or(item_id), + tool_name.clone(), + part["arguments"].as_str().unwrap_or_default().to_string(), + Some(true), + Some(true), + None, + ), + result( + mcp_tool_call_id.unwrap_or(item_id), + tool_name, + output, + metadata(), + ), + ] + } + _ => Vec::new(), + } +} + +pub(crate) fn web_search_output(action: &Value) -> Value { + match action["type"].as_str() { + Some("search") => { + let mut output = json!({"action": {"type": "search"}}); + for field in ["query", "queries"] { + if let Some(value) = action.get(field).filter(|value| !value.is_null()) { + output["action"][field] = value.clone(); + } + } + if let Some(sources) = action.get("sources").filter(|value| !value.is_null()) { + output["sources"] = sources.clone(); + } + output + } + Some("open_page" | "find_in_page") => { + let kind = if action["type"] == "open_page" { + "openPage" + } else { + "findInPage" + }; + let mut output = json!({"action": {"type": kind}}); + for field in ["url", "pattern"] { + if field == "pattern" && kind == "openPage" { + continue; + } + if let Some(value) = action.get(field) { + output["action"][field] = value.clone(); + } + } + output + } + _ => json!({}), + } +} + +/// A local shell action with its fields in the AI SDK's order: the schema's +/// (`type`, `command`, `timeout_ms`, `user`, `working_directory`, `env`) for a +/// generated call, and the same order under the camel-case names the stream +/// writes (`timeoutMs`, `workingDirectory`). +fn local_shell_action(action: &Value) -> Value { + let Some(object) = action.as_object() else { + return action.clone(); + }; + let mut output = serde_json::Map::new(); + for field in [ + "type", + "command", + "timeout_ms", + "timeoutMs", + "user", + "working_directory", + "workingDirectory", + "env", + ] { + if let Some(value) = object.get(field) { + output.insert(field.to_owned(), value.clone()); + } + } + Value::Object(output) +} + +/// An apply patch operation with the fields of its type in the order of the +/// AI SDK's schema (`type`, `path`, then `diff`), as zod's parse leaves it. +pub(crate) fn apply_patch_operation(operation: &Value) -> Value { + let fields: &[&str] = match operation["type"].as_str() { + Some("create_file" | "update_file") => &["path", "diff"], + Some("delete_file") => &["path"], + _ => return operation.clone(), + }; + let mut output = json!({"type": operation["type"]}); + for field in fields { + if let Some(value) = operation.get(*field) { + output[*field] = value.clone(); + } + } + output +} + +/// Keeps the fields each computer action type has, renaming the scroll +/// offsets and leaving out a null `keys`, as the AI SDK's `mapComputerAction`. +fn computer_action(action: &Value) -> Value { + let fields: &[(&str, &str)] = match action["type"].as_str() { + Some("click") => &[ + ("button", "button"), + ("x", "x"), + ("y", "y"), + ("keys", "keys"), + ], + Some("double_click" | "move") => &[("x", "x"), ("y", "y"), ("keys", "keys")], + Some("drag") => &[("path", "path"), ("keys", "keys")], + Some("keypress") => &[("keys", "keys")], + Some("scroll") => &[ + ("x", "x"), + ("y", "y"), + ("scroll_x", "scrollX"), + ("scroll_y", "scrollY"), + ("keys", "keys"), + ], + Some("type") => &[("text", "text")], + Some("screenshot" | "wait") => &[], + _ => return action.clone(), + }; + let mut output = json!({"type": action["type"]}); + for (from, to) in fields { + if let Some(value) = action.get(*from).filter(|value| !value.is_null()) { + output[*to] = value.clone(); + } + } + output +} + +fn computer_input(part: &Value) -> Value { + let actions = part["actions"].as_array().cloned().unwrap_or_else(|| { + part.get("action") + .filter(|action| !action.is_null()) + .cloned() + .into_iter() + .collect() + }); + let actions: Vec<_> = actions.iter().map(computer_action).collect(); + let checks: Vec<_> = part["pending_safety_checks"] + .as_array() + .map(|checks| { + checks + .iter() + .map(|check| { + let mut result = json!({"id": check["id"]}); + for field in ["code", "message"] { + if let Some(value) = check.get(field).filter(|value| !value.is_null()) { + result[field] = value.clone(); + } + } + result + }) + .collect() + }) + .unwrap_or_default(); + json!({"actions": actions, "pendingSafetyChecks": checks, "status": part["status"]}) +} diff --git a/aimux-providers/src/openai/responses/responses_language_model_options.rs b/aimux-providers/src/openai/responses/responses_language_model_options.rs new file mode 100644 index 000000000..4ae84a1ab --- /dev/null +++ b/aimux-providers/src/openai/responses/responses_language_model_options.rs @@ -0,0 +1,75 @@ +//! The provider-options namespace and profile of the Responses model +//! (openai-responses-language-model-options.ts). + +use aimux_core::shared::{JsonObject, SharedProviderOptions}; +use serde_json::Value; + +/// The providerOptions keys a Responses model reads, in order of precedence +/// (the first one present wins as a whole), and the key it writes response +/// metadata under. The AI SDK's `OpenAIResponsesLanguageModel` picks +/// `providerOptionsName` from the host: `openai` for the OpenAI API, `azure` +/// for Azure (which still falls back to `openai` when no `azure` options were +/// given). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResponsesNamespace { + read: &'static [&'static str], + write: &'static str, +} + +impl ResponsesNamespace { + /// The OpenAI API: reads and writes `openai`. + pub const OPENAI: Self = Self::new(&["openai"], "openai"); + + /// A host namespace. `read` is non-empty and in order of precedence; + /// `write` is the metadata key. + #[must_use] + pub const fn new(read: &'static [&'static str], write: &'static str) -> Self { + Self { read, write } + } + + /// The key response metadata is written under. + #[must_use] + pub fn write_key(self) -> &'static str { + self.write + } + + /// The first present options object among the read keys. + pub(crate) fn find(self, provider_options: &SharedProviderOptions) -> Option<&JsonObject> { + self.read.iter().find_map(|key| provider_options.get(*key)) + } + + /// [`find`](Self::find) for `CallOptions::provider_options`. + pub(crate) fn find_in(self, provider_options: &SharedProviderOptions) -> Option<&JsonObject> { + self.read.iter().find_map(|key| provider_options.get(*key)) + } +} + +impl Default for ResponsesNamespace { + fn default() -> Self { + Self::OPENAI + } +} + +/// How a host differs in the Responses API model: its namespace, and the +/// prefixes that mark a file part's data as an uploaded file id instead of +/// content to inline (`fileIdPrefixes` in the AI SDK). +#[derive(Debug, Clone, Default)] +pub(crate) struct ResponsesProfile { + pub(crate) namespace: ResponsesNamespace, + /// Empty: no file part is treated as a file id. + pub(crate) file_id_prefixes: Vec<&'static str>, + pub(crate) explicit_message_item_type: bool, +} + +/// Get a value from the host namespace's options (`openai.` for OpenAI). +pub(crate) fn openai_option( + ns: ResponsesNamespace, + options: &Option, + key: &str, +) -> Option { + options + .as_ref() + .and_then(|m| ns.find_in(m)) + .and_then(|o| o.get(key)) + .cloned() +} diff --git a/aimux-providers/src/openai/responses/responses_prepare_tools.rs b/aimux-providers/src/openai/responses/responses_prepare_tools.rs new file mode 100644 index 000000000..375fd4fb2 --- /dev/null +++ b/aimux-providers/src/openai/responses/responses_prepare_tools.rs @@ -0,0 +1,538 @@ +//! Tool preparation for the Responses API (openai-responses-prepare-tools.ts). + +use aimux_core::AiMuxError; +use aimux_core::options::LanguageModelV4ToolChoice; +use aimux_core::tool::{FunctionTool, Tool}; +use aimux_core::types::Warning; +use serde_json::{Value, json}; + +use crate::openai::responses::tool_args::validate_tool_args; + +/// The result of preparing tools for a Responses request body. +#[derive(Debug, Clone)] +pub struct PreparedResponsesTools { + pub tools: Option>, + pub tool_choice: Option, + pub tool_warnings: Vec, +} + +/// Prepare the upstream Responses function and provider tool family. +/// +/// # Errors +/// Returns an error for invalid provider tool arguments or conflicting namespaces. +pub fn prepare_responses_tools( + tools: &Option>, + tool_choice: Option<&LanguageModelV4ToolChoice>, +) -> Result { + prepare_responses_tools_for(tools, tool_choice, None, true) +} + +pub(crate) fn prepare_responses_tools_for( + tools: &Option>, + tool_choice: Option<&LanguageModelV4ToolChoice>, + allowed_tools: Option<&Value>, + supports_async: bool, +) -> Result { + let Some(tools) = tools.as_ref().filter(|tools| !tools.is_empty()) else { + return Ok(PreparedResponsesTools { + tools: None, + tool_choice: None, + tool_warnings: Vec::new(), + }); + }; + let mut tool_warnings = Vec::new(); + let mut prepared: Vec = Vec::new(); + for tool in tools { + match tool { + Tool::Function(ft) => { + let mut value = function_tool_to_json(ft); + value["parameters"] = crate::openai::normalize_json_schema::normalize_json_schema( + &ft.input_schema, + &mut tool_warnings, + )?; + let opts = ft + .provider_options + .as_ref() + .and_then(|options| options.get("openai")); + if let Some(opts) = opts { + for (key, wire) in [ + ("async", "async"), + ("deferLoading", "defer_loading"), + ("allowedCallers", "allowed_callers"), + ] { + if let Some(v) = opts.get(key) { + value[wire] = v.clone(); + } + } + if let Some(schema) = opts.get("outputSchema") { + value["output_schema"] = + crate::openai::normalize_json_schema::normalize_json_schema( + schema, + &mut tool_warnings, + )?; + } + resolve_async_tool_option( + &mut value, + supports_async, + &ft.name, + &mut tool_warnings, + ); + if let Some(namespace) = opts.get("namespace") { + let name = namespace + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| AiMuxError::invalid_argument("tool namespace name"))?; + let description = namespace + .get("description") + .and_then(Value::as_str) + .ok_or_else(|| { + AiMuxError::invalid_argument("tool namespace description") + })?; + if let Some(group) = prepared + .iter_mut() + .find(|group| group["type"] == "namespace" && group["name"] == name) + { + if group["description"] != description { + return Err(AiMuxError::UnsupportedFunctionality(format!( + "conflicting descriptions for OpenAI tool namespace \"{name}\"" + ))); + } + if let Some(items) = group["tools"].as_array_mut() { + items.push(value); + } + } else { + prepared.push(json!({"type":"namespace", "name":name, "description":description, "tools":[value]})); + } + continue; + } + } + prepared.push(value); + } + Tool::Provider(tool) => { + if let Some(mut value) = provider_tool_to_json(tool)? { + resolve_async_tool_option( + &mut value, + supports_async, + &tool.name, + &mut tool_warnings, + ); + prepared.push(value); + } + } + } + } + let mut tool_choice = tool_choice.map(|choice| match choice { + LanguageModelV4ToolChoice::Auto => json!("auto"), + LanguageModelV4ToolChoice::None => json!("none"), + LanguageModelV4ToolChoice::Required => json!("required"), + LanguageModelV4ToolChoice::Tool { tool_name } => { + let tool = tools.iter().find_map(|tool| match tool { + Tool::Provider(tool) if tool.name == *tool_name => Some(tool), + _ => None, + }); + match tool.map(|tool| tool.id.as_str()) { + Some("openai.custom") => json!({"type":"custom", "name":tool_name}), + Some(id) + if [ + "openai.code_interpreter", + "openai.file_search", + "openai.image_generation", + "openai.web_search_preview", + "openai.web_search", + "openai.mcp", + "openai.apply_patch", + "openai.computer", + "openai.programmatic_tool_calling", + ] + .contains(&id) => + { + json!({"type":id.trim_start_matches("openai.")}) + } + _ => json!({"type":"function", "name":tool_name}), + } + } + }); + if let Some(allowed) = allowed_tools { + tool_choice = Some(prepare_allowed_tools(allowed, tools, &mut tool_warnings)?); + } + Ok(PreparedResponsesTools { + tools: Some(prepared), + tool_choice, + tool_warnings, + }) +} + +fn resolve_async_tool_option( + value: &mut Value, + supported: bool, + name: &str, + warnings: &mut Vec, +) { + if !supported && value.get("async") == Some(&json!(true)) { + if let Some(object) = value.as_object_mut() { + object.remove("async"); + } + warnings.push(Warning::Unsupported { + feature: format!("async tool calling for \"{name}\""), + details: Some("Async tool calling is only supported by GPT-6 and later models.".into()), + }); + } +} + +fn prepare_allowed_tools( + allowed: &Value, + tools: &[Tool], + warnings: &mut Vec, +) -> Result { + let invalid = || AiMuxError::invalid_argument("allowedTools"); + let names = allowed + .get("toolNames") + .and_then(Value::as_array) + .filter(|names| !names.is_empty()) + .ok_or_else(invalid)?; + let mode = allowed + .get("mode") + .map(|mode| { + mode.as_str() + .filter(|mode| ["auto", "required"].contains(mode)) + .ok_or_else(invalid) + }) + .transpose()? + .unwrap_or("auto"); + let mut entries = Vec::new(); + let mut dropped = Vec::new(); + for name in names { + let name = name.as_str().ok_or_else(invalid)?; + let direct = tools.iter().find(|tool| match tool { + Tool::Function(tool) => tool.name == name, + Tool::Provider(tool) => tool.name == name, + }); + let aliases: Vec<_> = tools.iter().filter(|tool| matches!(tool, Tool::Provider(tool) if tool.id.as_str().strip_prefix("openai.") == Some(name) && tool.name != name)).collect(); + let tool = if let Some(tool) = direct { + if !aliases.is_empty() { + warnings.push(Warning::Unsupported { feature:format!("allowedTools entry \"{name}\""), details:Some("this name is both a tool name and the provider tool name of another tool in this request; the tool with this name is allowed".into()) }); + } + Some(tool) + } else { + if aliases.len() > 1 + && aliases.iter().any(|tool| match tool { + Tool::Provider(tool) => tool.id == "openai.custom" || tool.id == "openai.mcp", + _ => false, + }) + { + warnings.push(Warning::Unsupported { feature:format!("allowedTools entry \"{name}\""), details:Some("several tools in this request share this provider tool name; use the tool name from the tools for this request instead".into()) }); + dropped.push(name); + continue; + } + aliases.first().copied() + }; + let entry = match tool { + Some(Tool::Function(tool)) => { + let opts = tool + .provider_options + .as_ref() + .and_then(|options| options.get("openai")); + let reason = if opts.is_some_and(|opts| opts.contains_key("namespace")) { + Some( + "tools inside an OpenAI tool namespace are not visible to tool_choice.allowed_tools", + ) + } else if opts.is_some_and(|opts| opts.get("deferLoading") == Some(&json!(true))) { + Some("deferred tools are not visible to tool_choice.allowed_tools") + } else { + None + }; + if let Some(reason) = reason { + warnings.push(Warning::Unsupported { + feature: format!("allowedTools entry \"{name}\""), + details: Some(format!( + "{reason}; the tool is removed from the allowed tools" + )), + }); + dropped.push(name); + continue; + } + json!({"type":"function", "name":tool.name}) + } + Some(Tool::Provider(tool)) => { + let Some(value) = provider_tool_to_json(tool)? else { + entries.push(json!({"type":"function", "name":name})); + continue; + }; + match value["type"].as_str() { + Some("custom") => json!({"type":"custom", "name":tool.name}), + Some("mcp") => json!({"type":"mcp", "server_label":value["server_label"]}), + Some("tool_search") => { + warnings.push(Warning::Unsupported { feature:format!("allowedTools entry \"{name}\""), details:Some("OpenAI does not support tool_search tools in tool_choice.allowed_tools; the tool is removed from the allowed tools".into()) }); + dropped.push(name); + continue; + } + Some(kind) => json!({"type":kind}), + None => continue, + } + } + None => { + warnings.push(Warning::Unsupported { feature:format!("allowedTools entry \"{name}\""), details:Some("the tool is not part of the tools for this request and is sent as a function tool".into()) }); + json!({"type":"function", "name":name}) + } + }; + entries.push(entry); + } + if entries.is_empty() { + return Err(AiMuxError::UnsupportedFunctionality(format!( + "allowedTools with only tools that cannot be allow-listed ({})", + dropped.join(", ") + ))); + } + Ok(json!({"type":"allowed_tools", "mode":mode, "tools":entries})) +} + +// Only configured fields are sent: absent optional values are omitted, not null. +pub(crate) fn map_tool_fields(value: &Value, fields: &[(&str, &str)]) -> Value { + let mut result = json!({}); + for (name, wire) in fields { + if let Some(value) = value.get(*name) { + result[*wire] = value.clone(); + } + } + result +} + +fn provider_tool_to_json( + tool: &aimux_core::tool::ProviderTool, +) -> Result, AiMuxError> { + let kind = tool.id.as_str().strip_prefix("openai.").unwrap_or(""); + if ![ + "file_search", + "local_shell", + "shell", + "apply_patch", + "computer", + "web_search_preview", + "web_search", + "code_interpreter", + "image_generation", + "mcp", + "custom", + "programmatic_tool_calling", + "tool_search", + ] + .contains(&kind) + { + return Ok(None); + } + if [ + "local_shell", + "apply_patch", + "computer", + "programmatic_tool_calling", + ] + .contains(&kind) + { + return Ok(Some(json!({"type":kind}))); + } + let args = &Value::Object(tool.args.clone()); + validate_tool_args(kind, args)?; + let fields: &[(&str, &str)] = match kind { + "file_search" => &[ + ("vectorStoreIds", "vector_store_ids"), + ("maxNumResults", "max_num_results"), + ("filters", "filters"), + ], + "web_search_preview" | "web_search" => &[ + ("searchContextSize", "search_context_size"), + ("userLocation", "user_location"), + ], + "image_generation" => &[ + ("action", "action"), + ("background", "background"), + ("inputFidelity", "input_fidelity"), + ("model", "model"), + ("moderation", "moderation"), + ("partialImages", "partial_images"), + ("quality", "quality"), + ("outputCompression", "output_compression"), + ("outputFormat", "output_format"), + ("size", "size"), + ], + "mcp" => &[ + ("serverLabel", "server_label"), + ("authorization", "authorization"), + ("connectorId", "connector_id"), + ("headers", "headers"), + ("serverDescription", "server_description"), + ("serverUrl", "server_url"), + ], + "custom" => &[ + ("description", "description"), + ("format", "format"), + ("async", "async"), + ], + "tool_search" => &[ + ("execution", "execution"), + ("description", "description"), + ("parameters", "parameters"), + ], + _ => &[], + }; + let mut value = map_tool_fields(args, fields); + value["type"] = json!(kind); + if let Some(location) = args.get("userLocation") { + value["user_location"] = map_tool_fields( + location, + &[ + ("type", "type"), + ("country", "country"), + ("city", "city"), + ("region", "region"), + ("timezone", "timezone"), + ], + ); + } + match kind { + "file_search" => { + if let Some(ranking) = args.get("ranking") { + value["ranking_options"] = map_tool_fields( + ranking, + &[("ranker", "ranker"), ("scoreThreshold", "score_threshold")], + ); + } + } + "web_search" => { + if let Some(filters) = args.get("filters") { + value["filters"] = map_tool_fields( + filters, + &[ + ("allowedDomains", "allowed_domains"), + ("blockedDomains", "blocked_domains"), + ], + ); + } + if let Some(access) = args.get("externalWebAccess") { + value["external_web_access"] = access.clone(); + } + } + "code_interpreter" => { + value["container"] = match args.get("container") { + Some(container) if container.is_string() => container.clone(), + container => { + let mut value = container + .map(|container| map_tool_fields(container, &[("fileIds", "file_ids")])) + .unwrap_or_else(|| json!({})); + value["type"] = json!("auto"); + value + } + } + } + "image_generation" => { + if let Some(mask) = args.get("inputImageMask") { + value["input_image_mask"] = + map_tool_fields(mask, &[("fileId", "file_id"), ("imageUrl", "image_url")]); + } + } + "custom" => { + value["name"] = json!(tool.name); + if let Some(format) = args.get("format") { + value["format"] = map_tool_fields( + format, + &[ + ("type", "type"), + ("syntax", "syntax"), + ("definition", "definition"), + ], + ); + } + } + "mcp" => { + if let Some(allowed) = args.get("allowedTools") { + value["allowed_tools"] = if allowed.is_array() { + allowed.clone() + } else { + map_tool_fields( + allowed, + &[("readOnly", "read_only"), ("toolNames", "tool_names")], + ) + }; + } + value["require_approval"] = args + .get("requireApproval") + .map(|approval| { + if approval.is_string() { + approval.clone() + } else if let Some(never) = approval.get("never") { + json!({"never":map_tool_fields(never, &[("toolNames", "tool_names")])}) + } else { + json!("never") + } + }) + .unwrap_or_else(|| json!("never")); + } + "shell" => { + if let Some(environment) = args.get("environment") { + value["environment"] = map_shell_environment(environment)?; + } + } + _ => {} + } + Ok(Some(value)) +} + +fn map_shell_environment(environment: &Value) -> Result { + let kind = environment + .get("type") + .and_then(Value::as_str) + .unwrap_or("local"); + let mut value = match kind { + "containerReference" => map_tool_fields(environment, &[("containerId", "container_id")]), + "containerAuto" => map_tool_fields( + environment, + &[("fileIds", "file_ids"), ("memoryLimit", "memory_limit")], + ), + _ => map_tool_fields(environment, &[("skills", "skills")]), + }; + value["type"] = json!(match kind { + "containerReference" => "container_reference", + "containerAuto" => "container_auto", + _ => "local", + }); + if kind == "containerAuto" { + if let Some(policy) = environment.get("networkPolicy") { + value["network_policy"] = map_tool_fields( + policy, + &[ + ("type", "type"), + ("allowedDomains", "allowed_domains"), + ("domainSecrets", "domain_secrets"), + ], + ); + } + if let Some(skills) = environment.get("skills").and_then(Value::as_array) { + value["skills"] = Value::Array(skills.iter().map(|skill| { + if skill["type"] == "skillReference" { + let reference = skill.get("providerReference").and_then(Value::as_object).map(|reference| reference.iter().filter_map(|(provider, id)| Some((provider.clone(), id.as_str()?.to_owned()))).collect()).unwrap_or_default(); + let id = crate::openai::chat::convert_to_chat_messages::resolve_provider_reference(&reference, "openai").map_err(AiMuxError::invalid_argument)?; + Ok(json!({"type":"skill_reference", "skill_id":id, "version":skill.get("version").cloned().unwrap_or_else(|| json!("latest"))})) + } else { + let mut value = map_tool_fields(skill, &[("name", "name"), ("description", "description")]); + value["type"] = json!("inline"); + value["source"] = map_tool_fields(&skill["source"], &[("type", "type"), ("mediaType", "media_type"), ("data", "data")]); + Ok(value) + } + }).collect::, AiMuxError>>()?); + } + } + Ok(value) +} + +/// Convert a `FunctionTool` into the Responses `function` tool JSON shape. +fn function_tool_to_json(t: &FunctionTool) -> Value { + let mut func = json!({ + "type": "function", + "name": t.name, + "parameters": t.input_schema, + }); + if let Some(ref desc) = t.description { + func["description"] = json!(desc); + } + func["strict"] = json!(t.strict.unwrap_or(false)); + func +} diff --git a/aimux-providers/src/openai/responses/tool_args.rs b/aimux-providers/src/openai/responses/tool_args.rs index df0c3fef7..eaf1ef27b 100644 --- a/aimux-providers/src/openai/responses/tool_args.rs +++ b/aimux-providers/src/openai/responses/tool_args.rs @@ -1,4 +1,5 @@ -//! Validation of the pinned provider tool argument schemas. +//! aimux 扩展: validation of the pinned provider tool argument schemas, the +//! input schemas of upstream's `tool/*.ts` checked in one place. use aimux_core::AiMuxError; use serde_json::Value; diff --git a/aimux-providers/src/openai/speech/mod.rs b/aimux-providers/src/openai/speech/mod.rs new file mode 100644 index 000000000..b06112cbf --- /dev/null +++ b/aimux-providers/src/openai/speech/mod.rs @@ -0,0 +1,5 @@ +//! `speech/`: the OpenAI speech (TTS) model. + +pub(super) mod speech_model; + +pub use speech_model::OpenAISpeechModel; diff --git a/aimux-providers/src/openai/speech.rs b/aimux-providers/src/openai/speech/speech_model.rs similarity index 98% rename from aimux-providers/src/openai/speech.rs rename to aimux-providers/src/openai/speech/speech_model.rs index b39a470f3..a22faca52 100644 --- a/aimux-providers/src/openai/speech.rs +++ b/aimux-providers/src/openai/speech/speech_model.rs @@ -19,7 +19,7 @@ use aimux_core::speech_model::{ AudioData, SpeechCallOptions, SpeechModel, SpeechRequest, SpeechResponse, SpeechResult, }; -use super::config::OpenAIModelConfig; +use crate::openai::config::OpenAIModelConfig; /// The output formats accepted by the OpenAI TTS API. const SUPPORTED_OUTPUT_FORMATS: &[&str] = &["mp3", "opus", "aac", "flac", "wav", "pcm"]; @@ -62,7 +62,7 @@ impl SpeechModel for OpenAISpeechModel { .http_request(self.config.url("/audio/speech")?, headers, options), body.clone(), aimux_provider_utils::create_binary_response_handler(), - super::openai_failed_response_handler(), + crate::openai::error::openai_failed_response_handler(), ) .await?; diff --git a/aimux-providers/src/openai/stream_error.rs b/aimux-providers/src/openai/stream_error.rs new file mode 100644 index 000000000..1e502f852 --- /dev/null +++ b/aimux-providers/src/openai/stream_error.rs @@ -0,0 +1,107 @@ +//! The `ApiCallError` of an OpenAI stream error frame (openai-stream-error.ts). + +use aimux_core::error::AiMuxError; +use serde_json::Value; + +/// The `ApiCallError` of an OpenAI stream error frame, as upstream's +/// `createOpenAIProviderStreamError` builds it (@ai-sdk/openai 4.0.83 +/// src/openai-stream-error.ts:18-37): an HTTP status in `code` is the status, +/// otherwise one is inferred from `code`/`type` (`getStatusCode`, :235-267); +/// `insufficient_quota` is never retryable (:303-315). +pub(crate) fn openai_stream_error( + error: &Value, + url: &str, + request_body_values: Value, + response_headers: std::collections::HashMap, +) -> AiMuxError { + let response_failed = error.get("type").and_then(Value::as_str) == Some("response.failed"); + let payload = if response_failed { + error + .get("response") + .and_then(|response| response.get("error")) + .unwrap_or(error) + } else { + error.get("error").unwrap_or(error) + }; + let message = payload + .get("message") + .and_then(Value::as_str) + .unwrap_or("OpenAI stream failed before any output was generated") + .to_owned(); + let provider_code = payload.get("code").and_then(|value| match value { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + _ => None, + }); + // Upstream types a `response.failed` frame as `response.failed`, not by + // its inner error (`parseStreamError`, openai-stream-error.ts:207-217). + let error_type = if response_failed { + "response.failed" + } else { + payload + .get("type") + .and_then(Value::as_str) + .unwrap_or_default() + }; + let discriminator = format!( + "{} {error_type}", + provider_code.as_deref().unwrap_or_default() + ) + .to_ascii_lowercase(); + let explicit_status = payload + .get("code") + .and_then(|value| { + value + .as_u64() + .and_then(|value| u16::try_from(value).ok()) + .or_else(|| { + value + .as_str() + .filter(|value| { + value.len() == 3 && value.bytes().all(|byte| byte.is_ascii_digit()) + }) + .and_then(|value| value.parse::().ok()) + }) + }) + .filter(|value| (400..=599).contains(value)); + let status = explicit_status.unwrap_or_else(|| { + if ["insufficient_quota", "rate_limit"] + .iter() + .any(|term| discriminator.contains(term)) + { + 429 + } else if discriminator.contains("authentication") { + 401 + } else if discriminator.contains("permission") { + 403 + } else if discriminator.contains("not_found") { + 404 + } else if ["invalid", "bad_request", "context_length"] + .iter() + .any(|term| discriminator.contains(term)) + { + 400 + } else if discriminator.contains("overload") { + 503 + } else if discriminator.contains("timeout") { + 504 + } else { + 500 + } + }); + let quota = provider_code.as_deref() == Some("insufficient_quota") + || error_type == "insufficient_quota"; + let mut result = aimux_provider_utils::stream_error_api_call( + message, + provider_code, + Some(status), + error, + url, + request_body_values, + response_headers, + ); + if quota && let AiMuxError::ApiCall(details) = &mut result { + details.is_retryable = false; + } + result +} diff --git a/aimux-providers/src/openai/tests/chat/chat_language_model.rs b/aimux-providers/src/openai/tests/chat/chat_language_model.rs index 97ca289dd..ceccfc1cb 100644 --- a/aimux-providers/src/openai/tests/chat/chat_language_model.rs +++ b/aimux-providers/src/openai/tests/chat/chat_language_model.rs @@ -2873,3 +2873,126 @@ mod do_stream { } } } + +// ── Older ports (previously the inline tests of `src/openai/model.rs`) ── + +// The Chat Completions stream driven chunk by chunk. +mod older_stream { + use serde_json::json; + + use crate::stream::testing::run; + + use crate::openai::chat::chat_language_model::*; + use aimux_core::error::AiMuxError; + use serde_json::Value; + + fn delta(delta: Value) -> Result { + Ok(json!({ "choices": [{ "delta": delta }] })) + } + + fn tool_calls(tool_calls: Value) -> Result { + delta(json!({ "tool_calls": tool_calls })) + } + + #[test] + fn interleaved_tool_calls_stream_as_they_arrive_and_end_on_flush_in_index_order() { + let parts = run( + OpenAIChatStream::default(), + [ + tool_calls(json!([{ + "index": 1, "id": "call_b", "type": "function", + "function": { "name": "time", "arguments": "{" } + }])), + tool_calls(json!([{ + "index": 0, "id": "call_a", "type": "function", + "function": { "name": "weather", "arguments": "{\"city\":" } + }])), + tool_calls(json!([ + { "index": 1, "function": { "arguments": "}" } }, + { "index": 0, "function": { "arguments": "\"Paris\"}" } } + ])), + Ok(json!({ "choices": [{ "delta": {}, "finish_reason": "tool_calls" }] })), + ], + ); + assert_eq!( + parts, + [ + "stream-start", + "tool-input-start call_b time", + "tool-input-delta call_b {", + "tool-input-start call_a weather", + "tool-input-delta call_a {\"city\":", + "tool-input-delta call_b }", + "tool-input-delta call_a \"Paris\"}", + "tool-input-end call_a", + "tool-call call_a weather {\"city\":\"Paris\"}", + "tool-input-end call_b", + "tool-call call_b time {}", + "finish ToolCalls", + ] + ); + } + + /// partial: `should forward error stream parts after output has started`; full port in src/openai/tests/chat/chat_language_model.rs. + #[test] + fn an_error_frame_is_an_error_part_and_the_stream_finishes_with_error() { + let parts = run( + OpenAIChatStream::default(), + [ + delta(json!({ "content": "Hi" })), + Ok(json!({ "error": { "message": "overloaded", "type": "server_error" } })), + ], + ); + assert_eq!(parts.len(), 6, "{parts:?}"); + assert_eq!( + parts[..3], + ["stream-start", "text-start 0", "text-delta 0 Hi"] + ); + assert!(parts[3].starts_with("error ") && parts[3].contains("overloaded")); + assert_eq!(parts[4..], ["text-end 0", "finish Error"]); + } + + #[test] + fn flush_closes_open_blocks_and_finishes_with_other_without_a_finish_reason() { + let parts = run( + OpenAIChatStream::default(), + [ + delta(json!({ "reasoning_content": "Hmm" })), + delta(json!({ "content": "Hi" })), + ], + ); + assert_eq!( + parts, + [ + "stream-start", + "reasoning-start reasoning-0", + "reasoning-delta reasoning-0 Hmm", + "reasoning-end reasoning-0", + "text-start 0", + "text-delta 0 Hi", + "text-end 0", + "finish Other", + ] + ); + } + + #[test] + fn a_tool_call_of_another_type_ends_the_stream_without_flush() { + let parts = run( + OpenAIChatStream::default(), + [ + tool_calls(json!([{ + "index": 0, "id": "call_a", "type": "custom", + "function": { "name": "weather", "arguments": "{}" } + }])), + delta(json!({ "content": "ignored" })), + ], + ); + assert_eq!(parts.len(), 2, "{parts:?}"); + assert_eq!(parts[0], "stream-start"); + assert!( + parts[1].starts_with("err ") && parts[1].contains("Expected 'function' type."), + "{parts:?}" + ); + } +} diff --git a/aimux-providers/src/openai/tests/chat/chat_prepare_tools.rs b/aimux-providers/src/openai/tests/chat/chat_prepare_tools.rs index cd4e1bb8d..2e102859e 100644 --- a/aimux-providers/src/openai/tests/chat/chat_prepare_tools.rs +++ b/aimux-providers/src/openai/tests/chat/chat_prepare_tools.rs @@ -6,7 +6,7 @@ use aimux_core::options::CallOptions; use serde_json::{Map, Value, json}; -use crate::openai::convert::build_request_body_with_warnings; +use crate::openai::chat::chat_language_model::build_request_body_with_warnings; /// `prepareChatTools({ tools, toolChoice })`: `input` holds the upstream /// `tools` / `toolChoice` literals. diff --git a/aimux-providers/src/openai/tests/chat/convert_chat_usage.rs b/aimux-providers/src/openai/tests/chat/convert_chat_usage.rs index a16aff9a5..e9c217d33 100644 --- a/aimux-providers/src/openai/tests/chat/convert_chat_usage.rs +++ b/aimux-providers/src/openai/tests/chat/convert_chat_usage.rs @@ -1,7 +1,7 @@ use serde_json::{Value, json}; -use crate::openai::model::convert_usage; -use crate::openai::types::UsageResponse; +use crate::openai::chat::chat_api::UsageResponse; +use crate::openai::chat::convert_chat_usage::convert_usage; /// `convertOpenAIChatUsage(usage)`: the wire usage parsed as aimux parses it, /// with the same object kept as `raw`. diff --git a/aimux-providers/src/openai/tests/chat/convert_to_chat_messages.rs b/aimux-providers/src/openai/tests/chat/convert_to_chat_messages.rs index b279f9421..7493d1d2b 100644 --- a/aimux-providers/src/openai/tests/chat/convert_to_chat_messages.rs +++ b/aimux-providers/src/openai/tests/chat/convert_to_chat_messages.rs @@ -5,9 +5,8 @@ use aimux_core::language_model_message::{ use aimux_core::shared::{FileBytes, FileData}; use serde_json::{Value, json}; -use crate::openai::convert::{ - SystemMessageMode, convert_prompt_to_openai_messages_with_mode_fallible, -}; +use crate::openai::chat::convert_to_chat_messages::convert_prompt_to_openai_messages_with_mode_fallible; +use crate::openai::language_model_capabilities::SystemMessageMode; /// The upstream prompt literal (AI SDK V4 JSON shape) as an aimux prompt. fn prompt(value: Value) -> LanguageModelPrompt { @@ -813,3 +812,48 @@ mod tool_calls { ); } } + +// ── Older ports (previously the inline tests of `src/openai/convert.rs`) ── +mod older_convert { + use aimux_core::language_model_message::ToolCallPart; + + use crate::openai::chat::convert_to_chat_messages::*; + use crate::openai::language_model_capabilities::SystemMessageMode; + use aimux_core::language_model_message::AssistantPart; + use aimux_core::language_model_message::LanguageModelMessage; + use serde_json::Value; + use serde_json::json; + + fn replayed_arguments(input: Value) -> Value { + let prompt = vec![LanguageModelMessage::Assistant { + content: vec![AssistantPart::ToolCall(ToolCallPart { + tool_call_id: "call-1".to_string(), + tool_name: "echo".to_string(), + input, + provider_executed: None, + provider_options: None, + })], + provider_options: None, + }]; + let messages = convert_prompt_to_openai_messages_with_mode_fallible( + &prompt, + SystemMessageMode::System, + ) + .unwrap(); + messages[0]["tool_calls"][0]["function"]["arguments"].clone() + } + + /// partial: `should stringify arguments to tool calls`; full port in src/openai/tests/chat/convert_to_chat_messages.rs. + /// partial: `should default missing tool call input to an empty object`; full port in src/openai/tests/chat/convert_to_chat_messages.rs. + /// partial: `should normalize malformed tool call input and preserve the tool error`; full port in src/openai/tests/chat/convert_to_chat_messages.rs. + #[test] + fn replayed_tool_arguments_follow_upstream() { + assert_eq!( + replayed_arguments(json!({ "city": "Tokyo" })), + json!(r#"{"city":"Tokyo"}"#) + ); + for input in [json!("Tokyo"), json!(7), json!(["Tokyo"]), Value::Null] { + assert_eq!(replayed_arguments(input), json!("{}")); + } + } +} diff --git a/aimux-providers/src/openai/tests/embedding/embedding_model.rs b/aimux-providers/src/openai/tests/embedding/embedding_model.rs index 90c92686c..a93baa5fb 100644 --- a/aimux-providers/src/openai/tests/embedding/embedding_model.rs +++ b/aimux-providers/src/openai/tests/embedding/embedding_model.rs @@ -197,3 +197,368 @@ mod do_embed { assert!(user_agent.contains(PINNED_USER_AGENT), "{user_agent}"); } } + +// ── Older ports (previously `tests/openai_embedding_test.rs`) ── +// +// Rust translations of the AI SDK OpenAI embedding model tests. +// +// Source: `reference/ai/packages/openai/src/embedding/openai-embedding-model.test.ts` +// (164 lines, 7 cases). +// +// Each test uses `wiremock` to spin up a mock HTTP server, configures a JSON +// response, creates an `OpenAIEmbeddingModel` pointing at the mock, calls +// `do_embed`, and asserts on the result. + +mod older_embedding { + use std::collections::HashMap; + + use serde_json::{Value, json}; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + use crate::{OpenAIProviderSettings, create_openai}; + use aimux_core::embedding_model::EmbeddingCallOptions; + use aimux_core::provider::Provider; + + // ── helpers ───────────────────────────────────────────────────────────────── + + const TEST_VALUES: &[&str] = &["sunny day at the beach", "rainy day in the city"]; + + /// The fixture response body from `__fixtures__/openai-embedding.json`. + fn embedding_response_body() -> Value { + json!({ + "object": "list", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": [0.0057293195, -0.012727811, 0.020042092, -0.013437585, 0.022833068] + }, + { + "object": "embedding", + "index": 1, + "embedding": [-0.037104916, -0.05178114, -0.008340587, 0.001164541, -0.0035253682] + } + ], + "model": "text-embedding-3-small", + "usage": { + "prompt_tokens": 12, + "total_tokens": 12 + } + }) + } + + fn test_values() -> Vec { + TEST_VALUES + .iter() + .map(std::string::ToString::to_string) + .collect() + } + + fn default_options(values: Vec) -> EmbeddingCallOptions { + EmbeddingCallOptions { + values, + abort_signal: None, + provider_options: None, + headers: None, + max_retries: None, + timeout: None, + } + } + + // ════════════════════════════════════════════════════════════════════════════ + // doEmbed + // ════════════════════════════════════════════════════════════════════════════ + + mod do_embed { + use super::*; + + /// upstream: @ai-sdk/openai src/embedding/openai-embedding-model.test.ts › doEmbed › should extract embedding + #[tokio::test] + async fn should_extract_embedding() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/embeddings")) + .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) + .mount(&server) + .await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.embedding_model("text-embedding-3-large").unwrap(); + + let result = model + .do_embed(&default_options(test_values())) + .await + .expect("should succeed"); + + assert_eq!(result.embeddings.len(), 2); + assert_eq!( + result.embeddings[0], + vec![ + 0.0057293195, + -0.012727811, + 0.020042092, + -0.013437585, + 0.022833068 + ] + ); + assert_eq!( + result.embeddings[1], + vec![ + -0.037104916, + -0.05178114, + -0.008340587, + 0.001164541, + -0.0035253682 + ] + ); + } + + /// upstream: @ai-sdk/openai src/embedding/openai-embedding-model.test.ts › doEmbed › should extract usage + #[tokio::test] + async fn should_extract_usage() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/embeddings")) + .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) + .mount(&server) + .await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.embedding_model("text-embedding-3-large").unwrap(); + + let result = model + .do_embed(&default_options(test_values())) + .await + .expect("should succeed"); + + let usage = result.usage.expect("usage should be present"); + assert_eq!(usage.tokens, 12); + } + } + + /// partial: `should expose the raw response headers`; full port in src/openai/tests/embedding/embedding_model.rs. + #[tokio::test] + async fn should_expose_raw_response_headers() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/embeddings")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("test-header", "test-value") + .set_body_json(embedding_response_body()), + ) + .mount(&server) + .await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.embedding_model("text-embedding-3-large").unwrap(); + + let result = model + .do_embed(&default_options(test_values())) + .await + .expect("should succeed"); + + let headers = result + .response + .as_ref() + .and_then(|r| r.headers.as_ref()) + .expect("response headers should be present"); + assert_eq!( + headers.get("test-header").map(std::string::String::as_str), + Some("test-value") + ); + } + + /// partial: `should expose the raw response body`; full port in src/openai/tests/embedding/embedding_model.rs. + #[tokio::test] + async fn should_expose_raw_response_body() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/embeddings")) + .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) + .mount(&server) + .await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.embedding_model("text-embedding-3-large").unwrap(); + + let result = model + .do_embed(&default_options(test_values())) + .await + .expect("should succeed"); + + let body = result + .response + .as_ref() + .and_then(|r| r.body.as_ref()) + .expect("response body should be present"); + assert_eq!(body["usage"]["prompt_tokens"], 12); + assert_eq!(body["data"].as_array().unwrap().len(), 2); + } + + /// partial: `should pass the model and the values`; full port in src/openai/tests/embedding/embedding_model.rs. + #[tokio::test] + async fn should_pass_model_and_values() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/embeddings")) + .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) + .mount(&server) + .await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.embedding_model("text-embedding-3-large").unwrap(); + + let _ = model + .do_embed(&default_options(test_values())) + .await + .expect("should succeed"); + + let requests = server.received_requests().await.expect("requests recorded"); + assert_eq!(requests.len(), 1); + let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + + assert_eq!(body["model"], "text-embedding-3-large"); + assert_eq!( + body["input"], + json!(["sunny day at the beach", "rainy day in the city"]) + ); + assert_eq!(body["encoding_format"], "float"); + // dimensions and user should be absent when not provided + assert!(body.get("dimensions").is_none()); + assert!(body.get("user").is_none()); + } + + /// `providerOptions.openai.dimensions` is sent as the number it was given, so + /// an integer stays an integer, as the AI SDK passes it through + /// (`embedding/openai-embedding-model.ts:94`). + #[tokio::test] + async fn should_pass_integer_dimensions_as_an_integer() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/embeddings")) + .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) + .mount(&server) + .await; + + let provider = create_openai(OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }) + .unwrap(); + let model = provider.embedding_model("text-embedding-3-large").unwrap(); + let mut options = default_options(test_values()); + options.provider_options = Some( + aimux_core::shared::provider_namespace("openai", json!({ "dimensions": 64 })).unwrap(), + ); + model.do_embed(&options).await.expect("should succeed"); + + let requests = server.received_requests().await.expect("requests recorded"); + let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!(body["dimensions"], json!(64)); + } + + /// partial: `should pass headers`; full port in src/openai/tests/embedding/embedding_model.rs. + /// Verifies that Authorization, OpenAI-Organization, OpenAI-Project, config-level headers, and request-level headers are all sent. + #[tokio::test] + async fn should_pass_headers() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/embeddings")) + .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) + .mount(&server) + .await; + + let mut config_headers = HashMap::new(); + config_headers.insert( + "Custom-Provider-Header".to_string(), + "provider-header-value".to_string(), + ); + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + organization: Some("test-organization".into()), + project: Some("test-project".into()), + headers: Some( + config_headers + .into_iter() + .map(|(k, v)| (k, Some(v))) + .collect(), + ), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.embedding_model("text-embedding-3-large").unwrap(); + + let mut request_headers = HashMap::new(); + request_headers.insert( + "Custom-Request-Header".to_string(), + "request-header-value".to_string(), + ); + + let options = EmbeddingCallOptions { + values: test_values(), + abort_signal: None, + provider_options: None, + headers: Some(request_headers), + max_retries: None, + timeout: None, + }; + + let _ = model.do_embed(&options).await.expect("should succeed"); + + let requests = server.received_requests().await.expect("requests recorded"); + assert_eq!(requests.len(), 1); + + let h = &requests[0].headers; + assert_eq!( + h.get("authorization").and_then(|v| v.to_str().ok()), + Some("Bearer test-api-key") + ); + assert_eq!( + h.get("openai-organization").and_then(|v| v.to_str().ok()), + Some("test-organization") + ); + assert_eq!( + h.get("openai-project").and_then(|v| v.to_str().ok()), + Some("test-project") + ); + assert_eq!( + h.get("custom-provider-header") + .and_then(|v| v.to_str().ok()), + Some("provider-header-value") + ); + assert_eq!( + h.get("custom-request-header").and_then(|v| v.to_str().ok()), + Some("request-header-value") + ); + } +} diff --git a/aimux-providers/src/openai/tests/evaluation.rs b/aimux-providers/src/openai/tests/evaluation.rs index 8718820b5..e7973d49b 100644 --- a/aimux-providers/src/openai/tests/evaluation.rs +++ b/aimux-providers/src/openai/tests/evaluation.rs @@ -269,3 +269,688 @@ async fn rejects_a_refusal_instead_of_returning_partial_answers() { assert!(matches!(error, AiMuxError::ApiCall(_)), "{error:?}"); } + +// ── Older ports (previously `tests/openai_decision_test.rs`) ── +// +// Official-contract tests against a local HTTP server; no live API access. +// +// Two cases install the process-wide recorder (`recording::init_recording`), +// which records every `decide` call in the binary while it is installed, so +// every case that calls `decide` shares the `recording` key. + +mod older_decision { + use std::collections::HashMap; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + use crate::{OpenAIProvider, OpenAIProviderSettings, PresetSettings, create_openai}; + use aimux_core::decision_model::*; + use aimux_core::recording::{self, RingRecorder}; + use aimux_core::replay::{MockDecisionReplayModel, replay_decision_with_model}; + use aimux_core::shared::SharedProviderOptions; + use aimux_core::{AiMuxError, Provider}; + use indexmap::IndexMap; + use serde_json::{Value, json}; + use serial_test::serial; + use wiremock::matchers::{body_json, header, method, path}; + use wiremock::{Mock, MockServer, Request, ResponseTemplate}; + + fn object(value: Value) -> serde_json::Map { + value + .as_object() + .cloned() + .expect("provider options must be an object") + } + + fn fixture() -> Value { + serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/openai_decisions.json" + ))) + .unwrap() + } + fn options() -> DecideOptions { + serde_json::from_value(fixture()["options"].clone()).unwrap() + } + fn provider(server: &MockServer, api_key: &str) -> OpenAIProvider { + create_openai(OpenAIProviderSettings { + api_key: Some(api_key.into()), + base_url: Some(format!("{}/v1", server.uri())), + organization: Some("org-test".into()), + project: Some("proj-test".into()), + headers: Some(HashMap::from([( + "X-Client".into(), + Some("decision-tests".into()), + )])), + ..Default::default() + }) + .unwrap() + } + fn model(server: &MockServer) -> Arc { + provider(server, "decision-secret") + .decision_model("gpt-6-luna") + .unwrap() + .unwrap() + } + /// The caller's registry at replay time: the same OpenAI settings at the + /// replay server, with another key. + fn rebuild( + record: &aimux_core::recording::ProviderRecord, + server: &MockServer, + ) -> Arc { + let registry = aimux_core::create_provider_registry( + std::collections::BTreeMap::from([( + "openai".into(), + Arc::new(provider(server, "replacement-key")) as Arc, + )]), + Default::default(), + ); + crate::rebuild_decision_provider(record, ®istry).unwrap() + } + async fn mount(server: &MockServer, response: Value) { + Mock::given(method("POST")) + .and(path("/v1/decisions")) + .respond_with(ResponseTemplate::new(200).set_body_json(response)) + .mount(server) + .await; + } + + #[tokio::test] + #[serial(recording)] + async fn native_wire_maps_all_questions_to_v4_answers() { + let server = MockServer::start().await; + let mut expected = fixture()["request"].clone(); + expected["safety_identifier"] = json!("opaque-user"); + Mock::given(method("POST")) + .and(path("/v1/decisions")) + .and(header("authorization", "Bearer decision-secret")) + .and(header("openai-organization", "org-test")) + .and(header("openai-project", "proj-test")) + .and(header("x-client", "decision-tests")) + .and(header("x-per-call", "yes")) + .and(body_json(expected)) + .respond_with(ResponseTemplate::new(200).set_body_json(&fixture()["response"])) + .expect(1) + .mount(&server) + .await; + let mut request = options(); + request.headers = Some(HashMap::from([("X-Per-Call".into(), "yes".into())])); + request.provider_options = Some(aimux_core::shared::SharedProviderOptions::from([ + ( + "openai".into(), + object(json!({"safetyIdentifier":"opaque-user"})), + ), + ("other-provider".into(), object(json!({"ignored":true}))), + ])); + let result = decide(model(&server).as_ref(), request).await.unwrap(); + assert_eq!( + result.answers.keys().collect::>(), + ["urgent", "team", "severity"] + ); + assert_eq!( + result.answers["urgent"], + DecisionAnswer::Boolean { probability: 0.925 } + ); + assert_eq!( + result.answers["team"], + DecisionAnswer::Choice { + choice: "support".into(), + probabilities: Some(IndexMap::from([ + ("billing".to_string(), 0.0625), + ("support".to_string(), 0.9375), + ])), + } + ); + assert_eq!( + result.answers["severity"], + DecisionAnswer::Score { + score: 1.5, + probabilities: Some(IndexMap::from([ + ("0".to_string(), 0.125), + ("1".to_string(), 0.25), + ("2".to_string(), 0.625), + ])), + } + ); + assert_eq!(result.rounding, None); + assert_eq!( + Value::Object(result.provider_metadata.unwrap()["openai"].clone()), + json!({ + "probabilitySource": "native", + "answers": {"team": {"confidence": 0.9}, "severity": {"confidence": 0.6}} + }) + ); + assert_eq!( + result.response.body.as_ref().unwrap(), + &fixture()["response"] + ); + assert_eq!(result.response.model_id.as_deref(), Some("gpt-6-luna")); + assert_eq!( + result.usage, + DecideUsage { + input_tokens: Some(100), + output_tokens: Some(0), + total_tokens: Some(100), + } + ); + } + + /// A decision returns every answer or none (`ai@7.0.128 + /// src/decide/validate-decision.ts:197-202`): a refused question fails the + /// call, with the exchange's HTTP context. + #[tokio::test] + #[serial(recording)] + async fn a_refused_question_fails_the_decision() { + let server = MockServer::start().await; + let mut response = fixture()["response"].clone(); + response["answers"][0] = json!({"name": "urgent", "type": "refusal"}); + mount(&server, response).await; + let AiMuxError::ApiCall(error) = decide(model(&server).as_ref(), options()) + .await + .unwrap_err() + else { + panic!("expected HTTP context"); + }; + assert_eq!(error.status_code, Some(200)); + assert!(!error.is_retryable); + assert!( + error + .message + .ends_with("OpenAI declined to answer question \"urgent\""), + "{}", + error.message + ); + assert_eq!(server.received_requests().await.unwrap().len(), 1); + } + + #[tokio::test] + #[serial(recording)] + async fn response_order_and_usage_metadata_do_not_discard_answers() { + for usage in [ + Value::Null, + json!({}), + json!({"input_tokens":12}), + json!({"input_tokens":1,"output_tokens":0,"total_tokens":9,"input_tokens_details":{"cached_tokens":2,"cache_write_tokens":0}}), + ] { + let server = MockServer::start().await; + let mut response = fixture()["response"].clone(); + response["usage"] = usage.clone(); + response["answers"].as_array_mut().unwrap().reverse(); + response["answers"][1]["probabilities"] + .as_array_mut() + .unwrap() + .reverse(); + mount(&server, response).await; + let result = decide(model(&server).as_ref(), options()).await.unwrap(); + assert_eq!( + result.answers.keys().collect::>(), + ["urgent", "team", "severity"] + ); + let count = |key: &str| usage.get(key).and_then(Value::as_u64).map(|n| n as u32); + assert_eq!(result.usage.input_tokens, count("input_tokens")); + assert_eq!(result.usage.output_tokens, count("output_tokens")); + } + } + + #[tokio::test] + #[serial(recording)] + async fn json_state_is_sent_as_text_evidence() { + let server = MockServer::start().await; + mount(&server, fixture()["response"].clone()).await; + let mut request = options(); + request.state = + serde_json::from_value(json!({"messages":[{"role":"user","content":"ordinary JSON"}]})) + .unwrap(); + decide(model(&server).as_ref(), request.clone()) + .await + .unwrap(); + let body: Value = server.received_requests().await.unwrap()[0] + .body_json() + .unwrap(); + assert_eq!( + body["input"], + serde_json::to_string(&request.state).unwrap() + ); + } + + #[tokio::test] + #[serial(recording)] + async fn malformed_answers_fail_with_http_context() { + let mut cases = Vec::new(); + for (pointer, value) in [ + ("/answers/0/name", json!("unknown")), + ("/answers/0/name", Value::Null), + ("/answers/0/probability", json!(1.1)), + ("/answers/1/choice", json!(true)), + ("/answers/1/choice", json!("unknown")), + ("/answers/1/probabilities/1/value", json!("billing")), + ("/answers/1/probabilities/1/probability", json!(0.5)), + ("/answers/2/probabilities/1/value", json!(0)), + ("/answers/2/probabilities/1/value", json!(9)), + ("/answers/2/probabilities/1/label", json!("wrong")), + ("/answers/2/score", json!(0.1)), + ("/answers/2/name", json!("urgent")), + ("/answers/2/type", json!("skipped")), + ("/answers/2/type", json!("refusal")), + ] { + let mut response = fixture()["response"].clone(); + *response.pointer_mut(pointer).unwrap() = value; + cases.push(response); + } + let mut missing = fixture()["response"].clone(); + missing["answers"].as_array_mut().unwrap().pop(); + cases.push(missing); + let mut extra = fixture()["response"].clone(); + extra["answers"] + .as_array_mut() + .unwrap() + .push(json!({"name":"extra","type":"refusal"})); + cases.push(extra); + for response in cases { + let server = MockServer::start().await; + mount(&server, response).await; + let error = decide(model(&server).as_ref(), options()) + .await + .unwrap_err(); + let AiMuxError::ApiCall(error) = error else { + panic!("expected HTTP context: {error}"); + }; + assert_eq!(error.status_code, Some(200)); + assert!(!error.is_retryable); + assert!(error.response_body.is_some()); + assert_eq!(server.received_requests().await.unwrap().len(), 1); + } + } + + #[tokio::test] + #[serial(recording)] + async fn unsupported_native_fields_and_limits_fail_before_http() { + let server = MockServer::start().await; + let mut cases = Vec::new(); + let mut guidance = fixture()["options"].clone(); + guidance["questions"]["urgent"]["instructions"] = json!({"rule":"structured"}); + cases.push(guidance); + let mut criteria = fixture()["options"].clone(); + criteria["questions"]["urgent"]["criteria"] = json!({"true":"yes"}); + cases.push(criteria); + let mut levels = fixture()["options"].clone(); + levels["questions"]["severity"]["criteria"][0] = json!({"label":"low", "unsupported":true}); + cases.push(levels); + let mut choice = fixture()["options"].clone(); + choice["questions"]["team"]["criteria"] + .as_object_mut() + .unwrap() + .remove("support"); + cases.push(choice); + let mut opts = fixture()["options"].clone(); + opts["providerOptions"] = json!({"openai":{"temperature":1}}); + cases.push(opts); + let mut values = fixture()["options"].clone(); + values["providerOptions"] = json!({"openai":{"choiceValues":{"urgent":{"yes":true}}}}); + cases.push(values); + // aimux's own options are camelCase; the snake_case names are unknown. + for snake in [ + json!({"safety_identifier":"opaque-user"}), + json!({"choice_values":{"team":{"billing":true}}}), + json!({"images":[{"data":"iVBORw0KGgo=","media_type":"image/png"}]}), + ] { + let mut options = fixture()["options"].clone(); + options["providerOptions"] = json!({ "openai": snake }); + cases.push(options); + } + for request in cases { + let request = serde_json::from_value(request).unwrap(); + assert!(decide(model(&server).as_ref(), request).await.is_err()); + } + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + #[serial(recording)] + async fn retry_and_standard_provider_errors_use_the_shared_transport() { + let server = MockServer::start().await; + let attempts = Arc::new(AtomicUsize::new(0)); + let counter = attempts.clone(); + Mock::given(path("/v1/decisions")) + .respond_with(move |_: &Request| { + if counter.fetch_add(1, Ordering::Relaxed) == 0 { + ResponseTemplate::new(429) + .insert_header("retry-after", "0") + .set_body_json( + json!({"error":{"message":"busy","code":"rate_limit_exceeded"}}), + ) + } else { + ResponseTemplate::new(200).set_body_json(&fixture()["response"]) + } + }) + .mount(&server) + .await; + let model = model(&server); + let mut request = options(); + request.max_retries = Some(1); + decide(model.as_ref(), request).await.unwrap(); + assert_eq!(attempts.load(Ordering::Relaxed), 2); + server.reset().await; + Mock::given(path("/v1/decisions")) + .respond_with( + ResponseTemplate::new(401) + .set_body_json(json!({"error":{"message":"bad key","code":"invalid_api_key"}})), + ) + .mount(&server) + .await; + let AiMuxError::ApiCall(error) = decide(model.as_ref(), options()).await.unwrap_err() + else { + panic!("API error"); + }; + assert_eq!(error.provider_code.as_deref(), Some("invalid_api_key")); + assert!(!error.is_retryable); + } + + /// The native model is the provider's decision model, which the registry + /// takes before the evaluation model (`ai@7.0.128 + /// src/registry/provider-registry.ts:447-461`); a provider without one + /// offers none. + #[test] + fn factory_is_explicit_and_preserves_the_provider_boundary() { + let provider = crate::create_provider( + "openai", + PresetSettings { + api_key: Some("key".to_string().into()), + ..Default::default() + }, + ) + .unwrap(); + let model = provider + .decision_model("deployment-alias") + .unwrap() + .unwrap(); + assert_eq!(model.model_id(), "deployment-alias"); + assert_eq!(model.provider(), "openai"); + assert_eq!(model.supported_question_types(), DecisionQuestionType::ALL); + assert!(matches!( + provider.decision_model(" "), + Some(Err(AiMuxError::InvalidArgument { .. })) + )); + let registry = aimux_core::create_provider_registry( + std::collections::BTreeMap::from([("openai".to_string(), provider)]), + Default::default(), + ); + assert_eq!( + registry + .decision_model("openai:gpt-6-luna") + .unwrap() + .provider(), + "openai" + ); + let other = crate::create_provider( + "deepseek", + PresetSettings { + api_key: Some("key".to_string().into()), + ..Default::default() + }, + ) + .unwrap(); + assert!(other.decision_model("any").is_none()); + } + + /// partial: `evaluates through the configured Responses API with strict structured output`; full port in src/openai/tests/evaluation.rs. + /// `@ai-sdk/openai@4.0.83 src/openai-provider.ts:355-358`: the evaluation + /// model decides through the Responses model as `"{name}.evaluation"`, with + /// the structured output named `evaluation` (`src/openai-evaluation.test.ts:77`). + #[tokio::test] + #[serial(recording)] + async fn evaluation_model_decides_through_the_responses_api() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "resp_1", + "object": "response", + "created_at": 1_700_000_000, + "status": "completed", + "model": "gpt-6-luna", + "output": [{ + "id": "msg_1", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "{\"q0\":0.75,\"q1\":\"c1\",\"q2\":1.5}", + "annotations": [] + }] + }], + "usage": {"input_tokens": 30, "output_tokens": 5, "total_tokens": 35} + }))) + .expect(1) + .mount(&server) + .await; + let model = provider(&server, "decision-secret").evaluation("gpt-6-luna"); + assert_eq!(model.provider(), "openai.evaluation"); + assert_eq!(model.model_id(), "gpt-6-luna"); + let result = decide(&model, options()).await.unwrap(); + assert_eq!( + result.answers["urgent"], + DecisionAnswer::Boolean { probability: 0.75 } + ); + assert_eq!( + result.answers["team"], + DecisionAnswer::Choice { + choice: "support".into(), + probabilities: None, + } + ); + assert_eq!(result.usage.total_tokens, Some(35)); + let body: Value = server.received_requests().await.unwrap()[0] + .body_json() + .unwrap(); + assert_eq!(body["text"]["format"]["name"], "evaluation"); + } + + struct StopRecording; + impl Drop for StopRecording { + fn drop(&mut self) { + recording::init_recording(None); + } + } + + #[tokio::test] + #[serial(recording)] + async fn unified_recording_replays_and_rebuilds_from_the_registered_provider() { + let server = MockServer::start().await; + mount(&server, fixture()["response"].clone()).await; + let recorder = Arc::new(RingRecorder::new()); + recording::init_recording(Some(recorder.clone())); + let _stop = StopRecording; + let result = decide(model(&server).as_ref(), options()).await.unwrap(); + recording::init_recording(None); + let records = recorder.completed(); + assert_eq!(records.len(), 1); + assert!(records[0].complete); + assert!( + !serde_json::to_string(&records) + .unwrap() + .contains("decision-secret") + ); + let mock = MockDecisionReplayModel::new("openai", "gpt-6-luna", records.clone()).unwrap(); + let replay = decide(&mock, options()).await.unwrap(); + assert_eq!( + serde_json::to_value(replay.answers).unwrap(), + serde_json::to_value(&result.answers).unwrap() + ); + let replay_server = MockServer::start().await; + crate::test_support::replay::mount_recording(&replay_server, &records[0]).await; + let rebuilt = rebuild(&records[0].provider, &replay_server); + let again = replay_decision_with_model(&records[0], rebuilt.as_ref()) + .await + .unwrap(); + assert_eq!( + serde_json::to_value(again.answers).unwrap(), + serde_json::to_value(result.answers).unwrap() + ); + let request = &replay_server.received_requests().await.unwrap()[0]; + assert_eq!(request.headers["authorization"], "Bearer replacement-key"); + assert_eq!(request.headers["openai-project"], "proj-test"); + assert_eq!(request.headers["x-client"], "decision-tests"); + } + + fn openai_options(request: &mut DecideOptions) -> &mut serde_json::Map { + request + .provider_options + .as_mut() + .unwrap() + .get_mut("openai") + .unwrap() + } + + #[tokio::test] + #[serial(recording)] + async fn images_typed_choices_and_score_descriptions_roundtrip_and_key_replay() { + let contract: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../contract-tests/fixtures/decision-openai-full.json" + ))) + .unwrap(); + let server = MockServer::start().await; + Mock::given(path("/v1/decisions")) + .and(body_json(contract["request"].clone())) + .respond_with(ResponseTemplate::new(200).set_body_json(&contract["response"])) + .mount(&server) + .await; + let request: DecideOptions = serde_json::from_value(contract["options"].clone()).unwrap(); + let recorder = Arc::new(RingRecorder::new()); + recording::init_recording(Some(recorder.clone())); + let _stop = StopRecording; + let result = decide(model(&server).as_ref(), request.clone()) + .await + .unwrap(); + recording::init_recording(None); + assert_eq!( + result.answers["choice"], + DecisionAnswer::Choice { + choice: "boolean_true".into(), + probabilities: Some(IndexMap::from([ + ("boolean_true".to_string(), 0.75), + ("text_true".to_string(), 0.25), + ])), + } + ); + assert_eq!( + result.answers["score"], + DecisionAnswer::Score { + score: 0.75, + probabilities: Some(IndexMap::from([ + ("0".to_string(), 0.25), + ("1".to_string(), 0.75), + ])), + } + ); + // The native value the server chose is provider metadata of the answer. + assert_eq!( + result.provider_metadata.as_ref().unwrap()["openai"]["answers"]["choice"], + json!({"confidence": 0.7, "value": true}) + ); + let records = recorder.completed(); + let mock = MockDecisionReplayModel::new("openai", "gpt-6-luna", records.clone()).unwrap(); + decide(&mock, request.clone()).await.unwrap(); + let mut changed = request.clone(); + openai_options(&mut changed).remove("images"); + assert!(decide(&mock, changed).await.is_err()); + let mut changed = request.clone(); + openai_options(&mut changed)["images"][0]["detail"] = json!("high"); + assert!(decide(&mock, changed).await.is_err()); + let replay_server = MockServer::start().await; + crate::test_support::replay::mount_recording(&replay_server, &records[0]).await; + let rebuilt = rebuild(&records[0].provider, &replay_server); + let again = replay_decision_with_model(&records[0], rebuilt.as_ref()) + .await + .unwrap(); + assert_eq!( + serde_json::to_value(result.answers).unwrap(), + serde_json::to_value(again.answers).unwrap() + ); + } + + #[tokio::test] + #[serial(recording)] + async fn malformed_image_answers_bound_error_context_but_preserve_wire_and_response_evidence() { + use base64::Engine; + + let bytes = vec![42; 8192]; + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); + let server = MockServer::start().await; + let raw = + json!({"model":"gpt-6-luna","answers":[],"metadata":{"api_key":"response-secret"}}); + mount(&server, raw.clone()).await; + let mut request = options(); + request.provider_options = Some(SharedProviderOptions::from([( + "openai".into(), + object( + json!({"images": [{"data": encoded, "mediaType": "image/png", "detail": "low"}]}), + ), + )])); + let error = decide(model(&server).as_ref(), request).await.unwrap_err(); + let AiMuxError::ApiCall(error) = error else { + panic!("expected contextual API error"); + }; + let data_url = format!("data:image/png;base64,{encoded}"); + assert_eq!(error.status_code, Some(200)); + assert!(!error.is_retryable); + assert_eq!( + error.request_body_values["input"][0]["content"][0]["image_url"], + format!("[STRING {} bytes]", data_url.len()) + ); + assert_eq!(error.request_body_values["model"], "gpt-6-luna"); + assert_eq!( + error.data.as_ref().unwrap()["metadata"]["api_key"], + "[REDACTED]" + ); + assert_eq!( + serde_json::from_str::(error.response_body.as_ref().unwrap()).unwrap(), + raw + ); + let calls = server.received_requests().await.unwrap(); + assert_eq!(calls.len(), 1); + let wire: Value = calls[0].body_json().unwrap(); + assert_eq!(wire["input"][0]["content"][0]["image_url"], data_url); + assert_eq!(wire["input"][0]["content"][0]["detail"], "low"); + } + + #[tokio::test] + #[serial(recording)] + async fn bad_media_and_duplicate_native_values_are_rejected_before_http() { + let contract: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../contract-tests/fixtures/decision-openai-full.json" + ))) + .unwrap(); + let server = MockServer::start().await; + for (pointer, value) in [ + ("/providerOptions/openai/images/0/data", json!("bad!")), + ("/providerOptions/openai/images/0/detail", json!("unknown")), + ( + "/providerOptions/openai/images/0/mediaType", + json!("text/plain"), + ), + ( + "/providerOptions/openai/choiceValues/choice/text_true", + json!(true), + ), + ("/questions/score/criteria/1/label", json!("low")), + ] { + let mut request = contract["options"].clone(); + *request.pointer_mut(pointer).unwrap() = value; + assert!( + decide( + model(&server).as_ref(), + serde_json::from_value(request).unwrap() + ) + .await + .is_err() + ); + } + assert!(server.received_requests().await.unwrap().is_empty()); + } +} diff --git a/aimux-providers/src/openai/tests/image/image_model.rs b/aimux-providers/src/openai/tests/image/image_model.rs index 93dddf7ce..2cab228fb 100644 --- a/aimux-providers/src/openai/tests/image/image_model.rs +++ b/aimux-providers/src/openai/tests/image/image_model.rs @@ -687,3 +687,949 @@ mod do_generate_image_editing { ); } } + +// ── Older ports (previously `tests/openai_image_test.rs`) ── +// +// Rust translation of the OpenAI image model tests. +// +// Source: `reference/ai/packages/openai/src/image/openai-image-model.test.ts` +// +// Each test spins up a `wiremock` mock server, configures a JSON response, +// creates an `OpenAIImageModel` pointing at the mock, calls `do_generate`, +// and asserts on the request body / headers / result. +// +// The TS tests inject a custom `currentDate` via `_internal` for timestamp +// assertions. The Rust model always uses `Utc::now()`; timestamp tests +// verify that a timestamp is present and that `model_id` matches, rather +// than asserting an exact timestamp value. + +mod older_image { + use serde_json::{Value, json}; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + use crate::{OpenAIProviderSettings, create_openai}; + use aimux_core::image_model::{ImageCallOptions, ImageFile, ImageFileData, ImageOutputs}; + use aimux_core::provider::Provider; + use aimux_core::shared::{AspectRatio, Size}; + + // ── helpers ───────────────────────────────────────────────────────────────── + + const PROMPT: &str = "A cute baby sea otter"; + + /// The standard generation fixture response (2 images, first with revised_prompt). + fn image_response_body() -> Value { + json!({ + "created": 1770935200, + "data": [ + { + "b64_json": "iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAA3CGNhQlgAADcIanVtYgAAAB5qdW1kYzJwYQARABCAAACqADibcQNj", + "revised_prompt": "A small and adorable baby sea otter. This little creature is covered in a thick and fluffy brown fur, its tiny paws are slightly visible. The otter has bright, curious eyes and it's floating on its back on a calm sea, surrounded by floating seaweed." + }, + { + "b64_json": "iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAEp2GNhQlgAASnYanVtYgAAAB5qdW1kYzJwYQARABCAAACqADibcQNj" + } + ] + }) + } + + /// The standard edit fixture response (1 image). + fn image_edit_response_body() -> Value { + json!({ + "created": 1770935251, + "background": "opaque", + "data": [ + { + "b64_json": "iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAFEE2NhQlgAAUQTanVtYgAAAB5qdW1kYzJwYQARABCAAACqADibcQNj" + } + ], + "output_format": "png", + "quality": "high", + "size": "1024x1024" + }) + } + + /// Mount a mock JSON response at `/images/generations`. + async fn mock_generations_response(server: &MockServer, body: Value) { + mock_generations_response_with_headers(server, body, &[]).await; + } + + /// Mount a mock JSON response at `/images/generations` with extra headers. + async fn mock_generations_response_with_headers( + server: &MockServer, + body: Value, + headers: &[(&str, &str)], + ) { + let mut template = ResponseTemplate::new(200).set_body_json(body); + for (k, v) in headers { + template = template.insert_header(*k, *v); + } + Mock::given(method("POST")) + .and(path("/images/generations")) + .respond_with(template) + .mount(server) + .await; + } + + /// Mount a mock JSON response at `/images/edits`. + async fn mock_edits_response(server: &MockServer, body: Value) { + mock_edits_response_with_headers(server, body, &[]).await; + } + + /// Mount a mock JSON response at `/images/edits` with extra headers. + async fn mock_edits_response_with_headers( + server: &MockServer, + body: Value, + headers: &[(&str, &str)], + ) { + let mut template = ResponseTemplate::new(200).set_body_json(body); + for (k, v) in headers { + template = template.insert_header(*k, *v); + } + Mock::given(method("POST")) + .and(path("/images/edits")) + .respond_with(template) + .mount(server) + .await; + } + + /// Build options with a prompt and n=1. + fn options(prompt: &str) -> ImageCallOptions { + ImageCallOptions::new(prompt.to_string()) + } + + /// Build a binary image file. + fn binary_file(media_type: &str, bytes: &[u8]) -> ImageFile { + ImageFile::File { + media_type: media_type.to_string(), + data: ImageFileData::Binary(bytes.to_vec()), + } + } + + /// Build a base64 image file. + fn base64_file(media_type: &str, b64: &str) -> ImageFile { + ImageFile::File { + media_type: media_type.to_string(), + data: ImageFileData::Base64(b64.to_string()), + } + } + + // ════════════════════════════════════════════════════════════════════════════ + // doGenerate — generation + // ════════════════════════════════════════════════════════════════════════════ + + mod do_generate { + use super::*; + + /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate › should extract the generated images + #[tokio::test] + async fn should_extract_the_generated_images() { + let server = MockServer::start().await; + mock_generations_response(&server, image_response_body()).await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.image_model("dall-e-3").unwrap(); + + let result = model.do_generate(&options(PROMPT)).await.unwrap(); + + match &result.images { + ImageOutputs::Base64(imgs) => { + assert_eq!(imgs.len(), 2); + assert_eq!( + imgs[0], + "iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAA3CGNhQlgAADcIanVtYgAAAB5qdW1kYzJwYQARABCAAACqADibcQNj" + ); + assert_eq!( + imgs[1], + "iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAEp2GNhQlgAASnYanVtYgAAAB5qdW1kYzJwYQARABCAAACqADibcQNj" + ); + } + _ => panic!("expected Base64 images"), + } + } + + /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate › should return warnings for unsupported settings + #[tokio::test] + async fn should_return_warnings_for_unsupported_settings() { + let server = MockServer::start().await; + mock_generations_response(&server, image_response_body()).await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.image_model("dall-e-3").unwrap(); + + let mut opts = options(PROMPT); + opts.n = 1; + opts.size = Some(Size::new(1024, 1024)); + opts.aspect_ratio = Some(AspectRatio::new(1, 1)); + opts.seed = Some(123); + + let result = model.do_generate(&opts).await.unwrap(); + + assert_eq!(result.warnings.len(), 2); + // aspectRatio warning + match &result.warnings[0] { + aimux_core::types::Warning::Unsupported { feature, details } => { + assert_eq!(feature, "aspectRatio"); + assert_eq!( + details.as_deref(), + Some("This model does not support aspect ratio. Use `size` instead.") + ); + } + _ => panic!("expected Unsupported warning for aspectRatio"), + } + // seed warning + match &result.warnings[1] { + aimux_core::types::Warning::Unsupported { feature, .. } => { + assert_eq!(feature, "seed"); + } + _ => panic!("expected Unsupported warning for seed"), + } + } + + /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate › should respect maxImagesPerCall setting + #[tokio::test] + async fn should_respect_max_images_per_call_setting() { + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + + let dall_e_2 = provider.image_model("dall-e-2").unwrap(); + assert_eq!(dall_e_2.max_images_per_call(), Some(10)); + + let future_gpt = provider.image_model("gpt-image-99").unwrap(); + assert_eq!(future_gpt.max_images_per_call(), Some(10)); + + let unknown = provider.image_model("unknown-model").unwrap(); + assert_eq!(unknown.max_images_per_call(), Some(1)); + } + + /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › 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 server = MockServer::start().await; + mock_generations_response(&server, image_response_body()).await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.image_model("dall-e-3").unwrap(); + + let mut opts = options(PROMPT); + opts.n = 1; + opts.size = Some(Size::new(1024, 1024)); + + let before = chrono::Utc::now().timestamp_millis(); + let result = model.do_generate(&opts).await.unwrap(); + let after = chrono::Utc::now().timestamp_millis(); + + // A `Date`: millisecond precision. + let ts = result.response.timestamp.unwrap().unix_millis(); + assert!(ts >= before); + assert!(ts <= after); + assert_eq!(result.response.model_id.as_deref(), Some("dall-e-3")); + } + + /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate › should include response_format for dall-e-3 + #[tokio::test] + async fn should_include_response_format_for_dall_e_3() { + let server = MockServer::start().await; + mock_generations_response(&server, image_response_body()).await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.image_model("dall-e-3").unwrap(); + + let mut opts = options(PROMPT); + opts.n = 1; + opts.size = Some(Size::new(1024, 1024)); + + model.do_generate(&opts).await.unwrap(); + + let requests = server.received_requests().await.expect("requests recorded"); + let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!(body["response_format"], "b64_json"); + } + + /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate › should map OpenAI usage to usage + #[tokio::test] + async fn should_map_openai_usage_to_usage() { + let server = MockServer::start().await; + mock_generations_response( + &server, + json!({ + "created": 1733837122, + "data": [ + { "b64_json": "base64-image-1" } + ], + "usage": { + "input_tokens": 12, + "output_tokens": 0, + "total_tokens": 12, + "input_tokens_details": { + "image_tokens": 7, + "text_tokens": 5 + } + } + }), + ) + .await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.image_model("gpt-image-1").unwrap(); + + let mut opts = options(PROMPT); + opts.n = 1; + opts.size = Some(Size::new(1024, 1024)); + + let result = model.do_generate(&opts).await.unwrap(); + + let usage = result.usage.unwrap(); + assert_eq!(usage.input_tokens, Some(12)); + assert_eq!(usage.output_tokens, Some(0)); + assert_eq!(usage.total_tokens, Some(12)); + + let meta = result.provider_metadata.unwrap(); + let openai = meta.get("openai").unwrap(); + let images = openai.get("images").unwrap().as_array().unwrap(); + assert_eq!(images[0].get("imageTokens"), Some(&json!(7))); + assert_eq!(images[0].get("textTokens"), Some(&json!(5))); + } + + /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate › should distribute input token details evenly across images + #[tokio::test] + async fn should_distribute_input_token_details_evenly_across_images() { + let server = MockServer::start().await; + mock_generations_response( + &server, + json!({ + "created": 1733837122, + "data": [ + { "b64_json": "base64-image-1" }, + { "b64_json": "base64-image-2" }, + { "b64_json": "base64-image-3" } + ], + "usage": { + "input_tokens": 30, + "output_tokens": 900, + "total_tokens": 930, + "input_tokens_details": { + "image_tokens": 194, + "text_tokens": 28 + } + } + }), + ) + .await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.image_model("gpt-image-1").unwrap(); + + let mut opts = options(PROMPT); + opts.n = 3; + opts.size = Some(Size::new(1024, 1024)); + + let result = model.do_generate(&opts).await.unwrap(); + + let meta = result.provider_metadata.unwrap(); + let openai = meta.get("openai").unwrap(); + let images = openai.get("images").unwrap().as_array().unwrap(); + assert_eq!(images.len(), 3); + assert_eq!(images[0].get("imageTokens"), Some(&json!(64))); + assert_eq!(images[0].get("textTokens"), Some(&json!(9))); + assert_eq!(images[1].get("imageTokens"), Some(&json!(64))); + assert_eq!(images[1].get("textTokens"), Some(&json!(9))); + assert_eq!(images[2].get("imageTokens"), Some(&json!(66))); + assert_eq!(images[2].get("textTokens"), Some(&json!(10))); + } + } + + mod do_generate_image_editing { + use super::*; + + /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate - image editing › should call /images/edits endpoint when files are provided + #[tokio::test] + async fn should_call_edits_endpoint_when_files_are_provided() { + let server = MockServer::start().await; + mock_edits_response(&server, image_edit_response_body()).await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.image_model("gpt-image-1").unwrap(); + + let mut opts = options(PROMPT); + opts.n = 1; + opts.size = Some(Size::new(1024, 1024)); + opts.files = Some(vec![binary_file("image/png", &[137, 80, 78, 71])]); + + model.do_generate(&opts).await.unwrap(); + + let requests = server.received_requests().await.expect("requests recorded"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].url.path(), "/images/edits"); + } + + /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate - image editing › should send multiple images as form data array + #[tokio::test] + async fn should_send_multiple_images_as_form_data_array() { + let server = MockServer::start().await; + mock_edits_response(&server, image_edit_response_body()).await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.image_model("gpt-image-1").unwrap(); + + let mut opts = options(PROMPT); + opts.n = 1; + opts.files = Some(vec![ + binary_file("image/png", &[137, 80, 78, 71]), + binary_file("image/jpeg", &[255, 216, 255, 224]), + ]); + + model.do_generate(&opts).await.unwrap(); + + let requests = server.received_requests().await.expect("requests recorded"); + assert_eq!(requests.len(), 1); + let body_str = String::from_utf8_lossy(&requests[0].body); + assert!( + body_str.contains("name=\"image[]\""), + "should contain image[] field for multiple images: {body_str}" + ); + } + + /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate - image editing › should extract the edited images from response + #[tokio::test] + async fn should_extract_the_edited_images_from_response() { + let server = MockServer::start().await; + mock_edits_response(&server, image_edit_response_body()).await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.image_model("gpt-image-1").unwrap(); + + let mut opts = options(PROMPT); + opts.n = 1; + opts.files = Some(vec![binary_file("image/png", &[137, 80, 78, 71])]); + + let result = model.do_generate(&opts).await.unwrap(); + + match &result.images { + ImageOutputs::Base64(imgs) => { + assert_eq!(imgs.len(), 1); + assert_eq!( + imgs[0], + "iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAFEE2NhQlgAAUQTanVtYgAAAB5qdW1kYzJwYQARABCAAACqADibcQNj" + ); + } + _ => panic!("expected Base64 images"), + } + } + + /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate - image editing › should include response metadata for edited images + #[tokio::test] + async fn should_include_response_metadata_for_edited_images() { + let server = MockServer::start().await; + mock_edits_response_with_headers( + &server, + image_edit_response_body(), + &[("x-request-id", "edit-request-id")], + ) + .await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.image_model("gpt-image-1").unwrap(); + + let mut opts = options(PROMPT); + opts.n = 1; + opts.files = Some(vec![binary_file("image/png", &[137, 80, 78, 71])]); + + let result = model.do_generate(&opts).await.unwrap(); + + assert!(result.response.timestamp.is_some()); + assert_eq!(result.response.model_id.as_deref(), Some("gpt-image-1")); + let headers = result.response.headers.as_ref().unwrap(); + assert_eq!( + headers.get("x-request-id").map(std::string::String::as_str), + Some("edit-request-id") + ); + } + + /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate - image editing › should return usage information for edited images + #[tokio::test] + async fn should_return_usage_information_for_edited_images() { + let server = MockServer::start().await; + mock_edits_response( + &server, + json!({ + "created": 1733837122, + "data": [ + { "b64_json": "edited-base64-image-1" } + ], + "usage": { + "input_tokens": 25, + "output_tokens": 0, + "total_tokens": 25 + } + }), + ) + .await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.image_model("gpt-image-1").unwrap(); + + let mut opts = options(PROMPT); + opts.n = 1; + opts.files = Some(vec![binary_file("image/png", &[137, 80, 78, 71])]); + + let result = model.do_generate(&opts).await.unwrap(); + + let usage = result.usage.unwrap(); + assert_eq!(usage.input_tokens, Some(25)); + assert_eq!(usage.output_tokens, Some(0)); + assert_eq!(usage.total_tokens, Some(25)); + } + } + + /// partial: `should include response data with timestamp, modelId and headers`; full port in src/openai/tests/image/image_model.rs. + #[tokio::test] + async fn should_include_response_data_with_timestamp_model_id_and_headers() { + let server = MockServer::start().await; + mock_generations_response_with_headers( + &server, + image_response_body(), + &[ + ("x-request-id", "test-request-id"), + ("x-ratelimit-remaining", "123"), + ], + ) + .await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.image_model("dall-e-3").unwrap(); + + let mut opts = options(PROMPT); + opts.n = 1; + opts.size = Some(Size::new(1024, 1024)); + + let result = model.do_generate(&opts).await.unwrap(); + + assert!(result.response.timestamp.is_some()); + assert_eq!(result.response.model_id.as_deref(), Some("dall-e-3")); + let headers = result.response.headers.as_ref().unwrap(); + assert_eq!( + headers.get("x-request-id").map(std::string::String::as_str), + Some("test-request-id") + ); + assert_eq!( + headers + .get("x-ratelimit-remaining") + .map(std::string::String::as_str), + Some("123") + ); + } + + /// partial: `should not include response_format for gpt-image-1`; full port in src/openai/tests/image/image_model.rs. + #[tokio::test] + async fn should_not_include_response_format_for_gpt_image_1() { + let server = MockServer::start().await; + mock_generations_response(&server, image_response_body()).await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.image_model("gpt-image-1").unwrap(); + + let mut opts = options(PROMPT); + opts.n = 1; + opts.size = Some(Size::new(1024, 1024)); + + model.do_generate(&opts).await.unwrap(); + + let requests = server.received_requests().await.expect("requests recorded"); + let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!(body["model"], "gpt-image-1"); + assert_eq!(body["prompt"], PROMPT); + assert_eq!(body["n"], 1); + assert_eq!(body["size"], "1024x1024"); + assert!(body.get("response_format").is_none()); + } + + /// partial: `should not include response_format for gpt-image-2`; full port in src/openai/tests/image/image_model.rs. + #[tokio::test] + async fn should_not_include_response_format_for_gpt_image_2() { + let server = MockServer::start().await; + mock_generations_response(&server, image_response_body()).await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.image_model("gpt-image-2").unwrap(); + + let mut opts = options(PROMPT); + opts.n = 1; + opts.size = Some(Size::new(1024, 1024)); + + model.do_generate(&opts).await.unwrap(); + + let requests = server.received_requests().await.expect("requests recorded"); + let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!(body["model"], "gpt-image-2"); + assert!(body.get("response_format").is_none()); + } + + /// partial: `should not include response_format for future gpt-image models`; full port in src/openai/tests/image/image_model.rs. + #[tokio::test] + async fn should_not_include_response_format_for_future_gpt_image_models() { + let server = MockServer::start().await; + mock_generations_response(&server, image_response_body()).await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.image_model("gpt-image-99").unwrap(); + + let mut opts = options(PROMPT); + opts.n = 1; + opts.size = Some(Size::new(1024, 1024)); + + model.do_generate(&opts).await.unwrap(); + + let requests = server.received_requests().await.expect("requests recorded"); + let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!(body["model"], "gpt-image-99"); + assert!(body.get("response_format").is_none()); + } + + /// partial: `should not include response_format for chatgpt-image-latest`; full port in src/openai/tests/image/image_model.rs. + #[tokio::test] + async fn should_not_include_response_format_for_chatgpt_image_latest() { + let server = MockServer::start().await; + mock_generations_response(&server, image_response_body()).await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.image_model("chatgpt-image-latest").unwrap(); + + let mut opts = options(PROMPT); + opts.n = 1; + opts.size = Some(Size::new(1024, 1024)); + + model.do_generate(&opts).await.unwrap(); + + let requests = server.received_requests().await.expect("requests recorded"); + let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!(body["model"], "chatgpt-image-latest"); + assert!(body.get("response_format").is_none()); + } + + /// partial: `should not include response_format for date-suffixed gpt-image model IDs (Azure deployment names)`; full port in src/openai/tests/image/image_model.rs. + #[tokio::test] + async fn should_not_include_response_format_for_date_suffixed_gpt_image() { + let server = MockServer::start().await; + mock_generations_response(&server, image_response_body()).await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.image_model("gpt-image-1.5-2025-12-16").unwrap(); + + let mut opts = options(PROMPT); + opts.n = 1; + opts.size = Some(Size::new(1024, 1024)); + + model.do_generate(&opts).await.unwrap(); + + let requests = server.received_requests().await.expect("requests recorded"); + let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!(body["model"], "gpt-image-1.5-2025-12-16"); + assert!(body.get("response_format").is_none()); + } + + /// partial: `should handle null revised_prompt responses`; full port in src/openai/tests/image/image_model.rs. + #[tokio::test] + async fn should_handle_null_revised_prompt_responses() { + let server = MockServer::start().await; + mock_generations_response( + &server, + json!({ + "created": 1733837122, + "data": [ + { + "revised_prompt": null, + "b64_json": "base64-image-1" + } + ] + }), + ) + .await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.image_model("gpt-image-1").unwrap(); + + let mut opts = options(PROMPT); + opts.n = 1; + opts.size = Some(Size::new(1024, 1024)); + + let result = model.do_generate(&opts).await.unwrap(); + + match &result.images { + ImageOutputs::Base64(imgs) => { + assert_eq!(imgs, &["base64-image-1".to_string()]); + } + _ => panic!("expected Base64 images"), + } + assert!(result.warnings.is_empty()); + + let meta = result.provider_metadata.unwrap(); + let openai = meta.get("openai").unwrap(); + let images = openai.get("images").unwrap().as_array().unwrap(); + assert_eq!(images.len(), 1); + assert_eq!(images[0].get("created"), Some(&json!(1733837122))); + // revisedPrompt should be absent (revised_prompt was null) + assert!(images[0].get("revisedPrompt").is_none()); + // size, quality, background, outputFormat should be absent (not in response) + assert!(images[0].get("size").is_none()); + assert!(images[0].get("quality").is_none()); + assert!(images[0].get("background").is_none()); + assert!(images[0].get("outputFormat").is_none()); + } + + // ════════════════════════════════════════════════════════════════════════════ + // doGenerate — image editing + // ════════════════════════════════════════════════════════════════════════════ + + /// partial: `should send image as form data with Uint8Array input`; full port in src/openai/tests/image/image_model.rs. + #[tokio::test] + async fn should_send_image_as_form_data_with_uint8array_input() { + let server = MockServer::start().await; + mock_edits_response(&server, image_edit_response_body()).await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.image_model("gpt-image-1").unwrap(); + + let mut opts = options(PROMPT); + opts.n = 1; + opts.size = Some(Size::new(1024, 1024)); + opts.files = Some(vec![binary_file("image/png", &[137, 80, 78, 71])]); + + model.do_generate(&opts).await.unwrap(); + + let requests = server.received_requests().await.expect("requests recorded"); + assert_eq!(requests.len(), 1); + let body_str = String::from_utf8_lossy(&requests[0].body); + assert!( + body_str.contains("name=\"model\""), + "should contain model field: {body_str}" + ); + assert!( + body_str.contains("gpt-image-1"), + "should contain model value: {body_str}" + ); + assert!( + body_str.contains("name=\"prompt\""), + "should contain prompt field: {body_str}" + ); + assert!( + body_str.contains(PROMPT), + "should contain prompt value: {body_str}" + ); + assert!( + body_str.contains("name=\"n\""), + "should contain n field: {body_str}" + ); + assert!( + body_str.contains("name=\"size\""), + "should contain size field: {body_str}" + ); + assert!( + body_str.contains("1024x1024"), + "should contain size value: {body_str}" + ); + assert!( + body_str.contains("name=\"image\""), + "should contain image field: {body_str}" + ); + } + + /// partial: `should send image as form data with base64 string input`; full port in src/openai/tests/image/image_model.rs. + #[tokio::test] + async fn should_send_image_as_form_data_with_base64_string_input() { + let server = MockServer::start().await; + mock_edits_response(&server, image_edit_response_body()).await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.image_model("gpt-image-1").unwrap(); + + let mut opts = options(PROMPT); + opts.n = 1; + opts.files = Some(vec![base64_file("image/png", "iVBORw0KGgo=")]); + + model.do_generate(&opts).await.unwrap(); + + let requests = server.received_requests().await.expect("requests recorded"); + assert_eq!(requests.len(), 1); + let body_str = String::from_utf8_lossy(&requests[0].body); + assert!( + body_str.contains("name=\"model\""), + "should contain model: {body_str}" + ); + assert!( + body_str.contains("gpt-image-1"), + "should contain model value: {body_str}" + ); + assert!( + body_str.contains("name=\"image\""), + "should contain image field: {body_str}" + ); + } + + /// partial: `should return warnings for unsupported settings in edit mode`; full port in src/openai/tests/image/image_model.rs. + #[tokio::test] + async fn should_return_warnings_for_unsupported_settings_in_edit_mode() { + let server = MockServer::start().await; + mock_edits_response(&server, image_edit_response_body()).await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.image_model("gpt-image-1").unwrap(); + + let mut opts = options(PROMPT); + opts.n = 1; + opts.size = Some(Size::new(1024, 1024)); + opts.aspect_ratio = Some(AspectRatio::new(16, 9)); + opts.seed = Some(42); + opts.files = Some(vec![binary_file("image/png", &[137, 80, 78, 71])]); + + let result = model.do_generate(&opts).await.unwrap(); + + assert_eq!(result.warnings.len(), 2); + match &result.warnings[0] { + aimux_core::types::Warning::Unsupported { feature, .. } => { + assert_eq!(feature, "aspectRatio"); + } + _ => panic!("expected Unsupported warning for aspectRatio"), + } + match &result.warnings[1] { + aimux_core::types::Warning::Unsupported { feature, .. } => { + assert_eq!(feature, "seed"); + } + _ => panic!("expected Unsupported warning for seed"), + } + } + + /// File and mask support follow the model id like the AI SDK + /// (`image/openai-image-model.ts:61-92`): known editing models `true`, + /// `dall-e-3` `false`, anything else unknown. + #[test] + fn image_input_capabilities_follow_the_model_id() { + use aimux_core::image_model::ImageModel; + let provider = create_openai(OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some("https://example.test/v1".into()), + ..Default::default() + }) + .unwrap(); + for (model_id, expected) in [ + ("dall-e-2", Some(true)), + ("gpt-image-1", Some(true)), + ("gpt-image-2.5-sunburst-2026-09-08", Some(true)), + ("chatgpt-image-latest", Some(true)), + ("dall-e-3", Some(false)), + ("gpt-image-custom", None), + ] { + let model = provider.image(model_id); + assert_eq!(model.supports_file_inputs(), expected, "{model_id}"); + assert_eq!(model.supports_mask_inputs(), expected, "{model_id}"); + } + } +} diff --git a/aimux-providers/src/openai/tests/language_model_capabilities.rs b/aimux-providers/src/openai/tests/language_model_capabilities.rs index e6b62dc55..8b7c57def 100644 --- a/aimux-providers/src/openai/tests/language_model_capabilities.rs +++ b/aimux-providers/src/openai/tests/language_model_capabilities.rs @@ -1,4 +1,4 @@ -use crate::openai::convert_common::{SystemMessageMode, get_model_capabilities}; +use crate::openai::language_model_capabilities::{SystemMessageMode, get_model_capabilities}; mod get_open_ai_language_model_capabilities { use super::*; diff --git a/aimux-providers/src/openai/tests/normalize_json_schema.rs b/aimux-providers/src/openai/tests/normalize_json_schema.rs index af97e69b0..2800cc80b 100644 --- a/aimux-providers/src/openai/tests/normalize_json_schema.rs +++ b/aimux-providers/src/openai/tests/normalize_json_schema.rs @@ -1,7 +1,7 @@ use aimux_core::error::AiMuxError; use serde_json::{Value, json}; -use crate::openai::convert::normalize_json_schema; +use crate::openai::normalize_json_schema::normalize_json_schema; /// `normalizeOpenAIJsonSchema(schema)` as `{ schema, warnings }`. fn normalize(schema: &Value) -> Result { diff --git a/aimux-providers/src/openai/tests/provider.rs b/aimux-providers/src/openai/tests/provider.rs index 753227f1c..be1a83f6e 100644 --- a/aimux-providers/src/openai/tests/provider.rs +++ b/aimux-providers/src/openai/tests/provider.rs @@ -199,3 +199,953 @@ mod create_openai { } } } + +// ── Older ports (previously `tests/openai_factory_test.rs`) ── +// +// `create_openai` against the recorded AI SDK behavior. +// +// `fixtures/aisdk/openai/*.json` hold what `@ai-sdk/openai`'s `createOpenAI` +// 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 older_factory { + use aimux_core::tool::RawToolCall; + use std::collections::{BTreeMap, 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 serial_test::serial; + + use crate::openai::{DEFAULT_BASE_URL, OpenAIProvider, OpenAIProviderSettings, create_openai}; + use aimux_core::AiMuxError; + 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, + }; + + // ── 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 of a replayed case. The base URL is the default the + /// fixtures were recorded with, given explicitly so that a case does not + /// read `OPENAI_BASE_URL`, which the `#[serial]` cases of this binary set. + fn settings(mock: &Arc) -> OpenAIProviderSettings { + OpenAIProviderSettings { + api_key: Some(KEY.to_string()), + base_url: Some(DEFAULT_BASE_URL.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; 11] = [ + "chat-basic", + "chat-custom-name", + "chat-headers-merge", + "chat-provider-options", + "chat-stream-basic", + "chat-system-and-settings", + "chat-tools", + "embedding-basic", + "empty-api-key", + "missing-api-key", + "responses-basic", + ]; + + fn fixtures_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fixtures/aisdk/openai") + } + + 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. + fn assert_request(&self, seen: &Seen, authorization: &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/ 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/") + && 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/") { + assert!( + recorded_ua.starts_with(&format!("{package_ua} ")), + "{}: recorded user-agent {recorded_ua:?} does not start with {package_ua:?}", + self.name + ); + } + assert_eq!( + expected.get("authorization").map(String::as_str), + Some(""), + "{}: the recording redacts the key", + self.name + ); + expected.insert("authorization".to_string(), authorization.to_string()); + 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.into(), + 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 => panic!("unmapped role {other}"), + } + } + + /// 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(|value| serde_json::from_value(value.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}") + } + + // ── the fixtures ───────────────────────────────────────────────────────────── + + #[test] + fn every_fixture_is_replayed() { + let mut on_disk: Vec = std::fs::read_dir(fixtures_dir()) + .expect("fixtures/aisdk/openai 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" + ); + } + + /// Replay a chat fixture whose request is a plain `generateText`. + async fn replay_chat(name: &'static str) { + let fixture = Fixture::load(name); + let mock = fixture.mock(); + let provider = create_openai(settings(&mock)).unwrap(); + let model = provider.chat(fixture.model_id()); + 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], &bearer(KEY)); + assert_generate_result(&fixture, &result); + } + + #[tokio::test] + async fn chat_basic() { + replay_chat("chat-basic").await; + } + + #[tokio::test] + async fn chat_system_and_settings() { + replay_chat("chat-system-and-settings").await; + } + + #[tokio::test] + async fn chat_provider_options_use_the_fixed_openai_namespace() { + replay_chat("chat-provider-options").await; + } + + #[tokio::test] + async fn chat_tools() { + let fixture = Fixture::load("chat-tools"); + let mock = fixture.mock(); + let model = create_openai(settings(&mock)).unwrap().chat("gpt-4o"); + assert_eq!(model.provider(), fixture.provider_string()); + + let result = model.do_generate(&fixture.call_options()).await.unwrap(); + + fixture.assert_request(&mock.seen()[0], &bearer(KEY)); + assert_generate_result(&fixture, &result); + let recorded = &fixture.json["result"]["content"][0]; + let call = result + .content + .iter() + .find_map(|part| match part { + GenerateContent::ToolCall(RawToolCall { + tool_call_id, + tool_name, + input, + .. + }) => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), + _ => None, + }) + .expect("a tool call"); + assert_eq!(call.0, recorded["toolCallId"].as_str().unwrap()); + assert_eq!(call.1, recorded["toolName"].as_str().unwrap()); + assert_eq!( + serde_json::from_str::(&call.2).unwrap(), + recorded["input"] + ); + } + + #[tokio::test] + async fn chat_custom_name() { + let fixture = Fixture::load("chat-custom-name"); + let mock = fixture.mock(); + let provider = create_openai(OpenAIProviderSettings { + name: Some("proxy".to_string()), + ..settings(&mock) + }) + .unwrap(); + let model = provider.chat(fixture.model_id()); + assert_eq!(model.provider(), "proxy.chat"); + assert_eq!(model.provider(), fixture.provider_string()); + + let result = model.do_generate(&fixture.call_options()).await.unwrap(); + + // The name changes identity only, never the URL. + fixture.assert_request(&mock.seen()[0], &bearer(KEY)); + assert_generate_result(&fixture, &result); + assert_eq!( + fixture.json["observations"]["providerIsProxyChat"], + json!(true) + ); + } + + #[tokio::test] + async fn chat_headers_merge() { + let fixture = Fixture::load("chat-headers-merge"); + let mock = fixture.mock(); + // The recording was made with provider headers { "x-a": "1", "X-B": "2" } + // (scripts/aisdk-fixtures/cases/openai.mjs). + let headers: HeaderMapOpt = [("x-a", "1"), ("X-B", "2")] + .into_iter() + .map(|(k, v)| (k.to_string(), Some(v.to_string()))) + .collect(); + let provider = create_openai(OpenAIProviderSettings { + headers: Some(headers), + ..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], &bearer(KEY)); + assert_generate_result(&fixture, &result); + // Case-insensitive override: the call's `x-b` replaced the provider's + // `X-B`, and no second `x-b` went out. + 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"]) + ); + } + + #[tokio::test] + async fn chat_stream_basic() { + let fixture = Fixture::load("chat-stream-basic"); + let mock = fixture.mock(); + let model = create_openai(settings(&mock)).unwrap().chat("gpt-4o"); + 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; + + // The recording's user-agent names the provider package; the stream case + // is checked like the others. + assert!( + fixture.json["request"]["headers"]["user-agent"] + .as_str() + .unwrap() + .starts_with("ai-sdk-openai/"), + "the stream case is a provider-layer recording" + ); + let mut seen = mock.seen(); + assert_eq!(seen.len(), 1); + let sent = seen.remove(0); + fixture.assert_request(&sent, &bearer(KEY)); + + // Compare the text parts one by one with the recorded AI SDK + // `result.parts`: only text-start/delta/end are kept (which also drops + // the ai-layer-only start, start-step and finish-step), compared by id + // and text in order; finish is compared by finishReason and usage. + let expected = &fixture.json["result"]["parts"]; + let expected = expected.as_array().expect("result.parts"); + let expected_text: Vec<(String, String, Option)> = expected + .iter() + .filter_map(|part| { + let kind = part["type"].as_str()?; + kind.starts_with("text-").then(|| { + ( + kind.to_string(), + part["id"].as_str().unwrap().to_string(), + part["text"].as_str().map(str::to_string), + ) + }) + }) + .collect(); + let actual_text: Vec<(String, String, Option)> = parts + .iter() + .filter_map(|part| match part { + StreamPart::TextStart { id, .. } => Some(("text-start".into(), id.clone(), None)), + // The ai layer drops empty deltas without provider metadata + // (ai@7.0.127 stream-text.ts:2881-2891); the provider layer emits + // them (openai@4.0.83 openai-chat-language-model.ts:623-633), and + // so does aimux for the first `"content":""` chunk. + StreamPart::TextDelta { + id, + delta, + provider_metadata, + } if !delta.is_empty() || provider_metadata.is_some() => { + Some(("text-delta".into(), id.clone(), Some(delta.clone()))) + } + StreamPart::TextEnd { id, .. } => Some(("text-end".into(), id.clone(), None)), + _ => None, + }) + .collect(); + assert!( + parts.iter().any( + |part| matches!(part, StreamPart::TextDelta { delta, .. } if delta.is_empty()) + ) + ); + assert_eq!(actual_text, expected_text); + + let expected_finish = expected + .iter() + .find(|part| part["type"] == "finish") + .expect("a recorded finish part"); + let finish = parts.iter().find_map(|part| match part { + StreamPart::Finish { + finish_reason, + usage, + .. + } => Some((finish_reason, usage)), + _ => None, + }); + let (reason, usage) = finish.expect("a finish part"); + assert_eq!(reason.unified, FinishReasonUnified::Stop); + assert_eq!(json!(reason.unified), expected_finish["finishReason"]); + assert_eq!( + reason.raw.as_deref(), + expected_finish["rawFinishReason"].as_str() + ); + let total = &expected_finish["totalUsage"]; + let n = |v: &Value| v.as_u64().map(|n| u32::try_from(n).unwrap()); + assert_eq!(usage.input_tokens.total, n(&total["inputTokens"])); + assert_eq!( + usage.input_tokens.no_cache, + n(&total["inputTokenDetails"]["noCacheTokens"]) + ); + assert_eq!( + usage.input_tokens.cache_read, + n(&total["inputTokenDetails"]["cacheReadTokens"]) + ); + assert_eq!(usage.output_tokens.total, n(&total["outputTokens"])); + assert_eq!( + usage.output_tokens.text, + n(&total["outputTokenDetails"]["textTokens"]) + ); + assert_eq!( + usage.output_tokens.reasoning, + n(&total["outputTokenDetails"]["reasoningTokens"]) + ); + assert_eq!( + usage.input_tokens.total.unwrap() + usage.output_tokens.total.unwrap(), + n(&total["totalTokens"]).unwrap() + ); + } + + async fn chat_error(status: u16, body: &Value) -> Box { + let mut canned = Canned::json(body); + canned.status = status; + let mock = MockFetch::new(vec![canned]); + let error = create_openai(settings(&mock)) + .unwrap() + .chat("test-model") + .do_generate(&CallOptions::new(vec![LanguageModelMessage::user_text( + "Hello", + )])) + .await + .unwrap_err(); + let AiMuxError::ApiCall(error) = error else { + panic!("expected an API call error, got {error:?}") + }; + error + } + + /// partial: `should parse OpenRouter resource exhausted error`; full port in src/openai/tests/error.rs. + /// openai@4.0.83 `openai-error.test.ts` "should parse OpenRouter resource + /// exhausted error": the message is `error.message`, a numeric `code` is + /// accepted, and a 429 is retryable (provider@4.0.21 + /// `api-call-error.ts:28-32`: 408, 409, 429 and 5xx). + #[tokio::test] + async fn chat_error_429_is_retryable() { + let message = "{\n \"error\": {\n \"code\": 429,\n \"message\": \"Resource has been exhausted (e.g. check quota).\",\n \"status\": \"RESOURCE_EXHAUSTED\"\n }\n}\n"; + let response = json!({"error":{"message":message,"code":429}}); + let error = chat_error(429, &response).await; + assert_eq!(error.message, message); + assert_eq!(error.status_code, Some(429)); + assert_eq!(error.data, Some(response)); + assert!(error.is_retryable); + } + + /// A 400 is not retryable (provider@4.0.21 `api-call-error.ts:28-32`). + #[tokio::test] + async fn chat_error_400_is_not_retryable() { + let response = json!({"error":{"message":"Invalid request","type":"invalid_request_error","code":null}}); + let error = chat_error(400, &response).await; + assert_eq!(error.message, "Invalid request"); + assert_eq!(error.status_code, Some(400)); + assert!(!error.is_retryable); + } + + #[tokio::test] + async fn responses_basic() { + let fixture = Fixture::load("responses-basic"); + let mock = fixture.mock(); + let model = create_openai(settings(&mock)).unwrap().responses("gpt-4o"); + assert_eq!(model.provider(), fixture.provider_string()); + + let result = model.do_generate(&fixture.call_options()).await.unwrap(); + + fixture.assert_request(&mock.seen()[0], &bearer(KEY)); + assert_generate_result(&fixture, &result); + } + + #[tokio::test] + async fn embedding_basic() { + let fixture = Fixture::load("embedding-basic"); + let mock = fixture.mock(); + let model = create_openai(settings(&mock)) + .unwrap() + .embedding("text-embedding-3-small"); + 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], &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) + ); + } + + #[serial] + #[tokio::test] + async fn missing_api_key_fails_the_call_not_the_factory() { + let fixture = Fixture::load("missing-api-key"); + let _env = EnvVar::set("OPENAI_API_KEY", None); + let mock = fixture.mock(); + + // Creating the provider and a model reads no key. + let provider = create_openai(OpenAIProviderSettings { + base_url: Some(DEFAULT_BASE_URL.to_string()), + fetch: Some(mock.transport()), + ..Default::default() + }) + .expect("no key is read at creation"); + let model = provider.chat(fixture.model_id()); + assert_eq!(model.provider(), fixture.provider_string()); + + let error = model + .do_generate(&fixture.call_options()) + .await + .expect_err("the call has no key"); + match &error { + AiMuxError::LoadApiKey { + env_var, + description, + } => { + assert_eq!(env_var, "OPENAI_API_KEY"); + assert_eq!(description, "OpenAI"); + } + other => panic!("expected LoadApiKey, got {other:?}"), + } + // Same text as the SDK's: names the setting and the variable, no value. + let message = error.to_string(); + assert!( + message.contains("OpenAI") && message.contains("OPENAI_API_KEY"), + "{message}" + ); + assert_eq!( + fixture.json["error"]["name"].as_str(), + Some("AI_LoadAPIKeyError") + ); + assert!(mock.seen().is_empty(), "nothing was sent"); + assert_eq!(fixture.json["observations"]["requestsSent"], json!(0)); + } + + #[serial] + #[tokio::test] + async fn an_explicit_empty_key_is_sent_as_given() { + let fixture = Fixture::load("empty-api-key"); + // The environment holds a key; an explicit "" must not fall back to it. + let _env = EnvVar::set("OPENAI_API_KEY", Some(ENV_KEY)); + let mock = fixture.mock(); + let provider = create_openai(OpenAIProviderSettings { + api_key: Some(String::new()), + base_url: Some(DEFAULT_BASE_URL.to_string()), + fetch: Some(mock.transport()), + ..Default::default() + }) + .unwrap(); + let model = provider.chat(fixture.model_id()); + + let result = model.do_generate(&fixture.call_options()).await.unwrap(); + + let seen = mock.seen(); + assert_eq!(seen.len(), 1); + // The wire value is exactly `Bearer ` (the SDK's headers object trims the + // trailing space, which is what its observation records). + fixture.assert_request(&seen[0], "Bearer "); + assert_eq!(seen[0].headers["authorization"].trim(), "Bearer"); + assert!(!seen[0].headers["authorization"].contains(ENV_KEY)); + assert_eq!( + fixture.json["observations"]["authorizationIsEmptyBearer"], + json!(true) + ); + assert_eq!(fixture.json["observations"]["usedEnvKey"], json!(false)); + assert_generate_result(&fixture, &result); + } + + fn _assert_provider_is_shareable() { + fn shareable() {} + shareable::(); + } + + /// An empty or blank `base_url` fails the factory with the AI SDK's message + /// (`validateBaseURL` via `src/openai-provider.ts:207-215`). + #[test] + fn an_empty_base_url_fails_with_the_sdk_message() { + for base_url in ["", " "] { + let error = create_openai(OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(base_url.into()), + ..Default::default() + }) + .err() + .expect("an empty base URL is rejected"); + assert!( + matches!(&error, AiMuxError::InvalidArgument { message, .. } if message == "baseURL must be a non-empty string."), + "{base_url:?}: {error:?}" + ); + } + } +} diff --git a/aimux-providers/src/openai/tests/responses/convert_to_responses_input.rs b/aimux-providers/src/openai/tests/responses/convert_to_responses_input.rs index d540767ee..0bdbb4fa0 100644 --- a/aimux-providers/src/openai/tests/responses/convert_to_responses_input.rs +++ b/aimux-providers/src/openai/tests/responses/convert_to_responses_input.rs @@ -2396,3 +2396,135 @@ mod convert_to_openai_responses_input { } } } + +// ── Older ports (previously the inline tests of `src/openai/responses/convert.rs`) ── +mod older_responses_convert { + + use crate::openai::language_model_capabilities::SystemMessageMode; + use crate::openai::responses::ResponsesNamespace; + use crate::openai::responses::convert_to_responses_input::*; + use aimux_core::language_model_message::AssistantPart; + use aimux_core::language_model_message::LanguageModelMessage; + use aimux_core::language_model_message::ReasoningPart; + use aimux_core::types::Warning; + use serde_json::Value; + use serde_json::json; + + fn reasoning(text: &str, options: Value) -> AssistantPart { + AssistantPart::Reasoning(ReasoningPart { + text: text.to_string(), + provider_options: Some(serde_json::from_value(json!({ "openai": options })).unwrap()), + }) + } + + fn convert(store: bool, content: Vec) -> ResponsesInputResult { + let prompt = vec![LanguageModelMessage::Assistant { + content, + provider_options: None, + }]; + convert_to_responses_input( + ResponsesNamespace::OPENAI, + &prompt, + SystemMessageMode::System, + store, + false, + ) + .unwrap() + } + + /// partial: `should merge consecutive parts with same reasoning ID`; full port in src/openai/tests/responses/convert_to_responses_input.rs. + /// Parts of one reasoning item become one input item: the summaries are + /// appended and the last encrypted content wins (openai@4.0.83 + /// `convert-to-openai-responses-input.ts:1167-1220`). + #[test] + fn reasoning_parts_with_one_item_id_are_merged_without_store() { + let result = convert( + false, + vec![ + reasoning( + "first", + json!({ "itemId": "rs_1", "reasoningEncryptedContent": "enc-1" }), + ), + reasoning( + "second", + json!({ "itemId": "rs_1", "reasoningEncryptedContent": "enc-2" }), + ), + reasoning( + "other", + json!({ "itemId": "rs_2", "reasoningEncryptedContent": "enc-3" }), + ), + ], + ); + assert_eq!( + result.input, + vec![ + json!({ + "type": "reasoning", + "id": "rs_1", + "encrypted_content": "enc-2", + "summary": [ + { "type": "summary_text", "text": "first" }, + { "type": "summary_text", "text": "second" }, + ], + }), + json!({ + "type": "reasoning", + "id": "rs_2", + "encrypted_content": "enc-3", + "summary": [{ "type": "summary_text", "text": "other" }], + }), + ] + ); + assert!(result.warnings.is_empty(), "{:?}", result.warnings); + } + + /// partial: `should warn when appending empty text to existing sequence`; full port in src/openai/tests/responses/convert_to_responses_input.rs. + /// An empty part cannot extend an item that already has parts. + #[test] + fn an_empty_reasoning_part_is_not_appended_to_an_item() { + let result = convert( + false, + vec![ + reasoning( + "first", + json!({ "itemId": "rs_1", "reasoningEncryptedContent": "enc-1" }), + ), + reasoning("", json!({ "itemId": "rs_1" })), + ], + ); + assert_eq!( + result.input, + vec![json!({ + "type": "reasoning", + "id": "rs_1", + "encrypted_content": "enc-1", + "summary": [{ "type": "summary_text", "text": "first" }], + })] + ); + assert!(matches!( + result.warnings.as_slice(), + [Warning::Other { message }] + if message.starts_with("Cannot append empty reasoning part to existing reasoning sequence. Skipping reasoning part: ") + )); + } + + /// With `store`, one item reference stands for every part of the item. + #[test] + fn reasoning_parts_with_one_item_id_are_one_reference_with_store() { + let result = convert( + true, + vec![ + reasoning("first", json!({ "itemId": "rs_1" })), + reasoning("second", json!({ "itemId": "rs_1" })), + reasoning("other", json!({ "itemId": "rs_2" })), + ], + ); + assert_eq!( + result.input, + vec![ + json!({ "type": "item_reference", "id": "rs_1" }), + json!({ "type": "item_reference", "id": "rs_2" }), + ] + ); + } +} diff --git a/aimux-providers/src/openai/tests/responses/responses_api.rs b/aimux-providers/src/openai/tests/responses/responses_api.rs index a64b0ce3d..93813e948 100644 --- a/aimux-providers/src/openai/tests/responses/responses_api.rs +++ b/aimux-providers/src/openai/tests/responses/responses_api.rs @@ -1,9 +1,9 @@ //! The upstream zod chunk/response schemas have no Rust type: aimux reads each //! Responses event as JSON in the stream reducer -//! (`responses_convert::build_responses_event_stream`), so a chunk the schema +//! (`responses_language_model::build_responses_event_stream`), so a chunk the schema //! accepts must pass through the reducer without an `error` part, and a chunk //! it rejects must surface one (as upstream's `doStream` does for a chunk that -//! fails validation). Usage is the one typed part (`types::ResponsesUsage`, +//! fails validation). Usage is the one typed part (`responses_api::ResponsesUsage`, //! read with `parse_usage` and reported raw by `convert_responses_usage`). use std::collections::HashMap; @@ -11,9 +11,9 @@ use std::collections::HashMap; use futures::StreamExt; use serde_json::{Value, json}; -use crate::openai::responses::convert::parse_usage; use crate::openai::responses::convert_responses_usage; -use crate::openai::responses::responses_convert::build_responses_event_stream; +use crate::openai::responses::convert_responses_usage::parse_usage; +use crate::openai::responses::responses_language_model::build_responses_event_stream; /// Run one chunk through the stream reducer (after a `response.created` /// chunk, so the reducer's first-event error check is not what is tested) diff --git a/aimux-providers/src/openai/tests/responses/responses_language_model.rs b/aimux-providers/src/openai/tests/responses/responses_language_model.rs index daf763e98..2ebdda391 100644 --- a/aimux-providers/src/openai/tests/responses/responses_language_model.rs +++ b/aimux-providers/src/openai/tests/responses/responses_language_model.rs @@ -12391,3 +12391,1914 @@ mod openai_responses_language_model { } } } + +// ── Older ports (previously `tests/openai_upstream_e2e_test.rs`) ── +// +// Small end-to-end subset of the pinned upstream tests, using an in-process transport. + +mod older_upstream_e2e { + use crate::openai::tools::{FileSearchArgs, file_search}; + use crate::openai::{OpenAIProvider, OpenAIProviderSettings, create_openai}; + use crate::test_support::mock_fetch::{Canned, MockFetch}; + use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; + use aimux_core::error::AiMuxError; + use aimux_core::image_model::{ + ImageCallOptions, ImageFile, ImageFileData, 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::speech_model::{AudioData, SpeechCallOptions, SpeechModel}; + use aimux_core::stream_part::StreamPart; + use aimux_core::tool::{FunctionTool, Tool}; + use aimux_core::transcription_model::{ + AudioInput, TranscriptionCallOptions, TranscriptionModel, + }; + use aimux_core::types::FinishReasonUnified; + use futures::StreamExt; + use serde_json::{Value, json}; + + fn provider(fetch: &std::sync::Arc) -> OpenAIProvider { + create_openai(OpenAIProviderSettings { + base_url: Some("https://example.test/v1".into()), + api_key: Some("test-key".into()), + fetch: Some(fetch.transport()), + ..Default::default() + }) + .unwrap() + } + fn options() -> CallOptions { + CallOptions::new(vec![LanguageModelMessage::user_text("Hello")]) + } + fn chat_response() -> Value { + json!({"id":"chatcmpl-test","model":"test-model","created":1711115037,"choices":[{"index":0,"message":{"role":"assistant","content":"Hello, World!"},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"total_tokens":34,"completion_tokens":30}}) + } + fn responses_response() -> Value { + json!({"id":"resp_test","created_at":1741257730,"output":[{"id":"msg_test","type":"message","content":[{"type":"output_text","text":"answer text","annotations":[]}]}],"usage":{"input_tokens":345,"input_tokens_details":{"cached_tokens":234,"cache_write_tokens":45,"future_input_detail":{"tokens":7}},"output_tokens":538,"output_tokens_details":{"reasoning_tokens":123,"future_output_detail":["preserved"]},"total_tokens":572,"future_usage_field":{"value":true}}}) + } + fn sse(events: Vec) -> Canned { + Canned { + status: 200, + headers: vec![("content-type".into(), "text/event-stream".into())], + body: events + .into_iter() + .map(|event| format!("data: {event}\n\n")) + .collect::() + .into_bytes(), + } + } + fn assert_request(fetch: &MockFetch, endpoint: &str, expected: Value) { + let seen = fetch.seen(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].method, "POST"); + assert_eq!(seen[0].url, format!("https://example.test/v1{endpoint}")); + assert_eq!(seen[0].headers["authorization"], "Bearer test-key"); + assert_eq!(seen[0].json_body(), expected); + } + + /// partial: `should extract text response`; full port in src/openai/tests/chat/chat_language_model.rs. + /// partial: `should send request body`; full port in src/openai/tests/chat/chat_language_model.rs. + /// partial: `should extract usage`; full port in src/openai/tests/chat/chat_language_model.rs. + #[tokio::test] + async fn chat_generate() { + let fetch = MockFetch::new(vec![Canned::json(&chat_response())]); + let result = provider(&fetch) + .chat("test-model") + .do_generate(&options()) + .await + .unwrap(); + let body = json!({"model":"test-model","messages":[{"role":"user","content":"Hello"}]}); + assert_request(&fetch, "/chat/completions", body.clone()); + assert_eq!(result.request.unwrap().body, Some(body)); + assert!( + matches!(&result.content[..],[GenerateContent::Text{text,..}] if text=="Hello, World!") + ); + assert_eq!(result.usage.input_tokens.total, Some(4)); + assert_eq!(result.usage.input_tokens.no_cache, Some(4)); + assert_eq!(result.usage.output_tokens.text, Some(30)); + assert_eq!( + result.usage.raw, + chat_response()["usage"].as_object().cloned() + ); + assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); + assert_eq!( + result.response.unwrap().id.as_deref(), + Some("chatcmpl-test") + ); + } + + /// Chat drops the legacy `promptCacheRetention` for models that take + /// `promptCacheOptions` and names them like the AI SDK does + /// (`chat/openai-chat-language-model.ts:235`). + #[tokio::test] + async fn chat_prompt_cache_retention_warning_names_gpt_6_models() { + let fetch = MockFetch::new(vec![Canned::json(&chat_response())]); + let mut opts = options(); + opts.provider_options = Some( + provider_namespace("openai", json!({"promptCacheRetention":"24h"})) + .expect("provider metadata must be an object"), + ); + let result = provider(&fetch) + .chat("gpt-6-astra") + .do_generate(&opts) + .await + .unwrap(); + assert_eq!( + fetch.seen()[0].json_body().get("prompt_cache_retention"), + None + ); + assert_eq!( + result.warnings, + vec![aimux_core::types::Warning::Unsupported { + feature: "promptCacheRetention".into(), + details: Some( + "promptCacheRetention is not supported by GPT-6 and later models; use promptCacheOptions instead".into() + ), + }] + ); + } + + /// A singleton `allOf: [{ $ref }]` (Zod 4's recursive reference) becomes a + /// direct `$ref`, and is expanded from its definition at the schema root, + /// like the AI SDK (`normalize-openai-json-schema.ts:176-208`). + #[tokio::test] + async fn chat_json_schema_unwraps_singleton_reference_all_of() { + let fetch = MockFetch::new(vec![Canned::json(&chat_response())]); + let person = json!({ + "type": "object", + "properties": { + "firstName": {"type": "string"}, + "relatives": {"type": "array", "items": {"allOf": [{"$ref": "#/definitions/person"}]}}, + }, + "required": ["firstName", "relatives"], + "additionalProperties": false, + }); + let mut opts = options(); + opts.response_format = Some(aimux_core::options::ResponseFormat::Json { + schema: Some(json!({ + "allOf": [{"$ref": "#/definitions/person"}], + "definitions": {"person": person}, + })), + name: None, + description: None, + }); + provider(&fetch) + .chat("gpt-4o") + .do_generate(&opts) + .await + .unwrap(); + let direct = json!({ + "type": "object", + "properties": { + "firstName": {"type": "string"}, + "relatives": {"type": "array", "items": {"$ref": "#/definitions/person"}}, + }, + "required": ["firstName", "relatives"], + "additionalProperties": false, + }); + let mut expected = direct.clone(); + expected["definitions"] = json!({"person": direct}); + assert_eq!( + fetch.seen()[0].json_body()["response_format"]["json_schema"]["schema"], + expected + ); + } + + /// partial: `should stream text deltas`; full port in src/openai/tests/chat/chat_language_model.rs. + #[tokio::test] + async fn chat_stream() { + let chunk = |delta: Value, finish: Value| json!({"id":"chatcmpl-test","model":"test-model","created":1702657020,"choices":[{"index":0,"delta":delta,"finish_reason":finish}]}); + let fetch = MockFetch::new(vec![sse(vec![ + chunk(json!({"role":"assistant","content":""}), Value::Null), + chunk(json!({"content":"Hello"}), Value::Null), + chunk(json!({"content":", "}), Value::Null), + chunk(json!({"content":"World!"}), Value::Null), + chunk(json!({}), json!("stop")), + json!({"id":"chatcmpl-test","model":"test-model","created":1702657020,"choices":[],"usage":{"prompt_tokens":17,"total_tokens":244,"completion_tokens":227}}), + ])]); + let result = provider(&fetch) + .chat("test-model") + .do_stream(&options()) + .await + .unwrap(); + let parts = result + .stream + .map(|part| part.unwrap()) + .collect::>() + .await; + assert_request( + &fetch, + "/chat/completions", + json!({"model":"test-model","messages":[{"role":"user","content":"Hello"}],"stream":true,"stream_options":{"include_usage":true}}), + ); + let text = parts + .iter() + .filter_map(|part| match part { + StreamPart::TextDelta { delta, .. } => Some(delta.as_str()), + _ => None, + }) + .collect::(); + assert_eq!(text, "Hello, World!"); + assert_eq!( + parts + .iter() + .filter(|part| matches!(part, StreamPart::TextStart { .. })) + .count(), + 1 + ); + assert_eq!( + parts + .iter() + .filter(|part| matches!(part, StreamPart::TextEnd { .. })) + .count(), + 1 + ); + assert!( + matches!(parts.last(),Some(StreamPart::Finish{finish_reason,usage,..}) if finish_reason.unified==FinishReasonUnified::Stop && usage.input_tokens.total==Some(17) && usage.output_tokens.total==Some(227)) + ); + } + + /// partial: `should parse tool results`; full port in src/openai/tests/chat/chat_language_model.rs. + #[tokio::test] + async fn chat_tool_call() { + let mut response = chat_response(); + response["choices"][0]["message"]["content"] = json!(""); + response["choices"][0]["message"]["tool_calls"] = json!([{"id":"call_test","type":"function","function":{"name":"test-tool","arguments":"{\"value\":\"Spark\"}"}}]); + let fetch = MockFetch::new(vec![Canned::json(&response)]); + let schema = json!({"type":"object","properties":{"value":{"type":"string"}},"required":["value"],"additionalProperties":false}); + let mut opts = options(); + opts.tools = Some(vec![Tool::Function(FunctionTool::new( + "test-tool", + schema.clone(), + ))]); + let result = provider(&fetch) + .chat("test-model") + .do_generate(&opts) + .await + .unwrap(); + let body = fetch.seen()[0].json_body(); + assert_eq!(body["model"], "test-model"); + assert_eq!(body["messages"], json!([{"role":"user","content":"Hello"}])); + assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); + assert_eq!(body["tools"][0]["function"]["parameters"], schema); + assert!( + matches!(&result.content[..],[GenerateContent::ToolCall(call)] if call.tool_call_id=="call_test" && call.tool_name=="test-tool" && call.input==r#"{"value":"Spark"}"#) + ); + } + + /// partial: `should stream tool call with missing type field (Azure AI Foundry / Mistral)`; full port in src/openai/tests/chat/chat_language_model.rs. + /// Upstream: @ai-sdk/openai 4.0.83 src/chat/openai-chat-language-model.ts:539-542 + /// creates the stream's tool-call tracker with `typeValidation: 'if-present'`: + /// a tool-call delta without `type` starts a call, one whose `type` is + /// present and not `function` is invalid response data. + #[tokio::test] + async fn chat_stream_tool_call_type_is_validated_if_present() { + let tool_stream = |delta: Value| { + sse(vec![ + json!({"id":"chatcmpl-test","model":"test-model","created":1702657020,"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[delta]},"finish_reason":null}]}), + json!({"id":"chatcmpl-test","model":"test-model","created":1702657020,"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}), + ]) + }; + let fetch = MockFetch::new(vec![ + tool_stream( + json!({"index":0,"id":"call_1","function":{"name":"test-tool","arguments":"{}"}}), + ), + tool_stream( + json!({"index":0,"id":"call_2","type":"custom","function":{"name":"test-tool","arguments":"{}"}}), + ), + ]); + let model = provider(&fetch).chat("test-model"); + + let untyped = model + .do_stream(&options()) + .await + .unwrap() + .stream + .collect::>() + .await; + assert!( + untyped.iter().any( + |part| matches!(part, Ok(StreamPart::ToolCall(call)) if call.tool_call_id == "call_1") + ), + "{untyped:?}" + ); + assert!(untyped.iter().all(Result::is_ok), "{untyped:?}"); + + let custom = model + .do_stream(&options()) + .await + .unwrap() + .stream + .collect::>() + .await; + assert!( + custom.iter().any(|part| matches!(part, Err(error) if error.to_string().contains("Expected 'function' type."))), + "{custom:?}" + ); + assert!( + !custom + .iter() + .any(|part| matches!(part, Ok(StreamPart::ToolCall(_)))) + ); + } + + /// partial: `should throw an api error when the first stream chunk is an error`; full port in src/openai/tests/chat/chat_language_model.rs. + /// Upstream: @ai-sdk/openai 4.0.83 src/openai-stream-error.ts:18-37, + /// :235-267 and :303-315: an error frame before any output fails the call + /// with the status `createOpenAIProviderStreamError` gives it — an HTTP + /// status in `code`, else one inferred from `code`/`type` — and + /// `insufficient_quota` is never retryable. + #[tokio::test] + async fn chat_stream_error_frame_status_is_inferred_as_upstream() { + let cases = [ + ( + json!({"message":"quota","type":"insufficient_quota","code":"insufficient_quota","param":null}), + 429, + false, + ), + ( + json!({"message":"slow down","type":"requests","code":"rate_limit_exceeded"}), + 429, + true, + ), + ( + json!({"message":"busy","type":"server_error","code":"503"}), + 503, + true, + ), + ( + json!({"message":"bad","type":"invalid_request_error","code":null}), + 400, + false, + ), + ( + json!({"message":"oops","type":"server_error","code":null}), + 500, + true, + ), + ]; + let fetch = MockFetch::new( + cases + .iter() + .map(|(error, ..)| sse(vec![json!({ "error": error })])) + .collect(), + ); + let model = provider(&fetch).chat("test-model"); + for (frame, status, retryable) in cases { + let Err(AiMuxError::ApiCall(error)) = model.do_stream(&options()).await.map(|_| ()) + else { + panic!("expected an API error for {frame}"); + }; + assert_eq!(error.message, frame["message"], "{frame}"); + assert_eq!(error.status_code, Some(status), "{frame}"); + assert_eq!(error.is_retryable, retryable, "{frame}"); + } + } + + /// Upstream: @ai-sdk/openai 4.0.83 src/openai-stream-error.ts:207-217 types a + /// `response.failed` frame as `response.failed`, so its status comes from + /// the inner error's `code` alone, never from an inner `type`. + #[tokio::test] + async fn responses_failed_frame_status_ignores_the_inner_type() { + let fetch = MockFetch::new(vec![sse(vec![json!({ + "type":"response.failed","sequence_number":1, + "response":{"error":{"code":"server_error","message":"boom","type":"invalid_request_error"}} + })])]); + let Err(AiMuxError::ApiCall(error)) = provider(&fetch) + .responses("test-model") + .do_stream(&options()) + .await + .map(|_| ()) + else { + panic!("expected an API error"); + }; + assert_eq!(error.message, "boom"); + assert_eq!(error.status_code, Some(500)); + assert!(error.is_retryable); + } + + /// Upstream: @ai-sdk/openai 4.0.83 src/chat/map-openai-finish-reason.ts:13-15 + /// maps the legacy `function_call` finish reason to `tool-calls`; the raw + /// value stays `function_call`. + #[tokio::test] + async fn chat_function_call_finish_reason_is_tool_calls() { + let mut response = chat_response(); + response["choices"][0]["finish_reason"] = json!("function_call"); + let fetch = MockFetch::new(vec![Canned::json(&response)]); + let result = provider(&fetch) + .chat("test-model") + .do_generate(&options()) + .await + .unwrap(); + assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); + assert_eq!(result.finish_reason.raw.as_deref(), Some("function_call")); + } + + /// Upstream: @ai-sdk/openai 4.0.83 src/chat/convert-openai-chat-usage.ts:28 + /// reads cache reads only from the nested `prompt_tokens_details.cached_tokens`; + /// a top-level `cached_tokens` (Moonshot's wire format) is not a cache read and + /// only survives in `usage.raw`. + #[tokio::test] + async fn chat_cache_read_is_only_the_nested_cached_tokens() { + let usage = |usage: Value| { + let mut response = chat_response(); + response["usage"] = usage; + response + }; + let fetch = MockFetch::new(vec![ + Canned::json(&usage( + json!({"prompt_tokens":100,"completion_tokens":10,"cached_tokens":7,"prompt_tokens_details":{"cached_tokens":25}}), + )), + Canned::json(&usage( + json!({"prompt_tokens":100,"completion_tokens":10,"cached_tokens":7}), + )), + ]); + let model = provider(&fetch).chat("test-model"); + + let nested = model.do_generate(&options()).await.unwrap().usage; + assert_eq!(nested.input_tokens.cache_read, Some(25)); + assert_eq!(nested.input_tokens.no_cache, Some(75)); + assert_eq!(nested.raw.unwrap()["cached_tokens"], json!(7)); + + let top_level_only = model.do_generate(&options()).await.unwrap().usage; + assert_eq!(top_level_only.input_tokens.cache_read, Some(0)); + assert_eq!(top_level_only.input_tokens.no_cache, Some(100)); + assert_eq!(top_level_only.raw.unwrap()["cached_tokens"], json!(7)); + } + + /// openai-chat-api.ts:71-87 (response) and :159-175 (chunk) parse a logprobs + /// entry as `{token, logprob, top_logprobs: [{token, logprob}]}`, so `bytes` + /// never reaches `providerMetadata.openai.logprobs` + /// (openai-chat-language-model.ts:459-461 generate, :613-615 stream), and a + /// `null` `content` sets none. + #[tokio::test] + async fn chat_logprobs_metadata_has_no_bytes() { + let wire = json!({"content":[{"token":"Hello","logprob":-0.5,"bytes":[72,101,108,108,111], + "top_logprobs":[{"token":"Hello","logprob":-0.5,"bytes":[72,101,108,108,111]}, + {"token":"Hi","logprob":-1.5,"bytes":[72,105]}]}],"refusal":null}); + let expected = json!([{"token":"Hello","logprob":-0.5, + "top_logprobs":[{"token":"Hello","logprob":-0.5},{"token":"Hi","logprob":-1.5}]}]); + let generated_metadata = |logprobs: Value| async move { + let mut response = chat_response(); + response["choices"][0]["logprobs"] = logprobs; + let fetch = MockFetch::new(vec![Canned::json(&response)]); + let result = provider(&fetch) + .chat("test-model") + .do_generate(&options()) + .await + .unwrap(); + serde_json::to_value(result.provider_metadata).unwrap() + }; + assert_eq!( + generated_metadata(wire.clone()).await["openai"]["logprobs"], + expected + ); + let metadata = generated_metadata(json!({"content": null})).await; + assert_eq!(metadata["openai"].get("logprobs"), None, "{metadata}"); + + let fetch = MockFetch::new(vec![sse(vec![json!({ + "id":"chatcmpl-test","model":"test-model","created":1702657020, + "choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}, + "logprobs":wire,"finish_reason":"stop"}]})])]); + let parts = provider(&fetch) + .chat("test-model") + .do_stream(&options()) + .await + .unwrap() + .stream + .map(|part| part.unwrap()) + .collect::>() + .await; + let Some(StreamPart::Finish { + provider_metadata, .. + }) = parts.last() + else { + panic!("no finish part: {parts:?}"); + }; + assert_eq!( + serde_json::to_value(provider_metadata).unwrap()["openai"]["logprobs"], + expected + ); + } + + /// partial: `should parse OpenRouter resource exhausted error`; full port in src/openai/tests/error.rs. + #[tokio::test] + async fn chat_error_response() { + let message = "{\n \"error\": {\n \"code\": 429,\n \"message\": \"Resource has been exhausted (e.g. check quota).\",\n \"status\": \"RESOURCE_EXHAUSTED\"\n }\n}\n"; + let response = json!({"error":{"message":message,"code":429}}); + let mut canned = Canned::json(&response); + canned.status = 429; + let fetch = MockFetch::new(vec![canned]); + let error = provider(&fetch) + .chat("test-model") + .do_generate(&options()) + .await + .unwrap_err(); + assert_request( + &fetch, + "/chat/completions", + json!({"model":"test-model","messages":[{"role":"user","content":"Hello"}]}), + ); + let AiMuxError::ApiCall(error) = error else { + panic!("expected API error") + }; + assert_eq!(error.message, message); + assert_eq!(error.status_code, Some(429)); + assert_eq!(error.data, Some(response)); + } + + /// partial: `should generate text`; full port in src/openai/tests/responses/responses_language_model.rs. + /// partial: `should extract usage`; full port in src/openai/tests/responses/responses_language_model.rs. + #[tokio::test] + async fn responses_generate() { + let response = responses_response(); + let fetch = MockFetch::new(vec![Canned::json(&response)]); + let result = provider(&fetch) + .responses("test-model") + .do_generate(&options()) + .await + .unwrap(); + assert_request( + &fetch, + "/responses", + json!({"model":"test-model","input":[{"role":"user","content":[{"type":"input_text","text":"Hello"}]}]}), + ); + assert_eq!( + result.content, + vec![GenerateContent::Text { + text: "answer text".into(), + provider_metadata: Some( + provider_namespace("openai", json!({"itemId":"msg_test"})) + .expect("provider metadata must be an object") + ) + }] + ); + assert_eq!(result.usage.input_tokens.total, Some(345)); + assert_eq!(result.usage.input_tokens.cache_read, Some(234)); + assert_eq!(result.usage.input_tokens.cache_write, Some(45)); + assert_eq!(result.usage.input_tokens.no_cache, Some(66)); + assert_eq!(result.usage.output_tokens.total, Some(538)); + assert_eq!(result.usage.output_tokens.reasoning, Some(123)); + assert_eq!(result.usage.output_tokens.text, Some(415)); + assert_eq!(result.usage.raw, response["usage"].as_object().cloned()); + assert_eq!(result.response.unwrap().id.as_deref(), Some("resp_test")); + } + + /// partial: `should stream text deltas`; full port in src/openai/tests/responses/responses_language_model.rs. + #[tokio::test] + async fn responses_stream() { + let events=json!([{"type": "response.created", "response": {"id": "resp_67c9a81b6a048190a9ee441c5755a4e8", "object": "response", "created_at": 1741269019, "status": "in_progress", "error": null, "incomplete_details": null, "input": [], "instructions": null, "max_output_tokens": null, "model": "test-model", "output": [], "parallel_tool_calls": true, "previous_response_id": null, "reasoning": {"effort": null, "summary": null}, "store": true, "temperature": 0.3, "text": {"format": {"type": "text"}}, "tool_choice": "auto", "tools": [], "top_p": 1, "truncation": "disabled", "usage": null, "user": null, "metadata": {}}}, {"type": "response.in_progress", "response": {"id": "resp_67c9a81b6a048190a9ee441c5755a4e8", "object": "response", "created_at": 1741269019, "status": "in_progress", "error": null, "incomplete_details": null, "input": [], "instructions": null, "max_output_tokens": null, "model": "test-model", "output": [], "parallel_tool_calls": true, "previous_response_id": null, "reasoning": {"effort": null, "summary": null}, "store": true, "temperature": 0.3, "text": {"format": {"type": "text"}}, "tool_choice": "auto", "tools": [], "top_p": 1, "truncation": "disabled", "usage": null, "user": null, "metadata": {}}}, {"type": "response.output_item.added", "output_index": 0, "item": {"id": "msg_67c9a81dea8c8190b79651a2b3adf91e", "type": "message", "status": "in_progress", "role": "assistant", "content": []}}, {"type": "response.content_part.added", "item_id": "msg_67c9a81dea8c8190b79651a2b3adf91e", "output_index": 0, "content_index": 0, "part": {"type": "output_text", "text": "", "annotations": [], "logprobs": []}}, {"type": "response.output_text.delta", "item_id": "msg_67c9a81dea8c8190b79651a2b3adf91e", "output_index": 0, "content_index": 0, "delta": "Hello,", "logprobs": []}, {"type": "response.output_text.delta", "item_id": "msg_67c9a81dea8c8190b79651a2b3adf91e", "output_index": 0, "content_index": 0, "delta": " World!", "logprobs": []}, {"type": "response.output_text.done", "item_id": "msg_67c9a8787f4c8190b49c858d4c1cf20c", "output_index": 0, "content_index": 0, "text": "Hello, World!"}, {"type": "response.content_part.done", "item_id": "msg_67c9a8787f4c8190b49c858d4c1cf20c", "output_index": 0, "content_index": 0, "part": {"type": "output_text", "text": "Hello, World!", "annotations": [], "logprobs": []}}, {"type": "response.output_item.done", "output_index": 0, "item": {"id": "msg_67c9a8787f4c8190b49c858d4c1cf20c", "type": "message", "status": "completed", "role": "assistant", "content": [{"type": "output_text", "text": "Hello, World!", "annotations": [], "logprobs": []}]}}, {"type": "response.completed", "response": {"id": "resp_67c9a878139c8190aa2e3105411b408b", "object": "response", "created_at": 1741269112, "status": "completed", "error": null, "incomplete_details": null, "input": [], "instructions": null, "max_output_tokens": null, "model": "test-model", "output": [{"id": "msg_67c9a8787f4c8190b49c858d4c1cf20c", "type": "message", "status": "completed", "role": "assistant", "content": [{"type": "output_text", "text": "Hello, World!", "annotations": []}]}], "parallel_tool_calls": true, "previous_response_id": null, "reasoning": {"effort": null, "summary": null}, "store": true, "temperature": 0.3, "text": {"format": {"type": "text"}}, "tool_choice": "auto", "tools": [], "top_p": 1, "truncation": "disabled", "usage": {"input_tokens": 543, "input_tokens_details": {"cached_tokens": 234}, "output_tokens": 478, "output_tokens_details": {"reasoning_tokens": 123}, "total_tokens": 512}, "user": null, "metadata": {}}}]).as_array().unwrap().clone(); + let fetch = MockFetch::new(vec![sse(events)]); + let result = provider(&fetch) + .responses("test-model") + .do_stream(&options()) + .await + .unwrap(); + let parts = result + .stream + .map(|part| part.unwrap()) + .collect::>() + .await; + assert_request( + &fetch, + "/responses", + json!({"model":"test-model","input":[{"role":"user","content":[{"type":"input_text","text":"Hello"}]}],"stream":true}), + ); + assert_eq!( + serde_json::to_value(parts).unwrap(), + json!([ + {"type": "stream-start", "warnings": []}, + { + "type": "response-metadata", + "id": "resp_67c9a81b6a048190a9ee441c5755a4e8", + "modelId": "test-model", + "timestamp": "2025-03-06T13:50:19.000Z" + }, + { + "type": "text-start", + "id": "msg_67c9a81dea8c8190b79651a2b3adf91e", + "providerMetadata": {"openai": {"itemId": "msg_67c9a81dea8c8190b79651a2b3adf91e"}} + }, + {"type": "text-delta", "id": "msg_67c9a81dea8c8190b79651a2b3adf91e", "delta": "Hello,"}, + {"type": "text-delta", "id": "msg_67c9a81dea8c8190b79651a2b3adf91e", "delta": " World!"}, + { + "type": "text-end", + "id": "msg_67c9a81dea8c8190b79651a2b3adf91e", + "providerMetadata": {"openai": {"itemId": "msg_67c9a81dea8c8190b79651a2b3adf91e"}} + }, + { + "type": "finish", + "finishReason": {"unified": "stop"}, + "providerMetadata": {"openai": {"responseId": "resp_67c9a81b6a048190a9ee441c5755a4e8"}}, + "usage": { + "inputTokens": {"total": 543, "noCache": 309, "cacheRead": 234}, + "outputTokens": {"total": 478, "reasoning": 123, "text": 355}, + "raw": { + "input_tokens": 543, + "input_tokens_details": {"cached_tokens": 234}, + "output_tokens": 478, + "output_tokens_details": {"reasoning_tokens": 123}, + "total_tokens": 512 + } + } + } + ]) + ); + } + + /// partial: `should throw a descriptive error when the response has no output`; full port in src/openai/tests/responses/responses_language_model.rs. + #[tokio::test] + async fn responses_error_response() { + let fetch = MockFetch::new(vec![Canned::json( + &json!({"id":"resp_no_output","status":"incomplete","incomplete_details":{"reason":"content_filter"}}), + )]); + let error = provider(&fetch) + .responses("test-model") + .do_generate(&options()) + .await + .unwrap_err(); + assert_request( + &fetch, + "/responses", + json!({"model":"test-model","input":[{"role":"user","content":[{"type":"input_text","text":"Hello"}]}]}), + ); + assert!( + error + .to_string() + .contains("Responses API returned no output (content_filter)"), + "{error}" + ); + } + + fn embedding_response() -> Value { + serde_json::from_str( + r#"{ + "object": "list", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": [ + 0.0057293195, -0.012727811, 0.020042092, -0.013437585, 0.022833068 + ] + }, + { + "object": "embedding", + "index": 1, + "embedding": [ + -0.037104916, -0.05178114, -0.008340587, 0.001164541, -0.0035253682 + ] + } + ], + "model": "test-embedding", + "usage": { + "prompt_tokens": 12, + "total_tokens": 12 + } +} +"#, + ) + .unwrap() + } + + fn image_response() -> Value { + serde_json::from_str(r#"{ + "created": 1770935200, + "data": [ + { + "b64_json": "iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAA3CGNhQlgAADcIanVtYgAAAB5qdW1kYzJwYQARABCAAACqADibcQNj", + "revised_prompt": "A small and adorable baby sea otter. This little creature is covered in a thick and fluffy brown fur, its tiny paws are slightly visible. The otter has bright, curious eyes and it's floating on its back on a calm sea, surrounded by floating seaweed." + }, + { + "b64_json": "iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAEp2GNhQlgAASnYanVtYgAAAB5qdW1kYzJwYQARABCAAACqADibcQNj" + } + ] +} +"#).unwrap() + } + + fn transcription_response() -> Value { + serde_json::from_str(r#"{ + "task": "transcribe", + "language": "english", + "duration": 36.709999084472656, + "text": "Galileo was an American robotic space program that studied the planet Jupiter and its moons, as well as several other solar system bodies.", + "words": [ + { + "word": "Galileo", + "start": 0, + "end": 0.6600000262260437 + }, + { + "word": "was", + "start": 0.6600000262260437, + "end": 0.8999999761581421 + }, + { + "word": "an", + "start": 0.8999999761581421, + "end": 1.1399999856948853 + }, + { + "word": "American", + "start": 1.1399999856948853, + "end": 1.5 + }, + { + "word": "robotic", + "start": 1.5, + "end": 2.0199999809265137 + } + ], + "usage": { + "type": "duration", + "seconds": 37 + } +} +"#).unwrap() + } + + /// partial: `should extract embedding`; full port in src/openai/tests/embedding/embedding_model.rs. + /// partial: `should pass the model and the values`; full port in src/openai/tests/embedding/embedding_model.rs. + #[tokio::test] + async fn embedding_generate() { + let response = embedding_response(); + let fetch = MockFetch::new(vec![Canned::json(&response)]); + 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":opts.values,"encoding_format":"float"}), + ); + assert_eq!( + result.embeddings, + vec![ + vec![ + 0.0057293195, + -0.012727811, + 0.020042092, + -0.013437585, + 0.022833068 + ], + vec![ + -0.037104916, + -0.05178114, + -0.008340587, + 0.001164541, + -0.0035253682 + ] + ] + ); + assert_eq!(result.usage.unwrap().tokens, 12); + assert_eq!(result.response.unwrap().body, Some(response)); + } + + /// partial: `should pass the model and the settings`; full port in src/openai/tests/image/image_model.rs. + /// partial: `should extract the generated images`; full port in src/openai/tests/image/image_model.rs. + #[tokio::test] + async fn image_generate() { + let response = image_response(); + let fetch = MockFetch::new(vec![Canned::json(&response)]); + let mut opts = ImageCallOptions::new("A cute baby sea otter"); + opts.size = Some(Size::new(1024, 1024)); + opts.provider_options = provider_namespace("openai", json!({"style":"vivid"})) + .expect("provider metadata must be an object"); + let result = provider(&fetch) + .image("test-image") + .do_generate(&opts) + .await + .unwrap(); + assert_request( + &fetch, + "/images/generations", + json!({"model":"test-image","prompt":"A cute baby sea otter","n":1,"size":"1024x1024","style":"vivid","response_format":"b64_json"}), + ); + let expected = response["data"] + .as_array() + .unwrap() + .iter() + .map(|image| image["b64_json"].as_str().unwrap().to_owned()) + .collect::>(); + assert!(matches!(result.images,ImageOutputs::Base64(images) if images==expected)); + } + + /// openai-image-model.ts:166 (files) and :357 (mask) fetch a URL file with + /// `downloadBlob(file.url, {abortSignal})`, which goes through + /// `fetchUntrustedUrl` (provider-utils download-blob.ts:111-114): a + /// non-public target is refused before any request, and the edit request is + /// never sent. + #[tokio::test] + async fn image_edit_url_files_are_downloaded_through_the_guard() { + let url = || ImageFile::Url { + url: "http://169.254.169.254/latest/meta-data/image.png".into(), + }; + let mut files = ImageCallOptions::new("Edit"); + files.files = Some(vec![url()]); + let mut mask = ImageCallOptions::new("Edit"); + mask.files = Some(vec![ImageFile::File { + media_type: "image/png".into(), + data: ImageFileData::Binary(vec![1, 2, 3]), + }]); + mask.mask = Some(url()); + for options in [files, mask] { + let fetch = MockFetch::new(vec![Canned::json(&image_response())]); + let error = provider(&fetch) + .image("gpt-image-1") + .do_generate(&options) + .await + .unwrap_err(); + assert!( + matches!(error, AiMuxError::InvalidArgument { ref message, .. } if message.contains("non-public")), + "{error:?}" + ); + assert!(fetch.seen().is_empty()); + } + } + + /// partial: `should pass the model and text`; full port in src/openai/tests/speech/speech_model.rs. + /// partial: `should return audio data with correct content type`; full port in src/openai/tests/speech/speech_model.rs. + #[tokio::test] + async fn speech_generate() { + let audio = vec![1, 2, 3, 4]; + let fetch = MockFetch::new(vec![Canned { + status: 200, + headers: vec![("content-type".into(), "audio/mpeg".into())], + body: audio.clone(), + }]); + let result = provider(&fetch) + .speech("test-speech") + .do_generate(&SpeechCallOptions::new("Hello from the AI SDK!")) + .await + .unwrap(); + assert_request( + &fetch, + "/audio/speech", + json!({"model":"test-speech","input":"Hello from the AI SDK!","voice":"alloy","response_format":"mp3"}), + ); + assert!(matches!(result.audio,AudioData::Binary(bytes) if bytes==audio)); + assert_eq!( + result.response.headers.unwrap()["content-type"], + "audio/mpeg" + ); + } + + /// partial: `should extract the transcription text`; full port in src/openai/tests/transcription/transcription_model.rs. + #[tokio::test] + async fn transcription_generate() { + let response = transcription_response(); + let fetch = MockFetch::new(vec![Canned::json(&response)]); + let opts = TranscriptionCallOptions::new(AudioInput::Binary(vec![1, 2, 3]), "audio/wav"); + let result = provider(&fetch) + .transcription("test-transcription") + .do_generate(&opts) + .await + .unwrap(); + let seen = fetch.seen(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].url, "https://example.test/v1/audio/transcriptions"); + assert_eq!(seen[0].method, "POST"); + assert!(seen[0].headers["content-type"].starts_with("multipart/form-data; boundary=")); + let body = String::from_utf8_lossy(&seen[0].body); + assert!(body.contains("name=\"model\"\r\n\r\ntest-transcription\r\n")); + assert!(body.contains("name=\"file\"; filename=\"audio.wav\"")); + assert!(seen[0].body.windows(3).any(|window| window == [1, 2, 3])); + assert_eq!(result.text, response["text"].as_str().unwrap()); + assert_eq!(result.language.as_deref(), Some("en")); + assert_eq!(result.duration_in_seconds, Some(36.709999084472656)); + assert_eq!(result.segments.len(), 5); + } + + /// partial: `should handle streaming web search without action`; full port in src/openai/tests/responses/responses_language_model.rs. + #[tokio::test] + async fn responses_web_search_stream() { + let fetch = MockFetch::new(vec![sse(vec![ + json!({"type":"response.created","response":{"id":"resp_missing_action","created_at":1741630255,"model":"test-model"}}), + json!({"type":"response.output_item.added","output_index":0,"item":{"type":"web_search_call","id":"ws_missing_action","status":"in_progress"}}), + json!({"type":"response.web_search_call.in_progress","output_index":0,"item_id":"ws_missing_action"}), + json!({"type":"response.web_search_call.completed","output_index":0,"item_id":"ws_missing_action"}), + json!({"type":"response.output_item.done","output_index":0,"item":{"type":"web_search_call","id":"ws_missing_action","status":"completed"}}), + json!({"type":"response.completed","response":{"id":"resp_missing_action","usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":0},"output_tokens":2,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":12}}}), + ])]); + let mut call = options(); + call.tools = Some(vec![Tool::Provider(aimux_core::tool::ProviderTool { + id: "openai.web_search".parse().unwrap(), + name: "webSearch".into(), + args: serde_json::Map::new(), + supports_deferred_results: None, + })]); + let result = provider(&fetch) + .responses("test-model") + .do_stream(&call) + .await + .unwrap(); + let parts = result + .stream + .map(|part| part.unwrap()) + .collect::>() + .await; + assert_request( + &fetch, + "/responses", + json!({"model":"test-model","input":[{"role":"user","content":[{"type":"input_text","text":"Hello"}]}],"stream":true,"tools":[{"type":"web_search"}],"include":["web_search_call.action.sources"]}), + ); + assert_eq!( + serde_json::to_value(parts).unwrap(), + json!([ + {"type":"stream-start","warnings":[]}, + {"type":"response-metadata","id":"resp_missing_action","modelId":"test-model","timestamp":"2025-03-10T18:10:55.000Z"}, + {"type":"tool-input-start","id":"ws_missing_action","toolName":"webSearch","providerExecuted":true}, + {"type":"tool-input-end","id":"ws_missing_action"}, + {"type":"tool-call","toolCallId":"ws_missing_action","toolName":"webSearch","input":"{}","providerExecuted":true}, + {"type":"tool-result","toolCallId":"ws_missing_action","toolName":"webSearch","result":{}}, + {"type":"finish","finishReason":{"unified":"stop"},"usage":{"inputTokens":{"total":10,"noCache":10,"cacheRead":0},"outputTokens":{"total":2,"reasoning":0,"text":2},"raw":{"input_tokens":10,"input_tokens_details":{"cached_tokens":0},"output_tokens":2,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":12}},"providerMetadata":{"openai":{"responseId":"resp_missing_action"}}} + ]) + ); + } + + /// Upstream: @ai-sdk/openai 4.0.83 src/openai-provider.ts:387 puts + /// `openaiTools` on the provider, so `openai.tools.webSearch(…)` makes the + /// provider tool; it is the module's factory and reaches the request. + #[tokio::test] + async fn provider_tools_member_makes_the_provider_tools() { + use crate::openai::tools::{SearchContextSize, WebSearchArgs}; + let fetch = MockFetch::new(vec![Canned::json(&responses_response())]); + let openai = provider(&fetch); + let args = || WebSearchArgs { + search_context_size: Some(SearchContextSize::High), + ..Default::default() + }; + let tool = openai.tools.web_search(args()).unwrap(); + let factory = crate::openai::tools::web_search(args()).unwrap(); + assert_eq!( + (&tool.id, &tool.name, &tool.args), + (&factory.id, &factory.name, &factory.args) + ); + assert_eq!(openai.tools.apply_patch().id, "openai.apply_patch"); + // Of the OpenAI tools only programmatic tool calling supports deferred + // results (src/tool/programmatic-tool-calling.ts:55). + assert_eq!( + openai + .tools + .programmatic_tool_calling() + .supports_deferred_results, + Some(true) + ); + assert_eq!(openai.tools.apply_patch().supports_deferred_results, None); + let mut call = options(); + call.tools = Some(vec![Tool::Provider(tool)]); + openai + .responses("test-model") + .do_generate(&call) + .await + .unwrap(); + assert_eq!( + fetch.seen()[0].json_body()["tools"], + json!([{"type":"web_search","search_context_size":"high"}]) + ); + } + + /// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-language-model.ts:1660-1663 + /// enqueues `{ type: 'raw', rawValue }` for every chunk, before the parts + /// derived from it, when `includeRawChunks` is set, and nothing otherwise. + #[tokio::test] + async fn responses_stream_emits_raw_chunks_when_asked() { + let events = vec![ + json!({"type":"response.created","response":{"id":"resp_raw","created_at":1741630255,"model":"test-model"}}), + json!({"type":"response.completed","response":{"id":"resp_raw","usage":{"input_tokens":1,"output_tokens":1}}}), + ]; + let fetch = MockFetch::new(vec![sse(events.clone()), sse(events.clone())]); + let model = provider(&fetch).responses("test-model"); + let mut call = options(); + call.include_raw_chunks = Some(true); + let with_raw: Vec<_> = model + .do_stream(&call) + .await + .unwrap() + .stream + .map(|part| part.unwrap()) + .collect() + .await; + assert!(matches!(&with_raw[0], StreamPart::StreamStart { .. })); + assert!(matches!(&with_raw[1], StreamPart::Raw { raw_value } if *raw_value == events[0])); + assert!(matches!(&with_raw[2], StreamPart::ResponseMetadata(_))); + assert!(matches!(&with_raw[3], StreamPart::Raw { raw_value } if *raw_value == events[1])); + assert!(matches!(with_raw.last(), Some(StreamPart::Finish { .. }))); + + let without_raw: Vec<_> = model + .do_stream(&options()) + .await + .unwrap() + .stream + .map(|part| part.unwrap()) + .collect() + .await; + assert!( + !without_raw + .iter() + .any(|part| matches!(part, StreamPart::Raw { .. })) + ); + } + + /// Upstream: @ai-sdk/openai 4.0.83 + /// src/responses/openai-responses-language-model.ts:1587 starts `responseId` at + /// `null` and only `response.created` sets it, so a stream without that event + /// finishes with `responseId: null`, not an empty string. + #[tokio::test] + async fn a_stream_without_response_created_finishes_with_a_null_response_id() { + let fetch = MockFetch::new(vec![sse(vec![json!({ + "type":"response.completed", + "response":{"id":"resp_1","created_at":1,"model":"test-model","status":"completed","output":[], + "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}} + })])]); + let parts: Vec<_> = provider(&fetch) + .responses("test-model") + .do_stream(&options()) + .await + .unwrap() + .stream + .map(|part| part.unwrap()) + .collect() + .await; + let Some(StreamPart::Finish { + provider_metadata: Some(metadata), + .. + }) = parts.last() + else { + panic!("expected a finish part with provider metadata: {parts:?}"); + }; + assert_eq!(metadata["openai"]["responseId"], Value::Null); + } + + /// Upstream: @ai-sdk/openai 4.0.83 + /// src/responses/openai-responses-language-model.ts:2735-2749 gives every + /// `reasoning-delta` the reasoning item's id as `providerMetadata..itemId`, + /// as it does for `reasoning-start` and `reasoning-end`: the id the item + /// started with at its `output_index` (`resolveOutputItemId`), even when the + /// provider rotates the id on later events. + #[tokio::test] + async fn reasoning_deltas_carry_the_item_id() { + let fetch = MockFetch::new(vec![sse(vec![ + json!({"type":"response.output_item.added","output_index":0, + "item":{"id":"rs_1","type":"reasoning","summary":[]}}), + json!({"type":"response.reasoning_summary_part.added","item_id":"rs_2","output_index":0, + "summary_index":0,"part":{"type":"summary_text","text":""}}), + json!({"type":"response.reasoning_summary_text.delta","item_id":"rs_3","output_index":0, + "summary_index":0,"delta":"Thinking"}), + ])]); + let parts: Vec<_> = provider(&fetch) + .responses("test-model") + .do_stream(&options()) + .await + .unwrap() + .stream + .map(|part| part.unwrap()) + .collect() + .await; + let delta = parts + .iter() + .find_map(|part| match part { + StreamPart::ReasoningDelta { + provider_metadata, .. + } => Some(provider_metadata), + _ => None, + }) + .expect("a reasoning delta"); + assert_eq!( + serde_json::to_value(delta).unwrap(), + json!({ "openai": { "itemId": "rs_1" } }) + ); + } + + /// Upstream: @ai-sdk/openai 4.0.83 src/tool/file-search.ts:29 takes + /// `maxNumResults` as a JS number and + /// src/responses/openai-responses-prepare-tools.ts:219 sends it as is, so + /// `maxNumResults: 5` reaches the API's integer field as `5`, not `5.0`. + #[tokio::test] + async fn file_search_sends_max_num_results_as_an_integer() { + let fetch = MockFetch::new(vec![Canned::json(&responses_response())]); + let args: FileSearchArgs = + serde_json::from_value(json!({ "vectorStoreIds": ["vs_1"], "maxNumResults": 5 })) + .unwrap(); + let mut options = options(); + options.tools = Some(vec![Tool::Provider(file_search(args).unwrap())]); + provider(&fetch) + .responses("test-model") + .do_generate(&options) + .await + .unwrap(); + assert_eq!( + fetch.seen()[0].json_body()["tools"][0]["max_num_results"], + json!(5) + ); + } + + /// Upstream: @ai-sdk/openai 4.0.83 + /// src/responses/openai-responses-language-model.ts:128 (`mapComputerAction`) + /// builds each computer action from the fields its type has and adds `keys` + /// only when it is not null, so a `double_click` with `keys: null` reaches the + /// tool input without `keys`. + #[tokio::test] + async fn computer_actions_leave_out_null_keys() { + let fetch = MockFetch::new(vec![Canned::json(&json!({ + "id": "resp_computer", + "created_at": 1, + "model": "test-model", + "output": [{ + "type": "computer_call", + "id": "computer_item", + "call_id": "computer_call", + "status": "completed", + "actions": [ + {"type": "double_click", "x": 1, "y": 2, "keys": null}, + {"type": "scroll", "x": 3, "y": 4, "scroll_x": 0, "scroll_y": 5, "keys": null} + ], + "pending_safety_checks": [] + }], + "usage": {"input_tokens": 1, "output_tokens": 1} + }))]); + let result = provider(&fetch) + .responses("test-model") + .do_generate(&options()) + .await + .unwrap(); + let Some(GenerateContent::ToolCall(call)) = result.content.first() else { + panic!("expected a tool call: {:?}", result.content); + }; + assert_eq!( + call.input, + r#"{"actions":[{"type":"double_click","x":1,"y":2},{"type":"scroll","x":3,"y":4,"scrollX":0,"scrollY":5}],"pendingSafetyChecks":[],"status":"completed"}"# + ); + } + + /// Upstream: @ai-sdk/openai 4.0.83 + /// src/responses/openai-responses-language-model.ts:378 maps the names of + /// twelve provider tools back to the caller's tool names + /// (`providerToolNames`); `computer_use` is not one of them, so a computer + /// call without a `call_id` (line 1358) keeps the name `computer_use` even + /// when the request declares an `openai.computer_use` tool with another name. + #[tokio::test] + async fn computer_use_results_keep_the_canonical_tool_name() { + let fetch = MockFetch::new(vec![Canned::json(&json!({ + "id": "resp_computer_use", + "created_at": 1, + "model": "test-model", + "output": [{"type": "computer_call", "id": "computer_item", "status": "completed"}], + "usage": {"input_tokens": 1, "output_tokens": 1} + }))]); + let mut options = options(); + options.tools = Some(vec![Tool::Provider(aimux_core::tool::ProviderTool { + id: "openai.computer_use".parse().unwrap(), + name: "computerUse".into(), + args: serde_json::Map::new(), + supports_deferred_results: None, + })]); + let result = provider(&fetch) + .responses("test-model") + .do_generate(&options) + .await + .unwrap(); + let names: Vec<&str> = result + .content + .iter() + .filter_map(|part| match part { + GenerateContent::ToolCall(call) => Some(call.tool_name.as_str()), + GenerateContent::ToolResult(result) => Some(result.tool_name.as_str()), + _ => None, + }) + .collect(); + assert_eq!(names, ["computer_use", "computer_use"]); + } + + /// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-api.ts:1520 + /// parses a `container_file_citation` annotation with only `type`, + /// `container_id`, `file_id`, `filename`, `start_index` and `end_index`, and + /// src/responses/openai-responses-language-model.ts:1072 puts the parsed + /// annotations in the text part's provider metadata, so an extra `index` is + /// dropped. + #[tokio::test] + async fn text_annotations_keep_only_the_fields_of_their_type() { + let fetch = MockFetch::new(vec![Canned::json(&json!({ + "id": "resp_annotations", + "created_at": 1, + "model": "test-model", + "output": [{ + "type": "message", + "id": "msg_container", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "Generated with container file.", + "annotations": [{ + "type": "container_file_citation", + "container_id": "cntr_test", + "file_id": "file-container", + "filename": "data.csv", + "start_index": 0, + "end_index": 10, + "index": 2 + }] + }] + }], + "usage": {"input_tokens": 1, "output_tokens": 1} + }))]); + let result = provider(&fetch) + .responses("test-model") + .do_generate(&options()) + .await + .unwrap(); + let Some(GenerateContent::Text { + provider_metadata: Some(metadata), + .. + }) = result.content.first() + else { + panic!("expected a text part: {:?}", result.content); + }; + assert_eq!( + metadata["openai"]["annotations"], + json!([{ + "type": "container_file_citation", + "container_id": "cntr_test", + "file_id": "file-container", + "filename": "data.csv", + "start_index": 0, + "end_index": 10 + }]) + ); + } + + /// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-api.ts:1698 + /// parses an apply patch operation as `{type, path, diff}`, and + /// src/responses/openai-responses-language-model.ts:1443 stringifies that + /// parsed operation into the tool input, so its keys follow the schema order + /// whatever order the wire sent them in. + #[tokio::test] + async fn apply_patch_input_lists_operation_fields_in_schema_order() { + let fetch = MockFetch::new(vec![Canned::json(&json!({ + "id": "resp_apply_patch", + "created_at": 1, + "model": "test-model", + "output": [{ + "type": "apply_patch_call", + "id": "apc_1", + "call_id": "call_1", + "status": "completed", + "operation": {"diff": "+hello\n", "type": "create_file", "path": "a.md"} + }], + "usage": {"input_tokens": 1, "output_tokens": 1} + }))]); + let result = provider(&fetch) + .responses("test-model") + .do_generate(&options()) + .await + .unwrap(); + let Some(GenerateContent::ToolCall(call)) = result.content.first() else { + panic!("expected a tool call: {:?}", result.content); + }; + assert_eq!( + call.input, + r#"{"callId":"call_1","operation":{"type":"create_file","path":"a.md","diff":"+hello\n"}}"# + ); + } + + /// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-api.ts:147 + /// parses a local shell action as `{type, command, timeout_ms, user, + /// working_directory, env}`, and + /// src/responses/openai-responses-language-model.ts:998 stringifies the + /// parsed action into the tool input, so its keys follow that order. + #[tokio::test] + async fn local_shell_input_lists_action_fields_in_schema_order() { + let fetch = MockFetch::new(vec![Canned::json(&json!({ + "id": "resp_local_shell", + "created_at": 1, + "model": "test-model", + "output": [{ + "type": "local_shell_call", + "id": "lsh_1", + "call_id": "call_1", + "status": "completed", + "action": {"type": "exec", "command": ["ls"], "env": {}, "working_directory": "/root"} + }], + "usage": {"input_tokens": 1, "output_tokens": 1} + }))]); + let result = provider(&fetch) + .responses("test-model") + .do_generate(&options()) + .await + .unwrap(); + let Some(GenerateContent::ToolCall(call)) = result.content.first() else { + panic!("expected a tool call: {:?}", result.content); + }; + assert_eq!( + call.input, + r#"{"action":{"type":"exec","command":["ls"],"working_directory":"/root","env":{}}}"# + ); + } + + /// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-api.ts:1486 + /// lets a message's `content` hold only `output_text` parts, so a `refusal` + /// part fails the response schema and the JSON response handler rejects the + /// call with an `APICallError` ("Invalid JSON response"). + #[tokio::test] + async fn a_refusal_message_part_rejects_the_response() { + let fetch = MockFetch::new(vec![Canned::json(&json!({ + "id": "resp_refusal", + "created_at": 1, + "model": "test-model", + "output": [{ + "type": "message", + "id": "msg_refusal", + "role": "assistant", + "status": "completed", + "content": [{"type": "refusal", "refusal": "Cannot help with this."}] + }], + "usage": {"input_tokens": 1, "output_tokens": 1} + }))]); + let error = provider(&fetch) + .responses("test-model") + .do_generate(&options()) + .await + .unwrap_err(); + let AiMuxError::ApiCall(detail) = &error else { + panic!("expected an API call error: {error:?}"); + }; + assert!( + detail.message.starts_with("Invalid JSON response"), + "{error}" + ); + assert!( + detail + .response_body + .as_deref() + .is_some_and(|body| body.contains("Cannot help with this.")), + "{error:?}" + ); + } + + /// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-api.ts:875 + /// (chunk) and :1490 (response) parse a logprobs entry as `{token, logprob, + /// top_logprobs: [{token, logprob}]}`, so `bytes` never reaches + /// `providerMetadata.openai.logprobs` + /// (openai-responses-language-model.ts:1065 generate, :2685 stream). + #[tokio::test] + async fn responses_logprobs_metadata_has_no_bytes() { + let wire = json!([{"token":"N","logprob":-2.5,"bytes":[78], + "top_logprobs":[{"token":"N","logprob":-2.5,"bytes":[78]},{"token":"Y","logprob":-1.0,"bytes":[89]}]}]); + let expected = json!([[{"token":"N","logprob":-2.5, + "top_logprobs":[{"token":"N","logprob":-2.5},{"token":"Y","logprob":-1.0}]}]]); + let mut opts = options(); + opts.provider_options = provider_namespace("openai", json!({"logprobs": 2})).ok(); + + let mut response = responses_response(); + response["output"][0]["content"][0]["logprobs"] = wire.clone(); + let fetch = MockFetch::new(vec![Canned::json(&response)]); + let result = provider(&fetch) + .responses("test-model") + .do_generate(&opts) + .await + .unwrap(); + assert_eq!( + serde_json::to_value(result.provider_metadata).unwrap()["openai"]["logprobs"], + expected + ); + + let fetch = MockFetch::new(vec![sse(vec![ + json!({"type":"response.created","response":{"id":"resp_1","created_at":1,"model":"test-model"}}), + json!({"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_1"}}), + json!({"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"delta":"N","logprobs":wire}), + json!({"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}), + ])]); + let parts = provider(&fetch) + .responses("test-model") + .do_stream(&opts) + .await + .unwrap() + .stream + .map(|part| part.unwrap()) + .collect::>() + .await; + let Some(StreamPart::Finish { + provider_metadata, .. + }) = parts.last() + else { + panic!("no finish part: {parts:?}"); + }; + assert_eq!( + serde_json::to_value(provider_metadata).unwrap()["openai"]["logprobs"], + expected + ); + } + + /// Upstream: @ai-sdk/openai 4.0.83 + /// src/responses/openai-responses-language-model.ts:1986 gives the + /// `tool-input-end` of a streamed function call with a `namespace` the + /// provider metadata `{ namespace }`. + #[tokio::test] + async fn a_streamed_namespaced_function_call_ends_its_input_with_the_namespace() { + let fetch = MockFetch::new(vec![sse(vec![ + json!({"type":"response.created","response":{"id":"resp_1","created_at":1,"model":"test-model"}}), + json!({"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1", + "call_id":"call_1","name":"get_weather","arguments":"{}","status":"completed","namespace":"weather_ns"}}), + json!({"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}), + ])]); + let parts = provider(&fetch) + .responses("test-model") + .do_stream(&options()) + .await + .unwrap() + .stream + .map(|part| part.unwrap()) + .collect::>() + .await; + let metadata = parts + .iter() + .find_map(|part| match part { + StreamPart::ToolInputEnd { + provider_metadata, .. + } => Some(provider_metadata), + _ => None, + }) + .expect("a tool-input-end part"); + assert_eq!( + serde_json::to_value(metadata).unwrap(), + json!({"openai": {"namespace": "weather_ns"}}) + ); + } + + /// Upstream: @ai-sdk/openai 4.0.83 + /// src/responses/openai-responses-language-model.ts:2709 (a summary part + /// concluded when the next one starts) and :2762 (a summary part concluded + /// on `reasoning_summary_part.done` with `store`) end the reasoning part with + /// provider metadata `{ itemId }` only; the encrypted content goes on the + /// ends sent when the reasoning item is done (line 2523). + #[tokio::test] + async fn early_reasoning_summary_ends_carry_only_the_item_id() { + for store in [false, true] { + let fetch = MockFetch::new(vec![sse(vec![ + json!({"type":"response.created","response":{"id":"resp_1","created_at":1,"model":"test-model"}}), + json!({"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_1","encrypted_content":"enc"}}), + json!({"type":"response.reasoning_summary_part.added","item_id":"rs_1","output_index":0,"summary_index":0}), + json!({"type":"response.reasoning_summary_part.done","item_id":"rs_1","output_index":0,"summary_index":0}), + json!({"type":"response.reasoning_summary_part.added","item_id":"rs_1","output_index":0,"summary_index":1}), + json!({"type":"response.output_item.done","output_index":0,"item":{"type":"reasoning","id":"rs_1","encrypted_content":"enc"}}), + json!({"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}), + ])]); + let mut opts = options(); + opts.provider_options = provider_namespace("openai", json!({"store": store})).ok(); + let parts = provider(&fetch) + .responses("test-model") + .do_stream(&opts) + .await + .unwrap() + .stream + .map(|part| part.unwrap()) + .collect::>() + .await; + let ends: Vec = parts + .iter() + .filter_map(|part| match part { + StreamPart::ReasoningEnd { + id, + provider_metadata, + } => Some(json!({"id": id, "providerMetadata": provider_metadata})), + _ => None, + }) + .collect(); + assert_eq!( + ends, + [ + json!({"id": "rs_1:0", "providerMetadata": {"openai": {"itemId": "rs_1"}}}), + json!({"id": "rs_1:1", "providerMetadata": {"openai": {"itemId": "rs_1", "reasoningEncryptedContent": "enc"}}}), + ], + "store: {store}" + ); + } + } + + /// Upstream: @ai-sdk/openai 4.0.83 + /// src/responses/openai-responses-language-model.ts:1633-1644 keeps the id + /// each output item started with by `output_index` (OpenAI-compatible + /// providers can rotate item ids between events) and resolves the reasoning + /// summary events (:2688, :2736, :2751) and the reasoning item's end (:2503) + /// through it. + #[tokio::test] + async fn rotated_reasoning_item_ids_are_correlated_by_output_index() { + let fetch = MockFetch::new(vec![sse(vec![ + json!({"type":"response.created","response":{"id":"resp_1","created_at":1,"model":"test-model"}}), + json!({"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_1"}}), + json!({"type":"response.reasoning_summary_part.added","item_id":"rs_2","output_index":0,"summary_index":0}), + json!({"type":"response.reasoning_summary_text.delta","item_id":"rs_3","output_index":0,"summary_index":0,"delta":"thinking"}), + json!({"type":"response.reasoning_summary_part.done","item_id":"rs_4","output_index":0,"summary_index":0}), + json!({"type":"response.output_item.done","output_index":0,"item":{"type":"reasoning","id":"rs_5"}}), + json!({"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}), + ])]); + let parts = provider(&fetch) + .responses("test-model") + .do_stream(&options()) + .await + .unwrap() + .stream + .map(|part| part.unwrap()) + .collect::>() + .await; + let ids: Vec<(&str, &str)> = parts + .iter() + .filter_map(|part| match part { + StreamPart::ReasoningStart { id, .. } => Some(("start", id.as_str())), + StreamPart::ReasoningDelta { id, .. } => Some(("delta", id.as_str())), + StreamPart::ReasoningEnd { id, .. } => Some(("end", id.as_str())), + _ => None, + }) + .collect(); + assert_eq!( + ids, + [("start", "rs_1:0"), ("delta", "rs_1:0"), ("end", "rs_1:0")] + ); + } + + /// Upstream: @ai-sdk/openai 4.0.83 + /// src/responses/openai-responses-language-model.ts:1664-1680 turns a chunk + /// that fails the Responses schema and looks like a Chat Completions chunk + /// (:2962, a `choices` array and no string `type`) into an `APICallError` + /// that names the mix-up (:2972), sends it as an error part and finishes with + /// `error`. + #[tokio::test] + async fn a_chat_completions_chunk_on_a_responses_stream_gives_a_helpful_error() { + let chunk = json!({"choices":[],"created":0,"id":"","model":"","object":""}); + let fetch = MockFetch::new(vec![sse(vec![chunk.clone()])]); + let parts = provider(&fetch) + .responses("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 } => Some(error), + _ => None, + }) + .expect("an error part"); + let AiMuxError::ApiCall(detail) = error else { + panic!("expected an API call error: {error:?}"); + }; + assert!( + detail.message.starts_with( + "Received a Chat Completions stream while using the OpenAI Responses API." + ), + "{error}" + ); + assert_eq!( + detail.response_body.as_deref(), + Some(chunk.to_string().as_str()) + ); + assert!(!detail.is_retryable); + assert!(matches!( + parts.last(), + Some(StreamPart::Finish { finish_reason, .. }) if finish_reason.unified == FinishReasonUnified::Error + )); + } + + /// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-api.ts:1338 + /// requires `output_index` on `response.function_call_arguments.done` (and + /// :1095 on `response.output_item.done` of a modeled item), and a modeled + /// chunk that fails its schema is not skipped as unknown (:1433-1441): + /// src/responses/openai-responses-language-model.ts:1664-1680 sends an error + /// part for it and finishes with `error`. + #[tokio::test] + async fn schema_invalid_known_responses_events_are_errors() { + let fetch = MockFetch::new(vec![sse(vec![ + json!({"type":"response.created","response":{"id":"resp_1","created_at":1,"model":"test-model"}}), + json!({"type":"response.function_call_arguments.done","item_id":"fc_1","arguments":"{}"}), + json!({"type":"response.output_item.done","item":{"type":"function_call","id":"fc_1", + "call_id":"call_1","name":"get_weather","arguments":"{}","status":"completed"}}), + json!({"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}), + ])]); + let parts = provider(&fetch) + .responses("test-model") + .do_stream(&options()) + .await + .unwrap() + .stream + .map(|part| part.unwrap()) + .collect::>() + .await; + let errors = parts + .iter() + .filter(|part| { + matches!( + part, + StreamPart::Error { + error: AiMuxError::TypeValidation { .. } + } + ) + }) + .count(); + assert_eq!(errors, 2, "{parts:?}"); + assert!( + !parts + .iter() + .any(|part| matches!(part, StreamPart::ToolCall(_))), + "{parts:?}" + ); + assert!(matches!( + parts.last(), + Some(StreamPart::Finish { finish_reason, .. }) if finish_reason.unified == FinishReasonUnified::Error + )); + } + + /// Upstream: @ai-sdk/openai 4.0.83 src/openai-stream-error.ts:39 reads the + /// stream until its first output event (`throwIfOpenAIStreamErrorBeforeOutput`, + /// called at src/responses/openai-responses-language-model.ts:1552), so an + /// error frame after `response.created` but before any output rejects + /// `doStream` with an `APICallError` instead of becoming an error part. + #[tokio::test] + async fn a_responses_error_before_output_after_response_created_rejects_the_call() { + let fetch = MockFetch::new(vec![sse(vec![ + json!({"type":"response.created","sequence_number":0,"response":{"id":"resp_1","created_at":1,"model":"test-model"}}), + json!({"type":"error","sequence_number":1,"code":"rate_limit_exceeded","message":"Rate limit reached","param":null}), + ])]); + let Err(error) = provider(&fetch) + .responses("test-model") + .do_stream(&options()) + .await + else { + panic!("doStream resolved; expected it to reject"); + }; + let AiMuxError::ApiCall(detail) = &error else { + panic!("expected an API call error: {error:?}"); + }; + assert_eq!(detail.message, "Rate limit reached"); + assert_eq!(detail.status_code, Some(429)); + assert!(detail.is_retryable); + } + /// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-prepare-tools.ts:684-688 + /// drops `async: true` for a model without async tool calling and warns with + /// the quoted tool name and the GPT-6 requirement. + #[tokio::test] + async fn responses_async_tool_warning_names_the_gpt_6_requirement() { + let fetch = MockFetch::new(vec![Canned::json(&responses_response())]); + let mut call = options(); + call.tools = Some(vec![Tool::Function(FunctionTool { + provider_options: Some( + serde_json::from_value(json!({"openai":{"async":true}})).unwrap(), + ), + ..FunctionTool::new("weather", json!({"type":"object","properties":{}})) + })]); + let result = provider(&fetch) + .responses("gpt-5.6") + .do_generate(&call) + .await + .unwrap(); + assert!( + fetch.seen()[0].json_body()["tools"][0] + .get("async") + .is_none() + ); + assert_eq!( + serde_json::to_value(&result.warnings).unwrap(), + json!([{ + "type": "unsupported", + "feature": "async tool calling for \"weather\"", + "details": "Async tool calling is only supported by GPT-6 and later models." + }]) + ); + } + + /// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-prepare-tools.ts:183-202 + /// and :454-504 quote the `allowedTools` entry in the warning feature and give + /// the reason a tool is dropped: a deferred tool is not visible to + /// `tool_choice.allowed_tools`. + #[tokio::test] + async fn responses_allowed_tools_warnings_quote_the_entry_and_give_the_reason() { + let fetch = MockFetch::new(vec![Canned::json(&responses_response())]); + let mut call = options(); + call.tools = Some(vec![ + Tool::Function(FunctionTool::new( + "get_weather", + json!({"type":"object","properties":{}}), + )), + Tool::Function(FunctionTool { + provider_options: Some( + serde_json::from_value(json!({"openai":{"deferLoading":true}})).unwrap(), + ), + ..FunctionTool::new("deferred", json!({"type":"object","properties":{}})) + }), + ]); + call.provider_options = Some( + serde_json::from_value( + json!({"openai":{"allowedTools":{"toolNames":["get_weather","deferred"]}}}), + ) + .unwrap(), + ); + let result = provider(&fetch) + .responses("gpt-5.4") + .do_generate(&call) + .await + .unwrap(); + assert_eq!( + fetch.seen()[0].json_body()["tool_choice"], + json!({"type":"allowed_tools","mode":"auto","tools":[{"type":"function","name":"get_weather"}]}) + ); + assert_eq!( + serde_json::to_value(&result.warnings).unwrap(), + json!([{ + "type": "unsupported", + "feature": "allowedTools entry \"deferred\"", + "details": "deferred tools are not visible to tool_choice.allowed_tools; the tool is removed from the allowed tools" + }]) + ); + } + + /// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-prepare-tools.ts:509-515 + /// names the dropped entries when no `allowedTools` entry can be expressed. + #[tokio::test] + async fn responses_allowed_tools_error_names_the_dropped_entries() { + let fetch = MockFetch::new(vec![Canned::json(&responses_response())]); + let mut call = options(); + call.tools = Some(vec![Tool::Function(FunctionTool { + provider_options: Some( + serde_json::from_value(json!({"openai":{"deferLoading":true}})).unwrap(), + ), + ..FunctionTool::new("deferred", json!({"type":"object","properties":{}})) + })]); + call.provider_options = Some( + serde_json::from_value(json!({"openai":{"allowedTools":{"toolNames":["deferred"]}}})) + .unwrap(), + ); + let error = provider(&fetch) + .responses("gpt-5.4") + .do_generate(&call) + .await + .unwrap_err(); + assert!( + matches!(&error, AiMuxError::UnsupportedFunctionality(message) + if message == "allowedTools with only tools that cannot be allow-listed (deferred)"), + "{error:?}" + ); + assert!(fetch.seen().is_empty()); + } + + /// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-prepare-tools.ts:174-178 + /// rejects two descriptions for one tool namespace, quoting its name. + #[tokio::test] + async fn responses_conflicting_namespace_descriptions_quote_the_namespace() { + let fetch = MockFetch::new(vec![Canned::json(&responses_response())]); + let namespaced = |name: &str, description: &str| { + Tool::Function(FunctionTool { + provider_options: Some( + serde_json::from_value( + json!({"openai":{"namespace":{"name":"crm","description":description}}}), + ) + .unwrap(), + ), + ..FunctionTool::new(name, json!({"type":"object","properties":{}})) + }) + }; + let mut call = options(); + call.tools = Some(vec![ + namespaced("get_customer", "CRM tools"), + namespaced("list_orders", "Other CRM tools"), + ]); + let error = provider(&fetch) + .responses("gpt-5.4") + .do_generate(&call) + .await + .unwrap_err(); + assert!( + matches!(&error, AiMuxError::UnsupportedFunctionality(message) + if message == "conflicting descriptions for OpenAI tool namespace \"crm\""), + "{error:?}" + ); + } + + /// Upstream: @ai-sdk/openai 4.0.83 src/responses/convert-to-openai-responses-input.ts:142-145 + /// and src/responses/openai-responses-prepare-tools.ts:780-783 resolve file and + /// skill references with `resolveProviderReference`, whose + /// `NoSuchProviderReferenceError` names the provider and the available ones. + #[tokio::test] + async fn responses_missing_provider_reference_names_the_available_providers() { + let fetch = MockFetch::new(vec![]); + let model = provider(&fetch).responses("gpt-5.4"); + let file_call = CallOptions::new(vec![ + serde_json::from_value(json!({"role":"user","content":[{"type":"file","mediaType":"image/png","data":{"type":"reference","reference":{"anthropic":"file-xyz"}}}]})) + .unwrap(), + ]); + let mut skill_call = options(); + skill_call.tools = Some(vec![ + serde_json::from_value(json!({"type":"provider","id":"openai.shell","name":"shell","args":{"environment":{"type":"containerAuto","skills":[{"type":"skillReference","providerReference":{"anthropic":"skill_abc"}}]}}})) + .unwrap(), + ]); + for call in [file_call, skill_call] { + let error = model.do_generate(&call).await.unwrap_err(); + assert!( + matches!(&error, AiMuxError::InvalidArgument { message, .. } + if message == "No provider reference found for provider 'openai'. Available providers: anthropic"), + "{error:?}" + ); + } + assert!(fetch.seen().is_empty()); + } + + /// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-language-model.ts:455-492 + /// warns about a request-level `reasoningEffortUpdate` the model does not + /// support by listing the efforts it does support, and omits the update. + #[tokio::test] + async fn responses_unsupported_reasoning_effort_update_lists_the_supported_efforts() { + let fetch = MockFetch::new(vec![Canned::json(&responses_response())]); + let mut call = options(); + call.provider_options = Some( + serde_json::from_value(json!({"openai":{"reasoningEffortUpdate":"none"}})).unwrap(), + ); + let result = provider(&fetch) + .responses("gpt-6-astra") + .do_generate(&call) + .await + .unwrap(); + assert_eq!( + fetch.seen()[0].json_body()["input"], + json!([{"role":"user","content":[{"type":"input_text","text":"Hello"}]}]) + ); + assert_eq!( + serde_json::to_value(&result.warnings).unwrap(), + json!([{ + "type": "unsupported", + "feature": "reasoningEffortUpdate", + "details": "gpt-6-astra only supports the following reasoning efforts: low, medium, high, xhigh, max" + }]) + ); + } + + /// Upstream: @ai-sdk/openai 4.0.83 src/responses/convert-to-openai-responses-input.ts:780-842 + /// replays a provider-executed tool search or program call in full when + /// responses are not stored: the tool search and program checks come before + /// the provider-executed one. + #[tokio::test] + async fn responses_replays_hosted_tool_search_and_program_calls_without_storage() { + let fetch = MockFetch::new(vec![Canned::json(&responses_response())]); + let mut call = CallOptions::new(vec![ + serde_json::from_value(json!({"role":"assistant","content":[ + {"type":"tool-call","toolCallId":"tsc_1","toolName":"tool_search","input":{"arguments":{"paths":["get_weather"]},"call_id":null},"providerExecuted":true,"providerOptions":{"openai":{"itemId":"tsc_1"}}}, + {"type":"tool-call","toolCallId":"program_call_1","toolName":"program","input":{"code":"1","fingerprint":"fp"},"providerExecuted":true,"providerOptions":{"openai":{"itemId":"program_item_1"}}} + ]})) + .unwrap(), + ]); + call.tools = Some(vec![ + serde_json::from_value( + json!({"type":"provider","id":"openai.tool_search","name":"tool_search","args":{}}), + ) + .unwrap(), + serde_json::from_value( + json!({"type":"provider","id":"openai.programmatic_tool_calling","name":"program","args":{}}), + ) + .unwrap(), + ]); + call.provider_options = + Some(serde_json::from_value(json!({"openai":{"store":false}})).unwrap()); + provider(&fetch) + .responses("gpt-5.4") + .do_generate(&call) + .await + .unwrap(); + assert_eq!( + fetch.seen()[0].json_body()["input"], + json!([ + {"type":"tool_search_call","id":"tsc_1","execution":"server","call_id":null,"status":"completed","arguments":{"paths":["get_weather"]}}, + {"type":"program","id":"program_item_1","call_id":"program_call_1","code":"1","fingerprint":"fp"} + ]) + ); + } + + /// Upstream: @ai-sdk/openai 4.0.83 src/responses/convert-to-openai-responses-input.ts:1646-1683 + /// sends the `caller` provider option of a tool result on its + /// `function_call_output`, linking a nested call to its program. + #[tokio::test] + async fn responses_function_call_output_keeps_the_program_caller() { + let fetch = MockFetch::new(vec![Canned::json(&responses_response())]); + let call = CallOptions::new(vec![ + serde_json::from_value(json!({"role":"tool","content":[ + {"type":"tool-result","toolCallId":"function_call_1","toolName":"get_inventory","output":{"type":"text","value":"42"},"providerOptions":{"openai":{"caller":{"type":"program","callerId":"program_call_1"}}}} + ]})) + .unwrap(), + ]); + provider(&fetch) + .responses("gpt-5.4") + .do_generate(&call) + .await + .unwrap(); + assert_eq!( + fetch.seen()[0].json_body()["input"], + json!([{"type":"function_call_output","call_id":"function_call_1","output":"42","caller":{"type":"program","caller_id":"program_call_1"}}]) + ); + } +} diff --git a/aimux-providers/src/openai/tests/responses/responses_prepare_tools.rs b/aimux-providers/src/openai/tests/responses/responses_prepare_tools.rs index b2a20561d..61b3e0d94 100644 --- a/aimux-providers/src/openai/tests/responses/responses_prepare_tools.rs +++ b/aimux-providers/src/openai/tests/responses/responses_prepare_tools.rs @@ -3,7 +3,7 @@ use aimux_core::options::LanguageModelV4ToolChoice; use aimux_core::tool::Tool; use serde_json::{Value, json}; -use crate::openai::responses::convert::prepare_responses_tools_for; +use crate::openai::responses::responses_prepare_tools::prepare_responses_tools_for; /// `prepareResponsesTools({ tools, toolChoice, allowedTools, /// supportsAsyncToolCalling })` as the upstream result object; keys whose diff --git a/aimux-providers/src/openai/tests/speech/speech_model.rs b/aimux-providers/src/openai/tests/speech/speech_model.rs index 0d9aa0015..7be430f51 100644 --- a/aimux-providers/src/openai/tests/speech/speech_model.rs +++ b/aimux-providers/src/openai/tests/speech/speech_model.rs @@ -137,3 +137,376 @@ mod do_generate { ); } } + +// ── Older ports (previously `tests/openai_speech_test.rs`) ── +// +// Rust translation of the OpenAI speech (TTS) model tests. +// +// Source: `reference/ai/packages/openai/src/speech/openai-speech-model.test.ts` +// +// Each test spins up a `wiremock` mock server, configures a binary audio +// response, creates an `OpenAISpeechModel` pointing at the mock, calls +// `do_generate`, and asserts on the request body / headers / result. +// +// The TS tests inject a custom `currentDate` via `_internal` for timestamp +// assertions. The Rust model always uses `Utc::now()`; the timestamp tests +// therefore verify that a timestamp is present and that `model_id` matches, +// rather than asserting an exact timestamp value. + +mod older_speech { + use std::collections::HashMap; + + use serde_json::Value; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + use crate::{OpenAIProviderSettings, create_openai}; + use aimux_core::provider::Provider; + use aimux_core::speech_model::{AudioData, SpeechCallOptions}; + + // ── helpers ───────────────────────────────────────────────────────────────── + + /// 100 bytes of mock audio data, returned by the mock server. + fn mock_audio_bytes() -> Vec { + vec![0u8; 100] + } + + /// Mount a mock binary audio response on the server at `/audio/speech`. + async fn mock_audio_response(server: &MockServer, format: &str) { + Mock::given(method("POST")) + .and(path("/audio/speech")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", format!("audio/{format}")) + .set_body_bytes(mock_audio_bytes()), + ) + .mount(server) + .await; + } + + /// Mount a mock binary audio response with extra response headers. + async fn mock_audio_response_with_headers( + server: &MockServer, + format: &str, + headers: &[(&str, &str)], + ) { + let mut template = ResponseTemplate::new(200) + .insert_header("content-type", format!("audio/{format}")) + .set_body_bytes(mock_audio_bytes()); + for (k, v) in headers { + template = template.insert_header(*k, *v); + } + Mock::given(method("POST")) + .and(path("/audio/speech")) + .respond_with(template) + .mount(server) + .await; + } + + /// Build a `SpeechCallOptions` with just the text. + fn speech_options(text: &str) -> SpeechCallOptions { + SpeechCallOptions::new(text.to_string()) + } + + // ════════════════════════════════════════════════════════════════════════════ + // doGenerate + // (openai-speech-model.test.ts → describe('doGenerate')) + // ════════════════════════════════════════════════════════════════════════════ + + mod do_generate { + use super::*; + + /// upstream: @ai-sdk/openai src/speech/openai-speech-model.test.ts › doGenerate › should pass the model and text + #[tokio::test] + async fn should_pass_the_model_and_text() { + let server = MockServer::start().await; + mock_audio_response(&server, "mp3").await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.speech_model("tts-1").unwrap().unwrap(); + + model + .do_generate(&speech_options("Hello from the AI SDK!")) + .await + .unwrap(); + + let requests = server.received_requests().await.expect("requests recorded"); + assert_eq!(requests.len(), 1); + let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!(body["model"], "tts-1"); + assert_eq!(body["input"], "Hello from the AI SDK!"); + } + + /// upstream: @ai-sdk/openai src/speech/openai-speech-model.test.ts › doGenerate › should pass options + #[tokio::test] + async fn should_pass_options() { + let server = MockServer::start().await; + mock_audio_response(&server, "opus").await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.speech_model("tts-1").unwrap().unwrap(); + + let mut options = speech_options("Hello from the AI SDK!"); + options.voice = Some("nova".to_string()); + options.output_format = Some("opus".to_string()); + options.speed = Some(1.5); + + model.do_generate(&options).await.unwrap(); + + let requests = server.received_requests().await.expect("requests recorded"); + let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!(body["model"], "tts-1"); + assert_eq!(body["input"], "Hello from the AI SDK!"); + assert_eq!(body["voice"], "nova"); + assert_eq!(body["speed"], 1.5); + assert_eq!(body["response_format"], "opus"); + } + + /// upstream: @ai-sdk/openai src/speech/openai-speech-model.test.ts › doGenerate › should return audio data with correct content type + #[tokio::test] + async fn should_return_audio_data_with_correct_content_type() { + let server = MockServer::start().await; + mock_audio_response_with_headers( + &server, + "opus", + &[ + ("x-request-id", "test-request-id"), + ("x-ratelimit-remaining", "123"), + ], + ) + .await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.speech_model("tts-1").unwrap().unwrap(); + + let mut options = speech_options("Hello from the AI SDK!"); + options.output_format = Some("opus".to_string()); + + let result = model + .do_generate(&options) + .await + .expect("do_generate should succeed"); + + assert!(matches!(result.audio, AudioData::Binary(ref b) if b == &mock_audio_bytes())); + } + + /// upstream: @ai-sdk/openai src/speech/openai-speech-model.test.ts › doGenerate › should include response data with timestamp, modelId and headers + /// + /// The TS test injects a custom `currentDate` of `new Date(0)`; the Rust model + /// uses `Utc::now()`. We assert that the timestamp is present, the model id + /// matches, and the response headers carry the expected values. + #[tokio::test] + async fn should_include_response_data_with_timestamp_modelid_and_headers() { + let server = MockServer::start().await; + mock_audio_response_with_headers( + &server, + "mp3", + &[ + ("x-request-id", "test-request-id"), + ("x-ratelimit-remaining", "123"), + ], + ) + .await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.speech_model("tts-1").unwrap().unwrap(); + + let result = model + .do_generate(&speech_options("Hello from the AI SDK!")) + .await + .unwrap(); + + assert!( + result.response.timestamp.is_some(), + "timestamp should be set" + ); + assert_eq!(result.response.model_id.as_deref(), Some("tts-1")); + let headers = result.response.headers.expect("response headers"); + assert_eq!(headers.get("content-type"), Some(&"audio/mp3".to_string())); + assert_eq!( + headers.get("x-request-id"), + Some(&"test-request-id".to_string()) + ); + assert_eq!( + headers.get("x-ratelimit-remaining"), + Some(&"123".to_string()) + ); + } + + /// upstream: @ai-sdk/openai src/speech/openai-speech-model.test.ts › doGenerate › should use real date when no custom date provider is specified + /// + /// The TS test actually injects a custom date (a copy-paste oddity). The Rust + /// model always uses `Utc::now()`; we verify the timestamp is present and the + /// model id matches. + #[tokio::test] + async fn should_use_real_date_when_no_custom_date_provider_is_specified() { + let server = MockServer::start().await; + mock_audio_response(&server, "mp3").await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.speech_model("tts-1").unwrap().unwrap(); + + let result = model + .do_generate(&speech_options("Hello from the AI SDK!")) + .await + .unwrap(); + + assert!( + result.response.timestamp.is_some(), + "timestamp should be set" + ); + assert_eq!(result.response.model_id.as_deref(), Some("tts-1")); + } + + /// upstream: @ai-sdk/openai src/speech/openai-speech-model.test.ts › doGenerate › should handle different audio formats + /// + /// Iterates over the supported formats and verifies audio data is returned. + /// Matches the TS input `providerOptions.openai.response_format`, which the + /// upstream schema does not include. + #[tokio::test] + async fn should_handle_different_audio_formats() { + for format in &["mp3", "opus", "aac", "flac", "wav", "pcm"] { + let server = MockServer::start().await; + mock_audio_response(&server, format).await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.speech_model("tts-1").unwrap().unwrap(); + + let mut options = speech_options("Hello from the AI SDK!"); + options.provider_options = Some(aimux_core::shared::NamespaceMap::from([( + "openai".into(), + serde_json::Map::from_iter([( + "response_format".into(), + serde_json::Value::String((*format).into()), + )]), + )])); + + let result = model + .do_generate(&options) + .await + .expect("do_generate should succeed"); + + assert!( + matches!(result.audio, AudioData::Binary(ref b) if b == &mock_audio_bytes()), + "format {format} should return mock audio bytes" + ); + } + } + + /// upstream: @ai-sdk/openai src/speech/openai-speech-model.test.ts › doGenerate › should include warnings if any are generated + /// + /// With no unsupported options, warnings should be empty. + #[tokio::test] + async fn should_include_warnings_if_any_are_generated() { + let server = MockServer::start().await; + mock_audio_response(&server, "mp3").await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.speech_model("tts-1").unwrap().unwrap(); + + let result = model + .do_generate(&speech_options("Hello from the AI SDK!")) + .await + .unwrap(); + + assert!( + result.warnings.is_empty(), + "expected no warnings, got {:?}", + result.warnings + ); + } + } + + /// partial: `should pass headers`; full port in src/openai/tests/speech/speech_model.rs. + /// + /// Verifies that `Authorization`, `OpenAI-Organization`, `OpenAI-Project`, + /// config-level custom headers, and request-level custom headers are all + /// forwarded. The TS test also checks the `User-Agent` header; the Rust + /// provider does not set a `User-Agent` so that assertion is not translated. + #[tokio::test] + async fn should_pass_headers() { + let server = MockServer::start().await; + mock_audio_response(&server, "mp3").await; + + let mut provider_headers = HashMap::new(); + provider_headers.insert( + "Custom-Provider-Header".to_string(), + "provider-header-value".to_string(), + ); + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + organization: Some("test-organization".into()), + project: Some("test-project".into()), + headers: Some( + provider_headers + .into_iter() + .map(|(k, v)| (k, Some(v))) + .collect(), + ), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.speech_model("tts-1").unwrap().unwrap(); + + let mut options = speech_options("Hello from the AI SDK!"); + let mut request_headers = HashMap::new(); + request_headers.insert( + "Custom-Request-Header".to_string(), + "request-header-value".to_string(), + ); + options.headers = Some(request_headers); + + model.do_generate(&options).await.unwrap(); + + let requests = server.received_requests().await.expect("requests recorded"); + assert_eq!(requests.len(), 1); + let h = &requests[0].headers; + assert_eq!(h.get("authorization").unwrap(), "Bearer test-api-key"); + assert_eq!(h.get("openai-organization").unwrap(), "test-organization"); + assert_eq!(h.get("openai-project").unwrap(), "test-project"); + assert_eq!( + h.get("custom-provider-header").unwrap(), + "provider-header-value" + ); + assert_eq!( + h.get("custom-request-header").unwrap(), + "request-header-value" + ); + } +} diff --git a/aimux-providers/src/openai/tests/stream_error.rs b/aimux-providers/src/openai/tests/stream_error.rs index 7bc6c8ac3..5f55b79ad 100644 --- a/aimux-providers/src/openai/tests/stream_error.rs +++ b/aimux-providers/src/openai/tests/stream_error.rs @@ -1,7 +1,7 @@ use aimux_core::error::{AiMuxError, ApiCallError}; use serde_json::{Value, json}; -use crate::openai::openai_stream_error; +use crate::openai::stream_error::openai_stream_error; /// `createOpenAIProviderStreamError(frame)`. The Rust error is an /// `ApiCallError`; it has no `type` field, so upstream's `type` is not diff --git a/aimux-providers/src/openai/tests/transcription/transcription_model.rs b/aimux-providers/src/openai/tests/transcription/transcription_model.rs index ab5f8d1df..c2073200c 100644 --- a/aimux-providers/src/openai/tests/transcription/transcription_model.rs +++ b/aimux-providers/src/openai/tests/transcription/transcription_model.rs @@ -1277,3 +1277,1752 @@ mod do_stream { assert!(audio_cancelled.load(Ordering::SeqCst)); } } + +// ── Older ports (previously `tests/openai_transcription_test.rs`) ── +// +// Rust translation of the OpenAI transcription (STT) model tests. +// +// Source: `reference/ai/packages/openai/src/transcription/openai-transcription-model.test.ts` +// +// Each test spins up a `wiremock` mock server, configures a JSON response, +// creates an `OpenAITranscriptionModel` pointing at the mock, calls +// `do_generate`, and asserts on the multipart request body / headers / result. +// +// The TS tests inject a custom `currentDate` via `_internal` for timestamp +// assertions. The Rust model always uses `Utc::now()`; the timestamp tests +// therefore verify that a timestamp is present and that `model_id` matches, +// rather than asserting an exact timestamp value. +// +// The TS `doStream` tests are translated in +// `openai_transcription_stream_test.rs` (RFC-0028: local WebSocket server) +// and `openai_realtime_ws_replay_test.rs` (offline, through the provider's +// `web_socket` connector). + +mod older_transcription { + use std::collections::HashMap; + + use serde_json::{Value, json}; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + use crate::{OpenAIProviderSettings, create_openai}; + use aimux_core::error::AiMuxError; + use aimux_core::provider::Provider; + use aimux_core::transcription_model::{AudioInput, TranscriptionCallOptions}; + + // ── helpers ───────────────────────────────────────────────────────────────── + + /// Mock audio data (same as TS `audioData` — just some bytes). + fn mock_audio_data() -> Vec { + vec![1u8, 2, 3, 4, 5] + } + + /// Build `TranscriptionCallOptions` with binary audio and a media type. + fn transcription_options(audio: Vec, media_type: &str) -> TranscriptionCallOptions { + TranscriptionCallOptions::new(AudioInput::Binary(audio), media_type) + } + + /// Mount a mock JSON transcription response on the server at + /// `/audio/transcriptions`. + async fn mock_json_response(server: &MockServer, body: &Value) { + Mock::given(method("POST")) + .and(path("/audio/transcriptions")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/json") + .set_body_json(body.clone()), + ) + .mount(server) + .await; + } + + /// Mount a mock JSON transcription response with extra response headers. + async fn mock_json_response_with_headers( + server: &MockServer, + body: &Value, + headers: &[(&str, &str)], + ) { + let mut template = ResponseTemplate::new(200) + .insert_header("content-type", "application/json") + .set_body_json(body.clone()); + for (k, v) in headers { + template = template.insert_header(*k, *v); + } + Mock::given(method("POST")) + .and(path("/audio/transcriptions")) + .respond_with(template) + .mount(server) + .await; + } + + /// Parse a multipart/form-data body into a map of field name → value (string). + /// + /// Non-file fields are decoded as UTF-8 strings. File fields are returned as + /// the raw bytes converted to a string (for assertion purposes). + fn parse_multipart_form(body: &[u8]) -> HashMap { + let body_str = String::from_utf8_lossy(body); + let mut form = HashMap::new(); + + // The first line is the boundary with -- prefix. + // Find the boundary. + let boundary = body_str + .lines() + .next() + .unwrap_or("") + .trim_start_matches('-'); + if boundary.is_empty() { + return form; + } + + // Split on the boundary. + for part in body_str.split(boundary) { + let part = part.trim_matches(|c| c == '\r' || c == '\n' || c == '-'); + if part.is_empty() || part == "--" { + continue; + } + + // Each part has headers and a body separated by \r\n\r\n. + if let Some(header_end) = part.find("\r\n\r\n") { + let headers = &part[..header_end]; + let value = &part[header_end + 4..]; + + // Extract the field name from Content-Disposition. + if let Some(name_start) = headers.find("name=\"") { + let name_start = name_start + 6; + if let Some(name_end) = headers[name_start..].find('"') { + let name = &headers[name_start..name_start + name_end]; + form.insert(name.to_string(), value.trim_end_matches('\r').to_string()); + } + } + } + } + + form + } + + /// The full OpenAI transcription fixture response (matches the TS fixture). + fn fixture_response() -> Value { + json!({ + "task": "transcribe", + "language": "english", + "duration": 36.709999084472656, + "text": "Galileo was an American robotic space program that studied the planet Jupiter and its moons, as well as several other solar system bodies.", + "words": [ + {"word": "Galileo", "start": 0.0, "end": 0.6600000262260437}, + {"word": "was", "start": 0.6600000262260437, "end": 0.8999999761581421}, + {"word": "an", "start": 0.8999999761581421, "end": 1.1399999856948853}, + {"word": "American", "start": 1.1399999856948853, "end": 1.5}, + {"word": "robotic", "start": 1.5, "end": 2.0199999809265137} + ], + "usage": {"type": "duration", "seconds": 37} + }) + } + + // ════════════════════════════════════════════════════════════════════════════ + // doGenerate + // (openai-transcription-model.test.ts → describe('doGenerate')) + // ════════════════════════════════════════════════════════════════════════════ + + mod do_generate { + use super::*; + + /// upstream: @ai-sdk/openai src/transcription/openai-transcription-model.test.ts › doGenerate › should reject gpt-realtime-whisper for non-streaming transcription + #[tokio::test] + async fn should_reject_gpt_realtime_whisper_for_non_streaming() { + let server = MockServer::start().await; + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider + .transcription_model("gpt-realtime-whisper") + .unwrap() + .unwrap(); + + let result = model + .do_generate(&transcription_options(mock_audio_data(), "audio/wav")) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + AiMuxError::UnsupportedFunctionality(msg) => { + assert!(msg.contains("gpt-realtime-whisper")); + } + e => panic!("expected Unsupported error, got: {e:?}"), + } + } + + /// upstream: @ai-sdk/openai src/transcription/openai-transcription-model.test.ts › doGenerate › should pass the model + #[tokio::test] + async fn should_pass_the_model() { + let server = MockServer::start().await; + mock_json_response(&server, &fixture_response()).await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.transcription_model("whisper-1").unwrap().unwrap(); + + model + .do_generate(&transcription_options(mock_audio_data(), "audio/wav")) + .await + .unwrap(); + + let requests = server.received_requests().await.expect("requests recorded"); + assert_eq!(requests.len(), 1); + let form = parse_multipart_form(&requests[0].body); + assert_eq!(form.get("model").unwrap(), "whisper-1"); + } + + /// upstream: @ai-sdk/openai src/transcription/openai-transcription-model.test.ts › doGenerate › should default whisper-1 to verbose_json response format + #[tokio::test] + async fn should_default_whisper_1_to_verbose_json() { + let server = MockServer::start().await; + mock_json_response(&server, &fixture_response()).await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.transcription_model("whisper-1").unwrap().unwrap(); + + let result = model + .do_generate(&transcription_options(mock_audio_data(), "audio/wav")) + .await + .unwrap(); + + let requests = server.received_requests().await.expect("requests recorded"); + assert_eq!(requests.len(), 1); + let form = parse_multipart_form(&requests[0].body); + assert_eq!(form.get("model").unwrap(), "whisper-1"); + assert_eq!(form.get("response_format").unwrap(), "verbose_json"); + assert_eq!(result.duration_in_seconds, Some(36.709999084472656)); + } + + /// upstream: @ai-sdk/openai src/transcription/openai-transcription-model.test.ts › doGenerate › should extract the transcription text + #[tokio::test] + async fn should_extract_the_transcription_text() { + let server = MockServer::start().await; + mock_json_response(&server, &fixture_response()).await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.transcription_model("whisper-1").unwrap().unwrap(); + + let result = model + .do_generate(&transcription_options(mock_audio_data(), "audio/wav")) + .await + .unwrap(); + + assert_eq!( + result.text, + "Galileo was an American robotic space program that studied the planet Jupiter and its moons, as well as several other solar system bodies." + ); + } + + /// upstream: @ai-sdk/openai src/transcription/openai-transcription-model.test.ts › doGenerate › should include response data with timestamp, modelId and headers + #[tokio::test] + async fn should_include_response_data() { + let server = MockServer::start().await; + mock_json_response_with_headers( + &server, + &fixture_response(), + &[ + ("x-request-id", "test-request-id"), + ("x-ratelimit-remaining", "123"), + ], + ) + .await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.transcription_model("whisper-1").unwrap().unwrap(); + + let result = model + .do_generate(&transcription_options(mock_audio_data(), "audio/wav")) + .await + .unwrap(); + + assert!(result.response.timestamp.is_some()); + assert_eq!(result.response.model_id, Some("whisper-1".to_string())); + let headers = result.response.headers.as_ref().unwrap(); + assert_eq!(headers.get("content-type").unwrap(), "application/json"); + assert_eq!(headers.get("x-request-id").unwrap(), "test-request-id"); + assert_eq!(headers.get("x-ratelimit-remaining").unwrap(), "123"); + } + + /// upstream: @ai-sdk/openai src/transcription/openai-transcription-model.test.ts › doGenerate › should use real date when no custom date provider is specified + #[tokio::test] + async fn should_use_real_date() { + let server = MockServer::start().await; + mock_json_response(&server, &fixture_response()).await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.transcription_model("whisper-1").unwrap().unwrap(); + + let result = model + .do_generate(&transcription_options(mock_audio_data(), "audio/wav")) + .await + .unwrap(); + + assert!(result.response.timestamp.is_some()); + assert_eq!(result.response.model_id, Some("whisper-1".to_string())); + } + + /// upstream: @ai-sdk/openai src/transcription/openai-transcription-model.test.ts › doGenerate › should handle segments with missing optional fields + #[tokio::test] + async fn should_handle_segments_with_missing_optional_fields() { + let server = MockServer::start().await; + mock_json_response( + &server, + &json!({ + "task": "transcribe", + "text": "Test", + "segments": [ + { + "id": 0, "seek": 0, "start": 0.0, "end": 1.0, + "text": "Test", "tokens": [1234], + "temperature": 0.0, "avg_logprob": -0.5, + "compression_ratio": 1.0, "no_speech_prob": 0.1 + } + ], + "_request_id": "req_1234" + }), + ) + .await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.transcription_model("whisper-1").unwrap().unwrap(); + + let result = model + .do_generate(&transcription_options(mock_audio_data(), "audio/wav")) + .await + .unwrap(); + + assert_eq!(result.segments.len(), 1); + assert_eq!(result.segments[0].text, "Test"); + assert_eq!(result.segments[0].start_second, 0.0); + assert_eq!(result.segments[0].end_second, 1.0); + assert_eq!(result.language, None); + assert_eq!(result.duration_in_seconds, None); + } + } + + /// partial: `should pass headers`; full port in src/openai/tests/transcription/transcription_model.rs. + #[tokio::test] + async fn should_pass_headers() { + let server = MockServer::start().await; + mock_json_response(&server, &fixture_response()).await; + + let mut provider_headers = HashMap::new(); + provider_headers.insert( + "Custom-Provider-Header".to_string(), + "provider-header-value".to_string(), + ); + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + organization: Some("test-organization".into()), + project: Some("test-project".into()), + headers: Some( + provider_headers + .into_iter() + .map(|(k, v)| (k, Some(v))) + .collect(), + ), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.transcription_model("whisper-1").unwrap().unwrap(); + + let mut options = transcription_options(mock_audio_data(), "audio/wav"); + let mut request_headers = HashMap::new(); + request_headers.insert( + "Custom-Request-Header".to_string(), + "request-header-value".to_string(), + ); + options.headers = Some(request_headers); + + model.do_generate(&options).await.unwrap(); + + let requests = server.received_requests().await.expect("requests recorded"); + assert_eq!(requests.len(), 1); + let h = &requests[0].headers; + assert_eq!(h.get("authorization").unwrap(), "Bearer test-api-key"); + assert_eq!(h.get("openai-organization").unwrap(), "test-organization"); + assert_eq!(h.get("openai-project").unwrap(), "test-project"); + assert_eq!( + h.get("custom-provider-header").unwrap(), + "provider-header-value" + ); + assert_eq!( + h.get("custom-request-header").unwrap(), + "request-header-value" + ); + } + + /// partial: `should work when no words, language, or duration are returned`; full port in src/openai/tests/transcription/transcription_model.rs. + #[tokio::test] + async fn should_work_without_optional_fields() { + let server = MockServer::start().await; + mock_json_response( + &server, + &json!({ + "task": "transcribe", + "text": "Hello from the Vercel AI SDK!", + "_request_id": "req_1234" + }), + ) + .await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.transcription_model("whisper-1").unwrap().unwrap(); + + let result = model + .do_generate(&transcription_options(mock_audio_data(), "audio/wav")) + .await + .unwrap(); + + assert_eq!(result.duration_in_seconds, None); + assert_eq!(result.language, None); + assert!(result.segments.is_empty()); + assert_eq!(result.text, "Hello from the Vercel AI SDK!"); + assert!(result.warnings.is_empty()); + assert!(result.response.timestamp.is_some()); + assert_eq!(result.response.model_id, Some("whisper-1".to_string())); + let headers = result.response.headers.as_ref().unwrap(); + assert_eq!(headers.get("content-type").unwrap(), "application/json"); + } + + /// partial: `should handle empty segments array`; full port in src/openai/tests/transcription/transcription_model.rs. + #[tokio::test] + async fn should_handle_empty_segments() { + let server = MockServer::start().await; + mock_json_response( + &server, + &json!({ + "task": "transcribe", + "text": "Hello world", + "segments": [], + "language": "english", + "duration": 2.0, + "_request_id": "req_1234" + }), + ) + .await; + + let settings = OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }; + let provider = create_openai(settings).unwrap(); + let model = provider.transcription_model("whisper-1").unwrap().unwrap(); + + let result = model + .do_generate(&transcription_options(mock_audio_data(), "audio/wav")) + .await + .unwrap(); + + assert!(result.segments.is_empty()); + assert_eq!(result.text, "Hello world"); + } + + /// The diarized fixture response (`__fixtures__/openai-diarized-transcription.json`). + fn diarized_response() -> Value { + json!({ + "task": "transcribe", + "duration": 3.2, + "text": "Hello from Alice. Hello from Bob.", + "segments": [ + {"type": "transcript.text.segment", "id": "seg_001", "start": 0, "end": 1.5, "text": "Hello from Alice.", "speaker": "A"}, + {"type": "transcript.text.segment", "id": "seg_002", "start": 1.5, "end": 3.2, "text": "Hello from Bob.", "speaker": "B"} + ] + }) + } + + /// Sends one transcription with `provider_options` and returns the form. + async fn diarize_form(provider_options: Option) -> HashMap { + let server = MockServer::start().await; + mock_json_response(&server, &diarized_response()).await; + let provider = create_openai(OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }) + .unwrap(); + let model = provider + .transcription_model("gpt-4o-transcribe-diarize") + .unwrap() + .unwrap(); + let mut options = transcription_options(mock_audio_data(), "audio/mpeg"); + options.provider_options = provider_options + .map(|value| serde_json::from_value(value).expect("provider options are an object")); + model.do_generate(&options).await.unwrap(); + let requests = server.received_requests().await.expect("requests recorded"); + parse_multipart_form(&requests[0].body) + } + + /// `gpt-4o-transcribe-diarize` defaults to `diarized_json` with `auto` + /// chunking; `responseFormat` and `chunkingStrategy` (`"auto"` or a server-VAD + /// object sent as snake_case JSON) are passed through + /// (`transcription/openai-transcription-model.ts:196-259`). + #[tokio::test] + async fn diarization_response_format_and_chunking_strategy() { + let form = diarize_form(None).await; + assert_eq!(form["response_format"], "diarized_json"); + assert_eq!(form["chunking_strategy"], "auto"); + assert!(!form.contains_key("temperature")); + + let form = diarize_form(Some(json!({ + "openai": {"responseFormat": "json", "chunkingStrategy": "auto"} + }))) + .await; + assert_eq!(form["response_format"], "json"); + assert_eq!(form["chunking_strategy"], "auto"); + + let form = diarize_form(Some(json!({ + "openai": {"chunkingStrategy": { + "type": "server_vad", "threshold": 0.7, "prefixPaddingMs": 400, "silenceDurationMs": 300 + }} + }))) + .await; + assert_eq!(form["response_format"], "diarized_json"); + assert_eq!( + form["chunking_strategy"], + r#"{"type":"server_vad","threshold":0.7,"prefix_padding_ms":400,"silence_duration_ms":300}"# + ); + } + + /// Diarized segments come back as the usual segments plus + /// `providerMetadata.openai.segments` with each speaker + /// (`transcription/openai-transcription-model.ts:315-353`). + #[tokio::test] + async fn diarized_segments_carry_the_speaker_in_provider_metadata() { + let server = MockServer::start().await; + mock_json_response(&server, &diarized_response()).await; + let provider = create_openai(OpenAIProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }) + .unwrap(); + let model = provider + .transcription_model("gpt-4o-transcribe-diarize") + .unwrap() + .unwrap(); + let result = model + .do_generate(&transcription_options(mock_audio_data(), "audio/mpeg")) + .await + .unwrap(); + + assert_eq!(result.segments.len(), 2); + assert_eq!(result.segments[1].text, "Hello from Bob."); + assert_eq!( + serde_json::to_value(result.provider_metadata).unwrap(), + json!({"openai": {"segments": [ + {"text": "Hello from Alice.", "startSecond": 0.0, "endSecond": 1.5, "speaker": "A"}, + {"text": "Hello from Bob.", "startSecond": 1.5, "endSecond": 3.2, "speaker": "B"} + ]}}) + ); + } +} + +// ── Older ports (previously `tests/openai_transcription_stream_test.rs`) ── +// +// Realtime transcription streaming tests (RFC-0028 Phase 1). +// +// Each test spins up a local WebSocket server (tokio-tungstenite `accept_hdr_async`), +// plays the OpenAI realtime endpoint's role (consume `session.update` / +// `input_audio_buffer.append` / `commit`, emit `transcription.delta` / +// `completed` events), and asserts on the `TranscriptionStreamPart` sequence +// produced by `do_stream`. +// +// These replace the TS mock-WebSocket tests that +// `openai_transcription_test.rs` omitted ("not practical to translate" — +// they became practical with RFC-0028's WebSocket infrastructure). The +// provider is built with `create_openai`, whose base URL also names the +// realtime endpoint (`ws://` for an `http://` base URL). + +#[cfg(feature = "realtime")] +mod older_transcription_stream { + use std::time::Duration; + + use futures::{SinkExt, StreamExt}; + use tokio::net::TcpListener; + use tokio_tungstenite::tungstenite::Message; + + use crate::{OpenAIProvider, OpenAIProviderSettings, create_openai}; + use aimux_core::AbortSignal; + use aimux_core::error::AiMuxError; + use aimux_core::transcription_model::{ + AudioChunk, InputAudioFormat, TranscriptionModel, TranscriptionStreamOptions, + TranscriptionStreamPart, + }; + + // ── helpers ───────────────────────────────────────────────────────────────── + + /// A local WebSocket "OpenAI realtime" server. Handshake + `session.update` + /// ack, then run the scripted behavior. + struct RealtimeServer { + port: u16, + } + + impl RealtimeServer { + /// Start a listener; the accept loop runs on a spawned task. + async fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + // Accept a single connection (tests make one). + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = accept_realtime(stream).await; + serve(&mut ws).await; + }); + Self { port } + } + + fn base_url(&self) -> String { + format!("http://127.0.0.1:{}", self.port) + } + } + + /// Server behavior shared by the happy-path tests: read session.update, + /// ack it, collect audio appends until commit, then emit delta + completed, + /// and finally assert the client closes with code 1000. + async fn serve(ws: &mut tokio_tungstenite::WebSocketStream) { + // 1. Read session.update, capture the model + format from it. + let mut session_model = String::new(); + let mut format_type = String::new(); + if let Some(Ok(Message::Text(text))) = ws.next().await { + let v: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!( + v["type"], "session.update", + "first message must be session.update" + ); + assert_eq!(v["session"]["type"], "transcription"); + session_model = v["session"]["audio"]["input"]["transcription"]["model"] + .as_str() + .unwrap() + .to_string(); + format_type = v["session"]["audio"]["input"]["format"]["type"] + .as_str() + .unwrap() + .to_string(); + assert!( + v["session"]["audio"]["input"]["turn_detection"].is_null(), + "turn_detection must be null (nested under audio.input)" + ); + ws.send(Message::Text(r#"{"type":"session.created"}"#.to_string())) + .await + .unwrap(); + ws.send(Message::Text(r#"{"type":"session.updated"}"#.to_string())) + .await + .unwrap(); + } + let _ = (&session_model, &format_type); + + // 2. Collect audio appends until commit. + let mut appended: Vec = Vec::new(); + loop { + match ws.next().await { + Some(Ok(Message::Text(text))) => { + let v: serde_json::Value = serde_json::from_str(&text).unwrap(); + match v["type"].as_str().unwrap_or("") { + "input_audio_buffer.append" => { + appended.push(v["audio"].as_str().unwrap().to_string()); + } + "input_audio_buffer.commit" => break, + other => panic!("unexpected client message: {other}"), + } + } + other => panic!("expected client text message, got {other:?}"), + } + } + assert!( + !appended.is_empty(), + "client must append at least one audio chunk" + ); + + // 3. Emit transcript deltas + completed (the shape OpenAI sends). + ws.send(Message::Text( + r#"{"type":"conversation.item.input_audio_transcription.delta","item_id":"item-1","delta":"Hello"}"#.to_string(), + )) + .await + .unwrap(); + ws.send(Message::Text( + r#"{"type":"conversation.item.input_audio_transcription.delta","item_id":"item-1","delta":" world"}"#.to_string(), + )) + .await + .unwrap(); + ws.send(Message::Text( + r#"{"type":"conversation.item.input_audio_transcription.completed","item_id":"item-1","transcript":"Hello world"}"#.to_string(), + )) + .await + .unwrap(); + + // 4. Expect the client to close with code 1000 (normal). + match ws.next().await { + Some(Ok(Message::Close(frame))) => { + let code = frame.map(|f| u16::from(f.code)); + assert_eq!(code, Some(1000), "client must close(1000) after completion"); + } + other => panic!("expected close frame, got {other:?}"), + } + } + + /// Accept the handshake the way the OpenAI realtime endpoint does: the client + /// offers `realtime` plus `openai-insecure-api-key.` and sends no + /// Authorization header (`transcription/openai-transcription-model.ts:620-652`); + /// the server selects `realtime`. + async fn accept_realtime( + stream: tokio::net::TcpStream, + ) -> tokio_tungstenite::WebSocketStream { + use tokio_tungstenite::tungstenite::handshake::server::{Request, Response}; + #[allow(clippy::result_large_err)] + let handshake = |request: &Request, mut response: Response| { + let offered = request + .headers() + .get("sec-websocket-protocol") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + assert!( + offered.starts_with("realtime, openai-insecure-api-key."), + "{offered}" + ); + assert!(request.headers().get("authorization").is_none()); + response + .headers_mut() + .insert("sec-websocket-protocol", "realtime".parse().unwrap()); + Ok(response) + }; + tokio_tungstenite::accept_hdr_async(stream, handshake) + .await + .unwrap() + } + + /// `create_openai` pointed at `base_url` with `api_key`. + fn openai_at(base_url: impl Into, api_key: &str) -> OpenAIProvider { + create_openai(OpenAIProviderSettings { + api_key: Some(api_key.to_string()), + base_url: Some(base_url.into()), + ..Default::default() + }) + .expect("valid settings") + } + + fn realtime_model(base_url: &str) -> crate::OpenAITranscriptionModel { + openai_at(base_url, "test-api-key").transcription("gpt-realtime-whisper") + } + + fn stream_options( + chunks: Vec, + abort: Option, + ) -> TranscriptionStreamOptions { + TranscriptionStreamOptions { + audio: Box::pin(futures::stream::iter(chunks)), + input_audio_format: InputAudioFormat { + format_type: "audio/pcm".to_string(), + rate: Some(24000), + }, + provider_options: None, + abort_signal: abort, + headers: None, + include_raw_chunks: false, + timeout: None, + } + } + + /// Collect all parts from a do_stream result into a Vec (Err passthrough). + async fn collect( + result: aimux_core::transcription_model::TranscriptionStreamResult, + ) -> Vec> { + let mut out = Vec::new(); + let mut stream = result.stream; + while let Some(part) = stream.next().await { + out.push(part); + } + out + } + + // ── tests ─────────────────────────────────────────────────────────────────── + + /// partial: `should stream gpt-realtime-whisper using OpenAI realtime transcription`; full port in src/openai/tests/transcription/transcription_model.rs. + /// Happy path: connect → session.update → appends → commit → deltas → + /// completed → final + finish → client close(1000). + #[tokio::test] + async fn stream_realtime_happy_path() { + let server = RealtimeServer::start().await; + let model = realtime_model(&server.base_url()); + + let options = stream_options( + vec![ + AudioChunk::Binary(vec![1, 2, 3]), + AudioChunk::Binary(vec![4, 5, 6]), + ], + None, + ); + let result = model + .do_stream(options) + .expect("the OpenAI transcription model streams") + .await + .expect("do_stream should connect"); + let parts = collect(result).await; + + // StreamStart + 2 deltas + final + finish. + assert_eq!(parts.len(), 5, "parts: {parts:?}"); + + assert!(matches!( + &parts[0], + Ok(TranscriptionStreamPart::StreamStart { .. }) + )); + + match &parts[1] { + Ok(TranscriptionStreamPart::TranscriptDelta { id, delta, .. }) => { + assert_eq!(id.as_deref(), Some("item-1")); + assert_eq!(delta, "Hello"); + } + other => panic!("expected delta 'Hello', got {other:?}"), + } + match &parts[2] { + Ok(TranscriptionStreamPart::TranscriptDelta { delta, .. }) => { + assert_eq!(delta, " world"); + } + other => panic!("expected delta ' world', got {other:?}"), + } + match &parts[3] { + Ok(TranscriptionStreamPart::TranscriptFinal { id, text, .. }) => { + assert_eq!(id.as_deref(), Some("item-1")); + assert_eq!(text, "Hello world"); + } + other => panic!("expected final, got {other:?}"), + } + match &parts[4] { + Ok(TranscriptionStreamPart::Finish { text, segments, .. }) => { + assert_eq!(text, "Hello world"); + assert!(segments.is_empty()); + } + other => panic!("expected finish, got {other:?}"), + } + } + + /// The request snapshot records the exact session.update wire shape. + #[tokio::test] + async fn stream_request_carries_session_update() { + let server = RealtimeServer::start().await; + let model = realtime_model(&server.base_url()); + + let options = stream_options(vec![AudioChunk::Binary(vec![1])], None); + let result = model + .do_stream(options) + .expect("the OpenAI transcription model streams") + .await + .unwrap(); + let body = result + .request + .as_ref() + .and_then(|r| r.body.as_ref()) + .expect("request body snapshot"); + let v: serde_json::Value = serde_json::from_str(body).unwrap(); + // B1 regression guard: model in audio.input.transcription.model (not URL), + // turn_detection nested under audio.input, format carries the rate. + assert_eq!(v["type"], "session.update"); + assert_eq!( + v["session"]["audio"]["input"]["transcription"]["model"], + "gpt-realtime-whisper" + ); + assert_eq!( + v["session"]["audio"]["input"]["format"]["type"], + "audio/pcm" + ); + assert_eq!(v["session"]["audio"]["input"]["format"]["rate"], 24000); + assert!(v["session"]["audio"]["input"]["turn_detection"].is_null()); + drop(result); + } + + /// Binary audio chunks are base64-encoded in the append messages. + #[tokio::test] + async fn stream_audio_chunks_are_base64() { + // Server variant that asserts the base64 payload. + struct AssertBase64Server; + let _ = AssertBase64Server; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = accept_realtime(stream).await; + // session.update + let _ = ws.next().await; + // first append — verify base64 of [1,2,3] + if let Some(Ok(Message::Text(text))) = ws.next().await { + let v: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(v["type"], "input_audio_buffer.append"); + assert_eq!(v["audio"], "AQID", "binary [1,2,3] must arrive as base64"); + } + // second append — Base64 chunk passes through verbatim + if let Some(Ok(Message::Text(text))) = ws.next().await { + let v: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!( + v["audio"], "cGFzcw==", + "base64 chunk must pass through as-is" + ); + } + // commit + let _ = ws.next().await; + // complete the session so the client closes cleanly + ws.send(Message::Text( + r#"{"type":"conversation.item.input_audio_transcription.completed","item_id":"i","transcript":"x"}"# + .to_string(), + )) + .await + .unwrap(); + let _ = ws.next().await; // close + }); + + let model = realtime_model(&format!("http://127.0.0.1:{port}")); + let options = stream_options( + vec![ + AudioChunk::Binary(vec![1, 2, 3]), + AudioChunk::Base64("cGFzcw==".to_string()), + ], + None, + ); + let result = model + .do_stream(options) + .expect("the OpenAI transcription model streams") + .await + .unwrap(); + let parts = collect(result).await; + // StreamStart + final + finish (no deltas in this server script). + assert_eq!(parts.len(), 3, "parts: {parts:?}"); + } + + /// Abort mid-session surfaces `AiMuxError::Aborted`. + #[tokio::test] + async fn stream_abort_mid_session() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let abort = AbortSignal::new(); + let abort_clone = abort.clone(); + + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = accept_realtime(stream).await; + let _ = ws.next().await; // session.update + // Hold the connection open (drain client messages without acting) so + // the abort — not a server-side disconnect — is what ends the session. + while let Some(_msg) = ws.next().await { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }); + + let model = realtime_model(&format!("http://127.0.0.1:{port}")); + let options = stream_options(vec![AudioChunk::Binary(vec![1])], Some(abort_clone)); + + let result = model + .do_stream(options) + .expect("the OpenAI transcription model streams") + .await + .unwrap(); + // Abort shortly after the stream starts. + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + abort.abort(); + }); + let parts = collect(result).await; + assert!( + parts + .iter() + .any(|p| matches!(p, Err(AiMuxError::Aborted(_)))), + "expected Aborted in parts: {parts:?}" + ); + } + + /// Non-realtime models are rejected from do_stream (inverse gating). + #[tokio::test] + async fn stream_rejects_non_realtime_models() { + let provider = openai_at("http://127.0.0.1:1", "k"); + let model = provider.transcription("whisper-1"); + let err = model + .do_stream(stream_options(vec![], None)) + .expect("the OpenAI transcription model streams") + .await + .unwrap_err(); + match err { + AiMuxError::UnsupportedFunctionality(msg) => { + assert!(msg.contains("whisper-1"), "got: {msg}"); + } + other => panic!("expected UnsupportedFunctionality, got {other:?}"), + } + } + + /// A closed port (connection refused) — `do_stream` itself must fail with a + /// clear connect error, not an in-stream error part (#118). + #[tokio::test] + async fn stream_connect_failure_errors() { + // Port 1 is never listening on loopback: TCP refuses immediately. + let provider = openai_at("http://127.0.0.1:1".to_string(), "k"); + let model = provider.transcription("gpt-realtime-whisper"); + + let err = model + .do_stream(stream_options(vec![AudioChunk::Binary(vec![1])], None)) + .expect("the OpenAI transcription model streams") + .await + .unwrap_err(); + match &err { + AiMuxError::ApiCall(d) => { + assert!( + d.message.contains("websocket connect failed"), + "connect failure must name the phase, got: {}", + d.message + ); + } + other => panic!("expected ApiCall connect error, got {other:?}"), + } + } + + /// Abort while the WS handshake is still in flight — `Aborted`, not a + /// timeout and not an IO error (#118). + #[tokio::test] + async fn stream_abort_during_connect() { + // A black-hole server: accepts TCP but never completes the WS handshake, + // so `connect_async` pends and only the abort can end the connect phase. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + // Hold the socket open without writing the 101 response. + let _hold = stream; + std::future::pending::<()>().await; + }); + + let abort = AbortSignal::new(); + let abort_clone = abort.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(150)).await; + abort.abort(); + }); + + let model = realtime_model(&format!("http://127.0.0.1:{port}")); + let err = model + .do_stream(stream_options( + vec![AudioChunk::Binary(vec![1])], + Some(abort_clone), + )) + .expect("the OpenAI transcription model streams") + .await + .unwrap_err(); + // Abort semantics exactly: distinct from timeout and from IO failure. + assert!( + matches!(err, AiMuxError::Aborted(_)), + "connect-phase abort must surface Aborted, got {err:?}" + ); + assert!(!matches!(&err, AiMuxError::Timeout(_))); + assert!(!matches!(&err, AiMuxError::ApiCall(_))); + } + + /// `first_chunk_ms` doubles as the connect budget: against a black-hole + /// server the CONNECT phase itself must time out with its own message — + /// distinguishable from both the first-EVENT timeout and a connect IO + /// failure (#118). + #[tokio::test] + async fn stream_connect_timeout_fires() { + // Accepts TCP but never answers the WS handshake — connect pends. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let _hold = stream; + std::future::pending::<()>().await; + }); + + let provider = openai_at(format!("http://127.0.0.1:{port}"), "k"); + let model = provider.transcription("gpt-realtime-whisper"); + + let options = TranscriptionStreamOptions { + audio: Box::pin(futures::stream::iter(vec![AudioChunk::Binary(vec![1])])), + input_audio_format: InputAudioFormat { + format_type: "audio/pcm".to_string(), + rate: None, + }, + provider_options: None, + abort_signal: None, + headers: None, + include_raw_chunks: false, + timeout: Some(aimux_core::options::TimeoutConfiguration { + first_chunk_ms: Some(300), + total_ms: None, + step_ms: None, + chunk_ms: None, + }), + }; + let err = model + .do_stream(options) + .expect("the OpenAI transcription model streams") + .await + .unwrap_err(); + match &err { + AiMuxError::Timeout(msg) => { + assert_eq!( + msg, "websocket connect timed out", + "connect timeout must carry its own message" + ); + assert!( + !msg.contains("first websocket event"), + "must not be confused with the first-event timeout" + ); + } + other => panic!("expected connect Timeout, got {other:?}"), + } + } + + /// A silent server (accepts + reads session.update, then goes quiet) — the + /// first-chunk timeout must fire (B1/timer coverage). + #[tokio::test] + async fn stream_first_chunk_timeout_fires() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = accept_realtime(stream).await; + // Read session.update, then send NOTHING but KEEP the socket alive + // (drain client appends) well past the client's 500ms first-chunk + // deadline — the timeout must fire before any disconnect could. + let _ = ws.next().await; + while let Ok(Some(_)) = tokio::time::timeout(Duration::from_secs(2), ws.next()).await {} + }); + + let provider = openai_at(format!("http://127.0.0.1:{port}"), "k"); + let model = provider.transcription("gpt-realtime-whisper"); + + let options = TranscriptionStreamOptions { + audio: Box::pin(futures::stream::iter(vec![AudioChunk::Binary(vec![1])])), + input_audio_format: InputAudioFormat { + format_type: "audio/pcm".to_string(), + rate: None, + }, + provider_options: None, + abort_signal: None, + headers: None, + include_raw_chunks: false, + timeout: Some(aimux_core::options::TimeoutConfiguration { + // first_chunk covers connect + session ack; 500ms is generous on + // loopback but far below the server's 2s hold. + first_chunk_ms: Some(500), + total_ms: None, + step_ms: None, + chunk_ms: None, + }), + }; + let result = model + .do_stream(options) + .expect("the OpenAI transcription model streams") + .await + .unwrap(); + let parts = collect(result).await; + let timed_out = parts + .iter() + .any(|p| matches!(p, Err(AiMuxError::Timeout(_)))); + assert!( + timed_out, + "expected a Timeout error against a silent server; got: {parts:?}" + ); + } + + /// partial: `should error the stream with the server message on error events`; full port in src/openai/tests/transcription/transcription_model.rs. + /// The server emits a realtime `error` event — the provider message must + /// propagate verbatim and readable (not wrapped in transport noise), and the + /// session must terminate there (#118). + #[tokio::test] + async fn stream_server_error_event_propagates() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = accept_realtime(stream).await; + let _ = ws.next().await; // session.update + ws.send(Message::Text( + r#"{"type":"error","error":{"message":"insufficient quota for realtime transcription","type":"insufficient_quota"}}"# + .to_string(), + )) + .await + .unwrap(); + // The client closes(1000) after surfacing the error. + let _ = ws.next().await; + }); + + let model = realtime_model(&format!("http://127.0.0.1:{port}")); + let result = model + .do_stream(stream_options(vec![AudioChunk::Binary(vec![1])], None)) + .expect("the OpenAI transcription model streams") + .await + .unwrap(); + let parts = collect(result).await; + + // StreamStart then the terminal error — nothing after it. + assert_eq!(parts.len(), 2, "parts: {parts:?}"); + assert!(matches!( + &parts[0], + Ok(TranscriptionStreamPart::StreamStart { .. }) + )); + match &parts[1] { + Err(AiMuxError::ApiCall(d)) => { + assert_eq!( + d.message, "insufficient quota for realtime transcription", + "the provider's own message must propagate verbatim" + ); + assert!( + !d.message.contains("websocket"), + "must not be wrapped in transport noise: {}", + d.message + ); + } + other => panic!("expected ApiCall from server error event, got {other:?}"), + } + } + + /// The server closes the socket (close frame with code + reason) after the + /// handshake — the client must surface peer-closed semantics carrying the + /// code/reason, not a bare EOF or a hang (#118). + #[tokio::test] + async fn stream_peer_close_before_completion() { + use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; + use tokio_tungstenite::tungstenite::protocol::frame::{CloseFrame as WsCloseFrame, Frame}; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = accept_realtime(stream).await; + let _ = ws.next().await; // session.update + // Server-initiated abnormal close: code 1011 + a human reason. + let frame = WsCloseFrame { + code: CloseCode::Error, + reason: std::borrow::Cow::Borrowed("server overloaded"), + }; + ws.send(Message::Frame(Frame::close(Some(frame)))) + .await + .unwrap(); + // Drain until the client side tears down. + let _ = ws.next().await; + }); + + let model = realtime_model(&format!("http://127.0.0.1:{port}")); + let result = model + .do_stream(stream_options(vec![AudioChunk::Binary(vec![1])], None)) + .expect("the OpenAI transcription model streams") + .await + .unwrap(); + let parts = collect(result).await; + + assert_eq!(parts.len(), 2, "parts: {parts:?}"); + match &parts[1] { + Err(AiMuxError::ApiCall(d)) => { + assert!( + d.message.contains("websocket closed by peer"), + "peer close must be identifiable, got: {}", + d.message + ); + assert!( + d.message.contains("1011") && d.message.contains("server overloaded"), + "close code and reason must be carried: {}", + d.message + ); + } + other => panic!("expected ApiCall peer-closed error, got {other:?}"), + } + } + + /// chunk-idle timeout: the server delivers one event, then goes silent while + /// holding the socket open — `chunk_ms` must trip with its own message, + /// distinct from the total timeout (#118). + #[tokio::test] + async fn stream_chunk_idle_timeout_fires() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = accept_realtime(stream).await; + let _ = ws.next().await; // session.update + // One event so the stream is established and alive… + ws.send(Message::Text(r#"{"type":"session.created"}"#.to_string())) + .await + .unwrap(); + // …then total silence while KEEPING the socket open (drain client + // messages for 2s, far past the 400ms idle window) — the idle timer, + // not a disconnect, must end the session. + while let Ok(Some(_)) = tokio::time::timeout(Duration::from_secs(2), ws.next()).await {} + }); + + let provider = openai_at(format!("http://127.0.0.1:{port}"), "k"); + let model = provider.transcription("gpt-realtime-whisper"); + + let options = TranscriptionStreamOptions { + audio: Box::pin(futures::stream::iter(vec![AudioChunk::Binary(vec![1])])), + input_audio_format: InputAudioFormat { + format_type: "audio/pcm".to_string(), + rate: None, + }, + provider_options: None, + abort_signal: None, + headers: None, + include_raw_chunks: false, + timeout: Some(aimux_core::options::TimeoutConfiguration { + first_chunk_ms: None, + total_ms: None, + step_ms: None, + chunk_ms: Some(400), + }), + }; + let result = model + .do_stream(options) + .expect("the OpenAI transcription model streams") + .await + .unwrap(); + let parts = collect(result).await; + + assert_eq!(parts.len(), 2, "parts: {parts:?}"); + match &parts[1] { + Err(AiMuxError::Timeout(msg)) => { + assert_eq!( + msg, "websocket chunk idle timeout", + "chunk-idle must fire with its own message" + ); + assert!( + !msg.contains("total"), + "must not be confused with the total timeout" + ); + } + other => panic!("expected chunk-idle Timeout, got {other:?}"), + } + } + + /// total timeout: the server keeps events flowing (so chunk-idle never + /// trips) — the hard connection deadline must still fire, with a message + /// distinct from chunk-idle (#118). + #[tokio::test] + async fn stream_total_timeout_fires() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = accept_realtime(stream).await; + let _ = ws.next().await; // session.update + // Keep the stream visibly alive: a delta every 100ms for 2s — well + // inside the 300ms chunk window, well past the 700ms total deadline. + for i in 0..20 { + tokio::time::sleep(Duration::from_millis(100)).await; + let event = format!( + r#"{{"type":"conversation.item.input_audio_transcription.delta","item_id":"i","delta":"{i}"}}"# + ); + if ws.send(Message::Text(event)).await.is_err() { + break; // client tore down after the total timeout + } + } + }); + + let provider = openai_at(format!("http://127.0.0.1:{port}"), "k"); + let model = provider.transcription("gpt-realtime-whisper"); + + let options = TranscriptionStreamOptions { + audio: Box::pin(futures::stream::iter(vec![AudioChunk::Binary(vec![1])])), + input_audio_format: InputAudioFormat { + format_type: "audio/pcm".to_string(), + rate: None, + }, + provider_options: None, + abort_signal: None, + headers: None, + include_raw_chunks: false, + timeout: Some(aimux_core::options::TimeoutConfiguration { + first_chunk_ms: None, + total_ms: Some(700), + step_ms: None, + chunk_ms: Some(300), + }), + }; + let result = model + .do_stream(options) + .expect("the OpenAI transcription model streams") + .await + .unwrap(); + let parts = collect(result).await; + + // StreamStart + several deltas (alive stream) + the terminal total error. + let deltas = parts + .iter() + .filter(|p| matches!(p, Ok(TranscriptionStreamPart::TranscriptDelta { .. }))) + .count(); + assert!( + deltas >= 2, + "stream must be alive pre-timeout; parts: {parts:?}" + ); + let last = parts.last().expect("must end with an error part"); + match last { + Err(AiMuxError::Timeout(msg)) => { + assert_eq!( + msg, "websocket total timeout", + "total deadline must fire with its own message" + ); + assert!( + !msg.contains("chunk"), + "must not be confused with the chunk-idle timeout" + ); + } + other => panic!("expected total Timeout as the last part, got {other:?}"), + } + } +} + +// ── Older ports (previously `tests/openai_realtime_ws_replay_test.rs`) ── +// +// Offline replay of the OpenAI realtime transcription WebSocket path +// (RFC-0028 §3.2), driven through the connector that +// `OpenAITranscriptionModel::with_web_socket` installs. +// +// `OpenAIProviderSettings::web_socket` carries a custom [`WsConnector`] that +// never touches the network: it pairs the client with an in-process server +// over an in-memory duplex pipe. The server records every frame the client +// sends and replays the server events in +// `fixtures/openai_realtime_transcription.json` once the audio is committed. + +#[cfg(feature = "realtime")] +mod older_realtime_ws_replay { + use std::sync::{Arc, Mutex}; + + use crate::{OpenAIProviderSettings, create_openai}; + use aimux_core::error::AiMuxError; + use aimux_core::transcription_model::{ + AudioChunk, InputAudioFormat, TranscriptionModel, TranscriptionStreamOptions, + TranscriptionStreamPart, + }; + use aimux_provider_utils::ws::{WebSocketRequest, WsConnection, WsConnector}; + use futures::{SinkExt, StreamExt}; + use serde_json::Value; + use tokio_tungstenite::WebSocketStream; + use tokio_tungstenite::tungstenite::Message; + use tokio_tungstenite::tungstenite::protocol::Role; + + /// URL, headers and subprotocols of one connect request. + type Connect = (String, Vec<(String, String)>, Vec); + + /// What the in-process server saw. + #[derive(Default)] + struct Seen { + /// Every connect request, in order. + connects: Vec, + /// Every text frame the client sent, parsed. + frames: Vec, + /// Whether the client closed the socket. + closed: bool, + } + + /// A connector that serves the fixture over an in-memory pipe. + struct ReplayConnector { + seen: Arc>, + events: Vec, + } + + #[async_trait::async_trait] + impl WsConnector for ReplayConnector { + async fn connect(&self, request: &WebSocketRequest) -> Result { + self.seen.lock().unwrap().connects.push(( + request.url.clone(), + request.headers.clone(), + request.subprotocols.clone(), + )); + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let client = WebSocketStream::from_raw_socket(client_io, Role::Client, None).await; + let server = WebSocketStream::from_raw_socket(server_io, Role::Server, None).await; + tokio::spawn(serve(server, Arc::clone(&self.seen), self.events.clone())); + Ok(WsConnection::from_transport(Box::new(client), request)) + } + } + + /// Record client frames; after `input_audio_buffer.commit`, replay `events`. + async fn serve( + mut ws: WebSocketStream, + seen: Arc>, + events: Vec, + ) { + while let Some(message) = ws.next().await { + match message { + Ok(Message::Text(text)) => { + let frame: Value = serde_json::from_str(&text).expect("client sends JSON"); + let commit = frame["type"] == "input_audio_buffer.commit"; + seen.lock().unwrap().frames.push(frame); + if commit { + for event in &events { + ws.send(Message::Text(event.to_string())) + .await + .expect("replay event"); + } + } + } + Ok(Message::Close(_)) | Err(_) => { + seen.lock().unwrap().closed = true; + break; + } + Ok(_) => {} + } + } + } + + fn fixture_events() -> Vec { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/openai_realtime_transcription.json" + ))) + .expect("fixture is JSON"); + fixture["events"].as_array().expect("events array").clone() + } + + /// partial: `should stream gpt-realtime-whisper using OpenAI realtime transcription`; full port in src/openai/tests/transcription/transcription_model.rs. + #[tokio::test] + async fn realtime_transcription_replays_through_injected_web_socket() { + let seen = Arc::new(Mutex::new(Seen::default())); + let connector = ReplayConnector { + seen: Arc::clone(&seen), + events: fixture_events(), + }; + let provider = create_openai(OpenAIProviderSettings { + api_key: Some("sk-replay-test".to_string()), + base_url: Some("https://replay.invalid/v1".into()), + web_socket: Some(Arc::new(connector)), + ..Default::default() + }) + .expect("valid settings"); + let model = provider.transcription("gpt-realtime-whisper"); + + let options = TranscriptionStreamOptions { + audio: Box::pin(futures::stream::iter(vec![ + AudioChunk::Binary(vec![1, 2, 3]), + AudioChunk::Base64("BAUG".into()), + ])), + input_audio_format: InputAudioFormat { + format_type: "audio/pcm".to_string(), + rate: Some(24_000), + }, + provider_options: None, + abort_signal: None, + headers: None, + include_raw_chunks: false, + timeout: None, + }; + let result = model + .do_stream(options) + .expect("the OpenAI transcription model streams") + .await + .expect("stream opens"); + let parts: Vec = result + .stream + .map(|part| part.expect("no stream error")) + .collect() + .await; + + // Transcript events, mapped from the replayed server events. + let deltas: Vec<&str> = parts + .iter() + .filter_map(|part| match part { + TranscriptionStreamPart::TranscriptDelta { delta, id, .. } => { + assert_eq!(id.as_deref(), Some("item_1")); + Some(delta.as_str()) + } + _ => None, + }) + .collect(); + assert_eq!(deltas, ["Hello", " world"]); + assert!(parts.iter().any(|part| matches!( + part, + TranscriptionStreamPart::TranscriptFinal { text, id, .. } + if text == "Hello world" && id.as_deref() == Some("item_1") + ))); + assert!(matches!( + parts.last(), + Some(TranscriptionStreamPart::Finish { text, .. }) if text == "Hello world" + )); + assert!(matches!( + parts.first(), + Some(TranscriptionStreamPart::StreamStart { .. }) + )); + + // The injected connector carried the session: exactly one connect, to + // the realtime URL derived from the base URL, with the credential. + // The client closes the long-lived session after `completed` (B2). + for _ in 0..100 { + if seen.lock().unwrap().closed { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + let seen = seen.lock().unwrap(); + assert!(seen.closed, "client did not close the session"); + assert_eq!(seen.connects.len(), 1, "with_web_socket connector not used"); + let (url, headers, subprotocols) = &seen.connects[0]; + assert_eq!(url, "wss://replay.invalid/v1/realtime?intent=transcription"); + // The key rides the `openai-insecure-api-key` subprotocol and the + // Authorization header is dropped: OpenAI rejects a handshake that sends + // both (`transcription/openai-transcription-model.ts:620-652`). + assert_eq!( + subprotocols, + &["realtime", "openai-insecure-api-key.sk-replay-test"] + ); + assert!( + !headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization")) + ); + + // Frames the server saw: session.update, two appends, commit. + let types: Vec<&str> = seen + .frames + .iter() + .map(|frame| frame["type"].as_str().unwrap_or_default()) + .collect(); + assert_eq!( + types, + [ + "session.update", + "input_audio_buffer.append", + "input_audio_buffer.append", + "input_audio_buffer.commit", + ] + ); + let session = &seen.frames[0]["session"]; + assert_eq!(session["type"], "transcription"); + assert_eq!( + session["audio"]["input"]["transcription"]["model"], + "gpt-realtime-whisper" + ); + assert_eq!(session["audio"]["input"]["format"]["type"], "audio/pcm"); + assert_eq!(session["audio"]["input"]["format"]["rate"], 24_000); + assert_eq!(seen.frames[1]["audio"], "AQID"); + assert_eq!(seen.frames[2]["audio"], "BAUG"); + } + + /// Streams the fixture with `provider_options`; returns the parts and the + /// `session.update` the client sent. + async fn replay_with(provider_options: Value) -> (Vec, Value) { + let seen = Arc::new(Mutex::new(Seen::default())); + let provider = create_openai(OpenAIProviderSettings { + api_key: Some("sk-replay-test".to_string()), + base_url: Some("https://replay.invalid/v1".into()), + web_socket: Some(Arc::new(ReplayConnector { + seen: Arc::clone(&seen), + events: fixture_events(), + })), + ..Default::default() + }) + .expect("valid settings"); + let options = TranscriptionStreamOptions { + audio: Box::pin(futures::stream::iter(vec![AudioChunk::Binary(vec![ + 1, 2, 3, + ])])), + input_audio_format: InputAudioFormat { + format_type: "audio/pcm".to_string(), + rate: Some(24_000), + }, + provider_options: Some(serde_json::from_value(provider_options).unwrap()), + abort_signal: None, + headers: None, + include_raw_chunks: false, + timeout: None, + }; + let result = provider + .transcription("gpt-realtime-whisper") + .do_stream(options) + .expect("the OpenAI transcription model streams") + .await + .expect("stream opens"); + let parts = result + .stream + .map(|part| part.expect("no stream error")) + .collect() + .await; + let session = seen.lock().unwrap().frames[0].clone(); + (parts, session) + } + + /// `providerOptions.openai.streaming.delay` and `.include` configure the + /// realtime session; the REST `include` does not + /// (`transcription/openai-transcription-model.ts:580-617`). + #[tokio::test] + async fn realtime_session_takes_the_streaming_options() { + let (_, session) = replay_with(serde_json::json!({"openai": { + "language": "en", + "include": ["logprobs"], + "streaming": {"delay": "low", "include": ["item.input_audio_transcription.logprobs"]}, + }})) + .await; + assert_eq!( + session["session"]["audio"]["input"]["transcription"], + serde_json::json!({"model": "gpt-realtime-whisper", "language": "en", "delay": "low"}) + ); + assert_eq!( + session["session"]["include"], + serde_json::json!(["item.input_audio_transcription.logprobs"]) + ); + } + + /// `stream-start` warns about each REST-only option that is set + /// (`transcription/openai-transcription-model.ts:395-410`). + #[tokio::test] + async fn realtime_stream_start_warns_about_rest_only_options() { + let (parts, _) = replay_with(serde_json::json!({"openai": { + "prompt": "context prompt", + "temperature": 0.5, + "language": "en", + }})) + .await; + let Some(TranscriptionStreamPart::StreamStart { warnings }) = parts.first() else { + panic!("expected stream-start first, got {parts:?}"); + }; + assert_eq!( + serde_json::to_value(warnings).unwrap(), + serde_json::json!([ + { + "type": "unsupported", + "feature": "providerOptions.openai.prompt", + "details": "OpenAI streaming transcription does not support prompt.", + }, + { + "type": "unsupported", + "feature": "providerOptions.openai.temperature", + "details": "OpenAI streaming transcription does not support temperature.", + }, + ]) + ); + } +} diff --git a/aimux-providers/src/openai/tool/apply_patch.rs b/aimux-providers/src/openai/tool/apply_patch.rs new file mode 100644 index 000000000..59f842acc --- /dev/null +++ b/aimux-providers/src/openai/tool/apply_patch.rs @@ -0,0 +1,16 @@ +//! The apply patch provider tool and its arguments (tool/apply-patch.ts). + +use aimux_core::tool::ProviderTool; + +use crate::openai::tools::openai_id; + +/// Create the `apply_patch` provider tool. +#[must_use] +pub fn apply_patch() -> ProviderTool { + ProviderTool { + id: openai_id("apply_patch"), + name: "apply_patch".into(), + args: serde_json::Map::new(), + supports_deferred_results: None, + } +} diff --git a/aimux-providers/src/openai/tool/code_interpreter.rs b/aimux-providers/src/openai/tool/code_interpreter.rs new file mode 100644 index 000000000..514df0c03 --- /dev/null +++ b/aimux-providers/src/openai/tool/code_interpreter.rs @@ -0,0 +1,32 @@ +//! The code interpreter provider tool and its arguments (tool/code-interpreter.ts). + +use aimux_core::AiMuxError; +use aimux_core::tool::ProviderTool; +use serde::{Deserialize, Serialize}; + +use crate::openai::tools::provider_tool; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum CodeInterpreterContainer { + Id(String), + Files { + #[serde(rename = "fileIds", default, skip_serializing_if = "Option::is_none")] + file_ids: Option>, + }, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CodeInterpreterArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub container: Option, +} + +/// Configure the `code_interpreter` provider tool. +/// +/// # Errors +/// Returns an error when arguments do not match the provider tool schema. +pub fn code_interpreter(args: CodeInterpreterArgs) -> Result { + provider_tool("code_interpreter", args) +} diff --git a/aimux-providers/src/openai/tool/computer.rs b/aimux-providers/src/openai/tool/computer.rs new file mode 100644 index 000000000..b8f42907c --- /dev/null +++ b/aimux-providers/src/openai/tool/computer.rs @@ -0,0 +1,89 @@ +//! The computer use provider tool and its arguments (tool/computer.ts). + +use aimux_core::tool::ProviderTool; +use serde::{Deserialize, Serialize}; + +use crate::openai::tools::openai_id; + +/// Create the `computer` provider tool. +#[must_use] +pub fn computer() -> ProviderTool { + ProviderTool { + id: openai_id("computer"), + name: "computer".into(), + args: serde_json::Map::new(), + supports_deferred_results: None, + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenAIComputerSafetyCheck { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ComputerButton { + Left, + Right, + Wheel, + Back, + Forward, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComputerPoint { + pub x: f64, + pub y: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum OpenAIComputerAction { + Click { + button: ComputerButton, + x: f64, + y: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + keys: Option>, + }, + DoubleClick { + x: f64, + y: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + keys: Option>, + }, + Drag { + path: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + keys: Option>, + }, + Keypress { + keys: Vec, + }, + Move { + x: f64, + y: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + keys: Option>, + }, + Screenshot, + Scroll { + x: f64, + y: f64, + #[serde(rename = "scrollX")] + scroll_x: f64, + #[serde(rename = "scrollY")] + scroll_y: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + keys: Option>, + }, + Type { + text: String, + }, + Wait, +} diff --git a/aimux-providers/src/openai/tool/custom.rs b/aimux-providers/src/openai/tool/custom.rs new file mode 100644 index 000000000..db96264a3 --- /dev/null +++ b/aimux-providers/src/openai/tool/custom.rs @@ -0,0 +1,45 @@ +//! The custom provider tool and its arguments (tool/custom.ts). + +use aimux_core::AiMuxError; +use aimux_core::tool::ProviderTool; +use serde::{Deserialize, Serialize}; + +use crate::openai::tools::provider_tool; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GrammarSyntax { + Regex, + Lark, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum CustomFormat { + Text, + Grammar { + syntax: GrammarSyntax, + definition: String, + }, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomToolArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub r#async: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, +} + +/// Configure the `custom_tool` provider tool. +/// +/// # Errors +/// Returns an error when arguments do not match the provider tool schema. +pub fn custom_tool(args: CustomToolArgs) -> Result { + provider_tool("custom", args) +} diff --git a/aimux-providers/src/openai/tool/file_search.rs b/aimux-providers/src/openai/tool/file_search.rs new file mode 100644 index 000000000..3befe4818 --- /dev/null +++ b/aimux-providers/src/openai/tool/file_search.rs @@ -0,0 +1,83 @@ +//! The file search provider tool and its arguments (tool/file-search.ts). + +use aimux_core::AiMuxError; +use aimux_core::tool::ProviderTool; +use serde::{Deserialize, Serialize}; + +use crate::openai::tools::provider_tool; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ComparisonOperator { + Eq, + Ne, + Gt, + Gte, + Lt, + Lte, + In, + Nin, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompoundOperator { + And, + Or, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum FileSearchFilterValue { + String(String), + Number(f64), + Boolean(bool), + Strings(Vec), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum FileSearchFilter { + Comparison { + key: String, + r#type: ComparisonOperator, + value: FileSearchFilterValue, + }, + Compound { + r#type: CompoundOperator, + filters: Vec, + }, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileSearchRanking { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ranker: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub score_threshold: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileSearchArgs { + pub vector_store_ids: Vec, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_num_results: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ranking: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filters: Option, +} + +/// Configure the `file_search` provider tool. +/// +/// # Errors +/// Returns an error when arguments do not match the provider tool schema. +pub fn file_search(args: FileSearchArgs) -> Result { + provider_tool("file_search", args) +} diff --git a/aimux-providers/src/openai/tool/image_generation.rs b/aimux-providers/src/openai/tool/image_generation.rs new file mode 100644 index 000000000..ab2112c0c --- /dev/null +++ b/aimux-providers/src/openai/tool/image_generation.rs @@ -0,0 +1,111 @@ +//! The image generation provider tool and its arguments (tool/image-generation.ts). + +use aimux_core::AiMuxError; +use aimux_core::tool::ProviderTool; +use serde::{Deserialize, Serialize}; + +use crate::openai::tools::provider_tool; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ImageAction { + Generate, + Edit, + Auto, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ImageBackground { + Auto, + Opaque, + Transparent, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InputFidelity { + Low, + High, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ImageModeration { + Auto, + Low, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ImageOutputFormat { + Png, + Jpeg, + Webp, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ImageQuality { + Auto, + Low, + Medium, + High, + Xhigh, + Max, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InputImageMask { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_id: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image_url: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImageGenerationArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub action: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub background: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_fidelity: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_image_mask: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub moderation: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_compression: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub partial_images: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quality: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +/// Configure the `image_generation` provider tool. +/// +/// # Errors +/// Returns an error when arguments do not match the provider tool schema. +pub fn image_generation(args: ImageGenerationArgs) -> Result { + provider_tool("image_generation", args) +} diff --git a/aimux-providers/src/openai/tool/local_shell.rs b/aimux-providers/src/openai/tool/local_shell.rs new file mode 100644 index 000000000..ee3e5e3d3 --- /dev/null +++ b/aimux-providers/src/openai/tool/local_shell.rs @@ -0,0 +1,16 @@ +//! The local shell provider tool and its arguments (tool/local-shell.ts). + +use aimux_core::tool::ProviderTool; + +use crate::openai::tools::openai_id; + +/// Create the `local_shell` provider tool. +#[must_use] +pub fn local_shell() -> ProviderTool { + ProviderTool { + id: openai_id("local_shell"), + name: "local_shell".into(), + args: serde_json::Map::new(), + supports_deferred_results: None, + } +} diff --git a/aimux-providers/src/openai/tool/mcp.rs b/aimux-providers/src/openai/tool/mcp.rs new file mode 100644 index 000000000..de05d583a --- /dev/null +++ b/aimux-providers/src/openai/tool/mcp.rs @@ -0,0 +1,80 @@ +//! The MCP provider tool and its arguments (tool/mcp.ts). + +use std::collections::BTreeMap; + +use aimux_core::AiMuxError; +use aimux_core::tool::ProviderTool; +use serde::{Deserialize, Serialize}; + +use crate::openai::tools::provider_tool; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum McpAllowedTools { + Names(Vec), + Filter { + #[serde(rename = "readOnly", default, skip_serializing_if = "Option::is_none")] + read_only: Option, + #[serde(rename = "toolNames", default, skip_serializing_if = "Option::is_none")] + tool_names: Option>, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum McpApprovalPolicy { + Always, + Never, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpApprovalFilter { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_names: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum McpRequireApproval { + Policy(McpApprovalPolicy), + Filter { + #[serde(default, skip_serializing_if = "Option::is_none")] + never: Option, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpArgs { + pub server_label: String, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allowed_tools: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authorization: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub connector_id: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headers: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub require_approval: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_description: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_url: Option, +} + +/// Configure the `mcp` provider tool. +/// +/// # Errors +/// Returns an error when arguments do not match the provider tool schema. +pub fn mcp(args: McpArgs) -> Result { + provider_tool("mcp", args) +} diff --git a/aimux-providers/src/openai/tool/mod.rs b/aimux-providers/src/openai/tool/mod.rs new file mode 100644 index 000000000..41240a258 --- /dev/null +++ b/aimux-providers/src/openai/tool/mod.rs @@ -0,0 +1,15 @@ +//! `tool/`: the provider tools of the Responses API, one file per upstream tool. + +pub(super) mod apply_patch; +pub(super) mod code_interpreter; +pub(super) mod computer; +pub(super) mod custom; +pub(super) mod file_search; +pub(super) mod image_generation; +pub(super) mod local_shell; +pub(super) mod mcp; +pub(super) mod programmatic_tool_calling; +pub(super) mod shell; +pub(super) mod tool_search; +pub(super) mod web_search; +pub(super) mod web_search_preview; diff --git a/aimux-providers/src/openai/tool/programmatic_tool_calling.rs b/aimux-providers/src/openai/tool/programmatic_tool_calling.rs new file mode 100644 index 000000000..0b7bcdcfd --- /dev/null +++ b/aimux-providers/src/openai/tool/programmatic_tool_calling.rs @@ -0,0 +1,18 @@ +//! The programmatic tool calling provider tool and its arguments (tool/programmatic-tool-calling.ts). + +use aimux_core::tool::ProviderTool; + +use crate::openai::tools::openai_id; + +/// Create the `programmatic_tool_calling` provider tool. Its results may +/// arrive in a later response than its call, so it supports deferred results +/// (@ai-sdk/openai 4.0.83 src/tool/programmatic-tool-calling.ts:55). +#[must_use] +pub fn programmatic_tool_calling() -> ProviderTool { + ProviderTool { + id: openai_id("programmatic_tool_calling"), + name: "programmatic_tool_calling".into(), + args: serde_json::Map::new(), + supports_deferred_results: Some(true), + } +} diff --git a/aimux-providers/src/openai/tool/shell.rs b/aimux-providers/src/openai/tool/shell.rs new file mode 100644 index 000000000..4ddfcfe6d --- /dev/null +++ b/aimux-providers/src/openai/tool/shell.rs @@ -0,0 +1,169 @@ +//! The shell provider tool and its arguments (tool/shell.ts). + +use std::collections::BTreeMap; + +use aimux_core::AiMuxError; +use aimux_core::tool::ProviderTool; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::openai::tools::provider_tool; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ShellMemoryLimit { + #[serde(rename = "1g")] + OneG, + #[serde(rename = "4g")] + FourG, + #[serde(rename = "16g")] + SixteenG, + #[serde(rename = "64g")] + SixtyFourG, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ShellDomainSecret { + pub domain: String, + + pub name: String, + + pub value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ShellNetworkPolicy { + Disabled, + Allowlist { + #[serde(rename = "allowedDomains")] + allowed_domains: Vec, + #[serde( + rename = "domainSecrets", + default, + skip_serializing_if = "Option::is_none" + )] + domain_secrets: Option>, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ShellSkill { + SkillReference { + #[serde(rename = "providerReference")] + provider_reference: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + version: Option, + }, + Inline { + name: String, + description: String, + source: ShellSkillSource, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ShellSkillSource { + Base64 { + #[serde(rename = "mediaType")] + media_type: ShellSkillMediaType, + data: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ShellSkillMediaType { + #[serde(rename = "application/zip")] + Zip, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalShellSkill { + pub name: String, + + pub description: String, + + pub path: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ShellEnvironment { + ContainerAuto { + #[serde(rename = "fileIds", default, skip_serializing_if = "Option::is_none")] + file_ids: Option>, + #[serde( + rename = "memoryLimit", + default, + skip_serializing_if = "Option::is_none" + )] + memory_limit: Option, + #[serde( + rename = "networkPolicy", + default, + skip_serializing_if = "Option::is_none" + )] + network_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + skills: Option>, + }, + ContainerReference { + #[serde(rename = "containerId")] + container_id: String, + }, + Local { + #[serde(default, skip_serializing_if = "Option::is_none")] + skills: Option>, + }, +} + +impl<'de> Deserialize<'de> for ShellEnvironment { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(remote = "ShellEnvironment", tag = "type", rename_all = "camelCase")] + enum TaggedEnvironment { + ContainerAuto { + #[serde(rename = "fileIds")] + file_ids: Option>, + #[serde(rename = "memoryLimit")] + memory_limit: Option, + #[serde(rename = "networkPolicy")] + network_policy: Option, + skills: Option>, + }, + ContainerReference { + #[serde(rename = "containerId")] + container_id: String, + }, + Local { + skills: Option>, + }, + } + + let mut value = Value::deserialize(deserializer)?; + if let Some(object) = value.as_object_mut() { + object + .entry("type") + .or_insert_with(|| Value::String("local".into())); + } + TaggedEnvironment::deserialize(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ShellArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub environment: Option, +} + +/// Configure the `shell` provider tool. +/// +/// # Errors +/// Returns an error when arguments do not match the provider tool schema. +pub fn shell(args: ShellArgs) -> Result { + provider_tool("shell", args) +} diff --git a/aimux-providers/src/openai/tool/tool_search.rs b/aimux-providers/src/openai/tool/tool_search.rs new file mode 100644 index 000000000..8eab04f46 --- /dev/null +++ b/aimux-providers/src/openai/tool/tool_search.rs @@ -0,0 +1,38 @@ +//! The tool search provider tool and its arguments (tool/tool-search.ts). + +use std::collections::BTreeMap; + +use aimux_core::AiMuxError; +use aimux_core::tool::ProviderTool; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::openai::tools::provider_tool; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolSearchExecution { + Server, + Client, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolSearchArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parameters: Option>, +} + +/// Configure the `tool_search` provider tool. +/// +/// # Errors +/// Returns an error when arguments do not match the provider tool schema. +pub fn tool_search(args: ToolSearchArgs) -> Result { + provider_tool("tool_search", args) +} diff --git a/aimux-providers/src/openai/tool/web_search.rs b/aimux-providers/src/openai/tool/web_search.rs new file mode 100644 index 000000000..6dba3887f --- /dev/null +++ b/aimux-providers/src/openai/tool/web_search.rs @@ -0,0 +1,74 @@ +//! The web search provider tool and its arguments (tool/web-search.ts). + +use aimux_core::AiMuxError; +use aimux_core::tool::ProviderTool; +use serde::{Deserialize, Serialize}; + +use crate::openai::tools::provider_tool; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SearchContextSize { + Low, + Medium, + High, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub enum UserLocationType { + #[default] + #[serde(rename = "approximate")] + Approximate, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserLocation { + pub r#type: UserLocationType, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub country: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub city: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub region: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timezone: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebSearchFilters { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allowed_domains: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blocked_domains: Option>, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebSearchArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub external_web_access: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filters: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search_context_size: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_location: Option, +} + +/// Configure the `web_search` provider tool. +/// +/// # Errors +/// Returns an error when arguments do not match the provider tool schema. +pub fn web_search(args: WebSearchArgs) -> Result { + provider_tool("web_search", args) +} diff --git a/aimux-providers/src/openai/tool/web_search_preview.rs b/aimux-providers/src/openai/tool/web_search_preview.rs new file mode 100644 index 000000000..0e5053c38 --- /dev/null +++ b/aimux-providers/src/openai/tool/web_search_preview.rs @@ -0,0 +1,26 @@ +//! The web search preview provider tool and its arguments (tool/web-search-preview.ts). + +use aimux_core::AiMuxError; +use aimux_core::tool::ProviderTool; +use serde::{Deserialize, Serialize}; + +use crate::openai::tool::web_search::{SearchContextSize, UserLocation}; +use crate::openai::tools::provider_tool; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebSearchPreviewArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search_context_size: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_location: Option, +} + +/// Configure the `web_search_preview` provider tool. +/// +/// # Errors +/// Returns an error when arguments do not match the provider tool schema. +pub fn web_search_preview(args: WebSearchPreviewArgs) -> Result { + provider_tool("web_search_preview", args) +} diff --git a/aimux-providers/src/openai/tools.rs b/aimux-providers/src/openai/tools.rs index bb7c7b822..57ccbbbe2 100644 --- a/aimux-providers/src/openai/tools.rs +++ b/aimux-providers/src/openai/tools.rs @@ -1,532 +1,54 @@ -//! Typed factories for the native Responses provider tools. - -use std::collections::BTreeMap; - -use aimux_core::ErrorCause; -use aimux_core::{AiMuxError, tool::ProviderTool, types::NamespacedKind}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SearchContextSize { - Low, - Medium, - High, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ImageAction { - Generate, - Edit, - Auto, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ImageBackground { - Auto, - Opaque, - Transparent, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum InputFidelity { - Low, - High, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ImageModeration { - Auto, - Low, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ImageOutputFormat { - Png, - Jpeg, - Webp, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ImageQuality { - Auto, - Low, - Medium, - High, - Xhigh, - Max, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ToolSearchExecution { - Server, - Client, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum GrammarSyntax { - Regex, - Lark, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ComparisonOperator { - Eq, - Ne, - Gt, - Gte, - Lt, - Lte, - In, - Nin, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CompoundOperator { - And, - Or, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub enum UserLocationType { - #[default] - #[serde(rename = "approximate")] - Approximate, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UserLocation { - pub r#type: UserLocationType, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub country: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub city: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub region: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timezone: Option, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WebSearchFilters { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub allowed_domains: Option>, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub blocked_domains: Option>, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WebSearchArgs { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub external_web_access: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub filters: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub search_context_size: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub user_location: Option, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WebSearchPreviewArgs { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub search_context_size: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub user_location: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum FileSearchFilterValue { - String(String), - Number(f64), - Boolean(bool), - Strings(Vec), -} -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum FileSearchFilter { - Comparison { - key: String, - r#type: ComparisonOperator, - value: FileSearchFilterValue, - }, - Compound { - r#type: CompoundOperator, - filters: Vec, - }, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FileSearchRanking { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ranker: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub score_threshold: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FileSearchArgs { - pub vector_store_ids: Vec, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_num_results: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ranking: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub filters: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum CodeInterpreterContainer { - Id(String), - Files { - #[serde(rename = "fileIds", default, skip_serializing_if = "Option::is_none")] - file_ids: Option>, - }, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CodeInterpreterArgs { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub container: Option, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InputImageMask { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub file_id: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub image_url: Option, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ImageGenerationArgs { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub action: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub background: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub input_fidelity: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub input_image_mask: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub moderation: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub output_compression: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub output_format: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub partial_images: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub quality: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub size: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum CustomFormat { - Text, - Grammar { - syntax: GrammarSyntax, - definition: String, - }, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CustomToolArgs { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub r#async: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub format: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum McpAllowedTools { - Names(Vec), - Filter { - #[serde(rename = "readOnly", default, skip_serializing_if = "Option::is_none")] - read_only: Option, - #[serde(rename = "toolNames", default, skip_serializing_if = "Option::is_none")] - tool_names: Option>, - }, -} -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum McpApprovalPolicy { - Always, - Never, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct McpApprovalFilter { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tool_names: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum McpRequireApproval { - Policy(McpApprovalPolicy), - Filter { - #[serde(default, skip_serializing_if = "Option::is_none")] - never: Option, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct McpArgs { - pub server_label: String, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub allowed_tools: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub authorization: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub connector_id: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub headers: Option>, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub require_approval: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub server_description: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub server_url: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ShellMemoryLimit { - #[serde(rename = "1g")] - OneG, - #[serde(rename = "4g")] - FourG, - #[serde(rename = "16g")] - SixteenG, - #[serde(rename = "64g")] - SixtyFourG, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ShellDomainSecret { - pub domain: String, - - pub name: String, - - pub value: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum ShellNetworkPolicy { - Disabled, - Allowlist { - #[serde(rename = "allowedDomains")] - allowed_domains: Vec, - #[serde( - rename = "domainSecrets", - default, - skip_serializing_if = "Option::is_none" - )] - domain_secrets: Option>, - }, -} -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum ShellSkill { - SkillReference { - #[serde(rename = "providerReference")] - provider_reference: BTreeMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - version: Option, - }, - Inline { - name: String, - description: String, - source: ShellSkillSource, - }, -} -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum ShellSkillSource { - Base64 { - #[serde(rename = "mediaType")] - media_type: ShellSkillMediaType, - data: String, - }, -} -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ShellSkillMediaType { - #[serde(rename = "application/zip")] - Zip, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LocalShellSkill { - pub name: String, - - pub description: String, - - pub path: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum ShellEnvironment { - ContainerAuto { - #[serde(rename = "fileIds", default, skip_serializing_if = "Option::is_none")] - file_ids: Option>, - #[serde( - rename = "memoryLimit", - default, - skip_serializing_if = "Option::is_none" - )] - memory_limit: Option, - #[serde( - rename = "networkPolicy", - default, - skip_serializing_if = "Option::is_none" - )] - network_policy: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - skills: Option>, - }, - ContainerReference { - #[serde(rename = "containerId")] - container_id: String, - }, - Local { - #[serde(default, skip_serializing_if = "Option::is_none")] - skills: Option>, - }, -} - -impl<'de> Deserialize<'de> for ShellEnvironment { - fn deserialize>(deserializer: D) -> Result { - #[derive(Deserialize)] - #[serde(remote = "ShellEnvironment", tag = "type", rename_all = "camelCase")] - enum TaggedEnvironment { - ContainerAuto { - #[serde(rename = "fileIds")] - file_ids: Option>, - #[serde(rename = "memoryLimit")] - memory_limit: Option, - #[serde(rename = "networkPolicy")] - network_policy: Option, - skills: Option>, - }, - ContainerReference { - #[serde(rename = "containerId")] - container_id: String, - }, - Local { - skills: Option>, - }, - } - - let mut value = Value::deserialize(deserializer)?; - if let Some(object) = value.as_object_mut() { - object - .entry("type") - .or_insert_with(|| Value::String("local".into())); - } - TaggedEnvironment::deserialize(value).map_err(serde::de::Error::custom) - } -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ShellArgs { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub environment: Option, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ToolSearchArgs { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub execution: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parameters: Option>, -} +//! The native Responses provider tools as one value (openai-tools.ts). The +//! factories and their arguments live in `tool/`, one file per upstream tool, +//! and are re-exported here. + +use aimux_core::tool::ProviderTool; +use aimux_core::types::NamespacedKind; +use aimux_core::{AiMuxError, ErrorCause}; +use serde::Serialize; + +pub use super::tool::apply_patch::apply_patch; +pub use super::tool::code_interpreter::{ + CodeInterpreterArgs, CodeInterpreterContainer, code_interpreter, +}; +pub use super::tool::computer::{ + ComputerButton, ComputerPoint, OpenAIComputerAction, OpenAIComputerSafetyCheck, computer, +}; +pub use super::tool::custom::{CustomFormat, CustomToolArgs, GrammarSyntax, custom_tool}; +pub use super::tool::file_search::{ + ComparisonOperator, CompoundOperator, FileSearchArgs, FileSearchFilter, FileSearchFilterValue, + FileSearchRanking, file_search, +}; +pub use super::tool::image_generation::{ + ImageAction, ImageBackground, ImageGenerationArgs, ImageModeration, ImageOutputFormat, + ImageQuality, InputFidelity, InputImageMask, image_generation, +}; +pub use super::tool::local_shell::local_shell; +pub use super::tool::mcp::{ + McpAllowedTools, McpApprovalFilter, McpApprovalPolicy, McpArgs, McpRequireApproval, mcp, +}; +pub use super::tool::programmatic_tool_calling::programmatic_tool_calling; +pub use super::tool::shell::{ + LocalShellSkill, ShellArgs, ShellDomainSecret, ShellEnvironment, ShellMemoryLimit, + ShellNetworkPolicy, ShellSkill, ShellSkillMediaType, ShellSkillSource, shell, +}; +pub use super::tool::tool_search::{ToolSearchArgs, ToolSearchExecution, tool_search}; +pub use super::tool::web_search::{ + SearchContextSize, UserLocation, UserLocationType, WebSearchArgs, WebSearchFilters, web_search, +}; +pub use super::tool::web_search_preview::{WebSearchPreviewArgs, web_search_preview}; /// The `openai.` id of a provider tool. -fn openai_id(kind: &str) -> NamespacedKind { +pub(super) fn openai_id(kind: &str) -> NamespacedKind { NamespacedKind::new(format!("openai.{kind}")).expect("openai. is namespaced") } -fn provider_tool(kind: &str, args: impl Serialize) -> Result { +pub(super) fn provider_tool(kind: &str, args: impl Serialize) -> Result { let args = serde_json::to_value(args).map_err(|error| AiMuxError::InvalidArgument { message: error.to_string(), cause: Some(ErrorCause::source(&error)), })?; - super::responses::tool_args::validate_tool_args(kind, &args)?; + crate::openai::responses::tool_args::validate_tool_args(kind, &args)?; Ok(ProviderTool { id: openai_id(kind), name: kind.to_owned(), @@ -537,124 +59,6 @@ fn provider_tool(kind: &str, args: impl Serialize) -> Result Result { - provider_tool("web_search", args) -} - -/// Configure the `web_search_preview` provider tool. -/// -/// # Errors -/// Returns an error when arguments do not match the provider tool schema. -pub fn web_search_preview(args: WebSearchPreviewArgs) -> Result { - provider_tool("web_search_preview", args) -} - -/// Configure the `file_search` provider tool. -/// -/// # Errors -/// Returns an error when arguments do not match the provider tool schema. -pub fn file_search(args: FileSearchArgs) -> Result { - provider_tool("file_search", args) -} - -/// Configure the `code_interpreter` provider tool. -/// -/// # Errors -/// Returns an error when arguments do not match the provider tool schema. -pub fn code_interpreter(args: CodeInterpreterArgs) -> Result { - provider_tool("code_interpreter", args) -} - -/// Configure the `image_generation` provider tool. -/// -/// # Errors -/// Returns an error when arguments do not match the provider tool schema. -pub fn image_generation(args: ImageGenerationArgs) -> Result { - provider_tool("image_generation", args) -} - -/// Configure the `custom_tool` provider tool. -/// -/// # Errors -/// Returns an error when arguments do not match the provider tool schema. -pub fn custom_tool(args: CustomToolArgs) -> Result { - provider_tool("custom", args) -} - -/// Configure the `mcp` provider tool. -/// -/// # Errors -/// Returns an error when arguments do not match the provider tool schema. -pub fn mcp(args: McpArgs) -> Result { - provider_tool("mcp", args) -} - -/// Configure the `shell` provider tool. -/// -/// # Errors -/// Returns an error when arguments do not match the provider tool schema. -pub fn shell(args: ShellArgs) -> Result { - provider_tool("shell", args) -} - -/// Configure the `tool_search` provider tool. -/// -/// # Errors -/// Returns an error when arguments do not match the provider tool schema. -pub fn tool_search(args: ToolSearchArgs) -> Result { - provider_tool("tool_search", args) -} - -/// Create the `apply_patch` provider tool. -#[must_use] -pub fn apply_patch() -> ProviderTool { - ProviderTool { - id: openai_id("apply_patch"), - name: "apply_patch".into(), - args: serde_json::Map::new(), - supports_deferred_results: None, - } -} - -/// Create the `computer` provider tool. -#[must_use] -pub fn computer() -> ProviderTool { - ProviderTool { - id: openai_id("computer"), - name: "computer".into(), - args: serde_json::Map::new(), - supports_deferred_results: None, - } -} - -/// Create the `local_shell` provider tool. -#[must_use] -pub fn local_shell() -> ProviderTool { - ProviderTool { - id: openai_id("local_shell"), - name: "local_shell".into(), - args: serde_json::Map::new(), - supports_deferred_results: None, - } -} - -/// Create the `programmatic_tool_calling` provider tool. Its results may -/// arrive in a later response than its call, so it supports deferred results -/// (@ai-sdk/openai 4.0.83 src/tool/programmatic-tool-calling.ts:55). -#[must_use] -pub fn programmatic_tool_calling() -> ProviderTool { - ProviderTool { - id: openai_id("programmatic_tool_calling"), - name: "programmatic_tool_calling".into(), - args: serde_json::Map::new(), - supports_deferred_results: Some(true), - } -} - /// The provider tools as one value, the AI SDK's `openaiTools` /// (@ai-sdk/openai 4.0.83 src/openai-tools.ts:15), reachable on the provider /// as `openai.tools` (openai-provider.ts:387): `openai().tools.web_search(…)`. @@ -812,75 +216,3 @@ impl AzureOpenAITools { web_search_preview(args) } } - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OpenAIComputerSafetyCheck { - pub id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub code: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub message: Option, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ComputerButton { - Left, - Right, - Wheel, - Back, - Forward, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ComputerPoint { - pub x: f64, - pub y: f64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum OpenAIComputerAction { - Click { - button: ComputerButton, - x: f64, - y: f64, - #[serde(default, skip_serializing_if = "Option::is_none")] - keys: Option>, - }, - DoubleClick { - x: f64, - y: f64, - #[serde(default, skip_serializing_if = "Option::is_none")] - keys: Option>, - }, - Drag { - path: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - keys: Option>, - }, - Keypress { - keys: Vec, - }, - Move { - x: f64, - y: f64, - #[serde(default, skip_serializing_if = "Option::is_none")] - keys: Option>, - }, - Screenshot, - Scroll { - x: f64, - y: f64, - #[serde(rename = "scrollX")] - scroll_x: f64, - #[serde(rename = "scrollY")] - scroll_y: f64, - #[serde(default, skip_serializing_if = "Option::is_none")] - keys: Option>, - }, - Type { - text: String, - }, - Wait, -} diff --git a/aimux-providers/src/openai/transcription/mod.rs b/aimux-providers/src/openai/transcription/mod.rs new file mode 100644 index 000000000..8d39efe0f --- /dev/null +++ b/aimux-providers/src/openai/transcription/mod.rs @@ -0,0 +1,7 @@ +//! `transcription/`: the OpenAI transcription (STT) model, REST and realtime. + +pub(super) mod transcription_api; +pub(super) mod transcription_model; +pub(super) mod transcription_model_options; + +pub use transcription_model::OpenAITranscriptionModel; diff --git a/aimux-providers/src/openai/transcription/transcription_api.rs b/aimux-providers/src/openai/transcription/transcription_api.rs new file mode 100644 index 000000000..ff118a8a5 --- /dev/null +++ b/aimux-providers/src/openai/transcription/transcription_api.rs @@ -0,0 +1,36 @@ +//! Response shape of the OpenAI transcription endpoint (openai-transcription-api.ts). + +use serde::Deserialize; + +/// A word with timing information in the OpenAI transcription response. +#[derive(Debug, Deserialize)] +pub(crate) struct OpenAIWord { + pub(crate) word: String, + pub(crate) start: f64, + pub(crate) end: f64, +} + +/// A segment with timing information in the OpenAI transcription response. +#[derive(Debug, Deserialize)] +pub(crate) struct OpenAISegment { + pub(crate) text: String, + pub(crate) start: f64, + pub(crate) end: f64, + /// Set on `diarized_json` segments (`transcript.text.segment`). + #[serde(default)] + pub(crate) speaker: Option, +} + +/// The OpenAI transcription API response body. +#[derive(Debug, Deserialize)] +pub(crate) struct OpenAITranscriptionResponse { + pub(crate) text: String, + #[serde(default)] + pub(crate) language: Option, + #[serde(default)] + pub(crate) duration: Option, + #[serde(default)] + pub(crate) words: Option>, + #[serde(default)] + pub(crate) segments: Option>, +} diff --git a/aimux-providers/src/openai/transcription.rs b/aimux-providers/src/openai/transcription/transcription_model.rs similarity index 85% rename from aimux-providers/src/openai/transcription.rs rename to aimux-providers/src/openai/transcription/transcription_model.rs index 955b42d3f..5d9b08dfe 100644 --- a/aimux-providers/src/openai/transcription.rs +++ b/aimux-providers/src/openai/transcription/transcription_model.rs @@ -17,21 +17,20 @@ //! (RFC-0028, requires the `realtime` feature) is realtime-models-only — the //! inverse gating. -use async_trait::async_trait; -use serde::Deserialize; -use serde_json::Value; - use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; -use aimux_core::shared::{SharedProviderOptions, Warning}; +use aimux_core::shared::Warning; use aimux_core::transcription_model::{ AudioInput, TranscriptionCallOptions, TranscriptionModel, TranscriptionRequest, TranscriptionResponse, TranscriptionResult, TranscriptionSegment, }; - use aimux_provider_utils::{HttpBody, MultipartForm, media_type_to_extension}; +use async_trait::async_trait; +use serde_json::Value; -use super::config::OpenAIModelConfig; +use crate::openai::config::OpenAIModelConfig; +use crate::openai::transcription::transcription_api::OpenAITranscriptionResponse; +use crate::openai::transcription::transcription_model_options::parse_openai_options; // ── Language map ──────────────────────────────────────────────────────────── @@ -100,132 +99,6 @@ fn language_name_to_code(name: &str) -> Option<&'static str> { } } -// ── Provider options ──────────────────────────────────────────────────────── - -/// OpenAI-specific transcription options parsed from `providerOptions.openai`. -#[derive(Debug, Clone, Default)] -struct OpenAITranscriptionOptions { - /// Additional information to include in the transcription response. - include: Option>, - /// The language of the input audio in ISO-639-1 format. - language: Option, - /// An optional text to guide the model's style. - prompt: Option, - /// The sampling temperature, between 0 and 1. - temperature: Option, - /// The timestamp granularities to populate (`word` / `segment`). - timestamp_granularities: Option>, - /// `json`, `verbose_json` or `diarized_json`. - response_format: Option, - /// `chunking_strategy` as sent: `auto` or a server-VAD object as JSON. - chunking_strategy: Option, -} - -/// `chunkingStrategy`: `"auto"`, or `{ type, threshold?, prefixPaddingMs?, -/// silenceDurationMs? }` sent as snake_case JSON -/// (`transcription/openai-transcription-model.ts:241-259`). -fn chunking_strategy(value: &Value) -> Option { - if let Some(text) = value.as_str() { - return Some(text.to_string()); - } - let object = value.as_object()?; - let mut wire = serde_json::Map::new(); - wire.insert("type".into(), object.get("type")?.clone()); - for (key, wire_key) in [ - ("threshold", "threshold"), - ("prefixPaddingMs", "prefix_padding_ms"), - ("silenceDurationMs", "silence_duration_ms"), - ] { - if let Some(value) = object.get(key).filter(|value| !value.is_null()) { - wire.insert(wire_key.into(), value.clone()); - } - } - Some(Value::Object(wire).to_string()) -} - -/// Parse provider options from the shared options map. -fn parse_openai_options( - provider_options: Option<&SharedProviderOptions>, -) -> OpenAITranscriptionOptions { - let mut opts = OpenAITranscriptionOptions::default(); - if let Some(po) = provider_options - && let Some(openai) = po.get("openai") - { - if let Some(include) = openai.get("include").and_then(|v| v.as_array()) { - opts.include = Some( - include - .iter() - .filter_map(|v| v.as_str().map(std::string::ToString::to_string)) - .collect(), - ); - } - if let Some(language) = openai.get("language").and_then(|v| v.as_str()) { - opts.language = Some(language.to_string()); - } - if let Some(prompt) = openai.get("prompt").and_then(|v| v.as_str()) { - opts.prompt = Some(prompt.to_string()); - } - if let Some(temp) = openai - .get("temperature") - .and_then(serde_json::Value::as_f64) - { - opts.temperature = Some(temp); - } - if let Some(tg) = openai - .get("timestampGranularities") - .and_then(|v| v.as_array()) - { - opts.timestamp_granularities = Some( - tg.iter() - .filter_map(|v| v.as_str().map(std::string::ToString::to_string)) - .collect(), - ); - } - if let Some(format) = openai.get("responseFormat").and_then(|v| v.as_str()) { - opts.response_format = Some(format.to_string()); - } - opts.chunking_strategy = openai.get("chunkingStrategy").and_then(chunking_strategy); - } - opts -} - -// ── Response schema ───────────────────────────────────────────────────────── - -/// A word with timing information in the OpenAI transcription response. -#[derive(Debug, Deserialize)] -struct OpenAIWord { - word: String, - start: f64, - end: f64, -} - -/// A segment with timing information in the OpenAI transcription response. -#[derive(Debug, Deserialize)] -struct OpenAISegment { - text: String, - start: f64, - end: f64, - /// Set on `diarized_json` segments (`transcript.text.segment`). - #[serde(default)] - speaker: Option, -} - -/// The OpenAI transcription API response body. -#[derive(Debug, Deserialize)] -struct OpenAITranscriptionResponse { - text: String, - #[serde(default)] - language: Option, - #[serde(default)] - duration: Option, - #[serde(default)] - words: Option>, - #[serde(default)] - segments: Option>, -} - -// ── Model ─────────────────────────────────────────────────────────────────── - /// Realtime transcription model IDs stream over the realtime WebSocket and do /// not support the REST transcription endpoint. Prefix matching keeps dated /// snapshots (e.g. `gpt-realtime-whisper-2026-01-01`) working. @@ -390,7 +263,7 @@ impl TranscriptionModel for OpenAITranscriptionModel { .http_request(self.config.url("/audio/transcriptions")?, headers, options), HttpBody::Bytes(body_bytes, content_type), aimux_provider_utils::create_json_response_handler::(), - super::openai_failed_response_handler(), + crate::openai::error::openai_failed_response_handler(), ) .await?; diff --git a/aimux-providers/src/openai/transcription/transcription_model_options.rs b/aimux-providers/src/openai/transcription/transcription_model_options.rs new file mode 100644 index 000000000..f6874887b --- /dev/null +++ b/aimux-providers/src/openai/transcription/transcription_model_options.rs @@ -0,0 +1,91 @@ +//! Provider options of the OpenAI transcription model (openai-transcription-model-options.ts). + +use aimux_core::shared::SharedProviderOptions; +use serde_json::Value; + +/// OpenAI-specific transcription options parsed from `providerOptions.openai`. +#[derive(Debug, Clone, Default)] +pub(crate) struct OpenAITranscriptionOptions { + /// Additional information to include in the transcription response. + pub(crate) include: Option>, + /// The language of the input audio in ISO-639-1 format. + pub(crate) language: Option, + /// An optional text to guide the model's style. + pub(crate) prompt: Option, + /// The sampling temperature, between 0 and 1. + pub(crate) temperature: Option, + /// The timestamp granularities to populate (`word` / `segment`). + pub(crate) timestamp_granularities: Option>, + /// `json`, `verbose_json` or `diarized_json`. + pub(crate) response_format: Option, + /// `chunking_strategy` as sent: `auto` or a server-VAD object as JSON. + pub(crate) chunking_strategy: Option, +} + +/// `chunkingStrategy`: `"auto"`, or `{ type, threshold?, prefixPaddingMs?, +/// silenceDurationMs? }` sent as snake_case JSON +/// (`transcription/openai-transcription-model.ts:241-259`). +pub(crate) fn chunking_strategy(value: &Value) -> Option { + if let Some(text) = value.as_str() { + return Some(text.to_string()); + } + let object = value.as_object()?; + let mut wire = serde_json::Map::new(); + wire.insert("type".into(), object.get("type")?.clone()); + for (key, wire_key) in [ + ("threshold", "threshold"), + ("prefixPaddingMs", "prefix_padding_ms"), + ("silenceDurationMs", "silence_duration_ms"), + ] { + if let Some(value) = object.get(key).filter(|value| !value.is_null()) { + wire.insert(wire_key.into(), value.clone()); + } + } + Some(Value::Object(wire).to_string()) +} + +/// Parse provider options from the shared options map. +pub(crate) fn parse_openai_options( + provider_options: Option<&SharedProviderOptions>, +) -> OpenAITranscriptionOptions { + let mut opts = OpenAITranscriptionOptions::default(); + if let Some(po) = provider_options + && let Some(openai) = po.get("openai") + { + if let Some(include) = openai.get("include").and_then(|v| v.as_array()) { + opts.include = Some( + include + .iter() + .filter_map(|v| v.as_str().map(std::string::ToString::to_string)) + .collect(), + ); + } + if let Some(language) = openai.get("language").and_then(|v| v.as_str()) { + opts.language = Some(language.to_string()); + } + if let Some(prompt) = openai.get("prompt").and_then(|v| v.as_str()) { + opts.prompt = Some(prompt.to_string()); + } + if let Some(temp) = openai + .get("temperature") + .and_then(serde_json::Value::as_f64) + { + opts.temperature = Some(temp); + } + if let Some(tg) = openai + .get("timestampGranularities") + .and_then(|v| v.as_array()) + { + opts.timestamp_granularities = Some( + tg.iter() + .filter_map(|v| v.as_str().map(std::string::ToString::to_string)) + .collect(), + ); + } + if let Some(format) = openai.get("responseFormat").and_then(|v| v.as_str()) { + opts.response_format = Some(format.to_string()); + } + opts.chunking_strategy = openai.get("chunkingStrategy").and_then(chunking_strategy); + } + opts +} diff --git a/aimux-providers/src/test_support/mod.rs b/aimux-providers/src/test_support/mod.rs index 9bbf0e551..ca588c252 100644 --- a/aimux-providers/src/test_support/mod.rs +++ b/aimux-providers/src/test_support/mod.rs @@ -2,3 +2,6 @@ #[path = "../../tests/common/mock_fetch.rs"] pub(crate) mod mock_fetch; + +#[path = "../../tests/common/replay.rs"] +pub(crate) mod replay; diff --git a/aimux-providers/tests/openai_decision_test.rs b/aimux-providers/tests/openai_decision_test.rs deleted file mode 100644 index ca8fcef0d..000000000 --- a/aimux-providers/tests/openai_decision_test.rs +++ /dev/null @@ -1,665 +0,0 @@ -//! Official-contract tests against a local HTTP server; no live API access. -use std::collections::HashMap; -use std::sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, -}; - -use aimux_core::decision_model::*; -use aimux_core::recording::{self, RingRecorder}; -use aimux_core::replay::{MockDecisionReplayModel, replay_decision_with_model}; -use aimux_core::shared::SharedProviderOptions; -use aimux_core::{AiMuxError, Provider}; -use aimux_providers::{OpenAIProvider, OpenAIProviderSettings, PresetSettings, create_openai}; -use indexmap::IndexMap; -use serde_json::{Value, json}; -use serial_test::serial; -use wiremock::matchers::{body_json, header, method, path}; -use wiremock::{Mock, MockServer, Request, ResponseTemplate}; - -mod common; - -fn object(value: Value) -> serde_json::Map { - value - .as_object() - .cloned() - .expect("provider options must be an object") -} - -fn fixture() -> Value { - serde_json::from_str(include_str!("fixtures/openai_decisions.json")).unwrap() -} -fn options() -> DecideOptions { - serde_json::from_value(fixture()["options"].clone()).unwrap() -} -fn provider(server: &MockServer, api_key: &str) -> OpenAIProvider { - create_openai(OpenAIProviderSettings { - api_key: Some(api_key.into()), - base_url: Some(format!("{}/v1", server.uri())), - organization: Some("org-test".into()), - project: Some("proj-test".into()), - headers: Some(HashMap::from([( - "X-Client".into(), - Some("decision-tests".into()), - )])), - ..Default::default() - }) - .unwrap() -} -fn model(server: &MockServer) -> Arc { - provider(server, "decision-secret") - .decision_model("gpt-6-luna") - .unwrap() - .unwrap() -} -/// The caller's registry at replay time: the same OpenAI settings at the -/// replay server, with another key. -fn rebuild( - record: &aimux_core::recording::ProviderRecord, - server: &MockServer, -) -> Arc { - let registry = aimux_core::create_provider_registry( - std::collections::BTreeMap::from([( - "openai".into(), - Arc::new(provider(server, "replacement-key")) as Arc, - )]), - Default::default(), - ); - aimux_providers::rebuild_decision_provider(record, ®istry).unwrap() -} -async fn mount(server: &MockServer, response: Value) { - Mock::given(method("POST")) - .and(path("/v1/decisions")) - .respond_with(ResponseTemplate::new(200).set_body_json(response)) - .mount(server) - .await; -} - -#[tokio::test] -#[serial] -async fn native_wire_maps_all_questions_to_v4_answers() { - let server = MockServer::start().await; - let mut expected = fixture()["request"].clone(); - expected["safety_identifier"] = json!("opaque-user"); - Mock::given(method("POST")) - .and(path("/v1/decisions")) - .and(header("authorization", "Bearer decision-secret")) - .and(header("openai-organization", "org-test")) - .and(header("openai-project", "proj-test")) - .and(header("x-client", "decision-tests")) - .and(header("x-per-call", "yes")) - .and(body_json(expected)) - .respond_with(ResponseTemplate::new(200).set_body_json(&fixture()["response"])) - .expect(1) - .mount(&server) - .await; - let mut request = options(); - request.headers = Some(HashMap::from([("X-Per-Call".into(), "yes".into())])); - request.provider_options = Some(aimux_core::shared::SharedProviderOptions::from([ - ( - "openai".into(), - object(json!({"safetyIdentifier":"opaque-user"})), - ), - ("other-provider".into(), object(json!({"ignored":true}))), - ])); - let result = decide(model(&server).as_ref(), request).await.unwrap(); - assert_eq!( - result.answers.keys().collect::>(), - ["urgent", "team", "severity"] - ); - assert_eq!( - result.answers["urgent"], - DecisionAnswer::Boolean { probability: 0.925 } - ); - assert_eq!( - result.answers["team"], - DecisionAnswer::Choice { - choice: "support".into(), - probabilities: Some(IndexMap::from([ - ("billing".to_string(), 0.0625), - ("support".to_string(), 0.9375), - ])), - } - ); - assert_eq!( - result.answers["severity"], - DecisionAnswer::Score { - score: 1.5, - probabilities: Some(IndexMap::from([ - ("0".to_string(), 0.125), - ("1".to_string(), 0.25), - ("2".to_string(), 0.625), - ])), - } - ); - assert_eq!(result.rounding, None); - assert_eq!( - Value::Object(result.provider_metadata.unwrap()["openai"].clone()), - json!({ - "probabilitySource": "native", - "answers": {"team": {"confidence": 0.9}, "severity": {"confidence": 0.6}} - }) - ); - assert_eq!( - result.response.body.as_ref().unwrap(), - &fixture()["response"] - ); - assert_eq!(result.response.model_id.as_deref(), Some("gpt-6-luna")); - assert_eq!( - result.usage, - DecideUsage { - input_tokens: Some(100), - output_tokens: Some(0), - total_tokens: Some(100), - } - ); -} - -/// A decision returns every answer or none (`ai@7.0.128 -/// src/decide/validate-decision.ts:197-202`): a refused question fails the -/// call, with the exchange's HTTP context. -#[tokio::test] -#[serial] -async fn a_refused_question_fails_the_decision() { - let server = MockServer::start().await; - let mut response = fixture()["response"].clone(); - response["answers"][0] = json!({"name": "urgent", "type": "refusal"}); - mount(&server, response).await; - let AiMuxError::ApiCall(error) = decide(model(&server).as_ref(), options()) - .await - .unwrap_err() - else { - panic!("expected HTTP context"); - }; - assert_eq!(error.status_code, Some(200)); - assert!(!error.is_retryable); - assert!( - error - .message - .ends_with("OpenAI declined to answer question \"urgent\""), - "{}", - error.message - ); - assert_eq!(server.received_requests().await.unwrap().len(), 1); -} - -#[tokio::test] -#[serial] -async fn response_order_and_usage_metadata_do_not_discard_answers() { - for usage in [ - Value::Null, - json!({}), - json!({"input_tokens":12}), - json!({"input_tokens":1,"output_tokens":0,"total_tokens":9,"input_tokens_details":{"cached_tokens":2,"cache_write_tokens":0}}), - ] { - let server = MockServer::start().await; - let mut response = fixture()["response"].clone(); - response["usage"] = usage.clone(); - response["answers"].as_array_mut().unwrap().reverse(); - response["answers"][1]["probabilities"] - .as_array_mut() - .unwrap() - .reverse(); - mount(&server, response).await; - let result = decide(model(&server).as_ref(), options()).await.unwrap(); - assert_eq!( - result.answers.keys().collect::>(), - ["urgent", "team", "severity"] - ); - let count = |key: &str| usage.get(key).and_then(Value::as_u64).map(|n| n as u32); - assert_eq!(result.usage.input_tokens, count("input_tokens")); - assert_eq!(result.usage.output_tokens, count("output_tokens")); - } -} - -#[tokio::test] -#[serial] -async fn json_state_is_sent_as_text_evidence() { - let server = MockServer::start().await; - mount(&server, fixture()["response"].clone()).await; - let mut request = options(); - request.state = - serde_json::from_value(json!({"messages":[{"role":"user","content":"ordinary JSON"}]})) - .unwrap(); - decide(model(&server).as_ref(), request.clone()) - .await - .unwrap(); - let body: Value = server.received_requests().await.unwrap()[0] - .body_json() - .unwrap(); - assert_eq!( - body["input"], - serde_json::to_string(&request.state).unwrap() - ); -} - -#[tokio::test] -#[serial] -async fn malformed_answers_fail_with_http_context() { - let mut cases = Vec::new(); - for (pointer, value) in [ - ("/answers/0/name", json!("unknown")), - ("/answers/0/name", Value::Null), - ("/answers/0/probability", json!(1.1)), - ("/answers/1/choice", json!(true)), - ("/answers/1/choice", json!("unknown")), - ("/answers/1/probabilities/1/value", json!("billing")), - ("/answers/1/probabilities/1/probability", json!(0.5)), - ("/answers/2/probabilities/1/value", json!(0)), - ("/answers/2/probabilities/1/value", json!(9)), - ("/answers/2/probabilities/1/label", json!("wrong")), - ("/answers/2/score", json!(0.1)), - ("/answers/2/name", json!("urgent")), - ("/answers/2/type", json!("skipped")), - ("/answers/2/type", json!("refusal")), - ] { - let mut response = fixture()["response"].clone(); - *response.pointer_mut(pointer).unwrap() = value; - cases.push(response); - } - let mut missing = fixture()["response"].clone(); - missing["answers"].as_array_mut().unwrap().pop(); - cases.push(missing); - let mut extra = fixture()["response"].clone(); - extra["answers"] - .as_array_mut() - .unwrap() - .push(json!({"name":"extra","type":"refusal"})); - cases.push(extra); - for response in cases { - let server = MockServer::start().await; - mount(&server, response).await; - let error = decide(model(&server).as_ref(), options()) - .await - .unwrap_err(); - let AiMuxError::ApiCall(error) = error else { - panic!("expected HTTP context: {error}"); - }; - assert_eq!(error.status_code, Some(200)); - assert!(!error.is_retryable); - assert!(error.response_body.is_some()); - assert_eq!(server.received_requests().await.unwrap().len(), 1); - } -} - -#[tokio::test] -#[serial] -async fn unsupported_native_fields_and_limits_fail_before_http() { - let server = MockServer::start().await; - let mut cases = Vec::new(); - let mut guidance = fixture()["options"].clone(); - guidance["questions"]["urgent"]["instructions"] = json!({"rule":"structured"}); - cases.push(guidance); - let mut criteria = fixture()["options"].clone(); - criteria["questions"]["urgent"]["criteria"] = json!({"true":"yes"}); - cases.push(criteria); - let mut levels = fixture()["options"].clone(); - levels["questions"]["severity"]["criteria"][0] = json!({"label":"low", "unsupported":true}); - cases.push(levels); - let mut choice = fixture()["options"].clone(); - choice["questions"]["team"]["criteria"] - .as_object_mut() - .unwrap() - .remove("support"); - cases.push(choice); - let mut opts = fixture()["options"].clone(); - opts["providerOptions"] = json!({"openai":{"temperature":1}}); - cases.push(opts); - let mut values = fixture()["options"].clone(); - values["providerOptions"] = json!({"openai":{"choiceValues":{"urgent":{"yes":true}}}}); - cases.push(values); - // aimux's own options are camelCase; the snake_case names are unknown. - for snake in [ - json!({"safety_identifier":"opaque-user"}), - json!({"choice_values":{"team":{"billing":true}}}), - json!({"images":[{"data":"iVBORw0KGgo=","media_type":"image/png"}]}), - ] { - let mut options = fixture()["options"].clone(); - options["providerOptions"] = json!({ "openai": snake }); - cases.push(options); - } - for request in cases { - let request = serde_json::from_value(request).unwrap(); - assert!(decide(model(&server).as_ref(), request).await.is_err()); - } - assert!(server.received_requests().await.unwrap().is_empty()); -} - -#[tokio::test] -#[serial] -async fn retry_and_standard_provider_errors_use_the_shared_transport() { - let server = MockServer::start().await; - let attempts = Arc::new(AtomicUsize::new(0)); - let counter = attempts.clone(); - Mock::given(path("/v1/decisions")) - .respond_with(move |_: &Request| { - if counter.fetch_add(1, Ordering::Relaxed) == 0 { - ResponseTemplate::new(429) - .insert_header("retry-after", "0") - .set_body_json(json!({"error":{"message":"busy","code":"rate_limit_exceeded"}})) - } else { - ResponseTemplate::new(200).set_body_json(&fixture()["response"]) - } - }) - .mount(&server) - .await; - let model = model(&server); - let mut request = options(); - request.max_retries = Some(1); - decide(model.as_ref(), request).await.unwrap(); - assert_eq!(attempts.load(Ordering::Relaxed), 2); - server.reset().await; - Mock::given(path("/v1/decisions")) - .respond_with( - ResponseTemplate::new(401) - .set_body_json(json!({"error":{"message":"bad key","code":"invalid_api_key"}})), - ) - .mount(&server) - .await; - let AiMuxError::ApiCall(error) = decide(model.as_ref(), options()).await.unwrap_err() else { - panic!("API error"); - }; - assert_eq!(error.provider_code.as_deref(), Some("invalid_api_key")); - assert!(!error.is_retryable); -} - -/// The native model is the provider's decision model, which the registry -/// takes before the evaluation model (`ai@7.0.128 -/// src/registry/provider-registry.ts:447-461`); a provider without one -/// offers none. -#[test] -fn factory_is_explicit_and_preserves_the_provider_boundary() { - let provider = aimux_providers::create_provider( - "openai", - PresetSettings { - api_key: Some("key".to_string().into()), - ..Default::default() - }, - ) - .unwrap(); - let model = provider - .decision_model("deployment-alias") - .unwrap() - .unwrap(); - assert_eq!(model.model_id(), "deployment-alias"); - assert_eq!(model.provider(), "openai"); - assert_eq!(model.supported_question_types(), DecisionQuestionType::ALL); - assert!(matches!( - provider.decision_model(" "), - Some(Err(AiMuxError::InvalidArgument { .. })) - )); - let registry = aimux_core::create_provider_registry( - std::collections::BTreeMap::from([("openai".to_string(), provider)]), - Default::default(), - ); - assert_eq!( - registry - .decision_model("openai:gpt-6-luna") - .unwrap() - .provider(), - "openai" - ); - let other = aimux_providers::create_provider( - "deepseek", - PresetSettings { - api_key: Some("key".to_string().into()), - ..Default::default() - }, - ) - .unwrap(); - assert!(other.decision_model("any").is_none()); -} - -/// partial: `evaluates through the configured Responses API with strict structured output`; full port in src/openai/tests/evaluation.rs. -/// `@ai-sdk/openai@4.0.83 src/openai-provider.ts:355-358`: the evaluation -/// model decides through the Responses model as `"{name}.evaluation"`, with -/// the structured output named `evaluation` (`src/openai-evaluation.test.ts:77`). -#[tokio::test] -#[serial] -async fn evaluation_model_decides_through_the_responses_api() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_1", - "object": "response", - "created_at": 1_700_000_000, - "status": "completed", - "model": "gpt-6-luna", - "output": [{ - "id": "msg_1", - "type": "message", - "status": "completed", - "role": "assistant", - "content": [{ - "type": "output_text", - "text": "{\"q0\":0.75,\"q1\":\"c1\",\"q2\":1.5}", - "annotations": [] - }] - }], - "usage": {"input_tokens": 30, "output_tokens": 5, "total_tokens": 35} - }))) - .expect(1) - .mount(&server) - .await; - let model = provider(&server, "decision-secret").evaluation("gpt-6-luna"); - assert_eq!(model.provider(), "openai.evaluation"); - assert_eq!(model.model_id(), "gpt-6-luna"); - let result = decide(&model, options()).await.unwrap(); - assert_eq!( - result.answers["urgent"], - DecisionAnswer::Boolean { probability: 0.75 } - ); - assert_eq!( - result.answers["team"], - DecisionAnswer::Choice { - choice: "support".into(), - probabilities: None, - } - ); - assert_eq!(result.usage.total_tokens, Some(35)); - let body: Value = server.received_requests().await.unwrap()[0] - .body_json() - .unwrap(); - assert_eq!(body["text"]["format"]["name"], "evaluation"); -} - -struct StopRecording; -impl Drop for StopRecording { - fn drop(&mut self) { - recording::init_recording(None); - } -} - -#[tokio::test] -#[serial] -async fn unified_recording_replays_and_rebuilds_from_the_registered_provider() { - let server = MockServer::start().await; - mount(&server, fixture()["response"].clone()).await; - let recorder = Arc::new(RingRecorder::new()); - recording::init_recording(Some(recorder.clone())); - let _stop = StopRecording; - let result = decide(model(&server).as_ref(), options()).await.unwrap(); - recording::init_recording(None); - let records = recorder.completed(); - assert_eq!(records.len(), 1); - assert!(records[0].complete); - assert!( - !serde_json::to_string(&records) - .unwrap() - .contains("decision-secret") - ); - let mock = MockDecisionReplayModel::new("openai", "gpt-6-luna", records.clone()).unwrap(); - let replay = decide(&mock, options()).await.unwrap(); - assert_eq!( - serde_json::to_value(replay.answers).unwrap(), - serde_json::to_value(&result.answers).unwrap() - ); - let replay_server = MockServer::start().await; - common::replay::mount_recording(&replay_server, &records[0]).await; - let rebuilt = rebuild(&records[0].provider, &replay_server); - let again = replay_decision_with_model(&records[0], rebuilt.as_ref()) - .await - .unwrap(); - assert_eq!( - serde_json::to_value(again.answers).unwrap(), - serde_json::to_value(result.answers).unwrap() - ); - let request = &replay_server.received_requests().await.unwrap()[0]; - assert_eq!(request.headers["authorization"], "Bearer replacement-key"); - assert_eq!(request.headers["openai-project"], "proj-test"); - assert_eq!(request.headers["x-client"], "decision-tests"); -} - -fn openai_options(request: &mut DecideOptions) -> &mut serde_json::Map { - request - .provider_options - .as_mut() - .unwrap() - .get_mut("openai") - .unwrap() -} - -#[tokio::test] -#[serial] -async fn images_typed_choices_and_score_descriptions_roundtrip_and_key_replay() { - let contract: Value = serde_json::from_str(include_str!( - "../../contract-tests/fixtures/decision-openai-full.json" - )) - .unwrap(); - let server = MockServer::start().await; - Mock::given(path("/v1/decisions")) - .and(body_json(contract["request"].clone())) - .respond_with(ResponseTemplate::new(200).set_body_json(&contract["response"])) - .mount(&server) - .await; - let request: DecideOptions = serde_json::from_value(contract["options"].clone()).unwrap(); - let recorder = Arc::new(RingRecorder::new()); - recording::init_recording(Some(recorder.clone())); - let _stop = StopRecording; - let result = decide(model(&server).as_ref(), request.clone()) - .await - .unwrap(); - recording::init_recording(None); - assert_eq!( - result.answers["choice"], - DecisionAnswer::Choice { - choice: "boolean_true".into(), - probabilities: Some(IndexMap::from([ - ("boolean_true".to_string(), 0.75), - ("text_true".to_string(), 0.25), - ])), - } - ); - assert_eq!( - result.answers["score"], - DecisionAnswer::Score { - score: 0.75, - probabilities: Some(IndexMap::from([ - ("0".to_string(), 0.25), - ("1".to_string(), 0.75), - ])), - } - ); - // The native value the server chose is provider metadata of the answer. - assert_eq!( - result.provider_metadata.as_ref().unwrap()["openai"]["answers"]["choice"], - json!({"confidence": 0.7, "value": true}) - ); - let records = recorder.completed(); - let mock = MockDecisionReplayModel::new("openai", "gpt-6-luna", records.clone()).unwrap(); - decide(&mock, request.clone()).await.unwrap(); - let mut changed = request.clone(); - openai_options(&mut changed).remove("images"); - assert!(decide(&mock, changed).await.is_err()); - let mut changed = request.clone(); - openai_options(&mut changed)["images"][0]["detail"] = json!("high"); - assert!(decide(&mock, changed).await.is_err()); - let replay_server = MockServer::start().await; - common::replay::mount_recording(&replay_server, &records[0]).await; - let rebuilt = rebuild(&records[0].provider, &replay_server); - let again = replay_decision_with_model(&records[0], rebuilt.as_ref()) - .await - .unwrap(); - assert_eq!( - serde_json::to_value(result.answers).unwrap(), - serde_json::to_value(again.answers).unwrap() - ); -} - -#[tokio::test] -#[serial] -async fn malformed_image_answers_bound_error_context_but_preserve_wire_and_response_evidence() { - use base64::Engine; - - let bytes = vec![42; 8192]; - let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); - let server = MockServer::start().await; - let raw = json!({"model":"gpt-6-luna","answers":[],"metadata":{"api_key":"response-secret"}}); - mount(&server, raw.clone()).await; - let mut request = options(); - request.provider_options = Some(SharedProviderOptions::from([( - "openai".into(), - object(json!({"images": [{"data": encoded, "mediaType": "image/png", "detail": "low"}]})), - )])); - let error = decide(model(&server).as_ref(), request).await.unwrap_err(); - let AiMuxError::ApiCall(error) = error else { - panic!("expected contextual API error"); - }; - let data_url = format!("data:image/png;base64,{encoded}"); - assert_eq!(error.status_code, Some(200)); - assert!(!error.is_retryable); - assert_eq!( - error.request_body_values["input"][0]["content"][0]["image_url"], - format!("[STRING {} bytes]", data_url.len()) - ); - assert_eq!(error.request_body_values["model"], "gpt-6-luna"); - assert_eq!( - error.data.as_ref().unwrap()["metadata"]["api_key"], - "[REDACTED]" - ); - assert_eq!( - serde_json::from_str::(error.response_body.as_ref().unwrap()).unwrap(), - raw - ); - let calls = server.received_requests().await.unwrap(); - assert_eq!(calls.len(), 1); - let wire: Value = calls[0].body_json().unwrap(); - assert_eq!(wire["input"][0]["content"][0]["image_url"], data_url); - assert_eq!(wire["input"][0]["content"][0]["detail"], "low"); -} - -#[tokio::test] -#[serial] -async fn bad_media_and_duplicate_native_values_are_rejected_before_http() { - let contract: Value = serde_json::from_str(include_str!( - "../../contract-tests/fixtures/decision-openai-full.json" - )) - .unwrap(); - let server = MockServer::start().await; - for (pointer, value) in [ - ("/providerOptions/openai/images/0/data", json!("bad!")), - ("/providerOptions/openai/images/0/detail", json!("unknown")), - ( - "/providerOptions/openai/images/0/mediaType", - json!("text/plain"), - ), - ( - "/providerOptions/openai/choiceValues/choice/text_true", - json!(true), - ), - ("/questions/score/criteria/1/label", json!("low")), - ] { - let mut request = contract["options"].clone(); - *request.pointer_mut(pointer).unwrap() = value; - assert!( - decide( - model(&server).as_ref(), - serde_json::from_value(request).unwrap() - ) - .await - .is_err() - ); - } - assert!(server.received_requests().await.unwrap().is_empty()); -} diff --git a/aimux-providers/tests/openai_embedding_test.rs b/aimux-providers/tests/openai_embedding_test.rs deleted file mode 100644 index 5e111afb9..000000000 --- a/aimux-providers/tests/openai_embedding_test.rs +++ /dev/null @@ -1,360 +0,0 @@ -//! Rust translations of the AI SDK OpenAI embedding model tests. -//! -//! Source: `reference/ai/packages/openai/src/embedding/openai-embedding-model.test.ts` -//! (164 lines, 7 cases). -//! -//! Each test uses `wiremock` to spin up a mock HTTP server, configures a JSON -//! response, creates an `OpenAIEmbeddingModel` pointing at the mock, calls -//! `do_embed`, and asserts on the result. - -use std::collections::HashMap; - -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::embedding_model::EmbeddingCallOptions; -use aimux_core::provider::Provider; -use aimux_providers::{OpenAIProviderSettings, create_openai}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -const TEST_VALUES: &[&str] = &["sunny day at the beach", "rainy day in the city"]; - -/// The fixture response body from `__fixtures__/openai-embedding.json`. -fn embedding_response_body() -> Value { - json!({ - "object": "list", - "data": [ - { - "object": "embedding", - "index": 0, - "embedding": [0.0057293195, -0.012727811, 0.020042092, -0.013437585, 0.022833068] - }, - { - "object": "embedding", - "index": 1, - "embedding": [-0.037104916, -0.05178114, -0.008340587, 0.001164541, -0.0035253682] - } - ], - "model": "text-embedding-3-small", - "usage": { - "prompt_tokens": 12, - "total_tokens": 12 - } - }) -} - -fn test_values() -> Vec { - TEST_VALUES - .iter() - .map(std::string::ToString::to_string) - .collect() -} - -fn default_options(values: Vec) -> EmbeddingCallOptions { - EmbeddingCallOptions { - values, - abort_signal: None, - provider_options: None, - headers: None, - max_retries: None, - timeout: None, - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doEmbed -// ════════════════════════════════════════════════════════════════════════════ - -mod do_embed { - use super::*; - - /// upstream: @ai-sdk/openai src/embedding/openai-embedding-model.test.ts › doEmbed › should extract embedding - #[tokio::test] - async fn should_extract_embedding() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/embeddings")) - .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) - .mount(&server) - .await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.embedding_model("text-embedding-3-large").unwrap(); - - let result = model - .do_embed(&default_options(test_values())) - .await - .expect("should succeed"); - - assert_eq!(result.embeddings.len(), 2); - assert_eq!( - result.embeddings[0], - vec![ - 0.0057293195, - -0.012727811, - 0.020042092, - -0.013437585, - 0.022833068 - ] - ); - assert_eq!( - result.embeddings[1], - vec![ - -0.037104916, - -0.05178114, - -0.008340587, - 0.001164541, - -0.0035253682 - ] - ); - } - - /// upstream: @ai-sdk/openai src/embedding/openai-embedding-model.test.ts › doEmbed › should extract usage - #[tokio::test] - async fn should_extract_usage() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/embeddings")) - .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) - .mount(&server) - .await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.embedding_model("text-embedding-3-large").unwrap(); - - let result = model - .do_embed(&default_options(test_values())) - .await - .expect("should succeed"); - - let usage = result.usage.expect("usage should be present"); - assert_eq!(usage.tokens, 12); - } -} - -/// partial: `should expose the raw response headers`; full port in src/openai/tests/embedding/embedding_model.rs. -#[tokio::test] -async fn should_expose_raw_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/embeddings")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(embedding_response_body()), - ) - .mount(&server) - .await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.embedding_model("text-embedding-3-large").unwrap(); - - let result = model - .do_embed(&default_options(test_values())) - .await - .expect("should succeed"); - - let headers = result - .response - .as_ref() - .and_then(|r| r.headers.as_ref()) - .expect("response headers should be present"); - assert_eq!( - headers.get("test-header").map(std::string::String::as_str), - Some("test-value") - ); -} - -/// partial: `should expose the raw response body`; full port in src/openai/tests/embedding/embedding_model.rs. -#[tokio::test] -async fn should_expose_raw_response_body() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/embeddings")) - .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) - .mount(&server) - .await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.embedding_model("text-embedding-3-large").unwrap(); - - let result = model - .do_embed(&default_options(test_values())) - .await - .expect("should succeed"); - - let body = result - .response - .as_ref() - .and_then(|r| r.body.as_ref()) - .expect("response body should be present"); - assert_eq!(body["usage"]["prompt_tokens"], 12); - assert_eq!(body["data"].as_array().unwrap().len(), 2); -} - -/// partial: `should pass the model and the values`; full port in src/openai/tests/embedding/embedding_model.rs. -#[tokio::test] -async fn should_pass_model_and_values() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/embeddings")) - .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) - .mount(&server) - .await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.embedding_model("text-embedding-3-large").unwrap(); - - let _ = model - .do_embed(&default_options(test_values())) - .await - .expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - - assert_eq!(body["model"], "text-embedding-3-large"); - assert_eq!( - body["input"], - json!(["sunny day at the beach", "rainy day in the city"]) - ); - assert_eq!(body["encoding_format"], "float"); - // dimensions and user should be absent when not provided - assert!(body.get("dimensions").is_none()); - assert!(body.get("user").is_none()); -} - -/// `providerOptions.openai.dimensions` is sent as the number it was given, so -/// an integer stays an integer, as the AI SDK passes it through -/// (`embedding/openai-embedding-model.ts:94`). -#[tokio::test] -async fn should_pass_integer_dimensions_as_an_integer() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/embeddings")) - .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) - .mount(&server) - .await; - - let provider = create_openai(OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }) - .unwrap(); - let model = provider.embedding_model("text-embedding-3-large").unwrap(); - let mut options = default_options(test_values()); - options.provider_options = Some( - aimux_core::shared::provider_namespace("openai", json!({ "dimensions": 64 })).unwrap(), - ); - model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["dimensions"], json!(64)); -} - -/// partial: `should pass headers`; full port in src/openai/tests/embedding/embedding_model.rs. -/// Verifies that Authorization, OpenAI-Organization, OpenAI-Project, config-level headers, and request-level headers are all sent. -#[tokio::test] -async fn should_pass_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/embeddings")) - .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) - .mount(&server) - .await; - - let mut config_headers = HashMap::new(); - config_headers.insert( - "Custom-Provider-Header".to_string(), - "provider-header-value".to_string(), - ); - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - organization: Some("test-organization".into()), - project: Some("test-project".into()), - headers: Some( - config_headers - .into_iter() - .map(|(k, v)| (k, Some(v))) - .collect(), - ), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.embedding_model("text-embedding-3-large").unwrap(); - - let mut request_headers = HashMap::new(); - request_headers.insert( - "Custom-Request-Header".to_string(), - "request-header-value".to_string(), - ); - - let options = EmbeddingCallOptions { - values: test_values(), - abort_signal: None, - provider_options: None, - headers: Some(request_headers), - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - - let h = &requests[0].headers; - assert_eq!( - h.get("authorization").and_then(|v| v.to_str().ok()), - Some("Bearer test-api-key") - ); - assert_eq!( - h.get("openai-organization").and_then(|v| v.to_str().ok()), - Some("test-organization") - ); - assert_eq!( - h.get("openai-project").and_then(|v| v.to_str().ok()), - Some("test-project") - ); - assert_eq!( - h.get("custom-provider-header") - .and_then(|v| v.to_str().ok()), - Some("provider-header-value") - ); - assert_eq!( - h.get("custom-request-header").and_then(|v| v.to_str().ok()), - Some("request-header-value") - ); -} diff --git a/aimux-providers/tests/openai_factory_test.rs b/aimux-providers/tests/openai_factory_test.rs deleted file mode 100644 index a2c892914..000000000 --- a/aimux-providers/tests/openai_factory_test.rs +++ /dev/null @@ -1,939 +0,0 @@ -//! `create_openai` against the recorded AI SDK behavior. -//! -//! `fixtures/aisdk/openai/*.json` hold what `@ai-sdk/openai`'s `createOpenAI` -//! 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 aimux_core::tool::RawToolCall; -use std::collections::{BTreeMap, 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 serial_test::serial; - -use aimux_core::AiMuxError; -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::{OpenAIProvider, OpenAIProviderSettings, create_openai}; - -// ── 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), - )) - } -} - -fn settings(mock: &Arc) -> OpenAIProviderSettings { - OpenAIProviderSettings { - 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; 11] = [ - "chat-basic", - "chat-custom-name", - "chat-headers-merge", - "chat-provider-options", - "chat-stream-basic", - "chat-system-and-settings", - "chat-tools", - "embedding-basic", - "empty-api-key", - "missing-api-key", - "responses-basic", -]; - -fn fixtures_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fixtures/aisdk/openai") -} - -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. - fn assert_request(&self, seen: &Seen, authorization: &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/ 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/") - && 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/") { - assert!( - recorded_ua.starts_with(&format!("{package_ua} ")), - "{}: recorded user-agent {recorded_ua:?} does not start with {package_ua:?}", - self.name - ); - } - assert_eq!( - expected.get("authorization").map(String::as_str), - Some(""), - "{}: the recording redacts the key", - self.name - ); - expected.insert("authorization".to_string(), authorization.to_string()); - 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.into(), - 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 => panic!("unmapped role {other}"), - } -} - -/// 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(|value| serde_json::from_value(value.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}") -} - -// ── the fixtures ───────────────────────────────────────────────────────────── - -#[test] -fn every_fixture_is_replayed() { - let mut on_disk: Vec = std::fs::read_dir(fixtures_dir()) - .expect("fixtures/aisdk/openai 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" - ); -} - -/// Replay a chat fixture whose request is a plain `generateText`. -async fn replay_chat(name: &'static str) { - let fixture = Fixture::load(name); - let mock = fixture.mock(); - let provider = create_openai(settings(&mock)).unwrap(); - let model = provider.chat(fixture.model_id()); - 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], &bearer(KEY)); - assert_generate_result(&fixture, &result); -} - -#[tokio::test] -async fn chat_basic() { - replay_chat("chat-basic").await; -} - -#[tokio::test] -async fn chat_system_and_settings() { - replay_chat("chat-system-and-settings").await; -} - -#[tokio::test] -async fn chat_provider_options_use_the_fixed_openai_namespace() { - replay_chat("chat-provider-options").await; -} - -#[tokio::test] -async fn chat_tools() { - let fixture = Fixture::load("chat-tools"); - let mock = fixture.mock(); - let model = create_openai(settings(&mock)).unwrap().chat("gpt-4o"); - assert_eq!(model.provider(), fixture.provider_string()); - - let result = model.do_generate(&fixture.call_options()).await.unwrap(); - - fixture.assert_request(&mock.seen()[0], &bearer(KEY)); - assert_generate_result(&fixture, &result); - let recorded = &fixture.json["result"]["content"][0]; - let call = result - .content - .iter() - .find_map(|part| match part { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }) - .expect("a tool call"); - assert_eq!(call.0, recorded["toolCallId"].as_str().unwrap()); - assert_eq!(call.1, recorded["toolName"].as_str().unwrap()); - assert_eq!( - serde_json::from_str::(&call.2).unwrap(), - recorded["input"] - ); -} - -#[tokio::test] -async fn chat_custom_name() { - let fixture = Fixture::load("chat-custom-name"); - let mock = fixture.mock(); - let provider = create_openai(OpenAIProviderSettings { - name: Some("proxy".to_string()), - ..settings(&mock) - }) - .unwrap(); - let model = provider.chat(fixture.model_id()); - assert_eq!(model.provider(), "proxy.chat"); - assert_eq!(model.provider(), fixture.provider_string()); - - let result = model.do_generate(&fixture.call_options()).await.unwrap(); - - // The name changes identity only, never the URL. - fixture.assert_request(&mock.seen()[0], &bearer(KEY)); - assert_generate_result(&fixture, &result); - assert_eq!( - fixture.json["observations"]["providerIsProxyChat"], - json!(true) - ); -} - -#[tokio::test] -async fn chat_headers_merge() { - let fixture = Fixture::load("chat-headers-merge"); - let mock = fixture.mock(); - // The recording was made with provider headers { "x-a": "1", "X-B": "2" } - // (scripts/aisdk-fixtures/cases/openai.mjs). - let headers: HeaderMapOpt = [("x-a", "1"), ("X-B", "2")] - .into_iter() - .map(|(k, v)| (k.to_string(), Some(v.to_string()))) - .collect(); - let provider = create_openai(OpenAIProviderSettings { - headers: Some(headers), - ..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], &bearer(KEY)); - assert_generate_result(&fixture, &result); - // Case-insensitive override: the call's `x-b` replaced the provider's - // `X-B`, and no second `x-b` went out. - 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"]) - ); -} - -#[tokio::test] -async fn chat_stream_basic() { - let fixture = Fixture::load("chat-stream-basic"); - let mock = fixture.mock(); - let model = create_openai(settings(&mock)).unwrap().chat("gpt-4o"); - 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; - - // The recording's user-agent names the provider package; the stream case - // is checked like the others. - assert!( - fixture.json["request"]["headers"]["user-agent"] - .as_str() - .unwrap() - .starts_with("ai-sdk-openai/"), - "the stream case is a provider-layer recording" - ); - let mut seen = mock.seen(); - assert_eq!(seen.len(), 1); - let sent = seen.remove(0); - fixture.assert_request(&sent, &bearer(KEY)); - - // Compare the text parts one by one with the recorded AI SDK - // `result.parts`: only text-start/delta/end are kept (which also drops - // the ai-layer-only start, start-step and finish-step), compared by id - // and text in order; finish is compared by finishReason and usage. - let expected = &fixture.json["result"]["parts"]; - let expected = expected.as_array().expect("result.parts"); - let expected_text: Vec<(String, String, Option)> = expected - .iter() - .filter_map(|part| { - let kind = part["type"].as_str()?; - kind.starts_with("text-").then(|| { - ( - kind.to_string(), - part["id"].as_str().unwrap().to_string(), - part["text"].as_str().map(str::to_string), - ) - }) - }) - .collect(); - let actual_text: Vec<(String, String, Option)> = parts - .iter() - .filter_map(|part| match part { - StreamPart::TextStart { id, .. } => Some(("text-start".into(), id.clone(), None)), - // The ai layer drops empty deltas without provider metadata - // (ai@7.0.127 stream-text.ts:2881-2891); the provider layer emits - // them (openai@4.0.83 openai-chat-language-model.ts:623-633), and - // so does aimux for the first `"content":""` chunk. - StreamPart::TextDelta { - id, - delta, - provider_metadata, - } if !delta.is_empty() || provider_metadata.is_some() => { - Some(("text-delta".into(), id.clone(), Some(delta.clone()))) - } - StreamPart::TextEnd { id, .. } => Some(("text-end".into(), id.clone(), None)), - _ => None, - }) - .collect(); - assert!( - parts - .iter() - .any(|part| matches!(part, StreamPart::TextDelta { delta, .. } if delta.is_empty())) - ); - assert_eq!(actual_text, expected_text); - - let expected_finish = expected - .iter() - .find(|part| part["type"] == "finish") - .expect("a recorded finish part"); - let finish = parts.iter().find_map(|part| match part { - StreamPart::Finish { - finish_reason, - usage, - .. - } => Some((finish_reason, usage)), - _ => None, - }); - let (reason, usage) = finish.expect("a finish part"); - assert_eq!(reason.unified, FinishReasonUnified::Stop); - assert_eq!(json!(reason.unified), expected_finish["finishReason"]); - assert_eq!( - reason.raw.as_deref(), - expected_finish["rawFinishReason"].as_str() - ); - let total = &expected_finish["totalUsage"]; - let n = |v: &Value| v.as_u64().map(|n| u32::try_from(n).unwrap()); - assert_eq!(usage.input_tokens.total, n(&total["inputTokens"])); - assert_eq!( - usage.input_tokens.no_cache, - n(&total["inputTokenDetails"]["noCacheTokens"]) - ); - assert_eq!( - usage.input_tokens.cache_read, - n(&total["inputTokenDetails"]["cacheReadTokens"]) - ); - assert_eq!(usage.output_tokens.total, n(&total["outputTokens"])); - assert_eq!( - usage.output_tokens.text, - n(&total["outputTokenDetails"]["textTokens"]) - ); - assert_eq!( - usage.output_tokens.reasoning, - n(&total["outputTokenDetails"]["reasoningTokens"]) - ); - assert_eq!( - usage.input_tokens.total.unwrap() + usage.output_tokens.total.unwrap(), - n(&total["totalTokens"]).unwrap() - ); -} - -async fn chat_error(status: u16, body: &Value) -> Box { - let mut canned = Canned::json(body); - canned.status = status; - let mock = MockFetch::new(vec![canned]); - let error = create_openai(settings(&mock)) - .unwrap() - .chat("test-model") - .do_generate(&CallOptions::new(vec![LanguageModelMessage::user_text( - "Hello", - )])) - .await - .unwrap_err(); - let AiMuxError::ApiCall(error) = error else { - panic!("expected an API call error, got {error:?}") - }; - error -} - -/// partial: `should parse OpenRouter resource exhausted error`; full port in src/openai/tests/error.rs. -/// openai@4.0.83 `openai-error.test.ts` "should parse OpenRouter resource -/// exhausted error": the message is `error.message`, a numeric `code` is -/// accepted, and a 429 is retryable (provider@4.0.21 -/// `api-call-error.ts:28-32`: 408, 409, 429 and 5xx). -#[tokio::test] -async fn chat_error_429_is_retryable() { - let message = "{\n \"error\": {\n \"code\": 429,\n \"message\": \"Resource has been exhausted (e.g. check quota).\",\n \"status\": \"RESOURCE_EXHAUSTED\"\n }\n}\n"; - let response = json!({"error":{"message":message,"code":429}}); - let error = chat_error(429, &response).await; - assert_eq!(error.message, message); - assert_eq!(error.status_code, Some(429)); - assert_eq!(error.data, Some(response)); - assert!(error.is_retryable); -} - -/// A 400 is not retryable (provider@4.0.21 `api-call-error.ts:28-32`). -#[tokio::test] -async fn chat_error_400_is_not_retryable() { - let response = - json!({"error":{"message":"Invalid request","type":"invalid_request_error","code":null}}); - let error = chat_error(400, &response).await; - assert_eq!(error.message, "Invalid request"); - assert_eq!(error.status_code, Some(400)); - assert!(!error.is_retryable); -} - -#[tokio::test] -async fn responses_basic() { - let fixture = Fixture::load("responses-basic"); - let mock = fixture.mock(); - let model = create_openai(settings(&mock)).unwrap().responses("gpt-4o"); - assert_eq!(model.provider(), fixture.provider_string()); - - let result = model.do_generate(&fixture.call_options()).await.unwrap(); - - fixture.assert_request(&mock.seen()[0], &bearer(KEY)); - assert_generate_result(&fixture, &result); -} - -#[tokio::test] -async fn embedding_basic() { - let fixture = Fixture::load("embedding-basic"); - let mock = fixture.mock(); - let model = create_openai(settings(&mock)) - .unwrap() - .embedding("text-embedding-3-small"); - 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], &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) - ); -} - -#[serial] -#[tokio::test] -async fn missing_api_key_fails_the_call_not_the_factory() { - let fixture = Fixture::load("missing-api-key"); - let _env = EnvVar::set("OPENAI_API_KEY", None); - let mock = fixture.mock(); - - // Creating the provider and a model reads no key. - let provider = create_openai(OpenAIProviderSettings { - fetch: Some(mock.transport()), - ..Default::default() - }) - .expect("no key is read at creation"); - let model = provider.chat(fixture.model_id()); - assert_eq!(model.provider(), fixture.provider_string()); - - let error = model - .do_generate(&fixture.call_options()) - .await - .expect_err("the call has no key"); - match &error { - AiMuxError::LoadApiKey { - env_var, - description, - } => { - assert_eq!(env_var, "OPENAI_API_KEY"); - assert_eq!(description, "OpenAI"); - } - other => panic!("expected LoadApiKey, got {other:?}"), - } - // Same text as the SDK's: names the setting and the variable, no value. - let message = error.to_string(); - assert!( - message.contains("OpenAI") && message.contains("OPENAI_API_KEY"), - "{message}" - ); - assert_eq!( - fixture.json["error"]["name"].as_str(), - Some("AI_LoadAPIKeyError") - ); - assert!(mock.seen().is_empty(), "nothing was sent"); - assert_eq!(fixture.json["observations"]["requestsSent"], json!(0)); -} - -#[serial] -#[tokio::test] -async fn an_explicit_empty_key_is_sent_as_given() { - let fixture = Fixture::load("empty-api-key"); - // The environment holds a key; an explicit "" must not fall back to it. - let _env = EnvVar::set("OPENAI_API_KEY", Some(ENV_KEY)); - let mock = fixture.mock(); - let provider = create_openai(OpenAIProviderSettings { - api_key: Some(String::new()), - fetch: Some(mock.transport()), - ..Default::default() - }) - .unwrap(); - let model = provider.chat(fixture.model_id()); - - let result = model.do_generate(&fixture.call_options()).await.unwrap(); - - let seen = mock.seen(); - assert_eq!(seen.len(), 1); - // The wire value is exactly `Bearer ` (the SDK's headers object trims the - // trailing space, which is what its observation records). - fixture.assert_request(&seen[0], "Bearer "); - assert_eq!(seen[0].headers["authorization"].trim(), "Bearer"); - assert!(!seen[0].headers["authorization"].contains(ENV_KEY)); - assert_eq!( - fixture.json["observations"]["authorizationIsEmptyBearer"], - json!(true) - ); - assert_eq!(fixture.json["observations"]["usedEnvKey"], json!(false)); - assert_generate_result(&fixture, &result); -} - -fn _assert_provider_is_shareable() { - fn shareable() {} - shareable::(); -} - -/// An empty or blank `base_url` fails the factory with the AI SDK's message -/// (`validateBaseURL` via `src/openai-provider.ts:207-215`). -#[test] -fn an_empty_base_url_fails_with_the_sdk_message() { - for base_url in ["", " "] { - let error = create_openai(OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(base_url.into()), - ..Default::default() - }) - .err() - .expect("an empty base URL is rejected"); - assert!( - matches!(&error, AiMuxError::InvalidArgument { message, .. } if message == "baseURL must be a non-empty string."), - "{base_url:?}: {error:?}" - ); - } -} diff --git a/aimux-providers/tests/openai_image_test.rs b/aimux-providers/tests/openai_image_test.rs deleted file mode 100644 index e2e93e1df..000000000 --- a/aimux-providers/tests/openai_image_test.rs +++ /dev/null @@ -1,941 +0,0 @@ -//! Rust translation of the OpenAI image model tests. -//! -//! Source: `reference/ai/packages/openai/src/image/openai-image-model.test.ts` -//! -//! Each test spins up a `wiremock` mock server, configures a JSON response, -//! creates an `OpenAIImageModel` pointing at the mock, calls `do_generate`, -//! and asserts on the request body / headers / result. -//! -//! The TS tests inject a custom `currentDate` via `_internal` for timestamp -//! assertions. The Rust model always uses `Utc::now()`; timestamp tests -//! verify that a timestamp is present and that `model_id` matches, rather -//! than asserting an exact timestamp value. - -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::image_model::{ImageCallOptions, ImageFile, ImageFileData, ImageOutputs}; -use aimux_core::provider::Provider; -use aimux_core::shared::{AspectRatio, Size}; -use aimux_providers::{OpenAIProviderSettings, create_openai}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -const PROMPT: &str = "A cute baby sea otter"; - -/// The standard generation fixture response (2 images, first with revised_prompt). -fn image_response_body() -> Value { - json!({ - "created": 1770935200, - "data": [ - { - "b64_json": "iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAA3CGNhQlgAADcIanVtYgAAAB5qdW1kYzJwYQARABCAAACqADibcQNj", - "revised_prompt": "A small and adorable baby sea otter. This little creature is covered in a thick and fluffy brown fur, its tiny paws are slightly visible. The otter has bright, curious eyes and it's floating on its back on a calm sea, surrounded by floating seaweed." - }, - { - "b64_json": "iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAEp2GNhQlgAASnYanVtYgAAAB5qdW1kYzJwYQARABCAAACqADibcQNj" - } - ] - }) -} - -/// The standard edit fixture response (1 image). -fn image_edit_response_body() -> Value { - json!({ - "created": 1770935251, - "background": "opaque", - "data": [ - { - "b64_json": "iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAFEE2NhQlgAAUQTanVtYgAAAB5qdW1kYzJwYQARABCAAACqADibcQNj" - } - ], - "output_format": "png", - "quality": "high", - "size": "1024x1024" - }) -} - -/// Mount a mock JSON response at `/images/generations`. -async fn mock_generations_response(server: &MockServer, body: Value) { - mock_generations_response_with_headers(server, body, &[]).await; -} - -/// Mount a mock JSON response at `/images/generations` with extra headers. -async fn mock_generations_response_with_headers( - server: &MockServer, - body: Value, - headers: &[(&str, &str)], -) { - let mut template = ResponseTemplate::new(200).set_body_json(body); - for (k, v) in headers { - template = template.insert_header(*k, *v); - } - Mock::given(method("POST")) - .and(path("/images/generations")) - .respond_with(template) - .mount(server) - .await; -} - -/// Mount a mock JSON response at `/images/edits`. -async fn mock_edits_response(server: &MockServer, body: Value) { - mock_edits_response_with_headers(server, body, &[]).await; -} - -/// Mount a mock JSON response at `/images/edits` with extra headers. -async fn mock_edits_response_with_headers( - server: &MockServer, - body: Value, - headers: &[(&str, &str)], -) { - let mut template = ResponseTemplate::new(200).set_body_json(body); - for (k, v) in headers { - template = template.insert_header(*k, *v); - } - Mock::given(method("POST")) - .and(path("/images/edits")) - .respond_with(template) - .mount(server) - .await; -} - -/// Build options with a prompt and n=1. -fn options(prompt: &str) -> ImageCallOptions { - ImageCallOptions::new(prompt.to_string()) -} - -/// Build a binary image file. -fn binary_file(media_type: &str, bytes: &[u8]) -> ImageFile { - ImageFile::File { - media_type: media_type.to_string(), - data: ImageFileData::Binary(bytes.to_vec()), - } -} - -/// Build a base64 image file. -fn base64_file(media_type: &str, b64: &str) -> ImageFile { - ImageFile::File { - media_type: media_type.to_string(), - data: ImageFileData::Base64(b64.to_string()), - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — generation -// ════════════════════════════════════════════════════════════════════════════ - -mod do_generate { - use super::*; - - /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate › should extract the generated images - #[tokio::test] - async fn should_extract_the_generated_images() { - let server = MockServer::start().await; - mock_generations_response(&server, image_response_body()).await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.image_model("dall-e-3").unwrap(); - - let result = model.do_generate(&options(PROMPT)).await.unwrap(); - - match &result.images { - ImageOutputs::Base64(imgs) => { - assert_eq!(imgs.len(), 2); - assert_eq!( - imgs[0], - "iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAA3CGNhQlgAADcIanVtYgAAAB5qdW1kYzJwYQARABCAAACqADibcQNj" - ); - assert_eq!( - imgs[1], - "iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAEp2GNhQlgAASnYanVtYgAAAB5qdW1kYzJwYQARABCAAACqADibcQNj" - ); - } - _ => panic!("expected Base64 images"), - } - } - - /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate › should return warnings for unsupported settings - #[tokio::test] - async fn should_return_warnings_for_unsupported_settings() { - let server = MockServer::start().await; - mock_generations_response(&server, image_response_body()).await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.image_model("dall-e-3").unwrap(); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - opts.aspect_ratio = Some(AspectRatio::new(1, 1)); - opts.seed = Some(123); - - let result = model.do_generate(&opts).await.unwrap(); - - assert_eq!(result.warnings.len(), 2); - // aspectRatio warning - match &result.warnings[0] { - aimux_core::types::Warning::Unsupported { feature, details } => { - assert_eq!(feature, "aspectRatio"); - assert_eq!( - details.as_deref(), - Some("This model does not support aspect ratio. Use `size` instead.") - ); - } - _ => panic!("expected Unsupported warning for aspectRatio"), - } - // seed warning - match &result.warnings[1] { - aimux_core::types::Warning::Unsupported { feature, .. } => { - assert_eq!(feature, "seed"); - } - _ => panic!("expected Unsupported warning for seed"), - } - } - - /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate › should respect maxImagesPerCall setting - #[tokio::test] - async fn should_respect_max_images_per_call_setting() { - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - - let dall_e_2 = provider.image_model("dall-e-2").unwrap(); - assert_eq!(dall_e_2.max_images_per_call(), Some(10)); - - let future_gpt = provider.image_model("gpt-image-99").unwrap(); - assert_eq!(future_gpt.max_images_per_call(), Some(10)); - - let unknown = provider.image_model("unknown-model").unwrap(); - assert_eq!(unknown.max_images_per_call(), Some(1)); - } - - /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › 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 server = MockServer::start().await; - mock_generations_response(&server, image_response_body()).await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.image_model("dall-e-3").unwrap(); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - - let before = chrono::Utc::now().timestamp_millis(); - let result = model.do_generate(&opts).await.unwrap(); - let after = chrono::Utc::now().timestamp_millis(); - - // A `Date`: millisecond precision. - let ts = result.response.timestamp.unwrap().unix_millis(); - assert!(ts >= before); - assert!(ts <= after); - assert_eq!(result.response.model_id.as_deref(), Some("dall-e-3")); - } - - /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate › should include response_format for dall-e-3 - #[tokio::test] - async fn should_include_response_format_for_dall_e_3() { - let server = MockServer::start().await; - mock_generations_response(&server, image_response_body()).await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.image_model("dall-e-3").unwrap(); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["response_format"], "b64_json"); - } - - /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate › should map OpenAI usage to usage - #[tokio::test] - async fn should_map_openai_usage_to_usage() { - let server = MockServer::start().await; - mock_generations_response( - &server, - json!({ - "created": 1733837122, - "data": [ - { "b64_json": "base64-image-1" } - ], - "usage": { - "input_tokens": 12, - "output_tokens": 0, - "total_tokens": 12, - "input_tokens_details": { - "image_tokens": 7, - "text_tokens": 5 - } - } - }), - ) - .await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.image_model("gpt-image-1").unwrap(); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - - let result = model.do_generate(&opts).await.unwrap(); - - let usage = result.usage.unwrap(); - assert_eq!(usage.input_tokens, Some(12)); - assert_eq!(usage.output_tokens, Some(0)); - assert_eq!(usage.total_tokens, Some(12)); - - let meta = result.provider_metadata.unwrap(); - let openai = meta.get("openai").unwrap(); - let images = openai.get("images").unwrap().as_array().unwrap(); - assert_eq!(images[0].get("imageTokens"), Some(&json!(7))); - assert_eq!(images[0].get("textTokens"), Some(&json!(5))); - } - - /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate › should distribute input token details evenly across images - #[tokio::test] - async fn should_distribute_input_token_details_evenly_across_images() { - let server = MockServer::start().await; - mock_generations_response( - &server, - json!({ - "created": 1733837122, - "data": [ - { "b64_json": "base64-image-1" }, - { "b64_json": "base64-image-2" }, - { "b64_json": "base64-image-3" } - ], - "usage": { - "input_tokens": 30, - "output_tokens": 900, - "total_tokens": 930, - "input_tokens_details": { - "image_tokens": 194, - "text_tokens": 28 - } - } - }), - ) - .await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.image_model("gpt-image-1").unwrap(); - - let mut opts = options(PROMPT); - opts.n = 3; - opts.size = Some(Size::new(1024, 1024)); - - let result = model.do_generate(&opts).await.unwrap(); - - let meta = result.provider_metadata.unwrap(); - let openai = meta.get("openai").unwrap(); - let images = openai.get("images").unwrap().as_array().unwrap(); - assert_eq!(images.len(), 3); - assert_eq!(images[0].get("imageTokens"), Some(&json!(64))); - assert_eq!(images[0].get("textTokens"), Some(&json!(9))); - assert_eq!(images[1].get("imageTokens"), Some(&json!(64))); - assert_eq!(images[1].get("textTokens"), Some(&json!(9))); - assert_eq!(images[2].get("imageTokens"), Some(&json!(66))); - assert_eq!(images[2].get("textTokens"), Some(&json!(10))); - } -} - -mod do_generate_image_editing { - use super::*; - - /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate - image editing › should call /images/edits endpoint when files are provided - #[tokio::test] - async fn should_call_edits_endpoint_when_files_are_provided() { - let server = MockServer::start().await; - mock_edits_response(&server, image_edit_response_body()).await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.image_model("gpt-image-1").unwrap(); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - opts.files = Some(vec![binary_file("image/png", &[137, 80, 78, 71])]); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].url.path(), "/images/edits"); - } - - /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate - image editing › should send multiple images as form data array - #[tokio::test] - async fn should_send_multiple_images_as_form_data_array() { - let server = MockServer::start().await; - mock_edits_response(&server, image_edit_response_body()).await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.image_model("gpt-image-1").unwrap(); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.files = Some(vec![ - binary_file("image/png", &[137, 80, 78, 71]), - binary_file("image/jpeg", &[255, 216, 255, 224]), - ]); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let body_str = String::from_utf8_lossy(&requests[0].body); - assert!( - body_str.contains("name=\"image[]\""), - "should contain image[] field for multiple images: {body_str}" - ); - } - - /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate - image editing › should extract the edited images from response - #[tokio::test] - async fn should_extract_the_edited_images_from_response() { - let server = MockServer::start().await; - mock_edits_response(&server, image_edit_response_body()).await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.image_model("gpt-image-1").unwrap(); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.files = Some(vec![binary_file("image/png", &[137, 80, 78, 71])]); - - let result = model.do_generate(&opts).await.unwrap(); - - match &result.images { - ImageOutputs::Base64(imgs) => { - assert_eq!(imgs.len(), 1); - assert_eq!( - imgs[0], - "iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAFEE2NhQlgAAUQTanVtYgAAAB5qdW1kYzJwYQARABCAAACqADibcQNj" - ); - } - _ => panic!("expected Base64 images"), - } - } - - /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate - image editing › should include response metadata for edited images - #[tokio::test] - async fn should_include_response_metadata_for_edited_images() { - let server = MockServer::start().await; - mock_edits_response_with_headers( - &server, - image_edit_response_body(), - &[("x-request-id", "edit-request-id")], - ) - .await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.image_model("gpt-image-1").unwrap(); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.files = Some(vec![binary_file("image/png", &[137, 80, 78, 71])]); - - let result = model.do_generate(&opts).await.unwrap(); - - assert!(result.response.timestamp.is_some()); - assert_eq!(result.response.model_id.as_deref(), Some("gpt-image-1")); - let headers = result.response.headers.as_ref().unwrap(); - assert_eq!( - headers.get("x-request-id").map(std::string::String::as_str), - Some("edit-request-id") - ); - } - - /// upstream: @ai-sdk/openai src/image/openai-image-model.test.ts › doGenerate - image editing › should return usage information for edited images - #[tokio::test] - async fn should_return_usage_information_for_edited_images() { - let server = MockServer::start().await; - mock_edits_response( - &server, - json!({ - "created": 1733837122, - "data": [ - { "b64_json": "edited-base64-image-1" } - ], - "usage": { - "input_tokens": 25, - "output_tokens": 0, - "total_tokens": 25 - } - }), - ) - .await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.image_model("gpt-image-1").unwrap(); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.files = Some(vec![binary_file("image/png", &[137, 80, 78, 71])]); - - let result = model.do_generate(&opts).await.unwrap(); - - let usage = result.usage.unwrap(); - assert_eq!(usage.input_tokens, Some(25)); - assert_eq!(usage.output_tokens, Some(0)); - assert_eq!(usage.total_tokens, Some(25)); - } -} - -/// partial: `should include response data with timestamp, modelId and headers`; full port in src/openai/tests/image/image_model.rs. -#[tokio::test] -async fn should_include_response_data_with_timestamp_model_id_and_headers() { - let server = MockServer::start().await; - mock_generations_response_with_headers( - &server, - image_response_body(), - &[ - ("x-request-id", "test-request-id"), - ("x-ratelimit-remaining", "123"), - ], - ) - .await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.image_model("dall-e-3").unwrap(); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - - let result = model.do_generate(&opts).await.unwrap(); - - assert!(result.response.timestamp.is_some()); - assert_eq!(result.response.model_id.as_deref(), Some("dall-e-3")); - let headers = result.response.headers.as_ref().unwrap(); - assert_eq!( - headers.get("x-request-id").map(std::string::String::as_str), - Some("test-request-id") - ); - assert_eq!( - headers - .get("x-ratelimit-remaining") - .map(std::string::String::as_str), - Some("123") - ); -} - -/// partial: `should not include response_format for gpt-image-1`; full port in src/openai/tests/image/image_model.rs. -#[tokio::test] -async fn should_not_include_response_format_for_gpt_image_1() { - let server = MockServer::start().await; - mock_generations_response(&server, image_response_body()).await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.image_model("gpt-image-1").unwrap(); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "gpt-image-1"); - assert_eq!(body["prompt"], PROMPT); - assert_eq!(body["n"], 1); - assert_eq!(body["size"], "1024x1024"); - assert!(body.get("response_format").is_none()); -} - -/// partial: `should not include response_format for gpt-image-2`; full port in src/openai/tests/image/image_model.rs. -#[tokio::test] -async fn should_not_include_response_format_for_gpt_image_2() { - let server = MockServer::start().await; - mock_generations_response(&server, image_response_body()).await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.image_model("gpt-image-2").unwrap(); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "gpt-image-2"); - assert!(body.get("response_format").is_none()); -} - -/// partial: `should not include response_format for future gpt-image models`; full port in src/openai/tests/image/image_model.rs. -#[tokio::test] -async fn should_not_include_response_format_for_future_gpt_image_models() { - let server = MockServer::start().await; - mock_generations_response(&server, image_response_body()).await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.image_model("gpt-image-99").unwrap(); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "gpt-image-99"); - assert!(body.get("response_format").is_none()); -} - -/// partial: `should not include response_format for chatgpt-image-latest`; full port in src/openai/tests/image/image_model.rs. -#[tokio::test] -async fn should_not_include_response_format_for_chatgpt_image_latest() { - let server = MockServer::start().await; - mock_generations_response(&server, image_response_body()).await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.image_model("chatgpt-image-latest").unwrap(); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "chatgpt-image-latest"); - assert!(body.get("response_format").is_none()); -} - -/// partial: `should not include response_format for date-suffixed gpt-image model IDs (Azure deployment names)`; full port in src/openai/tests/image/image_model.rs. -#[tokio::test] -async fn should_not_include_response_format_for_date_suffixed_gpt_image() { - let server = MockServer::start().await; - mock_generations_response(&server, image_response_body()).await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.image_model("gpt-image-1.5-2025-12-16").unwrap(); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "gpt-image-1.5-2025-12-16"); - assert!(body.get("response_format").is_none()); -} - -/// partial: `should handle null revised_prompt responses`; full port in src/openai/tests/image/image_model.rs. -#[tokio::test] -async fn should_handle_null_revised_prompt_responses() { - let server = MockServer::start().await; - mock_generations_response( - &server, - json!({ - "created": 1733837122, - "data": [ - { - "revised_prompt": null, - "b64_json": "base64-image-1" - } - ] - }), - ) - .await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.image_model("gpt-image-1").unwrap(); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - - let result = model.do_generate(&opts).await.unwrap(); - - match &result.images { - ImageOutputs::Base64(imgs) => { - assert_eq!(imgs, &["base64-image-1".to_string()]); - } - _ => panic!("expected Base64 images"), - } - assert!(result.warnings.is_empty()); - - let meta = result.provider_metadata.unwrap(); - let openai = meta.get("openai").unwrap(); - let images = openai.get("images").unwrap().as_array().unwrap(); - assert_eq!(images.len(), 1); - assert_eq!(images[0].get("created"), Some(&json!(1733837122))); - // revisedPrompt should be absent (revised_prompt was null) - assert!(images[0].get("revisedPrompt").is_none()); - // size, quality, background, outputFormat should be absent (not in response) - assert!(images[0].get("size").is_none()); - assert!(images[0].get("quality").is_none()); - assert!(images[0].get("background").is_none()); - assert!(images[0].get("outputFormat").is_none()); -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — image editing -// ════════════════════════════════════════════════════════════════════════════ - -/// partial: `should send image as form data with Uint8Array input`; full port in src/openai/tests/image/image_model.rs. -#[tokio::test] -async fn should_send_image_as_form_data_with_uint8array_input() { - let server = MockServer::start().await; - mock_edits_response(&server, image_edit_response_body()).await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.image_model("gpt-image-1").unwrap(); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - opts.files = Some(vec![binary_file("image/png", &[137, 80, 78, 71])]); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let body_str = String::from_utf8_lossy(&requests[0].body); - assert!( - body_str.contains("name=\"model\""), - "should contain model field: {body_str}" - ); - assert!( - body_str.contains("gpt-image-1"), - "should contain model value: {body_str}" - ); - assert!( - body_str.contains("name=\"prompt\""), - "should contain prompt field: {body_str}" - ); - assert!( - body_str.contains(PROMPT), - "should contain prompt value: {body_str}" - ); - assert!( - body_str.contains("name=\"n\""), - "should contain n field: {body_str}" - ); - assert!( - body_str.contains("name=\"size\""), - "should contain size field: {body_str}" - ); - assert!( - body_str.contains("1024x1024"), - "should contain size value: {body_str}" - ); - assert!( - body_str.contains("name=\"image\""), - "should contain image field: {body_str}" - ); -} - -/// partial: `should send image as form data with base64 string input`; full port in src/openai/tests/image/image_model.rs. -#[tokio::test] -async fn should_send_image_as_form_data_with_base64_string_input() { - let server = MockServer::start().await; - mock_edits_response(&server, image_edit_response_body()).await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.image_model("gpt-image-1").unwrap(); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.files = Some(vec![base64_file("image/png", "iVBORw0KGgo=")]); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let body_str = String::from_utf8_lossy(&requests[0].body); - assert!( - body_str.contains("name=\"model\""), - "should contain model: {body_str}" - ); - assert!( - body_str.contains("gpt-image-1"), - "should contain model value: {body_str}" - ); - assert!( - body_str.contains("name=\"image\""), - "should contain image field: {body_str}" - ); -} - -/// partial: `should return warnings for unsupported settings in edit mode`; full port in src/openai/tests/image/image_model.rs. -#[tokio::test] -async fn should_return_warnings_for_unsupported_settings_in_edit_mode() { - let server = MockServer::start().await; - mock_edits_response(&server, image_edit_response_body()).await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.image_model("gpt-image-1").unwrap(); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - opts.aspect_ratio = Some(AspectRatio::new(16, 9)); - opts.seed = Some(42); - opts.files = Some(vec![binary_file("image/png", &[137, 80, 78, 71])]); - - let result = model.do_generate(&opts).await.unwrap(); - - assert_eq!(result.warnings.len(), 2); - match &result.warnings[0] { - aimux_core::types::Warning::Unsupported { feature, .. } => { - assert_eq!(feature, "aspectRatio"); - } - _ => panic!("expected Unsupported warning for aspectRatio"), - } - match &result.warnings[1] { - aimux_core::types::Warning::Unsupported { feature, .. } => { - assert_eq!(feature, "seed"); - } - _ => panic!("expected Unsupported warning for seed"), - } -} - -/// File and mask support follow the model id like the AI SDK -/// (`image/openai-image-model.ts:61-92`): known editing models `true`, -/// `dall-e-3` `false`, anything else unknown. -#[test] -fn image_input_capabilities_follow_the_model_id() { - use aimux_core::image_model::ImageModel; - let provider = create_openai(OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some("https://example.test/v1".into()), - ..Default::default() - }) - .unwrap(); - for (model_id, expected) in [ - ("dall-e-2", Some(true)), - ("gpt-image-1", Some(true)), - ("gpt-image-2.5-sunburst-2026-09-08", Some(true)), - ("chatgpt-image-latest", Some(true)), - ("dall-e-3", Some(false)), - ("gpt-image-custom", None), - ] { - let model = provider.image(model_id); - assert_eq!(model.supports_file_inputs(), expected, "{model_id}"); - assert_eq!(model.supports_mask_inputs(), expected, "{model_id}"); - } -} diff --git a/aimux-providers/tests/openai_realtime_ws_replay_test.rs b/aimux-providers/tests/openai_realtime_ws_replay_test.rs deleted file mode 100644 index f0daf3c38..000000000 --- a/aimux-providers/tests/openai_realtime_ws_replay_test.rs +++ /dev/null @@ -1,314 +0,0 @@ -//! Offline replay of the OpenAI realtime transcription WebSocket path -//! (RFC-0028 §3.2), driven through the connector that -//! `OpenAITranscriptionModel::with_web_socket` installs. -//! -//! `OpenAIProviderSettings::web_socket` carries a custom [`WsConnector`] that -//! never touches the network: it pairs the client with an in-process server -//! over an in-memory duplex pipe. The server records every frame the client -//! sends and replays the server events in -//! `fixtures/openai_realtime_transcription.json` once the audio is committed. -#![cfg(feature = "realtime")] - -use std::sync::{Arc, Mutex}; - -use aimux_core::error::AiMuxError; -use aimux_core::transcription_model::{ - AudioChunk, InputAudioFormat, TranscriptionModel, TranscriptionStreamOptions, - TranscriptionStreamPart, -}; -use aimux_provider_utils::ws::{WebSocketRequest, WsConnection, WsConnector}; -use aimux_providers::{OpenAIProviderSettings, create_openai}; -use futures::{SinkExt, StreamExt}; -use serde_json::Value; -use tokio_tungstenite::WebSocketStream; -use tokio_tungstenite::tungstenite::Message; -use tokio_tungstenite::tungstenite::protocol::Role; - -/// URL, headers and subprotocols of one connect request. -type Connect = (String, Vec<(String, String)>, Vec); - -/// What the in-process server saw. -#[derive(Default)] -struct Seen { - /// Every connect request, in order. - connects: Vec, - /// Every text frame the client sent, parsed. - frames: Vec, - /// Whether the client closed the socket. - closed: bool, -} - -/// A connector that serves the fixture over an in-memory pipe. -struct ReplayConnector { - seen: Arc>, - events: Vec, -} - -#[async_trait::async_trait] -impl WsConnector for ReplayConnector { - async fn connect(&self, request: &WebSocketRequest) -> Result { - self.seen.lock().unwrap().connects.push(( - request.url.clone(), - request.headers.clone(), - request.subprotocols.clone(), - )); - let (client_io, server_io) = tokio::io::duplex(64 * 1024); - let client = WebSocketStream::from_raw_socket(client_io, Role::Client, None).await; - let server = WebSocketStream::from_raw_socket(server_io, Role::Server, None).await; - tokio::spawn(serve(server, Arc::clone(&self.seen), self.events.clone())); - Ok(WsConnection::from_transport(Box::new(client), request)) - } -} - -/// Record client frames; after `input_audio_buffer.commit`, replay `events`. -async fn serve( - mut ws: WebSocketStream, - seen: Arc>, - events: Vec, -) { - while let Some(message) = ws.next().await { - match message { - Ok(Message::Text(text)) => { - let frame: Value = serde_json::from_str(&text).expect("client sends JSON"); - let commit = frame["type"] == "input_audio_buffer.commit"; - seen.lock().unwrap().frames.push(frame); - if commit { - for event in &events { - ws.send(Message::Text(event.to_string())) - .await - .expect("replay event"); - } - } - } - Ok(Message::Close(_)) | Err(_) => { - seen.lock().unwrap().closed = true; - break; - } - Ok(_) => {} - } - } -} - -fn fixture_events() -> Vec { - let fixture: Value = - serde_json::from_str(include_str!("fixtures/openai_realtime_transcription.json")) - .expect("fixture is JSON"); - fixture["events"].as_array().expect("events array").clone() -} - -/// partial: `should stream gpt-realtime-whisper using OpenAI realtime transcription`; full port in src/openai/tests/transcription/transcription_model.rs. -#[tokio::test] -async fn realtime_transcription_replays_through_injected_web_socket() { - let seen = Arc::new(Mutex::new(Seen::default())); - let connector = ReplayConnector { - seen: Arc::clone(&seen), - events: fixture_events(), - }; - let provider = create_openai(OpenAIProviderSettings { - api_key: Some("sk-replay-test".to_string()), - base_url: Some("https://replay.invalid/v1".into()), - web_socket: Some(Arc::new(connector)), - ..Default::default() - }) - .expect("valid settings"); - let model = provider.transcription("gpt-realtime-whisper"); - - let options = TranscriptionStreamOptions { - audio: Box::pin(futures::stream::iter(vec![ - AudioChunk::Binary(vec![1, 2, 3]), - AudioChunk::Base64("BAUG".into()), - ])), - input_audio_format: InputAudioFormat { - format_type: "audio/pcm".to_string(), - rate: Some(24_000), - }, - provider_options: None, - abort_signal: None, - headers: None, - include_raw_chunks: false, - timeout: None, - }; - let result = model - .do_stream(options) - .expect("the OpenAI transcription model streams") - .await - .expect("stream opens"); - let parts: Vec = result - .stream - .map(|part| part.expect("no stream error")) - .collect() - .await; - - // Transcript events, mapped from the replayed server events. - let deltas: Vec<&str> = parts - .iter() - .filter_map(|part| match part { - TranscriptionStreamPart::TranscriptDelta { delta, id, .. } => { - assert_eq!(id.as_deref(), Some("item_1")); - Some(delta.as_str()) - } - _ => None, - }) - .collect(); - assert_eq!(deltas, ["Hello", " world"]); - assert!(parts.iter().any(|part| matches!( - part, - TranscriptionStreamPart::TranscriptFinal { text, id, .. } - if text == "Hello world" && id.as_deref() == Some("item_1") - ))); - assert!(matches!( - parts.last(), - Some(TranscriptionStreamPart::Finish { text, .. }) if text == "Hello world" - )); - assert!(matches!( - parts.first(), - Some(TranscriptionStreamPart::StreamStart { .. }) - )); - - // The injected connector carried the session: exactly one connect, to - // the realtime URL derived from the base URL, with the credential. - // The client closes the long-lived session after `completed` (B2). - for _ in 0..100 { - if seen.lock().unwrap().closed { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - let seen = seen.lock().unwrap(); - assert!(seen.closed, "client did not close the session"); - assert_eq!(seen.connects.len(), 1, "with_web_socket connector not used"); - let (url, headers, subprotocols) = &seen.connects[0]; - assert_eq!(url, "wss://replay.invalid/v1/realtime?intent=transcription"); - // The key rides the `openai-insecure-api-key` subprotocol and the - // Authorization header is dropped: OpenAI rejects a handshake that sends - // both (`transcription/openai-transcription-model.ts:620-652`). - assert_eq!( - subprotocols, - &["realtime", "openai-insecure-api-key.sk-replay-test"] - ); - assert!( - !headers - .iter() - .any(|(name, _)| name.eq_ignore_ascii_case("authorization")) - ); - - // Frames the server saw: session.update, two appends, commit. - let types: Vec<&str> = seen - .frames - .iter() - .map(|frame| frame["type"].as_str().unwrap_or_default()) - .collect(); - assert_eq!( - types, - [ - "session.update", - "input_audio_buffer.append", - "input_audio_buffer.append", - "input_audio_buffer.commit", - ] - ); - let session = &seen.frames[0]["session"]; - assert_eq!(session["type"], "transcription"); - assert_eq!( - session["audio"]["input"]["transcription"]["model"], - "gpt-realtime-whisper" - ); - assert_eq!(session["audio"]["input"]["format"]["type"], "audio/pcm"); - assert_eq!(session["audio"]["input"]["format"]["rate"], 24_000); - assert_eq!(seen.frames[1]["audio"], "AQID"); - assert_eq!(seen.frames[2]["audio"], "BAUG"); -} - -/// Streams the fixture with `provider_options`; returns the parts and the -/// `session.update` the client sent. -async fn replay_with(provider_options: Value) -> (Vec, Value) { - let seen = Arc::new(Mutex::new(Seen::default())); - let provider = create_openai(OpenAIProviderSettings { - api_key: Some("sk-replay-test".to_string()), - base_url: Some("https://replay.invalid/v1".into()), - web_socket: Some(Arc::new(ReplayConnector { - seen: Arc::clone(&seen), - events: fixture_events(), - })), - ..Default::default() - }) - .expect("valid settings"); - let options = TranscriptionStreamOptions { - audio: Box::pin(futures::stream::iter(vec![AudioChunk::Binary(vec![ - 1, 2, 3, - ])])), - input_audio_format: InputAudioFormat { - format_type: "audio/pcm".to_string(), - rate: Some(24_000), - }, - provider_options: Some(serde_json::from_value(provider_options).unwrap()), - abort_signal: None, - headers: None, - include_raw_chunks: false, - timeout: None, - }; - let result = provider - .transcription("gpt-realtime-whisper") - .do_stream(options) - .expect("the OpenAI transcription model streams") - .await - .expect("stream opens"); - let parts = result - .stream - .map(|part| part.expect("no stream error")) - .collect() - .await; - let session = seen.lock().unwrap().frames[0].clone(); - (parts, session) -} - -/// `providerOptions.openai.streaming.delay` and `.include` configure the -/// realtime session; the REST `include` does not -/// (`transcription/openai-transcription-model.ts:580-617`). -#[tokio::test] -async fn realtime_session_takes_the_streaming_options() { - let (_, session) = replay_with(serde_json::json!({"openai": { - "language": "en", - "include": ["logprobs"], - "streaming": {"delay": "low", "include": ["item.input_audio_transcription.logprobs"]}, - }})) - .await; - assert_eq!( - session["session"]["audio"]["input"]["transcription"], - serde_json::json!({"model": "gpt-realtime-whisper", "language": "en", "delay": "low"}) - ); - assert_eq!( - session["session"]["include"], - serde_json::json!(["item.input_audio_transcription.logprobs"]) - ); -} - -/// `stream-start` warns about each REST-only option that is set -/// (`transcription/openai-transcription-model.ts:395-410`). -#[tokio::test] -async fn realtime_stream_start_warns_about_rest_only_options() { - let (parts, _) = replay_with(serde_json::json!({"openai": { - "prompt": "context prompt", - "temperature": 0.5, - "language": "en", - }})) - .await; - let Some(TranscriptionStreamPart::StreamStart { warnings }) = parts.first() else { - panic!("expected stream-start first, got {parts:?}"); - }; - assert_eq!( - serde_json::to_value(warnings).unwrap(), - serde_json::json!([ - { - "type": "unsupported", - "feature": "providerOptions.openai.prompt", - "details": "OpenAI streaming transcription does not support prompt.", - }, - { - "type": "unsupported", - "feature": "providerOptions.openai.temperature", - "details": "OpenAI streaming transcription does not support temperature.", - }, - ]) - ); -} diff --git a/aimux-providers/tests/openai_speech_test.rs b/aimux-providers/tests/openai_speech_test.rs deleted file mode 100644 index ffe286ea5..000000000 --- a/aimux-providers/tests/openai_speech_test.rs +++ /dev/null @@ -1,368 +0,0 @@ -//! Rust translation of the OpenAI speech (TTS) model tests. -//! -//! Source: `reference/ai/packages/openai/src/speech/openai-speech-model.test.ts` -//! -//! Each test spins up a `wiremock` mock server, configures a binary audio -//! response, creates an `OpenAISpeechModel` pointing at the mock, calls -//! `do_generate`, and asserts on the request body / headers / result. -//! -//! The TS tests inject a custom `currentDate` via `_internal` for timestamp -//! assertions. The Rust model always uses `Utc::now()`; the timestamp tests -//! therefore verify that a timestamp is present and that `model_id` matches, -//! rather than asserting an exact timestamp value. - -use std::collections::HashMap; - -use serde_json::Value; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::provider::Provider; -use aimux_core::speech_model::{AudioData, SpeechCallOptions}; -use aimux_providers::{OpenAIProviderSettings, create_openai}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -/// 100 bytes of mock audio data, returned by the mock server. -fn mock_audio_bytes() -> Vec { - vec![0u8; 100] -} - -/// Mount a mock binary audio response on the server at `/audio/speech`. -async fn mock_audio_response(server: &MockServer, format: &str) { - Mock::given(method("POST")) - .and(path("/audio/speech")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", format!("audio/{format}")) - .set_body_bytes(mock_audio_bytes()), - ) - .mount(server) - .await; -} - -/// Mount a mock binary audio response with extra response headers. -async fn mock_audio_response_with_headers( - server: &MockServer, - format: &str, - headers: &[(&str, &str)], -) { - let mut template = ResponseTemplate::new(200) - .insert_header("content-type", format!("audio/{format}")) - .set_body_bytes(mock_audio_bytes()); - for (k, v) in headers { - template = template.insert_header(*k, *v); - } - Mock::given(method("POST")) - .and(path("/audio/speech")) - .respond_with(template) - .mount(server) - .await; -} - -/// Build a `SpeechCallOptions` with just the text. -fn speech_options(text: &str) -> SpeechCallOptions { - SpeechCallOptions::new(text.to_string()) -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate -// (openai-speech-model.test.ts → describe('doGenerate')) -// ════════════════════════════════════════════════════════════════════════════ - -mod do_generate { - use super::*; - - /// upstream: @ai-sdk/openai src/speech/openai-speech-model.test.ts › doGenerate › should pass the model and text - #[tokio::test] - async fn should_pass_the_model_and_text() { - let server = MockServer::start().await; - mock_audio_response(&server, "mp3").await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.speech_model("tts-1").unwrap().unwrap(); - - model - .do_generate(&speech_options("Hello from the AI SDK!")) - .await - .unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "tts-1"); - assert_eq!(body["input"], "Hello from the AI SDK!"); - } - - /// upstream: @ai-sdk/openai src/speech/openai-speech-model.test.ts › doGenerate › should pass options - #[tokio::test] - async fn should_pass_options() { - let server = MockServer::start().await; - mock_audio_response(&server, "opus").await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.speech_model("tts-1").unwrap().unwrap(); - - let mut options = speech_options("Hello from the AI SDK!"); - options.voice = Some("nova".to_string()); - options.output_format = Some("opus".to_string()); - options.speed = Some(1.5); - - model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "tts-1"); - assert_eq!(body["input"], "Hello from the AI SDK!"); - assert_eq!(body["voice"], "nova"); - assert_eq!(body["speed"], 1.5); - assert_eq!(body["response_format"], "opus"); - } - - /// upstream: @ai-sdk/openai src/speech/openai-speech-model.test.ts › doGenerate › should return audio data with correct content type - #[tokio::test] - async fn should_return_audio_data_with_correct_content_type() { - let server = MockServer::start().await; - mock_audio_response_with_headers( - &server, - "opus", - &[ - ("x-request-id", "test-request-id"), - ("x-ratelimit-remaining", "123"), - ], - ) - .await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.speech_model("tts-1").unwrap().unwrap(); - - let mut options = speech_options("Hello from the AI SDK!"); - options.output_format = Some("opus".to_string()); - - let result = model - .do_generate(&options) - .await - .expect("do_generate should succeed"); - - assert!(matches!(result.audio, AudioData::Binary(ref b) if b == &mock_audio_bytes())); - } - - /// upstream: @ai-sdk/openai src/speech/openai-speech-model.test.ts › doGenerate › should include response data with timestamp, modelId and headers - /// - /// The TS test injects a custom `currentDate` of `new Date(0)`; the Rust model - /// uses `Utc::now()`. We assert that the timestamp is present, the model id - /// matches, and the response headers carry the expected values. - #[tokio::test] - async fn should_include_response_data_with_timestamp_modelid_and_headers() { - let server = MockServer::start().await; - mock_audio_response_with_headers( - &server, - "mp3", - &[ - ("x-request-id", "test-request-id"), - ("x-ratelimit-remaining", "123"), - ], - ) - .await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.speech_model("tts-1").unwrap().unwrap(); - - let result = model - .do_generate(&speech_options("Hello from the AI SDK!")) - .await - .unwrap(); - - assert!( - result.response.timestamp.is_some(), - "timestamp should be set" - ); - assert_eq!(result.response.model_id.as_deref(), Some("tts-1")); - let headers = result.response.headers.expect("response headers"); - assert_eq!(headers.get("content-type"), Some(&"audio/mp3".to_string())); - assert_eq!( - headers.get("x-request-id"), - Some(&"test-request-id".to_string()) - ); - assert_eq!( - headers.get("x-ratelimit-remaining"), - Some(&"123".to_string()) - ); - } - - /// upstream: @ai-sdk/openai src/speech/openai-speech-model.test.ts › doGenerate › should use real date when no custom date provider is specified - /// - /// The TS test actually injects a custom date (a copy-paste oddity). The Rust - /// model always uses `Utc::now()`; we verify the timestamp is present and the - /// model id matches. - #[tokio::test] - async fn should_use_real_date_when_no_custom_date_provider_is_specified() { - let server = MockServer::start().await; - mock_audio_response(&server, "mp3").await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.speech_model("tts-1").unwrap().unwrap(); - - let result = model - .do_generate(&speech_options("Hello from the AI SDK!")) - .await - .unwrap(); - - assert!( - result.response.timestamp.is_some(), - "timestamp should be set" - ); - assert_eq!(result.response.model_id.as_deref(), Some("tts-1")); - } - - /// upstream: @ai-sdk/openai src/speech/openai-speech-model.test.ts › doGenerate › should handle different audio formats - /// - /// Iterates over the supported formats and verifies audio data is returned. - /// Matches the TS input `providerOptions.openai.response_format`, which the - /// upstream schema does not include. - #[tokio::test] - async fn should_handle_different_audio_formats() { - for format in &["mp3", "opus", "aac", "flac", "wav", "pcm"] { - let server = MockServer::start().await; - mock_audio_response(&server, format).await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.speech_model("tts-1").unwrap().unwrap(); - - let mut options = speech_options("Hello from the AI SDK!"); - options.provider_options = Some(aimux_core::shared::NamespaceMap::from([( - "openai".into(), - serde_json::Map::from_iter([( - "response_format".into(), - serde_json::Value::String((*format).into()), - )]), - )])); - - let result = model - .do_generate(&options) - .await - .expect("do_generate should succeed"); - - assert!( - matches!(result.audio, AudioData::Binary(ref b) if b == &mock_audio_bytes()), - "format {format} should return mock audio bytes" - ); - } - } - - /// upstream: @ai-sdk/openai src/speech/openai-speech-model.test.ts › doGenerate › should include warnings if any are generated - /// - /// With no unsupported options, warnings should be empty. - #[tokio::test] - async fn should_include_warnings_if_any_are_generated() { - let server = MockServer::start().await; - mock_audio_response(&server, "mp3").await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.speech_model("tts-1").unwrap().unwrap(); - - let result = model - .do_generate(&speech_options("Hello from the AI SDK!")) - .await - .unwrap(); - - assert!( - result.warnings.is_empty(), - "expected no warnings, got {:?}", - result.warnings - ); - } -} - -/// partial: `should pass headers`; full port in src/openai/tests/speech/speech_model.rs. -/// -/// Verifies that `Authorization`, `OpenAI-Organization`, `OpenAI-Project`, -/// config-level custom headers, and request-level custom headers are all -/// forwarded. The TS test also checks the `User-Agent` header; the Rust -/// provider does not set a `User-Agent` so that assertion is not translated. -#[tokio::test] -async fn should_pass_headers() { - let server = MockServer::start().await; - mock_audio_response(&server, "mp3").await; - - let mut provider_headers = HashMap::new(); - provider_headers.insert( - "Custom-Provider-Header".to_string(), - "provider-header-value".to_string(), - ); - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - organization: Some("test-organization".into()), - project: Some("test-project".into()), - headers: Some( - provider_headers - .into_iter() - .map(|(k, v)| (k, Some(v))) - .collect(), - ), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.speech_model("tts-1").unwrap().unwrap(); - - let mut options = speech_options("Hello from the AI SDK!"); - let mut request_headers = HashMap::new(); - request_headers.insert( - "Custom-Request-Header".to_string(), - "request-header-value".to_string(), - ); - options.headers = Some(request_headers); - - model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let h = &requests[0].headers; - assert_eq!(h.get("authorization").unwrap(), "Bearer test-api-key"); - assert_eq!(h.get("openai-organization").unwrap(), "test-organization"); - assert_eq!(h.get("openai-project").unwrap(), "test-project"); - assert_eq!( - h.get("custom-provider-header").unwrap(), - "provider-header-value" - ); - assert_eq!( - h.get("custom-request-header").unwrap(), - "request-header-value" - ); -} diff --git a/aimux-providers/tests/openai_transcription_stream_test.rs b/aimux-providers/tests/openai_transcription_stream_test.rs deleted file mode 100644 index 26c2606a0..000000000 --- a/aimux-providers/tests/openai_transcription_stream_test.rs +++ /dev/null @@ -1,844 +0,0 @@ -//! Realtime transcription streaming tests (RFC-0028 Phase 1). -//! -//! Each test spins up a local WebSocket server (tokio-tungstenite `accept_hdr_async`), -//! plays the OpenAI realtime endpoint's role (consume `session.update` / -//! `input_audio_buffer.append` / `commit`, emit `transcription.delta` / -//! `completed` events), and asserts on the `TranscriptionStreamPart` sequence -//! produced by `do_stream`. -//! -//! These replace the TS mock-WebSocket tests that -//! `openai_transcription_test.rs` omitted ("not practical to translate" — -//! they became practical with RFC-0028's WebSocket infrastructure). The -//! provider is built with `create_openai`, whose base URL also names the -//! realtime endpoint (`ws://` for an `http://` base URL). - -#![cfg(feature = "realtime")] - -use std::time::Duration; - -use futures::{SinkExt, StreamExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::Message; - -use aimux_core::AbortSignal; -use aimux_core::error::AiMuxError; -use aimux_core::transcription_model::{ - AudioChunk, InputAudioFormat, TranscriptionModel, TranscriptionStreamOptions, - TranscriptionStreamPart, -}; -use aimux_providers::{OpenAIProvider, OpenAIProviderSettings, create_openai}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -/// A local WebSocket "OpenAI realtime" server. Handshake + `session.update` -/// ack, then run the scripted behavior. -struct RealtimeServer { - port: u16, -} - -impl RealtimeServer { - /// Start a listener; the accept loop runs on a spawned task. - async fn start() -> Self { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - // Accept a single connection (tests make one). - let (stream, _) = listener.accept().await.unwrap(); - let mut ws = accept_realtime(stream).await; - serve(&mut ws).await; - }); - Self { port } - } - - fn base_url(&self) -> String { - format!("http://127.0.0.1:{}", self.port) - } -} - -/// Server behavior shared by the happy-path tests: read session.update, -/// ack it, collect audio appends until commit, then emit delta + completed, -/// and finally assert the client closes with code 1000. -async fn serve(ws: &mut tokio_tungstenite::WebSocketStream) { - // 1. Read session.update, capture the model + format from it. - let mut session_model = String::new(); - let mut format_type = String::new(); - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: serde_json::Value = serde_json::from_str(&text).unwrap(); - assert_eq!( - v["type"], "session.update", - "first message must be session.update" - ); - assert_eq!(v["session"]["type"], "transcription"); - session_model = v["session"]["audio"]["input"]["transcription"]["model"] - .as_str() - .unwrap() - .to_string(); - format_type = v["session"]["audio"]["input"]["format"]["type"] - .as_str() - .unwrap() - .to_string(); - assert!( - v["session"]["audio"]["input"]["turn_detection"].is_null(), - "turn_detection must be null (nested under audio.input)" - ); - ws.send(Message::Text(r#"{"type":"session.created"}"#.to_string())) - .await - .unwrap(); - ws.send(Message::Text(r#"{"type":"session.updated"}"#.to_string())) - .await - .unwrap(); - } - let _ = (&session_model, &format_type); - - // 2. Collect audio appends until commit. - let mut appended: Vec = Vec::new(); - loop { - match ws.next().await { - Some(Ok(Message::Text(text))) => { - let v: serde_json::Value = serde_json::from_str(&text).unwrap(); - match v["type"].as_str().unwrap_or("") { - "input_audio_buffer.append" => { - appended.push(v["audio"].as_str().unwrap().to_string()); - } - "input_audio_buffer.commit" => break, - other => panic!("unexpected client message: {other}"), - } - } - other => panic!("expected client text message, got {other:?}"), - } - } - assert!( - !appended.is_empty(), - "client must append at least one audio chunk" - ); - - // 3. Emit transcript deltas + completed (the shape OpenAI sends). - ws.send(Message::Text( - r#"{"type":"conversation.item.input_audio_transcription.delta","item_id":"item-1","delta":"Hello"}"#.to_string(), - )) - .await - .unwrap(); - ws.send(Message::Text( - r#"{"type":"conversation.item.input_audio_transcription.delta","item_id":"item-1","delta":" world"}"#.to_string(), - )) - .await - .unwrap(); - ws.send(Message::Text( - r#"{"type":"conversation.item.input_audio_transcription.completed","item_id":"item-1","transcript":"Hello world"}"#.to_string(), - )) - .await - .unwrap(); - - // 4. Expect the client to close with code 1000 (normal). - match ws.next().await { - Some(Ok(Message::Close(frame))) => { - let code = frame.map(|f| u16::from(f.code)); - assert_eq!(code, Some(1000), "client must close(1000) after completion"); - } - other => panic!("expected close frame, got {other:?}"), - } -} - -/// Accept the handshake the way the OpenAI realtime endpoint does: the client -/// offers `realtime` plus `openai-insecure-api-key.` and sends no -/// Authorization header (`transcription/openai-transcription-model.ts:620-652`); -/// the server selects `realtime`. -async fn accept_realtime( - stream: tokio::net::TcpStream, -) -> tokio_tungstenite::WebSocketStream { - use tokio_tungstenite::tungstenite::handshake::server::{Request, Response}; - #[allow(clippy::result_large_err)] - let handshake = |request: &Request, mut response: Response| { - let offered = request - .headers() - .get("sec-websocket-protocol") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - assert!( - offered.starts_with("realtime, openai-insecure-api-key."), - "{offered}" - ); - assert!(request.headers().get("authorization").is_none()); - response - .headers_mut() - .insert("sec-websocket-protocol", "realtime".parse().unwrap()); - Ok(response) - }; - tokio_tungstenite::accept_hdr_async(stream, handshake) - .await - .unwrap() -} - -/// `create_openai` pointed at `base_url` with `api_key`. -fn openai_at(base_url: impl Into, api_key: &str) -> OpenAIProvider { - create_openai(OpenAIProviderSettings { - api_key: Some(api_key.to_string()), - base_url: Some(base_url.into()), - ..Default::default() - }) - .expect("valid settings") -} - -fn realtime_model(base_url: &str) -> aimux_providers::OpenAITranscriptionModel { - openai_at(base_url, "test-api-key").transcription("gpt-realtime-whisper") -} - -fn stream_options( - chunks: Vec, - abort: Option, -) -> TranscriptionStreamOptions { - TranscriptionStreamOptions { - audio: Box::pin(futures::stream::iter(chunks)), - input_audio_format: InputAudioFormat { - format_type: "audio/pcm".to_string(), - rate: Some(24000), - }, - provider_options: None, - abort_signal: abort, - headers: None, - include_raw_chunks: false, - timeout: None, - } -} - -/// Collect all parts from a do_stream result into a Vec (Err passthrough). -async fn collect( - result: aimux_core::transcription_model::TranscriptionStreamResult, -) -> Vec> { - let mut out = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - out.push(part); - } - out -} - -// ── tests ─────────────────────────────────────────────────────────────────── - -/// partial: `should stream gpt-realtime-whisper using OpenAI realtime transcription`; full port in src/openai/tests/transcription/transcription_model.rs. -/// Happy path: connect → session.update → appends → commit → deltas → -/// completed → final + finish → client close(1000). -#[tokio::test] -async fn stream_realtime_happy_path() { - let server = RealtimeServer::start().await; - let model = realtime_model(&server.base_url()); - - let options = stream_options( - vec![ - AudioChunk::Binary(vec![1, 2, 3]), - AudioChunk::Binary(vec![4, 5, 6]), - ], - None, - ); - let result = model - .do_stream(options) - .expect("the OpenAI transcription model streams") - .await - .expect("do_stream should connect"); - let parts = collect(result).await; - - // StreamStart + 2 deltas + final + finish. - assert_eq!(parts.len(), 5, "parts: {parts:?}"); - - assert!(matches!( - &parts[0], - Ok(TranscriptionStreamPart::StreamStart { .. }) - )); - - match &parts[1] { - Ok(TranscriptionStreamPart::TranscriptDelta { id, delta, .. }) => { - assert_eq!(id.as_deref(), Some("item-1")); - assert_eq!(delta, "Hello"); - } - other => panic!("expected delta 'Hello', got {other:?}"), - } - match &parts[2] { - Ok(TranscriptionStreamPart::TranscriptDelta { delta, .. }) => { - assert_eq!(delta, " world"); - } - other => panic!("expected delta ' world', got {other:?}"), - } - match &parts[3] { - Ok(TranscriptionStreamPart::TranscriptFinal { id, text, .. }) => { - assert_eq!(id.as_deref(), Some("item-1")); - assert_eq!(text, "Hello world"); - } - other => panic!("expected final, got {other:?}"), - } - match &parts[4] { - Ok(TranscriptionStreamPart::Finish { text, segments, .. }) => { - assert_eq!(text, "Hello world"); - assert!(segments.is_empty()); - } - other => panic!("expected finish, got {other:?}"), - } -} - -/// The request snapshot records the exact session.update wire shape. -#[tokio::test] -async fn stream_request_carries_session_update() { - let server = RealtimeServer::start().await; - let model = realtime_model(&server.base_url()); - - let options = stream_options(vec![AudioChunk::Binary(vec![1])], None); - let result = model - .do_stream(options) - .expect("the OpenAI transcription model streams") - .await - .unwrap(); - let body = result - .request - .as_ref() - .and_then(|r| r.body.as_ref()) - .expect("request body snapshot"); - let v: serde_json::Value = serde_json::from_str(body).unwrap(); - // B1 regression guard: model in audio.input.transcription.model (not URL), - // turn_detection nested under audio.input, format carries the rate. - assert_eq!(v["type"], "session.update"); - assert_eq!( - v["session"]["audio"]["input"]["transcription"]["model"], - "gpt-realtime-whisper" - ); - assert_eq!( - v["session"]["audio"]["input"]["format"]["type"], - "audio/pcm" - ); - assert_eq!(v["session"]["audio"]["input"]["format"]["rate"], 24000); - assert!(v["session"]["audio"]["input"]["turn_detection"].is_null()); - drop(result); -} - -/// Binary audio chunks are base64-encoded in the append messages. -#[tokio::test] -async fn stream_audio_chunks_are_base64() { - // Server variant that asserts the base64 payload. - struct AssertBase64Server; - let _ = AssertBase64Server; - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut ws = accept_realtime(stream).await; - // session.update - let _ = ws.next().await; - // first append — verify base64 of [1,2,3] - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: serde_json::Value = serde_json::from_str(&text).unwrap(); - assert_eq!(v["type"], "input_audio_buffer.append"); - assert_eq!(v["audio"], "AQID", "binary [1,2,3] must arrive as base64"); - } - // second append — Base64 chunk passes through verbatim - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: serde_json::Value = serde_json::from_str(&text).unwrap(); - assert_eq!( - v["audio"], "cGFzcw==", - "base64 chunk must pass through as-is" - ); - } - // commit - let _ = ws.next().await; - // complete the session so the client closes cleanly - ws.send(Message::Text( - r#"{"type":"conversation.item.input_audio_transcription.completed","item_id":"i","transcript":"x"}"# - .to_string(), - )) - .await - .unwrap(); - let _ = ws.next().await; // close - }); - - let model = realtime_model(&format!("http://127.0.0.1:{port}")); - let options = stream_options( - vec![ - AudioChunk::Binary(vec![1, 2, 3]), - AudioChunk::Base64("cGFzcw==".to_string()), - ], - None, - ); - let result = model - .do_stream(options) - .expect("the OpenAI transcription model streams") - .await - .unwrap(); - let parts = collect(result).await; - // StreamStart + final + finish (no deltas in this server script). - assert_eq!(parts.len(), 3, "parts: {parts:?}"); -} - -/// Abort mid-session surfaces `AiMuxError::Aborted`. -#[tokio::test] -async fn stream_abort_mid_session() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let abort = AbortSignal::new(); - let abort_clone = abort.clone(); - - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut ws = accept_realtime(stream).await; - let _ = ws.next().await; // session.update - // Hold the connection open (drain client messages without acting) so - // the abort — not a server-side disconnect — is what ends the session. - while let Some(_msg) = ws.next().await { - tokio::time::sleep(Duration::from_millis(10)).await; - } - }); - - let model = realtime_model(&format!("http://127.0.0.1:{port}")); - let options = stream_options(vec![AudioChunk::Binary(vec![1])], Some(abort_clone)); - - let result = model - .do_stream(options) - .expect("the OpenAI transcription model streams") - .await - .unwrap(); - // Abort shortly after the stream starts. - tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(100)).await; - abort.abort(); - }); - let parts = collect(result).await; - assert!( - parts - .iter() - .any(|p| matches!(p, Err(AiMuxError::Aborted(_)))), - "expected Aborted in parts: {parts:?}" - ); -} - -/// Non-realtime models are rejected from do_stream (inverse gating). -#[tokio::test] -async fn stream_rejects_non_realtime_models() { - let provider = openai_at("http://127.0.0.1:1", "k"); - let model = provider.transcription("whisper-1"); - let err = model - .do_stream(stream_options(vec![], None)) - .expect("the OpenAI transcription model streams") - .await - .unwrap_err(); - match err { - AiMuxError::UnsupportedFunctionality(msg) => { - assert!(msg.contains("whisper-1"), "got: {msg}"); - } - other => panic!("expected UnsupportedFunctionality, got {other:?}"), - } -} - -/// A closed port (connection refused) — `do_stream` itself must fail with a -/// clear connect error, not an in-stream error part (#118). -#[tokio::test] -async fn stream_connect_failure_errors() { - // Port 1 is never listening on loopback: TCP refuses immediately. - let provider = openai_at("http://127.0.0.1:1".to_string(), "k"); - let model = provider.transcription("gpt-realtime-whisper"); - - let err = model - .do_stream(stream_options(vec![AudioChunk::Binary(vec![1])], None)) - .expect("the OpenAI transcription model streams") - .await - .unwrap_err(); - match &err { - AiMuxError::ApiCall(d) => { - assert!( - d.message.contains("websocket connect failed"), - "connect failure must name the phase, got: {}", - d.message - ); - } - other => panic!("expected ApiCall connect error, got {other:?}"), - } -} - -/// Abort while the WS handshake is still in flight — `Aborted`, not a -/// timeout and not an IO error (#118). -#[tokio::test] -async fn stream_abort_during_connect() { - // A black-hole server: accepts TCP but never completes the WS handshake, - // so `connect_async` pends and only the abort can end the connect phase. - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - // Hold the socket open without writing the 101 response. - let _hold = stream; - std::future::pending::<()>().await; - }); - - let abort = AbortSignal::new(); - let abort_clone = abort.clone(); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(150)).await; - abort.abort(); - }); - - let model = realtime_model(&format!("http://127.0.0.1:{port}")); - let err = model - .do_stream(stream_options( - vec![AudioChunk::Binary(vec![1])], - Some(abort_clone), - )) - .expect("the OpenAI transcription model streams") - .await - .unwrap_err(); - // Abort semantics exactly: distinct from timeout and from IO failure. - assert!( - matches!(err, AiMuxError::Aborted(_)), - "connect-phase abort must surface Aborted, got {err:?}" - ); - assert!(!matches!(&err, AiMuxError::Timeout(_))); - assert!(!matches!(&err, AiMuxError::ApiCall(_))); -} - -/// `first_chunk_ms` doubles as the connect budget: against a black-hole -/// server the CONNECT phase itself must time out with its own message — -/// distinguishable from both the first-EVENT timeout and a connect IO -/// failure (#118). -#[tokio::test] -async fn stream_connect_timeout_fires() { - // Accepts TCP but never answers the WS handshake — connect pends. - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let _hold = stream; - std::future::pending::<()>().await; - }); - - let provider = openai_at(format!("http://127.0.0.1:{port}"), "k"); - let model = provider.transcription("gpt-realtime-whisper"); - - let options = TranscriptionStreamOptions { - audio: Box::pin(futures::stream::iter(vec![AudioChunk::Binary(vec![1])])), - input_audio_format: InputAudioFormat { - format_type: "audio/pcm".to_string(), - rate: None, - }, - provider_options: None, - abort_signal: None, - headers: None, - include_raw_chunks: false, - timeout: Some(aimux_core::options::TimeoutConfiguration { - first_chunk_ms: Some(300), - total_ms: None, - step_ms: None, - chunk_ms: None, - }), - }; - let err = model - .do_stream(options) - .expect("the OpenAI transcription model streams") - .await - .unwrap_err(); - match &err { - AiMuxError::Timeout(msg) => { - assert_eq!( - msg, "websocket connect timed out", - "connect timeout must carry its own message" - ); - assert!( - !msg.contains("first websocket event"), - "must not be confused with the first-event timeout" - ); - } - other => panic!("expected connect Timeout, got {other:?}"), - } -} - -/// A silent server (accepts + reads session.update, then goes quiet) — the -/// first-chunk timeout must fire (B1/timer coverage). -#[tokio::test] -async fn stream_first_chunk_timeout_fires() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut ws = accept_realtime(stream).await; - // Read session.update, then send NOTHING but KEEP the socket alive - // (drain client appends) well past the client's 500ms first-chunk - // deadline — the timeout must fire before any disconnect could. - let _ = ws.next().await; - while let Ok(Some(_)) = tokio::time::timeout(Duration::from_secs(2), ws.next()).await {} - }); - - let provider = openai_at(format!("http://127.0.0.1:{port}"), "k"); - let model = provider.transcription("gpt-realtime-whisper"); - - let options = TranscriptionStreamOptions { - audio: Box::pin(futures::stream::iter(vec![AudioChunk::Binary(vec![1])])), - input_audio_format: InputAudioFormat { - format_type: "audio/pcm".to_string(), - rate: None, - }, - provider_options: None, - abort_signal: None, - headers: None, - include_raw_chunks: false, - timeout: Some(aimux_core::options::TimeoutConfiguration { - // first_chunk covers connect + session ack; 500ms is generous on - // loopback but far below the server's 2s hold. - first_chunk_ms: Some(500), - total_ms: None, - step_ms: None, - chunk_ms: None, - }), - }; - let result = model - .do_stream(options) - .expect("the OpenAI transcription model streams") - .await - .unwrap(); - let parts = collect(result).await; - let timed_out = parts - .iter() - .any(|p| matches!(p, Err(AiMuxError::Timeout(_)))); - assert!( - timed_out, - "expected a Timeout error against a silent server; got: {parts:?}" - ); -} - -/// partial: `should error the stream with the server message on error events`; full port in src/openai/tests/transcription/transcription_model.rs. -/// The server emits a realtime `error` event — the provider message must -/// propagate verbatim and readable (not wrapped in transport noise), and the -/// session must terminate there (#118). -#[tokio::test] -async fn stream_server_error_event_propagates() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut ws = accept_realtime(stream).await; - let _ = ws.next().await; // session.update - ws.send(Message::Text( - r#"{"type":"error","error":{"message":"insufficient quota for realtime transcription","type":"insufficient_quota"}}"# - .to_string(), - )) - .await - .unwrap(); - // The client closes(1000) after surfacing the error. - let _ = ws.next().await; - }); - - let model = realtime_model(&format!("http://127.0.0.1:{port}")); - let result = model - .do_stream(stream_options(vec![AudioChunk::Binary(vec![1])], None)) - .expect("the OpenAI transcription model streams") - .await - .unwrap(); - let parts = collect(result).await; - - // StreamStart then the terminal error — nothing after it. - assert_eq!(parts.len(), 2, "parts: {parts:?}"); - assert!(matches!( - &parts[0], - Ok(TranscriptionStreamPart::StreamStart { .. }) - )); - match &parts[1] { - Err(AiMuxError::ApiCall(d)) => { - assert_eq!( - d.message, "insufficient quota for realtime transcription", - "the provider's own message must propagate verbatim" - ); - assert!( - !d.message.contains("websocket"), - "must not be wrapped in transport noise: {}", - d.message - ); - } - other => panic!("expected ApiCall from server error event, got {other:?}"), - } -} - -/// The server closes the socket (close frame with code + reason) after the -/// handshake — the client must surface peer-closed semantics carrying the -/// code/reason, not a bare EOF or a hang (#118). -#[tokio::test] -async fn stream_peer_close_before_completion() { - use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; - use tokio_tungstenite::tungstenite::protocol::frame::{CloseFrame as WsCloseFrame, Frame}; - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut ws = accept_realtime(stream).await; - let _ = ws.next().await; // session.update - // Server-initiated abnormal close: code 1011 + a human reason. - let frame = WsCloseFrame { - code: CloseCode::Error, - reason: std::borrow::Cow::Borrowed("server overloaded"), - }; - ws.send(Message::Frame(Frame::close(Some(frame)))) - .await - .unwrap(); - // Drain until the client side tears down. - let _ = ws.next().await; - }); - - let model = realtime_model(&format!("http://127.0.0.1:{port}")); - let result = model - .do_stream(stream_options(vec![AudioChunk::Binary(vec![1])], None)) - .expect("the OpenAI transcription model streams") - .await - .unwrap(); - let parts = collect(result).await; - - assert_eq!(parts.len(), 2, "parts: {parts:?}"); - match &parts[1] { - Err(AiMuxError::ApiCall(d)) => { - assert!( - d.message.contains("websocket closed by peer"), - "peer close must be identifiable, got: {}", - d.message - ); - assert!( - d.message.contains("1011") && d.message.contains("server overloaded"), - "close code and reason must be carried: {}", - d.message - ); - } - other => panic!("expected ApiCall peer-closed error, got {other:?}"), - } -} - -/// chunk-idle timeout: the server delivers one event, then goes silent while -/// holding the socket open — `chunk_ms` must trip with its own message, -/// distinct from the total timeout (#118). -#[tokio::test] -async fn stream_chunk_idle_timeout_fires() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut ws = accept_realtime(stream).await; - let _ = ws.next().await; // session.update - // One event so the stream is established and alive… - ws.send(Message::Text(r#"{"type":"session.created"}"#.to_string())) - .await - .unwrap(); - // …then total silence while KEEPING the socket open (drain client - // messages for 2s, far past the 400ms idle window) — the idle timer, - // not a disconnect, must end the session. - while let Ok(Some(_)) = tokio::time::timeout(Duration::from_secs(2), ws.next()).await {} - }); - - let provider = openai_at(format!("http://127.0.0.1:{port}"), "k"); - let model = provider.transcription("gpt-realtime-whisper"); - - let options = TranscriptionStreamOptions { - audio: Box::pin(futures::stream::iter(vec![AudioChunk::Binary(vec![1])])), - input_audio_format: InputAudioFormat { - format_type: "audio/pcm".to_string(), - rate: None, - }, - provider_options: None, - abort_signal: None, - headers: None, - include_raw_chunks: false, - timeout: Some(aimux_core::options::TimeoutConfiguration { - first_chunk_ms: None, - total_ms: None, - step_ms: None, - chunk_ms: Some(400), - }), - }; - let result = model - .do_stream(options) - .expect("the OpenAI transcription model streams") - .await - .unwrap(); - let parts = collect(result).await; - - assert_eq!(parts.len(), 2, "parts: {parts:?}"); - match &parts[1] { - Err(AiMuxError::Timeout(msg)) => { - assert_eq!( - msg, "websocket chunk idle timeout", - "chunk-idle must fire with its own message" - ); - assert!( - !msg.contains("total"), - "must not be confused with the total timeout" - ); - } - other => panic!("expected chunk-idle Timeout, got {other:?}"), - } -} - -/// total timeout: the server keeps events flowing (so chunk-idle never -/// trips) — the hard connection deadline must still fire, with a message -/// distinct from chunk-idle (#118). -#[tokio::test] -async fn stream_total_timeout_fires() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut ws = accept_realtime(stream).await; - let _ = ws.next().await; // session.update - // Keep the stream visibly alive: a delta every 100ms for 2s — well - // inside the 300ms chunk window, well past the 700ms total deadline. - for i in 0..20 { - tokio::time::sleep(Duration::from_millis(100)).await; - let event = format!( - r#"{{"type":"conversation.item.input_audio_transcription.delta","item_id":"i","delta":"{i}"}}"# - ); - if ws.send(Message::Text(event)).await.is_err() { - break; // client tore down after the total timeout - } - } - }); - - let provider = openai_at(format!("http://127.0.0.1:{port}"), "k"); - let model = provider.transcription("gpt-realtime-whisper"); - - let options = TranscriptionStreamOptions { - audio: Box::pin(futures::stream::iter(vec![AudioChunk::Binary(vec![1])])), - input_audio_format: InputAudioFormat { - format_type: "audio/pcm".to_string(), - rate: None, - }, - provider_options: None, - abort_signal: None, - headers: None, - include_raw_chunks: false, - timeout: Some(aimux_core::options::TimeoutConfiguration { - first_chunk_ms: None, - total_ms: Some(700), - step_ms: None, - chunk_ms: Some(300), - }), - }; - let result = model - .do_stream(options) - .expect("the OpenAI transcription model streams") - .await - .unwrap(); - let parts = collect(result).await; - - // StreamStart + several deltas (alive stream) + the terminal total error. - let deltas = parts - .iter() - .filter(|p| matches!(p, Ok(TranscriptionStreamPart::TranscriptDelta { .. }))) - .count(); - assert!( - deltas >= 2, - "stream must be alive pre-timeout; parts: {parts:?}" - ); - let last = parts.last().expect("must end with an error part"); - match last { - Err(AiMuxError::Timeout(msg)) => { - assert_eq!( - msg, "websocket total timeout", - "total deadline must fire with its own message" - ); - assert!( - !msg.contains("chunk"), - "must not be confused with the chunk-idle timeout" - ); - } - other => panic!("expected total Timeout as the last part, got {other:?}"), - } -} diff --git a/aimux-providers/tests/openai_transcription_test.rs b/aimux-providers/tests/openai_transcription_test.rs deleted file mode 100644 index 2964a56e7..000000000 --- a/aimux-providers/tests/openai_transcription_test.rs +++ /dev/null @@ -1,575 +0,0 @@ -//! Rust translation of the OpenAI transcription (STT) model tests. -//! -//! Source: `reference/ai/packages/openai/src/transcription/openai-transcription-model.test.ts` -//! -//! Each test spins up a `wiremock` mock server, configures a JSON response, -//! creates an `OpenAITranscriptionModel` pointing at the mock, calls -//! `do_generate`, and asserts on the multipart request body / headers / result. -//! -//! The TS tests inject a custom `currentDate` via `_internal` for timestamp -//! assertions. The Rust model always uses `Utc::now()`; the timestamp tests -//! therefore verify that a timestamp is present and that `model_id` matches, -//! rather than asserting an exact timestamp value. -//! -//! The TS `doStream` tests are translated in -//! `openai_transcription_stream_test.rs` (RFC-0028: local WebSocket server) -//! and `openai_realtime_ws_replay_test.rs` (offline, through the provider's -//! `web_socket` connector). - -use std::collections::HashMap; - -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::error::AiMuxError; -use aimux_core::provider::Provider; -use aimux_core::transcription_model::{AudioInput, TranscriptionCallOptions}; -use aimux_providers::{OpenAIProviderSettings, create_openai}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -/// Mock audio data (same as TS `audioData` — just some bytes). -fn mock_audio_data() -> Vec { - vec![1u8, 2, 3, 4, 5] -} - -/// Build `TranscriptionCallOptions` with binary audio and a media type. -fn transcription_options(audio: Vec, media_type: &str) -> TranscriptionCallOptions { - TranscriptionCallOptions::new(AudioInput::Binary(audio), media_type) -} - -/// Mount a mock JSON transcription response on the server at -/// `/audio/transcriptions`. -async fn mock_json_response(server: &MockServer, body: &Value) { - Mock::given(method("POST")) - .and(path("/audio/transcriptions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/json") - .set_body_json(body.clone()), - ) - .mount(server) - .await; -} - -/// Mount a mock JSON transcription response with extra response headers. -async fn mock_json_response_with_headers( - server: &MockServer, - body: &Value, - headers: &[(&str, &str)], -) { - let mut template = ResponseTemplate::new(200) - .insert_header("content-type", "application/json") - .set_body_json(body.clone()); - for (k, v) in headers { - template = template.insert_header(*k, *v); - } - Mock::given(method("POST")) - .and(path("/audio/transcriptions")) - .respond_with(template) - .mount(server) - .await; -} - -/// Parse a multipart/form-data body into a map of field name → value (string). -/// -/// Non-file fields are decoded as UTF-8 strings. File fields are returned as -/// the raw bytes converted to a string (for assertion purposes). -fn parse_multipart_form(body: &[u8]) -> HashMap { - let body_str = String::from_utf8_lossy(body); - let mut form = HashMap::new(); - - // The first line is the boundary with -- prefix. - // Find the boundary. - let boundary = body_str - .lines() - .next() - .unwrap_or("") - .trim_start_matches('-'); - if boundary.is_empty() { - return form; - } - - // Split on the boundary. - for part in body_str.split(boundary) { - let part = part.trim_matches(|c| c == '\r' || c == '\n' || c == '-'); - if part.is_empty() || part == "--" { - continue; - } - - // Each part has headers and a body separated by \r\n\r\n. - if let Some(header_end) = part.find("\r\n\r\n") { - let headers = &part[..header_end]; - let value = &part[header_end + 4..]; - - // Extract the field name from Content-Disposition. - if let Some(name_start) = headers.find("name=\"") { - let name_start = name_start + 6; - if let Some(name_end) = headers[name_start..].find('"') { - let name = &headers[name_start..name_start + name_end]; - form.insert(name.to_string(), value.trim_end_matches('\r').to_string()); - } - } - } - } - - form -} - -/// The full OpenAI transcription fixture response (matches the TS fixture). -fn fixture_response() -> Value { - json!({ - "task": "transcribe", - "language": "english", - "duration": 36.709999084472656, - "text": "Galileo was an American robotic space program that studied the planet Jupiter and its moons, as well as several other solar system bodies.", - "words": [ - {"word": "Galileo", "start": 0.0, "end": 0.6600000262260437}, - {"word": "was", "start": 0.6600000262260437, "end": 0.8999999761581421}, - {"word": "an", "start": 0.8999999761581421, "end": 1.1399999856948853}, - {"word": "American", "start": 1.1399999856948853, "end": 1.5}, - {"word": "robotic", "start": 1.5, "end": 2.0199999809265137} - ], - "usage": {"type": "duration", "seconds": 37} - }) -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate -// (openai-transcription-model.test.ts → describe('doGenerate')) -// ════════════════════════════════════════════════════════════════════════════ - -mod do_generate { - use super::*; - - /// upstream: @ai-sdk/openai src/transcription/openai-transcription-model.test.ts › doGenerate › should reject gpt-realtime-whisper for non-streaming transcription - #[tokio::test] - async fn should_reject_gpt_realtime_whisper_for_non_streaming() { - let server = MockServer::start().await; - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider - .transcription_model("gpt-realtime-whisper") - .unwrap() - .unwrap(); - - let result = model - .do_generate(&transcription_options(mock_audio_data(), "audio/wav")) - .await; - - assert!(result.is_err()); - match result.unwrap_err() { - AiMuxError::UnsupportedFunctionality(msg) => { - assert!(msg.contains("gpt-realtime-whisper")); - } - e => panic!("expected Unsupported error, got: {e:?}"), - } - } - - /// upstream: @ai-sdk/openai src/transcription/openai-transcription-model.test.ts › doGenerate › should pass the model - #[tokio::test] - async fn should_pass_the_model() { - let server = MockServer::start().await; - mock_json_response(&server, &fixture_response()).await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.transcription_model("whisper-1").unwrap().unwrap(); - - model - .do_generate(&transcription_options(mock_audio_data(), "audio/wav")) - .await - .unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let form = parse_multipart_form(&requests[0].body); - assert_eq!(form.get("model").unwrap(), "whisper-1"); - } - - /// upstream: @ai-sdk/openai src/transcription/openai-transcription-model.test.ts › doGenerate › should default whisper-1 to verbose_json response format - #[tokio::test] - async fn should_default_whisper_1_to_verbose_json() { - let server = MockServer::start().await; - mock_json_response(&server, &fixture_response()).await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.transcription_model("whisper-1").unwrap().unwrap(); - - let result = model - .do_generate(&transcription_options(mock_audio_data(), "audio/wav")) - .await - .unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let form = parse_multipart_form(&requests[0].body); - assert_eq!(form.get("model").unwrap(), "whisper-1"); - assert_eq!(form.get("response_format").unwrap(), "verbose_json"); - assert_eq!(result.duration_in_seconds, Some(36.709999084472656)); - } - - /// upstream: @ai-sdk/openai src/transcription/openai-transcription-model.test.ts › doGenerate › should extract the transcription text - #[tokio::test] - async fn should_extract_the_transcription_text() { - let server = MockServer::start().await; - mock_json_response(&server, &fixture_response()).await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.transcription_model("whisper-1").unwrap().unwrap(); - - let result = model - .do_generate(&transcription_options(mock_audio_data(), "audio/wav")) - .await - .unwrap(); - - assert_eq!( - result.text, - "Galileo was an American robotic space program that studied the planet Jupiter and its moons, as well as several other solar system bodies." - ); - } - - /// upstream: @ai-sdk/openai src/transcription/openai-transcription-model.test.ts › doGenerate › should include response data with timestamp, modelId and headers - #[tokio::test] - async fn should_include_response_data() { - let server = MockServer::start().await; - mock_json_response_with_headers( - &server, - &fixture_response(), - &[ - ("x-request-id", "test-request-id"), - ("x-ratelimit-remaining", "123"), - ], - ) - .await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.transcription_model("whisper-1").unwrap().unwrap(); - - let result = model - .do_generate(&transcription_options(mock_audio_data(), "audio/wav")) - .await - .unwrap(); - - assert!(result.response.timestamp.is_some()); - assert_eq!(result.response.model_id, Some("whisper-1".to_string())); - let headers = result.response.headers.as_ref().unwrap(); - assert_eq!(headers.get("content-type").unwrap(), "application/json"); - assert_eq!(headers.get("x-request-id").unwrap(), "test-request-id"); - assert_eq!(headers.get("x-ratelimit-remaining").unwrap(), "123"); - } - - /// upstream: @ai-sdk/openai src/transcription/openai-transcription-model.test.ts › doGenerate › should use real date when no custom date provider is specified - #[tokio::test] - async fn should_use_real_date() { - let server = MockServer::start().await; - mock_json_response(&server, &fixture_response()).await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.transcription_model("whisper-1").unwrap().unwrap(); - - let result = model - .do_generate(&transcription_options(mock_audio_data(), "audio/wav")) - .await - .unwrap(); - - assert!(result.response.timestamp.is_some()); - assert_eq!(result.response.model_id, Some("whisper-1".to_string())); - } - - /// upstream: @ai-sdk/openai src/transcription/openai-transcription-model.test.ts › doGenerate › should handle segments with missing optional fields - #[tokio::test] - async fn should_handle_segments_with_missing_optional_fields() { - let server = MockServer::start().await; - mock_json_response( - &server, - &json!({ - "task": "transcribe", - "text": "Test", - "segments": [ - { - "id": 0, "seek": 0, "start": 0.0, "end": 1.0, - "text": "Test", "tokens": [1234], - "temperature": 0.0, "avg_logprob": -0.5, - "compression_ratio": 1.0, "no_speech_prob": 0.1 - } - ], - "_request_id": "req_1234" - }), - ) - .await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.transcription_model("whisper-1").unwrap().unwrap(); - - let result = model - .do_generate(&transcription_options(mock_audio_data(), "audio/wav")) - .await - .unwrap(); - - assert_eq!(result.segments.len(), 1); - assert_eq!(result.segments[0].text, "Test"); - assert_eq!(result.segments[0].start_second, 0.0); - assert_eq!(result.segments[0].end_second, 1.0); - assert_eq!(result.language, None); - assert_eq!(result.duration_in_seconds, None); - } -} - -/// partial: `should pass headers`; full port in src/openai/tests/transcription/transcription_model.rs. -#[tokio::test] -async fn should_pass_headers() { - let server = MockServer::start().await; - mock_json_response(&server, &fixture_response()).await; - - let mut provider_headers = HashMap::new(); - provider_headers.insert( - "Custom-Provider-Header".to_string(), - "provider-header-value".to_string(), - ); - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - organization: Some("test-organization".into()), - project: Some("test-project".into()), - headers: Some( - provider_headers - .into_iter() - .map(|(k, v)| (k, Some(v))) - .collect(), - ), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.transcription_model("whisper-1").unwrap().unwrap(); - - let mut options = transcription_options(mock_audio_data(), "audio/wav"); - let mut request_headers = HashMap::new(); - request_headers.insert( - "Custom-Request-Header".to_string(), - "request-header-value".to_string(), - ); - options.headers = Some(request_headers); - - model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let h = &requests[0].headers; - assert_eq!(h.get("authorization").unwrap(), "Bearer test-api-key"); - assert_eq!(h.get("openai-organization").unwrap(), "test-organization"); - assert_eq!(h.get("openai-project").unwrap(), "test-project"); - assert_eq!( - h.get("custom-provider-header").unwrap(), - "provider-header-value" - ); - assert_eq!( - h.get("custom-request-header").unwrap(), - "request-header-value" - ); -} - -/// partial: `should work when no words, language, or duration are returned`; full port in src/openai/tests/transcription/transcription_model.rs. -#[tokio::test] -async fn should_work_without_optional_fields() { - let server = MockServer::start().await; - mock_json_response( - &server, - &json!({ - "task": "transcribe", - "text": "Hello from the Vercel AI SDK!", - "_request_id": "req_1234" - }), - ) - .await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.transcription_model("whisper-1").unwrap().unwrap(); - - let result = model - .do_generate(&transcription_options(mock_audio_data(), "audio/wav")) - .await - .unwrap(); - - assert_eq!(result.duration_in_seconds, None); - assert_eq!(result.language, None); - assert!(result.segments.is_empty()); - assert_eq!(result.text, "Hello from the Vercel AI SDK!"); - assert!(result.warnings.is_empty()); - assert!(result.response.timestamp.is_some()); - assert_eq!(result.response.model_id, Some("whisper-1".to_string())); - let headers = result.response.headers.as_ref().unwrap(); - assert_eq!(headers.get("content-type").unwrap(), "application/json"); -} - -/// partial: `should handle empty segments array`; full port in src/openai/tests/transcription/transcription_model.rs. -#[tokio::test] -async fn should_handle_empty_segments() { - let server = MockServer::start().await; - mock_json_response( - &server, - &json!({ - "task": "transcribe", - "text": "Hello world", - "segments": [], - "language": "english", - "duration": 2.0, - "_request_id": "req_1234" - }), - ) - .await; - - let settings = OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }; - let provider = create_openai(settings).unwrap(); - let model = provider.transcription_model("whisper-1").unwrap().unwrap(); - - let result = model - .do_generate(&transcription_options(mock_audio_data(), "audio/wav")) - .await - .unwrap(); - - assert!(result.segments.is_empty()); - assert_eq!(result.text, "Hello world"); -} - -/// The diarized fixture response (`__fixtures__/openai-diarized-transcription.json`). -fn diarized_response() -> Value { - json!({ - "task": "transcribe", - "duration": 3.2, - "text": "Hello from Alice. Hello from Bob.", - "segments": [ - {"type": "transcript.text.segment", "id": "seg_001", "start": 0, "end": 1.5, "text": "Hello from Alice.", "speaker": "A"}, - {"type": "transcript.text.segment", "id": "seg_002", "start": 1.5, "end": 3.2, "text": "Hello from Bob.", "speaker": "B"} - ] - }) -} - -/// Sends one transcription with `provider_options` and returns the form. -async fn diarize_form(provider_options: Option) -> HashMap { - let server = MockServer::start().await; - mock_json_response(&server, &diarized_response()).await; - let provider = create_openai(OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }) - .unwrap(); - let model = provider - .transcription_model("gpt-4o-transcribe-diarize") - .unwrap() - .unwrap(); - let mut options = transcription_options(mock_audio_data(), "audio/mpeg"); - options.provider_options = provider_options - .map(|value| serde_json::from_value(value).expect("provider options are an object")); - model.do_generate(&options).await.unwrap(); - let requests = server.received_requests().await.expect("requests recorded"); - parse_multipart_form(&requests[0].body) -} - -/// `gpt-4o-transcribe-diarize` defaults to `diarized_json` with `auto` -/// chunking; `responseFormat` and `chunkingStrategy` (`"auto"` or a server-VAD -/// object sent as snake_case JSON) are passed through -/// (`transcription/openai-transcription-model.ts:196-259`). -#[tokio::test] -async fn diarization_response_format_and_chunking_strategy() { - let form = diarize_form(None).await; - assert_eq!(form["response_format"], "diarized_json"); - assert_eq!(form["chunking_strategy"], "auto"); - assert!(!form.contains_key("temperature")); - - let form = diarize_form(Some(json!({ - "openai": {"responseFormat": "json", "chunkingStrategy": "auto"} - }))) - .await; - assert_eq!(form["response_format"], "json"); - assert_eq!(form["chunking_strategy"], "auto"); - - let form = diarize_form(Some(json!({ - "openai": {"chunkingStrategy": { - "type": "server_vad", "threshold": 0.7, "prefixPaddingMs": 400, "silenceDurationMs": 300 - }} - }))) - .await; - assert_eq!(form["response_format"], "diarized_json"); - assert_eq!( - form["chunking_strategy"], - r#"{"type":"server_vad","threshold":0.7,"prefix_padding_ms":400,"silence_duration_ms":300}"# - ); -} - -/// Diarized segments come back as the usual segments plus -/// `providerMetadata.openai.segments` with each speaker -/// (`transcription/openai-transcription-model.ts:315-353`). -#[tokio::test] -async fn diarized_segments_carry_the_speaker_in_provider_metadata() { - let server = MockServer::start().await; - mock_json_response(&server, &diarized_response()).await; - let provider = create_openai(OpenAIProviderSettings { - api_key: Some("test-api-key".into()), - base_url: Some(server.uri()), - ..Default::default() - }) - .unwrap(); - let model = provider - .transcription_model("gpt-4o-transcribe-diarize") - .unwrap() - .unwrap(); - let result = model - .do_generate(&transcription_options(mock_audio_data(), "audio/mpeg")) - .await - .unwrap(); - - assert_eq!(result.segments.len(), 2); - assert_eq!(result.segments[1].text, "Hello from Bob."); - assert_eq!( - serde_json::to_value(result.provider_metadata).unwrap(), - json!({"openai": {"segments": [ - {"text": "Hello from Alice.", "startSecond": 0.0, "endSecond": 1.5, "speaker": "A"}, - {"text": "Hello from Bob.", "startSecond": 1.5, "endSecond": 3.2, "speaker": "B"} - ]}}) - ); -} diff --git a/aimux-providers/tests/openai_upstream_e2e_test.rs b/aimux-providers/tests/openai_upstream_e2e_test.rs deleted file mode 100644 index caf253a1a..000000000 --- a/aimux-providers/tests/openai_upstream_e2e_test.rs +++ /dev/null @@ -1,1901 +0,0 @@ -//! Small end-to-end subset of the pinned upstream tests, using an in-process transport. -#[path = "common/mock_fetch.rs"] -mod mock_fetch; - -use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; -use aimux_core::error::AiMuxError; -use aimux_core::image_model::{ - ImageCallOptions, ImageFile, ImageFileData, 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::speech_model::{AudioData, SpeechCallOptions, SpeechModel}; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::{FunctionTool, Tool}; -use aimux_core::transcription_model::{AudioInput, TranscriptionCallOptions, TranscriptionModel}; -use aimux_core::types::FinishReasonUnified; -use aimux_providers::openai::tools::{FileSearchArgs, file_search}; -use aimux_providers::openai::{OpenAIProvider, OpenAIProviderSettings, create_openai}; -use futures::StreamExt; -use mock_fetch::{Canned, MockFetch}; -use serde_json::{Value, json}; - -fn provider(fetch: &std::sync::Arc) -> OpenAIProvider { - create_openai(OpenAIProviderSettings { - base_url: Some("https://example.test/v1".into()), - api_key: Some("test-key".into()), - fetch: Some(fetch.transport()), - ..Default::default() - }) - .unwrap() -} -fn options() -> CallOptions { - CallOptions::new(vec![LanguageModelMessage::user_text("Hello")]) -} -fn chat_response() -> Value { - json!({"id":"chatcmpl-test","model":"test-model","created":1711115037,"choices":[{"index":0,"message":{"role":"assistant","content":"Hello, World!"},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"total_tokens":34,"completion_tokens":30}}) -} -fn responses_response() -> Value { - json!({"id":"resp_test","created_at":1741257730,"output":[{"id":"msg_test","type":"message","content":[{"type":"output_text","text":"answer text","annotations":[]}]}],"usage":{"input_tokens":345,"input_tokens_details":{"cached_tokens":234,"cache_write_tokens":45,"future_input_detail":{"tokens":7}},"output_tokens":538,"output_tokens_details":{"reasoning_tokens":123,"future_output_detail":["preserved"]},"total_tokens":572,"future_usage_field":{"value":true}}}) -} -fn sse(events: Vec) -> Canned { - Canned { - status: 200, - headers: vec![("content-type".into(), "text/event-stream".into())], - body: events - .into_iter() - .map(|event| format!("data: {event}\n\n")) - .collect::() - .into_bytes(), - } -} -fn assert_request(fetch: &MockFetch, endpoint: &str, expected: Value) { - let seen = fetch.seen(); - assert_eq!(seen.len(), 1); - assert_eq!(seen[0].method, "POST"); - assert_eq!(seen[0].url, format!("https://example.test/v1{endpoint}")); - assert_eq!(seen[0].headers["authorization"], "Bearer test-key"); - assert_eq!(seen[0].json_body(), expected); -} - -/// partial: `should extract text response`; full port in src/openai/tests/chat/chat_language_model.rs. -/// partial: `should send request body`; full port in src/openai/tests/chat/chat_language_model.rs. -/// partial: `should extract usage`; full port in src/openai/tests/chat/chat_language_model.rs. -#[tokio::test] -async fn chat_generate() { - let fetch = MockFetch::new(vec![Canned::json(&chat_response())]); - let result = provider(&fetch) - .chat("test-model") - .do_generate(&options()) - .await - .unwrap(); - let body = json!({"model":"test-model","messages":[{"role":"user","content":"Hello"}]}); - assert_request(&fetch, "/chat/completions", body.clone()); - assert_eq!(result.request.unwrap().body, Some(body)); - assert!( - matches!(&result.content[..],[GenerateContent::Text{text,..}] if text=="Hello, World!") - ); - assert_eq!(result.usage.input_tokens.total, Some(4)); - assert_eq!(result.usage.input_tokens.no_cache, Some(4)); - assert_eq!(result.usage.output_tokens.text, Some(30)); - assert_eq!( - result.usage.raw, - chat_response()["usage"].as_object().cloned() - ); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!( - result.response.unwrap().id.as_deref(), - Some("chatcmpl-test") - ); -} - -/// Chat drops the legacy `promptCacheRetention` for models that take -/// `promptCacheOptions` and names them like the AI SDK does -/// (`chat/openai-chat-language-model.ts:235`). -#[tokio::test] -async fn chat_prompt_cache_retention_warning_names_gpt_6_models() { - let fetch = MockFetch::new(vec![Canned::json(&chat_response())]); - let mut opts = options(); - opts.provider_options = Some( - provider_namespace("openai", json!({"promptCacheRetention":"24h"})) - .expect("provider metadata must be an object"), - ); - let result = provider(&fetch) - .chat("gpt-6-astra") - .do_generate(&opts) - .await - .unwrap(); - assert_eq!( - fetch.seen()[0].json_body().get("prompt_cache_retention"), - None - ); - assert_eq!( - result.warnings, - vec![aimux_core::types::Warning::Unsupported { - feature: "promptCacheRetention".into(), - details: Some( - "promptCacheRetention is not supported by GPT-6 and later models; use promptCacheOptions instead".into() - ), - }] - ); -} - -/// A singleton `allOf: [{ $ref }]` (Zod 4's recursive reference) becomes a -/// direct `$ref`, and is expanded from its definition at the schema root, -/// like the AI SDK (`normalize-openai-json-schema.ts:176-208`). -#[tokio::test] -async fn chat_json_schema_unwraps_singleton_reference_all_of() { - let fetch = MockFetch::new(vec![Canned::json(&chat_response())]); - let person = json!({ - "type": "object", - "properties": { - "firstName": {"type": "string"}, - "relatives": {"type": "array", "items": {"allOf": [{"$ref": "#/definitions/person"}]}}, - }, - "required": ["firstName", "relatives"], - "additionalProperties": false, - }); - let mut opts = options(); - opts.response_format = Some(aimux_core::options::ResponseFormat::Json { - schema: Some(json!({ - "allOf": [{"$ref": "#/definitions/person"}], - "definitions": {"person": person}, - })), - name: None, - description: None, - }); - provider(&fetch) - .chat("gpt-4o") - .do_generate(&opts) - .await - .unwrap(); - let direct = json!({ - "type": "object", - "properties": { - "firstName": {"type": "string"}, - "relatives": {"type": "array", "items": {"$ref": "#/definitions/person"}}, - }, - "required": ["firstName", "relatives"], - "additionalProperties": false, - }); - let mut expected = direct.clone(); - expected["definitions"] = json!({"person": direct}); - assert_eq!( - fetch.seen()[0].json_body()["response_format"]["json_schema"]["schema"], - expected - ); -} - -/// partial: `should stream text deltas`; full port in src/openai/tests/chat/chat_language_model.rs. -#[tokio::test] -async fn chat_stream() { - let chunk = |delta: Value, finish: Value| json!({"id":"chatcmpl-test","model":"test-model","created":1702657020,"choices":[{"index":0,"delta":delta,"finish_reason":finish}]}); - let fetch = MockFetch::new(vec![sse(vec![ - chunk(json!({"role":"assistant","content":""}), Value::Null), - chunk(json!({"content":"Hello"}), Value::Null), - chunk(json!({"content":", "}), Value::Null), - chunk(json!({"content":"World!"}), Value::Null), - chunk(json!({}), json!("stop")), - json!({"id":"chatcmpl-test","model":"test-model","created":1702657020,"choices":[],"usage":{"prompt_tokens":17,"total_tokens":244,"completion_tokens":227}}), - ])]); - let result = provider(&fetch) - .chat("test-model") - .do_stream(&options()) - .await - .unwrap(); - let parts = result - .stream - .map(|part| part.unwrap()) - .collect::>() - .await; - assert_request( - &fetch, - "/chat/completions", - json!({"model":"test-model","messages":[{"role":"user","content":"Hello"}],"stream":true,"stream_options":{"include_usage":true}}), - ); - let text = parts - .iter() - .filter_map(|part| match part { - StreamPart::TextDelta { delta, .. } => Some(delta.as_str()), - _ => None, - }) - .collect::(); - assert_eq!(text, "Hello, World!"); - assert_eq!( - parts - .iter() - .filter(|part| matches!(part, StreamPart::TextStart { .. })) - .count(), - 1 - ); - assert_eq!( - parts - .iter() - .filter(|part| matches!(part, StreamPart::TextEnd { .. })) - .count(), - 1 - ); - assert!( - matches!(parts.last(),Some(StreamPart::Finish{finish_reason,usage,..}) if finish_reason.unified==FinishReasonUnified::Stop && usage.input_tokens.total==Some(17) && usage.output_tokens.total==Some(227)) - ); -} - -/// partial: `should parse tool results`; full port in src/openai/tests/chat/chat_language_model.rs. -#[tokio::test] -async fn chat_tool_call() { - let mut response = chat_response(); - response["choices"][0]["message"]["content"] = json!(""); - response["choices"][0]["message"]["tool_calls"] = json!([{"id":"call_test","type":"function","function":{"name":"test-tool","arguments":"{\"value\":\"Spark\"}"}}]); - let fetch = MockFetch::new(vec![Canned::json(&response)]); - let schema = json!({"type":"object","properties":{"value":{"type":"string"}},"required":["value"],"additionalProperties":false}); - let mut opts = options(); - opts.tools = Some(vec![Tool::Function(FunctionTool::new( - "test-tool", - schema.clone(), - ))]); - let result = provider(&fetch) - .chat("test-model") - .do_generate(&opts) - .await - .unwrap(); - let body = fetch.seen()[0].json_body(); - assert_eq!(body["model"], "test-model"); - assert_eq!(body["messages"], json!([{"role":"user","content":"Hello"}])); - assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); - assert_eq!(body["tools"][0]["function"]["parameters"], schema); - assert!( - matches!(&result.content[..],[GenerateContent::ToolCall(call)] if call.tool_call_id=="call_test" && call.tool_name=="test-tool" && call.input==r#"{"value":"Spark"}"#) - ); -} - -/// partial: `should stream tool call with missing type field (Azure AI Foundry / Mistral)`; full port in src/openai/tests/chat/chat_language_model.rs. -/// Upstream: @ai-sdk/openai 4.0.83 src/chat/openai-chat-language-model.ts:539-542 -/// creates the stream's tool-call tracker with `typeValidation: 'if-present'`: -/// a tool-call delta without `type` starts a call, one whose `type` is -/// present and not `function` is invalid response data. -#[tokio::test] -async fn chat_stream_tool_call_type_is_validated_if_present() { - let tool_stream = |delta: Value| { - sse(vec![ - json!({"id":"chatcmpl-test","model":"test-model","created":1702657020,"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[delta]},"finish_reason":null}]}), - json!({"id":"chatcmpl-test","model":"test-model","created":1702657020,"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}), - ]) - }; - let fetch = MockFetch::new(vec![ - tool_stream( - json!({"index":0,"id":"call_1","function":{"name":"test-tool","arguments":"{}"}}), - ), - tool_stream( - json!({"index":0,"id":"call_2","type":"custom","function":{"name":"test-tool","arguments":"{}"}}), - ), - ]); - let model = provider(&fetch).chat("test-model"); - - let untyped = model - .do_stream(&options()) - .await - .unwrap() - .stream - .collect::>() - .await; - assert!( - untyped.iter().any( - |part| matches!(part, Ok(StreamPart::ToolCall(call)) if call.tool_call_id == "call_1") - ), - "{untyped:?}" - ); - assert!(untyped.iter().all(Result::is_ok), "{untyped:?}"); - - let custom = model - .do_stream(&options()) - .await - .unwrap() - .stream - .collect::>() - .await; - assert!( - custom.iter().any(|part| matches!(part, Err(error) if error.to_string().contains("Expected 'function' type."))), - "{custom:?}" - ); - assert!( - !custom - .iter() - .any(|part| matches!(part, Ok(StreamPart::ToolCall(_)))) - ); -} - -/// partial: `should throw an api error when the first stream chunk is an error`; full port in src/openai/tests/chat/chat_language_model.rs. -/// Upstream: @ai-sdk/openai 4.0.83 src/openai-stream-error.ts:18-37, -/// :235-267 and :303-315: an error frame before any output fails the call -/// with the status `createOpenAIProviderStreamError` gives it — an HTTP -/// status in `code`, else one inferred from `code`/`type` — and -/// `insufficient_quota` is never retryable. -#[tokio::test] -async fn chat_stream_error_frame_status_is_inferred_as_upstream() { - let cases = [ - ( - json!({"message":"quota","type":"insufficient_quota","code":"insufficient_quota","param":null}), - 429, - false, - ), - ( - json!({"message":"slow down","type":"requests","code":"rate_limit_exceeded"}), - 429, - true, - ), - ( - json!({"message":"busy","type":"server_error","code":"503"}), - 503, - true, - ), - ( - json!({"message":"bad","type":"invalid_request_error","code":null}), - 400, - false, - ), - ( - json!({"message":"oops","type":"server_error","code":null}), - 500, - true, - ), - ]; - let fetch = MockFetch::new( - cases - .iter() - .map(|(error, ..)| sse(vec![json!({ "error": error })])) - .collect(), - ); - let model = provider(&fetch).chat("test-model"); - for (frame, status, retryable) in cases { - let Err(AiMuxError::ApiCall(error)) = model.do_stream(&options()).await.map(|_| ()) else { - panic!("expected an API error for {frame}"); - }; - assert_eq!(error.message, frame["message"], "{frame}"); - assert_eq!(error.status_code, Some(status), "{frame}"); - assert_eq!(error.is_retryable, retryable, "{frame}"); - } -} - -/// Upstream: @ai-sdk/openai 4.0.83 src/openai-stream-error.ts:207-217 types a -/// `response.failed` frame as `response.failed`, so its status comes from -/// the inner error's `code` alone, never from an inner `type`. -#[tokio::test] -async fn responses_failed_frame_status_ignores_the_inner_type() { - let fetch = MockFetch::new(vec![sse(vec![json!({ - "type":"response.failed","sequence_number":1, - "response":{"error":{"code":"server_error","message":"boom","type":"invalid_request_error"}} - })])]); - let Err(AiMuxError::ApiCall(error)) = provider(&fetch) - .responses("test-model") - .do_stream(&options()) - .await - .map(|_| ()) - else { - panic!("expected an API error"); - }; - assert_eq!(error.message, "boom"); - assert_eq!(error.status_code, Some(500)); - assert!(error.is_retryable); -} - -/// Upstream: @ai-sdk/openai 4.0.83 src/chat/map-openai-finish-reason.ts:13-15 -/// maps the legacy `function_call` finish reason to `tool-calls`; the raw -/// value stays `function_call`. -#[tokio::test] -async fn chat_function_call_finish_reason_is_tool_calls() { - let mut response = chat_response(); - response["choices"][0]["finish_reason"] = json!("function_call"); - let fetch = MockFetch::new(vec![Canned::json(&response)]); - let result = provider(&fetch) - .chat("test-model") - .do_generate(&options()) - .await - .unwrap(); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); - assert_eq!(result.finish_reason.raw.as_deref(), Some("function_call")); -} - -/// Upstream: @ai-sdk/openai 4.0.83 src/chat/convert-openai-chat-usage.ts:28 -/// reads cache reads only from the nested `prompt_tokens_details.cached_tokens`; -/// a top-level `cached_tokens` (Moonshot's wire format) is not a cache read and -/// only survives in `usage.raw`. -#[tokio::test] -async fn chat_cache_read_is_only_the_nested_cached_tokens() { - let usage = |usage: Value| { - let mut response = chat_response(); - response["usage"] = usage; - response - }; - let fetch = MockFetch::new(vec![ - Canned::json(&usage( - json!({"prompt_tokens":100,"completion_tokens":10,"cached_tokens":7,"prompt_tokens_details":{"cached_tokens":25}}), - )), - Canned::json(&usage( - json!({"prompt_tokens":100,"completion_tokens":10,"cached_tokens":7}), - )), - ]); - let model = provider(&fetch).chat("test-model"); - - let nested = model.do_generate(&options()).await.unwrap().usage; - assert_eq!(nested.input_tokens.cache_read, Some(25)); - assert_eq!(nested.input_tokens.no_cache, Some(75)); - assert_eq!(nested.raw.unwrap()["cached_tokens"], json!(7)); - - let top_level_only = model.do_generate(&options()).await.unwrap().usage; - assert_eq!(top_level_only.input_tokens.cache_read, Some(0)); - assert_eq!(top_level_only.input_tokens.no_cache, Some(100)); - assert_eq!(top_level_only.raw.unwrap()["cached_tokens"], json!(7)); -} - -/// openai-chat-api.ts:71-87 (response) and :159-175 (chunk) parse a logprobs -/// entry as `{token, logprob, top_logprobs: [{token, logprob}]}`, so `bytes` -/// never reaches `providerMetadata.openai.logprobs` -/// (openai-chat-language-model.ts:459-461 generate, :613-615 stream), and a -/// `null` `content` sets none. -#[tokio::test] -async fn chat_logprobs_metadata_has_no_bytes() { - let wire = json!({"content":[{"token":"Hello","logprob":-0.5,"bytes":[72,101,108,108,111], - "top_logprobs":[{"token":"Hello","logprob":-0.5,"bytes":[72,101,108,108,111]}, - {"token":"Hi","logprob":-1.5,"bytes":[72,105]}]}],"refusal":null}); - let expected = json!([{"token":"Hello","logprob":-0.5, - "top_logprobs":[{"token":"Hello","logprob":-0.5},{"token":"Hi","logprob":-1.5}]}]); - let generated_metadata = |logprobs: Value| async move { - let mut response = chat_response(); - response["choices"][0]["logprobs"] = logprobs; - let fetch = MockFetch::new(vec![Canned::json(&response)]); - let result = provider(&fetch) - .chat("test-model") - .do_generate(&options()) - .await - .unwrap(); - serde_json::to_value(result.provider_metadata).unwrap() - }; - assert_eq!( - generated_metadata(wire.clone()).await["openai"]["logprobs"], - expected - ); - let metadata = generated_metadata(json!({"content": null})).await; - assert_eq!(metadata["openai"].get("logprobs"), None, "{metadata}"); - - let fetch = MockFetch::new(vec![sse(vec![json!({ - "id":"chatcmpl-test","model":"test-model","created":1702657020, - "choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}, - "logprobs":wire,"finish_reason":"stop"}]})])]); - let parts = provider(&fetch) - .chat("test-model") - .do_stream(&options()) - .await - .unwrap() - .stream - .map(|part| part.unwrap()) - .collect::>() - .await; - let Some(StreamPart::Finish { - provider_metadata, .. - }) = parts.last() - else { - panic!("no finish part: {parts:?}"); - }; - assert_eq!( - serde_json::to_value(provider_metadata).unwrap()["openai"]["logprobs"], - expected - ); -} - -/// partial: `should parse OpenRouter resource exhausted error`; full port in src/openai/tests/error.rs. -#[tokio::test] -async fn chat_error_response() { - let message = "{\n \"error\": {\n \"code\": 429,\n \"message\": \"Resource has been exhausted (e.g. check quota).\",\n \"status\": \"RESOURCE_EXHAUSTED\"\n }\n}\n"; - let response = json!({"error":{"message":message,"code":429}}); - let mut canned = Canned::json(&response); - canned.status = 429; - let fetch = MockFetch::new(vec![canned]); - let error = provider(&fetch) - .chat("test-model") - .do_generate(&options()) - .await - .unwrap_err(); - assert_request( - &fetch, - "/chat/completions", - json!({"model":"test-model","messages":[{"role":"user","content":"Hello"}]}), - ); - let AiMuxError::ApiCall(error) = error else { - panic!("expected API error") - }; - assert_eq!(error.message, message); - assert_eq!(error.status_code, Some(429)); - assert_eq!(error.data, Some(response)); -} - -/// partial: `should generate text`; full port in src/openai/tests/responses/responses_language_model.rs. -/// partial: `should extract usage`; full port in src/openai/tests/responses/responses_language_model.rs. -#[tokio::test] -async fn responses_generate() { - let response = responses_response(); - let fetch = MockFetch::new(vec![Canned::json(&response)]); - let result = provider(&fetch) - .responses("test-model") - .do_generate(&options()) - .await - .unwrap(); - assert_request( - &fetch, - "/responses", - json!({"model":"test-model","input":[{"role":"user","content":[{"type":"input_text","text":"Hello"}]}]}), - ); - assert_eq!( - result.content, - vec![GenerateContent::Text { - text: "answer text".into(), - provider_metadata: Some( - provider_namespace("openai", json!({"itemId":"msg_test"})) - .expect("provider metadata must be an object") - ) - }] - ); - assert_eq!(result.usage.input_tokens.total, Some(345)); - assert_eq!(result.usage.input_tokens.cache_read, Some(234)); - assert_eq!(result.usage.input_tokens.cache_write, Some(45)); - assert_eq!(result.usage.input_tokens.no_cache, Some(66)); - assert_eq!(result.usage.output_tokens.total, Some(538)); - assert_eq!(result.usage.output_tokens.reasoning, Some(123)); - assert_eq!(result.usage.output_tokens.text, Some(415)); - assert_eq!(result.usage.raw, response["usage"].as_object().cloned()); - assert_eq!(result.response.unwrap().id.as_deref(), Some("resp_test")); -} - -/// partial: `should stream text deltas`; full port in src/openai/tests/responses/responses_language_model.rs. -#[tokio::test] -async fn responses_stream() { - let events=json!([{"type": "response.created", "response": {"id": "resp_67c9a81b6a048190a9ee441c5755a4e8", "object": "response", "created_at": 1741269019, "status": "in_progress", "error": null, "incomplete_details": null, "input": [], "instructions": null, "max_output_tokens": null, "model": "test-model", "output": [], "parallel_tool_calls": true, "previous_response_id": null, "reasoning": {"effort": null, "summary": null}, "store": true, "temperature": 0.3, "text": {"format": {"type": "text"}}, "tool_choice": "auto", "tools": [], "top_p": 1, "truncation": "disabled", "usage": null, "user": null, "metadata": {}}}, {"type": "response.in_progress", "response": {"id": "resp_67c9a81b6a048190a9ee441c5755a4e8", "object": "response", "created_at": 1741269019, "status": "in_progress", "error": null, "incomplete_details": null, "input": [], "instructions": null, "max_output_tokens": null, "model": "test-model", "output": [], "parallel_tool_calls": true, "previous_response_id": null, "reasoning": {"effort": null, "summary": null}, "store": true, "temperature": 0.3, "text": {"format": {"type": "text"}}, "tool_choice": "auto", "tools": [], "top_p": 1, "truncation": "disabled", "usage": null, "user": null, "metadata": {}}}, {"type": "response.output_item.added", "output_index": 0, "item": {"id": "msg_67c9a81dea8c8190b79651a2b3adf91e", "type": "message", "status": "in_progress", "role": "assistant", "content": []}}, {"type": "response.content_part.added", "item_id": "msg_67c9a81dea8c8190b79651a2b3adf91e", "output_index": 0, "content_index": 0, "part": {"type": "output_text", "text": "", "annotations": [], "logprobs": []}}, {"type": "response.output_text.delta", "item_id": "msg_67c9a81dea8c8190b79651a2b3adf91e", "output_index": 0, "content_index": 0, "delta": "Hello,", "logprobs": []}, {"type": "response.output_text.delta", "item_id": "msg_67c9a81dea8c8190b79651a2b3adf91e", "output_index": 0, "content_index": 0, "delta": " World!", "logprobs": []}, {"type": "response.output_text.done", "item_id": "msg_67c9a8787f4c8190b49c858d4c1cf20c", "output_index": 0, "content_index": 0, "text": "Hello, World!"}, {"type": "response.content_part.done", "item_id": "msg_67c9a8787f4c8190b49c858d4c1cf20c", "output_index": 0, "content_index": 0, "part": {"type": "output_text", "text": "Hello, World!", "annotations": [], "logprobs": []}}, {"type": "response.output_item.done", "output_index": 0, "item": {"id": "msg_67c9a8787f4c8190b49c858d4c1cf20c", "type": "message", "status": "completed", "role": "assistant", "content": [{"type": "output_text", "text": "Hello, World!", "annotations": [], "logprobs": []}]}}, {"type": "response.completed", "response": {"id": "resp_67c9a878139c8190aa2e3105411b408b", "object": "response", "created_at": 1741269112, "status": "completed", "error": null, "incomplete_details": null, "input": [], "instructions": null, "max_output_tokens": null, "model": "test-model", "output": [{"id": "msg_67c9a8787f4c8190b49c858d4c1cf20c", "type": "message", "status": "completed", "role": "assistant", "content": [{"type": "output_text", "text": "Hello, World!", "annotations": []}]}], "parallel_tool_calls": true, "previous_response_id": null, "reasoning": {"effort": null, "summary": null}, "store": true, "temperature": 0.3, "text": {"format": {"type": "text"}}, "tool_choice": "auto", "tools": [], "top_p": 1, "truncation": "disabled", "usage": {"input_tokens": 543, "input_tokens_details": {"cached_tokens": 234}, "output_tokens": 478, "output_tokens_details": {"reasoning_tokens": 123}, "total_tokens": 512}, "user": null, "metadata": {}}}]).as_array().unwrap().clone(); - let fetch = MockFetch::new(vec![sse(events)]); - let result = provider(&fetch) - .responses("test-model") - .do_stream(&options()) - .await - .unwrap(); - let parts = result - .stream - .map(|part| part.unwrap()) - .collect::>() - .await; - assert_request( - &fetch, - "/responses", - json!({"model":"test-model","input":[{"role":"user","content":[{"type":"input_text","text":"Hello"}]}],"stream":true}), - ); - assert_eq!( - serde_json::to_value(parts).unwrap(), - json!([ - {"type": "stream-start", "warnings": []}, - { - "type": "response-metadata", - "id": "resp_67c9a81b6a048190a9ee441c5755a4e8", - "modelId": "test-model", - "timestamp": "2025-03-06T13:50:19.000Z" - }, - { - "type": "text-start", - "id": "msg_67c9a81dea8c8190b79651a2b3adf91e", - "providerMetadata": {"openai": {"itemId": "msg_67c9a81dea8c8190b79651a2b3adf91e"}} - }, - {"type": "text-delta", "id": "msg_67c9a81dea8c8190b79651a2b3adf91e", "delta": "Hello,"}, - {"type": "text-delta", "id": "msg_67c9a81dea8c8190b79651a2b3adf91e", "delta": " World!"}, - { - "type": "text-end", - "id": "msg_67c9a81dea8c8190b79651a2b3adf91e", - "providerMetadata": {"openai": {"itemId": "msg_67c9a81dea8c8190b79651a2b3adf91e"}} - }, - { - "type": "finish", - "finishReason": {"unified": "stop"}, - "providerMetadata": {"openai": {"responseId": "resp_67c9a81b6a048190a9ee441c5755a4e8"}}, - "usage": { - "inputTokens": {"total": 543, "noCache": 309, "cacheRead": 234}, - "outputTokens": {"total": 478, "reasoning": 123, "text": 355}, - "raw": { - "input_tokens": 543, - "input_tokens_details": {"cached_tokens": 234}, - "output_tokens": 478, - "output_tokens_details": {"reasoning_tokens": 123}, - "total_tokens": 512 - } - } - } - ]) - ); -} - -/// partial: `should throw a descriptive error when the response has no output`; full port in src/openai/tests/responses/responses_language_model.rs. -#[tokio::test] -async fn responses_error_response() { - let fetch = MockFetch::new(vec![Canned::json( - &json!({"id":"resp_no_output","status":"incomplete","incomplete_details":{"reason":"content_filter"}}), - )]); - let error = provider(&fetch) - .responses("test-model") - .do_generate(&options()) - .await - .unwrap_err(); - assert_request( - &fetch, - "/responses", - json!({"model":"test-model","input":[{"role":"user","content":[{"type":"input_text","text":"Hello"}]}]}), - ); - assert!( - error - .to_string() - .contains("Responses API returned no output (content_filter)"), - "{error}" - ); -} - -fn embedding_response() -> Value { - serde_json::from_str( - r#"{ - "object": "list", - "data": [ - { - "object": "embedding", - "index": 0, - "embedding": [ - 0.0057293195, -0.012727811, 0.020042092, -0.013437585, 0.022833068 - ] - }, - { - "object": "embedding", - "index": 1, - "embedding": [ - -0.037104916, -0.05178114, -0.008340587, 0.001164541, -0.0035253682 - ] - } - ], - "model": "test-embedding", - "usage": { - "prompt_tokens": 12, - "total_tokens": 12 - } -} -"#, - ) - .unwrap() -} - -fn image_response() -> Value { - serde_json::from_str(r#"{ - "created": 1770935200, - "data": [ - { - "b64_json": "iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAA3CGNhQlgAADcIanVtYgAAAB5qdW1kYzJwYQARABCAAACqADibcQNj", - "revised_prompt": "A small and adorable baby sea otter. This little creature is covered in a thick and fluffy brown fur, its tiny paws are slightly visible. The otter has bright, curious eyes and it's floating on its back on a calm sea, surrounded by floating seaweed." - }, - { - "b64_json": "iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAEp2GNhQlgAASnYanVtYgAAAB5qdW1kYzJwYQARABCAAACqADibcQNj" - } - ] -} -"#).unwrap() -} - -fn transcription_response() -> Value { - serde_json::from_str(r#"{ - "task": "transcribe", - "language": "english", - "duration": 36.709999084472656, - "text": "Galileo was an American robotic space program that studied the planet Jupiter and its moons, as well as several other solar system bodies.", - "words": [ - { - "word": "Galileo", - "start": 0, - "end": 0.6600000262260437 - }, - { - "word": "was", - "start": 0.6600000262260437, - "end": 0.8999999761581421 - }, - { - "word": "an", - "start": 0.8999999761581421, - "end": 1.1399999856948853 - }, - { - "word": "American", - "start": 1.1399999856948853, - "end": 1.5 - }, - { - "word": "robotic", - "start": 1.5, - "end": 2.0199999809265137 - } - ], - "usage": { - "type": "duration", - "seconds": 37 - } -} -"#).unwrap() -} - -/// partial: `should extract embedding`; full port in src/openai/tests/embedding/embedding_model.rs. -/// partial: `should pass the model and the values`; full port in src/openai/tests/embedding/embedding_model.rs. -#[tokio::test] -async fn embedding_generate() { - let response = embedding_response(); - let fetch = MockFetch::new(vec![Canned::json(&response)]); - 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":opts.values,"encoding_format":"float"}), - ); - assert_eq!( - result.embeddings, - vec![ - vec![ - 0.0057293195, - -0.012727811, - 0.020042092, - -0.013437585, - 0.022833068 - ], - vec![ - -0.037104916, - -0.05178114, - -0.008340587, - 0.001164541, - -0.0035253682 - ] - ] - ); - assert_eq!(result.usage.unwrap().tokens, 12); - assert_eq!(result.response.unwrap().body, Some(response)); -} - -/// partial: `should pass the model and the settings`; full port in src/openai/tests/image/image_model.rs. -/// partial: `should extract the generated images`; full port in src/openai/tests/image/image_model.rs. -#[tokio::test] -async fn image_generate() { - let response = image_response(); - let fetch = MockFetch::new(vec![Canned::json(&response)]); - let mut opts = ImageCallOptions::new("A cute baby sea otter"); - opts.size = Some(Size::new(1024, 1024)); - opts.provider_options = provider_namespace("openai", json!({"style":"vivid"})) - .expect("provider metadata must be an object"); - let result = provider(&fetch) - .image("test-image") - .do_generate(&opts) - .await - .unwrap(); - assert_request( - &fetch, - "/images/generations", - json!({"model":"test-image","prompt":"A cute baby sea otter","n":1,"size":"1024x1024","style":"vivid","response_format":"b64_json"}), - ); - let expected = response["data"] - .as_array() - .unwrap() - .iter() - .map(|image| image["b64_json"].as_str().unwrap().to_owned()) - .collect::>(); - assert!(matches!(result.images,ImageOutputs::Base64(images) if images==expected)); -} - -/// openai-image-model.ts:166 (files) and :357 (mask) fetch a URL file with -/// `downloadBlob(file.url, {abortSignal})`, which goes through -/// `fetchUntrustedUrl` (provider-utils download-blob.ts:111-114): a -/// non-public target is refused before any request, and the edit request is -/// never sent. -#[tokio::test] -async fn image_edit_url_files_are_downloaded_through_the_guard() { - let url = || ImageFile::Url { - url: "http://169.254.169.254/latest/meta-data/image.png".into(), - }; - let mut files = ImageCallOptions::new("Edit"); - files.files = Some(vec![url()]); - let mut mask = ImageCallOptions::new("Edit"); - mask.files = Some(vec![ImageFile::File { - media_type: "image/png".into(), - data: ImageFileData::Binary(vec![1, 2, 3]), - }]); - mask.mask = Some(url()); - for options in [files, mask] { - let fetch = MockFetch::new(vec![Canned::json(&image_response())]); - let error = provider(&fetch) - .image("gpt-image-1") - .do_generate(&options) - .await - .unwrap_err(); - assert!( - matches!(error, AiMuxError::InvalidArgument { ref message, .. } if message.contains("non-public")), - "{error:?}" - ); - assert!(fetch.seen().is_empty()); - } -} - -/// partial: `should pass the model and text`; full port in src/openai/tests/speech/speech_model.rs. -/// partial: `should return audio data with correct content type`; full port in src/openai/tests/speech/speech_model.rs. -#[tokio::test] -async fn speech_generate() { - let audio = vec![1, 2, 3, 4]; - let fetch = MockFetch::new(vec![Canned { - status: 200, - headers: vec![("content-type".into(), "audio/mpeg".into())], - body: audio.clone(), - }]); - let result = provider(&fetch) - .speech("test-speech") - .do_generate(&SpeechCallOptions::new("Hello from the AI SDK!")) - .await - .unwrap(); - assert_request( - &fetch, - "/audio/speech", - json!({"model":"test-speech","input":"Hello from the AI SDK!","voice":"alloy","response_format":"mp3"}), - ); - assert!(matches!(result.audio,AudioData::Binary(bytes) if bytes==audio)); - assert_eq!( - result.response.headers.unwrap()["content-type"], - "audio/mpeg" - ); -} - -/// partial: `should extract the transcription text`; full port in src/openai/tests/transcription/transcription_model.rs. -#[tokio::test] -async fn transcription_generate() { - let response = transcription_response(); - let fetch = MockFetch::new(vec![Canned::json(&response)]); - let opts = TranscriptionCallOptions::new(AudioInput::Binary(vec![1, 2, 3]), "audio/wav"); - let result = provider(&fetch) - .transcription("test-transcription") - .do_generate(&opts) - .await - .unwrap(); - let seen = fetch.seen(); - assert_eq!(seen.len(), 1); - assert_eq!(seen[0].url, "https://example.test/v1/audio/transcriptions"); - assert_eq!(seen[0].method, "POST"); - assert!(seen[0].headers["content-type"].starts_with("multipart/form-data; boundary=")); - let body = String::from_utf8_lossy(&seen[0].body); - assert!(body.contains("name=\"model\"\r\n\r\ntest-transcription\r\n")); - assert!(body.contains("name=\"file\"; filename=\"audio.wav\"")); - assert!(seen[0].body.windows(3).any(|window| window == [1, 2, 3])); - assert_eq!(result.text, response["text"].as_str().unwrap()); - assert_eq!(result.language.as_deref(), Some("en")); - assert_eq!(result.duration_in_seconds, Some(36.709999084472656)); - assert_eq!(result.segments.len(), 5); -} - -/// partial: `should handle streaming web search without action`; full port in src/openai/tests/responses/responses_language_model.rs. -#[tokio::test] -async fn responses_web_search_stream() { - let fetch = MockFetch::new(vec![sse(vec![ - json!({"type":"response.created","response":{"id":"resp_missing_action","created_at":1741630255,"model":"test-model"}}), - json!({"type":"response.output_item.added","output_index":0,"item":{"type":"web_search_call","id":"ws_missing_action","status":"in_progress"}}), - json!({"type":"response.web_search_call.in_progress","output_index":0,"item_id":"ws_missing_action"}), - json!({"type":"response.web_search_call.completed","output_index":0,"item_id":"ws_missing_action"}), - json!({"type":"response.output_item.done","output_index":0,"item":{"type":"web_search_call","id":"ws_missing_action","status":"completed"}}), - json!({"type":"response.completed","response":{"id":"resp_missing_action","usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":0},"output_tokens":2,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":12}}}), - ])]); - let mut call = options(); - call.tools = Some(vec![Tool::Provider(aimux_core::tool::ProviderTool { - id: "openai.web_search".parse().unwrap(), - name: "webSearch".into(), - args: serde_json::Map::new(), - supports_deferred_results: None, - })]); - let result = provider(&fetch) - .responses("test-model") - .do_stream(&call) - .await - .unwrap(); - let parts = result - .stream - .map(|part| part.unwrap()) - .collect::>() - .await; - assert_request( - &fetch, - "/responses", - json!({"model":"test-model","input":[{"role":"user","content":[{"type":"input_text","text":"Hello"}]}],"stream":true,"tools":[{"type":"web_search"}],"include":["web_search_call.action.sources"]}), - ); - assert_eq!( - serde_json::to_value(parts).unwrap(), - json!([ - {"type":"stream-start","warnings":[]}, - {"type":"response-metadata","id":"resp_missing_action","modelId":"test-model","timestamp":"2025-03-10T18:10:55.000Z"}, - {"type":"tool-input-start","id":"ws_missing_action","toolName":"webSearch","providerExecuted":true}, - {"type":"tool-input-end","id":"ws_missing_action"}, - {"type":"tool-call","toolCallId":"ws_missing_action","toolName":"webSearch","input":"{}","providerExecuted":true}, - {"type":"tool-result","toolCallId":"ws_missing_action","toolName":"webSearch","result":{}}, - {"type":"finish","finishReason":{"unified":"stop"},"usage":{"inputTokens":{"total":10,"noCache":10,"cacheRead":0},"outputTokens":{"total":2,"reasoning":0,"text":2},"raw":{"input_tokens":10,"input_tokens_details":{"cached_tokens":0},"output_tokens":2,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":12}},"providerMetadata":{"openai":{"responseId":"resp_missing_action"}}} - ]) - ); -} - -/// Upstream: @ai-sdk/openai 4.0.83 src/openai-provider.ts:387 puts -/// `openaiTools` on the provider, so `openai.tools.webSearch(…)` makes the -/// provider tool; it is the module's factory and reaches the request. -#[tokio::test] -async fn provider_tools_member_makes_the_provider_tools() { - use aimux_providers::openai::tools::{SearchContextSize, WebSearchArgs}; - let fetch = MockFetch::new(vec![Canned::json(&responses_response())]); - let openai = provider(&fetch); - let args = || WebSearchArgs { - search_context_size: Some(SearchContextSize::High), - ..Default::default() - }; - let tool = openai.tools.web_search(args()).unwrap(); - let factory = aimux_providers::openai::tools::web_search(args()).unwrap(); - assert_eq!( - (&tool.id, &tool.name, &tool.args), - (&factory.id, &factory.name, &factory.args) - ); - assert_eq!(openai.tools.apply_patch().id, "openai.apply_patch"); - // Of the OpenAI tools only programmatic tool calling supports deferred - // results (src/tool/programmatic-tool-calling.ts:55). - assert_eq!( - openai - .tools - .programmatic_tool_calling() - .supports_deferred_results, - Some(true) - ); - assert_eq!(openai.tools.apply_patch().supports_deferred_results, None); - let mut call = options(); - call.tools = Some(vec![Tool::Provider(tool)]); - openai - .responses("test-model") - .do_generate(&call) - .await - .unwrap(); - assert_eq!( - fetch.seen()[0].json_body()["tools"], - json!([{"type":"web_search","search_context_size":"high"}]) - ); -} - -/// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-language-model.ts:1660-1663 -/// enqueues `{ type: 'raw', rawValue }` for every chunk, before the parts -/// derived from it, when `includeRawChunks` is set, and nothing otherwise. -#[tokio::test] -async fn responses_stream_emits_raw_chunks_when_asked() { - let events = vec![ - json!({"type":"response.created","response":{"id":"resp_raw","created_at":1741630255,"model":"test-model"}}), - json!({"type":"response.completed","response":{"id":"resp_raw","usage":{"input_tokens":1,"output_tokens":1}}}), - ]; - let fetch = MockFetch::new(vec![sse(events.clone()), sse(events.clone())]); - let model = provider(&fetch).responses("test-model"); - let mut call = options(); - call.include_raw_chunks = Some(true); - let with_raw: Vec<_> = model - .do_stream(&call) - .await - .unwrap() - .stream - .map(|part| part.unwrap()) - .collect() - .await; - assert!(matches!(&with_raw[0], StreamPart::StreamStart { .. })); - assert!(matches!(&with_raw[1], StreamPart::Raw { raw_value } if *raw_value == events[0])); - assert!(matches!(&with_raw[2], StreamPart::ResponseMetadata(_))); - assert!(matches!(&with_raw[3], StreamPart::Raw { raw_value } if *raw_value == events[1])); - assert!(matches!(with_raw.last(), Some(StreamPart::Finish { .. }))); - - let without_raw: Vec<_> = model - .do_stream(&options()) - .await - .unwrap() - .stream - .map(|part| part.unwrap()) - .collect() - .await; - assert!( - !without_raw - .iter() - .any(|part| matches!(part, StreamPart::Raw { .. })) - ); -} - -/// Upstream: @ai-sdk/openai 4.0.83 -/// src/responses/openai-responses-language-model.ts:1587 starts `responseId` at -/// `null` and only `response.created` sets it, so a stream without that event -/// finishes with `responseId: null`, not an empty string. -#[tokio::test] -async fn a_stream_without_response_created_finishes_with_a_null_response_id() { - let fetch = MockFetch::new(vec![sse(vec![json!({ - "type":"response.completed", - "response":{"id":"resp_1","created_at":1,"model":"test-model","status":"completed","output":[], - "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}} - })])]); - let parts: Vec<_> = provider(&fetch) - .responses("test-model") - .do_stream(&options()) - .await - .unwrap() - .stream - .map(|part| part.unwrap()) - .collect() - .await; - let Some(StreamPart::Finish { - provider_metadata: Some(metadata), - .. - }) = parts.last() - else { - panic!("expected a finish part with provider metadata: {parts:?}"); - }; - assert_eq!(metadata["openai"]["responseId"], Value::Null); -} - -/// Upstream: @ai-sdk/openai 4.0.83 -/// src/responses/openai-responses-language-model.ts:2735-2749 gives every -/// `reasoning-delta` the reasoning item's id as `providerMetadata..itemId`, -/// as it does for `reasoning-start` and `reasoning-end`: the id the item -/// started with at its `output_index` (`resolveOutputItemId`), even when the -/// provider rotates the id on later events. -#[tokio::test] -async fn reasoning_deltas_carry_the_item_id() { - let fetch = MockFetch::new(vec![sse(vec![ - json!({"type":"response.output_item.added","output_index":0, - "item":{"id":"rs_1","type":"reasoning","summary":[]}}), - json!({"type":"response.reasoning_summary_part.added","item_id":"rs_2","output_index":0, - "summary_index":0,"part":{"type":"summary_text","text":""}}), - json!({"type":"response.reasoning_summary_text.delta","item_id":"rs_3","output_index":0, - "summary_index":0,"delta":"Thinking"}), - ])]); - let parts: Vec<_> = provider(&fetch) - .responses("test-model") - .do_stream(&options()) - .await - .unwrap() - .stream - .map(|part| part.unwrap()) - .collect() - .await; - let delta = parts - .iter() - .find_map(|part| match part { - StreamPart::ReasoningDelta { - provider_metadata, .. - } => Some(provider_metadata), - _ => None, - }) - .expect("a reasoning delta"); - assert_eq!( - serde_json::to_value(delta).unwrap(), - json!({ "openai": { "itemId": "rs_1" } }) - ); -} - -/// Upstream: @ai-sdk/openai 4.0.83 src/tool/file-search.ts:29 takes -/// `maxNumResults` as a JS number and -/// src/responses/openai-responses-prepare-tools.ts:219 sends it as is, so -/// `maxNumResults: 5` reaches the API's integer field as `5`, not `5.0`. -#[tokio::test] -async fn file_search_sends_max_num_results_as_an_integer() { - let fetch = MockFetch::new(vec![Canned::json(&responses_response())]); - let args: FileSearchArgs = - serde_json::from_value(json!({ "vectorStoreIds": ["vs_1"], "maxNumResults": 5 })).unwrap(); - let mut options = options(); - options.tools = Some(vec![Tool::Provider(file_search(args).unwrap())]); - provider(&fetch) - .responses("test-model") - .do_generate(&options) - .await - .unwrap(); - assert_eq!( - fetch.seen()[0].json_body()["tools"][0]["max_num_results"], - json!(5) - ); -} - -/// Upstream: @ai-sdk/openai 4.0.83 -/// src/responses/openai-responses-language-model.ts:128 (`mapComputerAction`) -/// builds each computer action from the fields its type has and adds `keys` -/// only when it is not null, so a `double_click` with `keys: null` reaches the -/// tool input without `keys`. -#[tokio::test] -async fn computer_actions_leave_out_null_keys() { - let fetch = MockFetch::new(vec![Canned::json(&json!({ - "id": "resp_computer", - "created_at": 1, - "model": "test-model", - "output": [{ - "type": "computer_call", - "id": "computer_item", - "call_id": "computer_call", - "status": "completed", - "actions": [ - {"type": "double_click", "x": 1, "y": 2, "keys": null}, - {"type": "scroll", "x": 3, "y": 4, "scroll_x": 0, "scroll_y": 5, "keys": null} - ], - "pending_safety_checks": [] - }], - "usage": {"input_tokens": 1, "output_tokens": 1} - }))]); - let result = provider(&fetch) - .responses("test-model") - .do_generate(&options()) - .await - .unwrap(); - let Some(GenerateContent::ToolCall(call)) = result.content.first() else { - panic!("expected a tool call: {:?}", result.content); - }; - assert_eq!( - call.input, - r#"{"actions":[{"type":"double_click","x":1,"y":2},{"type":"scroll","x":3,"y":4,"scrollX":0,"scrollY":5}],"pendingSafetyChecks":[],"status":"completed"}"# - ); -} - -/// Upstream: @ai-sdk/openai 4.0.83 -/// src/responses/openai-responses-language-model.ts:378 maps the names of -/// twelve provider tools back to the caller's tool names -/// (`providerToolNames`); `computer_use` is not one of them, so a computer -/// call without a `call_id` (line 1358) keeps the name `computer_use` even -/// when the request declares an `openai.computer_use` tool with another name. -#[tokio::test] -async fn computer_use_results_keep_the_canonical_tool_name() { - let fetch = MockFetch::new(vec![Canned::json(&json!({ - "id": "resp_computer_use", - "created_at": 1, - "model": "test-model", - "output": [{"type": "computer_call", "id": "computer_item", "status": "completed"}], - "usage": {"input_tokens": 1, "output_tokens": 1} - }))]); - let mut options = options(); - options.tools = Some(vec![Tool::Provider(aimux_core::tool::ProviderTool { - id: "openai.computer_use".parse().unwrap(), - name: "computerUse".into(), - args: serde_json::Map::new(), - supports_deferred_results: None, - })]); - let result = provider(&fetch) - .responses("test-model") - .do_generate(&options) - .await - .unwrap(); - let names: Vec<&str> = result - .content - .iter() - .filter_map(|part| match part { - GenerateContent::ToolCall(call) => Some(call.tool_name.as_str()), - GenerateContent::ToolResult(result) => Some(result.tool_name.as_str()), - _ => None, - }) - .collect(); - assert_eq!(names, ["computer_use", "computer_use"]); -} - -/// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-api.ts:1520 -/// parses a `container_file_citation` annotation with only `type`, -/// `container_id`, `file_id`, `filename`, `start_index` and `end_index`, and -/// src/responses/openai-responses-language-model.ts:1072 puts the parsed -/// annotations in the text part's provider metadata, so an extra `index` is -/// dropped. -#[tokio::test] -async fn text_annotations_keep_only_the_fields_of_their_type() { - let fetch = MockFetch::new(vec![Canned::json(&json!({ - "id": "resp_annotations", - "created_at": 1, - "model": "test-model", - "output": [{ - "type": "message", - "id": "msg_container", - "role": "assistant", - "content": [{ - "type": "output_text", - "text": "Generated with container file.", - "annotations": [{ - "type": "container_file_citation", - "container_id": "cntr_test", - "file_id": "file-container", - "filename": "data.csv", - "start_index": 0, - "end_index": 10, - "index": 2 - }] - }] - }], - "usage": {"input_tokens": 1, "output_tokens": 1} - }))]); - let result = provider(&fetch) - .responses("test-model") - .do_generate(&options()) - .await - .unwrap(); - let Some(GenerateContent::Text { - provider_metadata: Some(metadata), - .. - }) = result.content.first() - else { - panic!("expected a text part: {:?}", result.content); - }; - assert_eq!( - metadata["openai"]["annotations"], - json!([{ - "type": "container_file_citation", - "container_id": "cntr_test", - "file_id": "file-container", - "filename": "data.csv", - "start_index": 0, - "end_index": 10 - }]) - ); -} - -/// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-api.ts:1698 -/// parses an apply patch operation as `{type, path, diff}`, and -/// src/responses/openai-responses-language-model.ts:1443 stringifies that -/// parsed operation into the tool input, so its keys follow the schema order -/// whatever order the wire sent them in. -#[tokio::test] -async fn apply_patch_input_lists_operation_fields_in_schema_order() { - let fetch = MockFetch::new(vec![Canned::json(&json!({ - "id": "resp_apply_patch", - "created_at": 1, - "model": "test-model", - "output": [{ - "type": "apply_patch_call", - "id": "apc_1", - "call_id": "call_1", - "status": "completed", - "operation": {"diff": "+hello\n", "type": "create_file", "path": "a.md"} - }], - "usage": {"input_tokens": 1, "output_tokens": 1} - }))]); - let result = provider(&fetch) - .responses("test-model") - .do_generate(&options()) - .await - .unwrap(); - let Some(GenerateContent::ToolCall(call)) = result.content.first() else { - panic!("expected a tool call: {:?}", result.content); - }; - assert_eq!( - call.input, - r#"{"callId":"call_1","operation":{"type":"create_file","path":"a.md","diff":"+hello\n"}}"# - ); -} - -/// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-api.ts:147 -/// parses a local shell action as `{type, command, timeout_ms, user, -/// working_directory, env}`, and -/// src/responses/openai-responses-language-model.ts:998 stringifies the -/// parsed action into the tool input, so its keys follow that order. -#[tokio::test] -async fn local_shell_input_lists_action_fields_in_schema_order() { - let fetch = MockFetch::new(vec![Canned::json(&json!({ - "id": "resp_local_shell", - "created_at": 1, - "model": "test-model", - "output": [{ - "type": "local_shell_call", - "id": "lsh_1", - "call_id": "call_1", - "status": "completed", - "action": {"type": "exec", "command": ["ls"], "env": {}, "working_directory": "/root"} - }], - "usage": {"input_tokens": 1, "output_tokens": 1} - }))]); - let result = provider(&fetch) - .responses("test-model") - .do_generate(&options()) - .await - .unwrap(); - let Some(GenerateContent::ToolCall(call)) = result.content.first() else { - panic!("expected a tool call: {:?}", result.content); - }; - assert_eq!( - call.input, - r#"{"action":{"type":"exec","command":["ls"],"working_directory":"/root","env":{}}}"# - ); -} - -/// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-api.ts:1486 -/// lets a message's `content` hold only `output_text` parts, so a `refusal` -/// part fails the response schema and the JSON response handler rejects the -/// call with an `APICallError` ("Invalid JSON response"). -#[tokio::test] -async fn a_refusal_message_part_rejects_the_response() { - let fetch = MockFetch::new(vec![Canned::json(&json!({ - "id": "resp_refusal", - "created_at": 1, - "model": "test-model", - "output": [{ - "type": "message", - "id": "msg_refusal", - "role": "assistant", - "status": "completed", - "content": [{"type": "refusal", "refusal": "Cannot help with this."}] - }], - "usage": {"input_tokens": 1, "output_tokens": 1} - }))]); - let error = provider(&fetch) - .responses("test-model") - .do_generate(&options()) - .await - .unwrap_err(); - let AiMuxError::ApiCall(detail) = &error else { - panic!("expected an API call error: {error:?}"); - }; - assert!( - detail.message.starts_with("Invalid JSON response"), - "{error}" - ); - assert!( - detail - .response_body - .as_deref() - .is_some_and(|body| body.contains("Cannot help with this.")), - "{error:?}" - ); -} - -/// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-api.ts:875 -/// (chunk) and :1490 (response) parse a logprobs entry as `{token, logprob, -/// top_logprobs: [{token, logprob}]}`, so `bytes` never reaches -/// `providerMetadata.openai.logprobs` -/// (openai-responses-language-model.ts:1065 generate, :2685 stream). -#[tokio::test] -async fn responses_logprobs_metadata_has_no_bytes() { - let wire = json!([{"token":"N","logprob":-2.5,"bytes":[78], - "top_logprobs":[{"token":"N","logprob":-2.5,"bytes":[78]},{"token":"Y","logprob":-1.0,"bytes":[89]}]}]); - let expected = json!([[{"token":"N","logprob":-2.5, - "top_logprobs":[{"token":"N","logprob":-2.5},{"token":"Y","logprob":-1.0}]}]]); - let mut opts = options(); - opts.provider_options = provider_namespace("openai", json!({"logprobs": 2})).ok(); - - let mut response = responses_response(); - response["output"][0]["content"][0]["logprobs"] = wire.clone(); - let fetch = MockFetch::new(vec![Canned::json(&response)]); - let result = provider(&fetch) - .responses("test-model") - .do_generate(&opts) - .await - .unwrap(); - assert_eq!( - serde_json::to_value(result.provider_metadata).unwrap()["openai"]["logprobs"], - expected - ); - - let fetch = MockFetch::new(vec![sse(vec![ - json!({"type":"response.created","response":{"id":"resp_1","created_at":1,"model":"test-model"}}), - json!({"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_1"}}), - json!({"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"delta":"N","logprobs":wire}), - json!({"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}), - ])]); - let parts = provider(&fetch) - .responses("test-model") - .do_stream(&opts) - .await - .unwrap() - .stream - .map(|part| part.unwrap()) - .collect::>() - .await; - let Some(StreamPart::Finish { - provider_metadata, .. - }) = parts.last() - else { - panic!("no finish part: {parts:?}"); - }; - assert_eq!( - serde_json::to_value(provider_metadata).unwrap()["openai"]["logprobs"], - expected - ); -} - -/// Upstream: @ai-sdk/openai 4.0.83 -/// src/responses/openai-responses-language-model.ts:1986 gives the -/// `tool-input-end` of a streamed function call with a `namespace` the -/// provider metadata `{ namespace }`. -#[tokio::test] -async fn a_streamed_namespaced_function_call_ends_its_input_with_the_namespace() { - let fetch = MockFetch::new(vec![sse(vec![ - json!({"type":"response.created","response":{"id":"resp_1","created_at":1,"model":"test-model"}}), - json!({"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1", - "call_id":"call_1","name":"get_weather","arguments":"{}","status":"completed","namespace":"weather_ns"}}), - json!({"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}), - ])]); - let parts = provider(&fetch) - .responses("test-model") - .do_stream(&options()) - .await - .unwrap() - .stream - .map(|part| part.unwrap()) - .collect::>() - .await; - let metadata = parts - .iter() - .find_map(|part| match part { - StreamPart::ToolInputEnd { - provider_metadata, .. - } => Some(provider_metadata), - _ => None, - }) - .expect("a tool-input-end part"); - assert_eq!( - serde_json::to_value(metadata).unwrap(), - json!({"openai": {"namespace": "weather_ns"}}) - ); -} - -/// Upstream: @ai-sdk/openai 4.0.83 -/// src/responses/openai-responses-language-model.ts:2709 (a summary part -/// concluded when the next one starts) and :2762 (a summary part concluded -/// on `reasoning_summary_part.done` with `store`) end the reasoning part with -/// provider metadata `{ itemId }` only; the encrypted content goes on the -/// ends sent when the reasoning item is done (line 2523). -#[tokio::test] -async fn early_reasoning_summary_ends_carry_only_the_item_id() { - for store in [false, true] { - let fetch = MockFetch::new(vec![sse(vec![ - json!({"type":"response.created","response":{"id":"resp_1","created_at":1,"model":"test-model"}}), - json!({"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_1","encrypted_content":"enc"}}), - json!({"type":"response.reasoning_summary_part.added","item_id":"rs_1","output_index":0,"summary_index":0}), - json!({"type":"response.reasoning_summary_part.done","item_id":"rs_1","output_index":0,"summary_index":0}), - json!({"type":"response.reasoning_summary_part.added","item_id":"rs_1","output_index":0,"summary_index":1}), - json!({"type":"response.output_item.done","output_index":0,"item":{"type":"reasoning","id":"rs_1","encrypted_content":"enc"}}), - json!({"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}), - ])]); - let mut opts = options(); - opts.provider_options = provider_namespace("openai", json!({"store": store})).ok(); - let parts = provider(&fetch) - .responses("test-model") - .do_stream(&opts) - .await - .unwrap() - .stream - .map(|part| part.unwrap()) - .collect::>() - .await; - let ends: Vec = parts - .iter() - .filter_map(|part| match part { - StreamPart::ReasoningEnd { - id, - provider_metadata, - } => Some(json!({"id": id, "providerMetadata": provider_metadata})), - _ => None, - }) - .collect(); - assert_eq!( - ends, - [ - json!({"id": "rs_1:0", "providerMetadata": {"openai": {"itemId": "rs_1"}}}), - json!({"id": "rs_1:1", "providerMetadata": {"openai": {"itemId": "rs_1", "reasoningEncryptedContent": "enc"}}}), - ], - "store: {store}" - ); - } -} - -/// Upstream: @ai-sdk/openai 4.0.83 -/// src/responses/openai-responses-language-model.ts:1633-1644 keeps the id -/// each output item started with by `output_index` (OpenAI-compatible -/// providers can rotate item ids between events) and resolves the reasoning -/// summary events (:2688, :2736, :2751) and the reasoning item's end (:2503) -/// through it. -#[tokio::test] -async fn rotated_reasoning_item_ids_are_correlated_by_output_index() { - let fetch = MockFetch::new(vec![sse(vec![ - json!({"type":"response.created","response":{"id":"resp_1","created_at":1,"model":"test-model"}}), - json!({"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_1"}}), - json!({"type":"response.reasoning_summary_part.added","item_id":"rs_2","output_index":0,"summary_index":0}), - json!({"type":"response.reasoning_summary_text.delta","item_id":"rs_3","output_index":0,"summary_index":0,"delta":"thinking"}), - json!({"type":"response.reasoning_summary_part.done","item_id":"rs_4","output_index":0,"summary_index":0}), - json!({"type":"response.output_item.done","output_index":0,"item":{"type":"reasoning","id":"rs_5"}}), - json!({"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}), - ])]); - let parts = provider(&fetch) - .responses("test-model") - .do_stream(&options()) - .await - .unwrap() - .stream - .map(|part| part.unwrap()) - .collect::>() - .await; - let ids: Vec<(&str, &str)> = parts - .iter() - .filter_map(|part| match part { - StreamPart::ReasoningStart { id, .. } => Some(("start", id.as_str())), - StreamPart::ReasoningDelta { id, .. } => Some(("delta", id.as_str())), - StreamPart::ReasoningEnd { id, .. } => Some(("end", id.as_str())), - _ => None, - }) - .collect(); - assert_eq!( - ids, - [("start", "rs_1:0"), ("delta", "rs_1:0"), ("end", "rs_1:0")] - ); -} - -/// Upstream: @ai-sdk/openai 4.0.83 -/// src/responses/openai-responses-language-model.ts:1664-1680 turns a chunk -/// that fails the Responses schema and looks like a Chat Completions chunk -/// (:2962, a `choices` array and no string `type`) into an `APICallError` -/// that names the mix-up (:2972), sends it as an error part and finishes with -/// `error`. -#[tokio::test] -async fn a_chat_completions_chunk_on_a_responses_stream_gives_a_helpful_error() { - let chunk = json!({"choices":[],"created":0,"id":"","model":"","object":""}); - let fetch = MockFetch::new(vec![sse(vec![chunk.clone()])]); - let parts = provider(&fetch) - .responses("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 } => Some(error), - _ => None, - }) - .expect("an error part"); - let AiMuxError::ApiCall(detail) = error else { - panic!("expected an API call error: {error:?}"); - }; - assert!( - detail.message.starts_with( - "Received a Chat Completions stream while using the OpenAI Responses API." - ), - "{error}" - ); - assert_eq!( - detail.response_body.as_deref(), - Some(chunk.to_string().as_str()) - ); - assert!(!detail.is_retryable); - assert!(matches!( - parts.last(), - Some(StreamPart::Finish { finish_reason, .. }) if finish_reason.unified == FinishReasonUnified::Error - )); -} - -/// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-api.ts:1338 -/// requires `output_index` on `response.function_call_arguments.done` (and -/// :1095 on `response.output_item.done` of a modeled item), and a modeled -/// chunk that fails its schema is not skipped as unknown (:1433-1441): -/// src/responses/openai-responses-language-model.ts:1664-1680 sends an error -/// part for it and finishes with `error`. -#[tokio::test] -async fn schema_invalid_known_responses_events_are_errors() { - let fetch = MockFetch::new(vec![sse(vec![ - json!({"type":"response.created","response":{"id":"resp_1","created_at":1,"model":"test-model"}}), - json!({"type":"response.function_call_arguments.done","item_id":"fc_1","arguments":"{}"}), - json!({"type":"response.output_item.done","item":{"type":"function_call","id":"fc_1", - "call_id":"call_1","name":"get_weather","arguments":"{}","status":"completed"}}), - json!({"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}), - ])]); - let parts = provider(&fetch) - .responses("test-model") - .do_stream(&options()) - .await - .unwrap() - .stream - .map(|part| part.unwrap()) - .collect::>() - .await; - let errors = parts - .iter() - .filter(|part| { - matches!( - part, - StreamPart::Error { - error: AiMuxError::TypeValidation { .. } - } - ) - }) - .count(); - assert_eq!(errors, 2, "{parts:?}"); - assert!( - !parts - .iter() - .any(|part| matches!(part, StreamPart::ToolCall(_))), - "{parts:?}" - ); - assert!(matches!( - parts.last(), - Some(StreamPart::Finish { finish_reason, .. }) if finish_reason.unified == FinishReasonUnified::Error - )); -} - -/// Upstream: @ai-sdk/openai 4.0.83 src/openai-stream-error.ts:39 reads the -/// stream until its first output event (`throwIfOpenAIStreamErrorBeforeOutput`, -/// called at src/responses/openai-responses-language-model.ts:1552), so an -/// error frame after `response.created` but before any output rejects -/// `doStream` with an `APICallError` instead of becoming an error part. -#[tokio::test] -async fn a_responses_error_before_output_after_response_created_rejects_the_call() { - let fetch = MockFetch::new(vec![sse(vec![ - json!({"type":"response.created","sequence_number":0,"response":{"id":"resp_1","created_at":1,"model":"test-model"}}), - json!({"type":"error","sequence_number":1,"code":"rate_limit_exceeded","message":"Rate limit reached","param":null}), - ])]); - let Err(error) = provider(&fetch) - .responses("test-model") - .do_stream(&options()) - .await - else { - panic!("doStream resolved; expected it to reject"); - }; - let AiMuxError::ApiCall(detail) = &error else { - panic!("expected an API call error: {error:?}"); - }; - assert_eq!(detail.message, "Rate limit reached"); - assert_eq!(detail.status_code, Some(429)); - assert!(detail.is_retryable); -} -/// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-prepare-tools.ts:684-688 -/// drops `async: true` for a model without async tool calling and warns with -/// the quoted tool name and the GPT-6 requirement. -#[tokio::test] -async fn responses_async_tool_warning_names_the_gpt_6_requirement() { - let fetch = MockFetch::new(vec![Canned::json(&responses_response())]); - let mut call = options(); - call.tools = Some(vec![Tool::Function(FunctionTool { - provider_options: Some(serde_json::from_value(json!({"openai":{"async":true}})).unwrap()), - ..FunctionTool::new("weather", json!({"type":"object","properties":{}})) - })]); - let result = provider(&fetch) - .responses("gpt-5.6") - .do_generate(&call) - .await - .unwrap(); - assert!( - fetch.seen()[0].json_body()["tools"][0] - .get("async") - .is_none() - ); - assert_eq!( - serde_json::to_value(&result.warnings).unwrap(), - json!([{ - "type": "unsupported", - "feature": "async tool calling for \"weather\"", - "details": "Async tool calling is only supported by GPT-6 and later models." - }]) - ); -} - -/// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-prepare-tools.ts:183-202 -/// and :454-504 quote the `allowedTools` entry in the warning feature and give -/// the reason a tool is dropped: a deferred tool is not visible to -/// `tool_choice.allowed_tools`. -#[tokio::test] -async fn responses_allowed_tools_warnings_quote_the_entry_and_give_the_reason() { - let fetch = MockFetch::new(vec![Canned::json(&responses_response())]); - let mut call = options(); - call.tools = Some(vec![ - Tool::Function(FunctionTool::new( - "get_weather", - json!({"type":"object","properties":{}}), - )), - Tool::Function(FunctionTool { - provider_options: Some( - serde_json::from_value(json!({"openai":{"deferLoading":true}})).unwrap(), - ), - ..FunctionTool::new("deferred", json!({"type":"object","properties":{}})) - }), - ]); - call.provider_options = Some( - serde_json::from_value( - json!({"openai":{"allowedTools":{"toolNames":["get_weather","deferred"]}}}), - ) - .unwrap(), - ); - let result = provider(&fetch) - .responses("gpt-5.4") - .do_generate(&call) - .await - .unwrap(); - assert_eq!( - fetch.seen()[0].json_body()["tool_choice"], - json!({"type":"allowed_tools","mode":"auto","tools":[{"type":"function","name":"get_weather"}]}) - ); - assert_eq!( - serde_json::to_value(&result.warnings).unwrap(), - json!([{ - "type": "unsupported", - "feature": "allowedTools entry \"deferred\"", - "details": "deferred tools are not visible to tool_choice.allowed_tools; the tool is removed from the allowed tools" - }]) - ); -} - -/// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-prepare-tools.ts:509-515 -/// names the dropped entries when no `allowedTools` entry can be expressed. -#[tokio::test] -async fn responses_allowed_tools_error_names_the_dropped_entries() { - let fetch = MockFetch::new(vec![Canned::json(&responses_response())]); - let mut call = options(); - call.tools = Some(vec![Tool::Function(FunctionTool { - provider_options: Some( - serde_json::from_value(json!({"openai":{"deferLoading":true}})).unwrap(), - ), - ..FunctionTool::new("deferred", json!({"type":"object","properties":{}})) - })]); - call.provider_options = Some( - serde_json::from_value(json!({"openai":{"allowedTools":{"toolNames":["deferred"]}}})) - .unwrap(), - ); - let error = provider(&fetch) - .responses("gpt-5.4") - .do_generate(&call) - .await - .unwrap_err(); - assert!( - matches!(&error, AiMuxError::UnsupportedFunctionality(message) - if message == "allowedTools with only tools that cannot be allow-listed (deferred)"), - "{error:?}" - ); - assert!(fetch.seen().is_empty()); -} - -/// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-prepare-tools.ts:174-178 -/// rejects two descriptions for one tool namespace, quoting its name. -#[tokio::test] -async fn responses_conflicting_namespace_descriptions_quote_the_namespace() { - let fetch = MockFetch::new(vec![Canned::json(&responses_response())]); - let namespaced = |name: &str, description: &str| { - Tool::Function(FunctionTool { - provider_options: Some( - serde_json::from_value( - json!({"openai":{"namespace":{"name":"crm","description":description}}}), - ) - .unwrap(), - ), - ..FunctionTool::new(name, json!({"type":"object","properties":{}})) - }) - }; - let mut call = options(); - call.tools = Some(vec![ - namespaced("get_customer", "CRM tools"), - namespaced("list_orders", "Other CRM tools"), - ]); - let error = provider(&fetch) - .responses("gpt-5.4") - .do_generate(&call) - .await - .unwrap_err(); - assert!( - matches!(&error, AiMuxError::UnsupportedFunctionality(message) - if message == "conflicting descriptions for OpenAI tool namespace \"crm\""), - "{error:?}" - ); -} - -/// Upstream: @ai-sdk/openai 4.0.83 src/responses/convert-to-openai-responses-input.ts:142-145 -/// and src/responses/openai-responses-prepare-tools.ts:780-783 resolve file and -/// skill references with `resolveProviderReference`, whose -/// `NoSuchProviderReferenceError` names the provider and the available ones. -#[tokio::test] -async fn responses_missing_provider_reference_names_the_available_providers() { - let fetch = MockFetch::new(vec![]); - let model = provider(&fetch).responses("gpt-5.4"); - let file_call = CallOptions::new(vec![ - serde_json::from_value(json!({"role":"user","content":[{"type":"file","mediaType":"image/png","data":{"type":"reference","reference":{"anthropic":"file-xyz"}}}]})) - .unwrap(), - ]); - let mut skill_call = options(); - skill_call.tools = Some(vec![ - serde_json::from_value(json!({"type":"provider","id":"openai.shell","name":"shell","args":{"environment":{"type":"containerAuto","skills":[{"type":"skillReference","providerReference":{"anthropic":"skill_abc"}}]}}})) - .unwrap(), - ]); - for call in [file_call, skill_call] { - let error = model.do_generate(&call).await.unwrap_err(); - assert!( - matches!(&error, AiMuxError::InvalidArgument { message, .. } - if message == "No provider reference found for provider 'openai'. Available providers: anthropic"), - "{error:?}" - ); - } - assert!(fetch.seen().is_empty()); -} - -/// Upstream: @ai-sdk/openai 4.0.83 src/responses/openai-responses-language-model.ts:455-492 -/// warns about a request-level `reasoningEffortUpdate` the model does not -/// support by listing the efforts it does support, and omits the update. -#[tokio::test] -async fn responses_unsupported_reasoning_effort_update_lists_the_supported_efforts() { - let fetch = MockFetch::new(vec![Canned::json(&responses_response())]); - let mut call = options(); - call.provider_options = - Some(serde_json::from_value(json!({"openai":{"reasoningEffortUpdate":"none"}})).unwrap()); - let result = provider(&fetch) - .responses("gpt-6-astra") - .do_generate(&call) - .await - .unwrap(); - assert_eq!( - fetch.seen()[0].json_body()["input"], - json!([{"role":"user","content":[{"type":"input_text","text":"Hello"}]}]) - ); - assert_eq!( - serde_json::to_value(&result.warnings).unwrap(), - json!([{ - "type": "unsupported", - "feature": "reasoningEffortUpdate", - "details": "gpt-6-astra only supports the following reasoning efforts: low, medium, high, xhigh, max" - }]) - ); -} - -/// Upstream: @ai-sdk/openai 4.0.83 src/responses/convert-to-openai-responses-input.ts:780-842 -/// replays a provider-executed tool search or program call in full when -/// responses are not stored: the tool search and program checks come before -/// the provider-executed one. -#[tokio::test] -async fn responses_replays_hosted_tool_search_and_program_calls_without_storage() { - let fetch = MockFetch::new(vec![Canned::json(&responses_response())]); - let mut call = CallOptions::new(vec![ - serde_json::from_value(json!({"role":"assistant","content":[ - {"type":"tool-call","toolCallId":"tsc_1","toolName":"tool_search","input":{"arguments":{"paths":["get_weather"]},"call_id":null},"providerExecuted":true,"providerOptions":{"openai":{"itemId":"tsc_1"}}}, - {"type":"tool-call","toolCallId":"program_call_1","toolName":"program","input":{"code":"1","fingerprint":"fp"},"providerExecuted":true,"providerOptions":{"openai":{"itemId":"program_item_1"}}} - ]})) - .unwrap(), - ]); - call.tools = Some(vec![ - serde_json::from_value( - json!({"type":"provider","id":"openai.tool_search","name":"tool_search","args":{}}), - ) - .unwrap(), - serde_json::from_value( - json!({"type":"provider","id":"openai.programmatic_tool_calling","name":"program","args":{}}), - ) - .unwrap(), - ]); - call.provider_options = - Some(serde_json::from_value(json!({"openai":{"store":false}})).unwrap()); - provider(&fetch) - .responses("gpt-5.4") - .do_generate(&call) - .await - .unwrap(); - assert_eq!( - fetch.seen()[0].json_body()["input"], - json!([ - {"type":"tool_search_call","id":"tsc_1","execution":"server","call_id":null,"status":"completed","arguments":{"paths":["get_weather"]}}, - {"type":"program","id":"program_item_1","call_id":"program_call_1","code":"1","fingerprint":"fp"} - ]) - ); -} - -/// Upstream: @ai-sdk/openai 4.0.83 src/responses/convert-to-openai-responses-input.ts:1646-1683 -/// sends the `caller` provider option of a tool result on its -/// `function_call_output`, linking a nested call to its program. -#[tokio::test] -async fn responses_function_call_output_keeps_the_program_caller() { - let fetch = MockFetch::new(vec![Canned::json(&responses_response())]); - let call = CallOptions::new(vec![ - serde_json::from_value(json!({"role":"tool","content":[ - {"type":"tool-result","toolCallId":"function_call_1","toolName":"get_inventory","output":{"type":"text","value":"42"},"providerOptions":{"openai":{"caller":{"type":"program","callerId":"program_call_1"}}}} - ]})) - .unwrap(), - ]); - provider(&fetch) - .responses("gpt-5.4") - .do_generate(&call) - .await - .unwrap(); - assert_eq!( - fetch.seen()[0].json_body()["input"], - json!([{"type":"function_call_output","call_id":"function_call_1","output":"42","caller":{"type":"program","caller_id":"program_call_1"}}]) - ); -} diff --git a/scripts/check_provider_boundaries.sh b/scripts/check_provider_boundaries.sh index 3337dab75..c97f3fdfd 100755 --- a/scripts/check_provider_boundaries.sh +++ b/scripts/check_provider_boundaries.sh @@ -61,7 +61,7 @@ rule1_hits="$( { grep -rnE "$rule1_pattern" aimux-providers/src --include='*.rs' || true; } | grep -v 'HttpRequest::new(' | grep -v '^aimux-providers/src/replay\.rs:' | - grep -vE '^aimux-providers/src/(elevenlabs\.rs|openai/transcription\.rs):[0-9]+: *timeout: options\.timeout,$' || + grep -vE '^aimux-providers/src/(elevenlabs\.rs|openai/transcription/transcription_model\.rs):[0-9]+: *timeout: options\.timeout,$' || true )" violation 'aimux-providers/src reads options.max_retries / timeout / session_id / call_id' "$rule1_hits"