diff --git a/aimux-providers/layout.toml b/aimux-providers/layout.toml index de6e0328f..a0ca39bdc 100644 --- a/aimux-providers/layout.toml +++ b/aimux-providers/layout.toml @@ -506,140 +506,140 @@ package = "@ai-sdk/openai-compatible" [[openai_compatible.files]] upstream = "src/chat/convert-openai-compatible-chat-usage.ts" -rust = "openai_compatible/chat.rs" +rust = "openai_compatible/chat/convert_chat_usage.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/chat/convert-to-openai-compatible-chat-messages.ts" -rust = "openai_compatible/convert.rs" +rust = "openai_compatible/chat/convert_to_chat_messages.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/chat/get-response-metadata.ts" -rust = "openai_compatible/chat.rs" +rust = "openai_compatible/chat/chat_language_model.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/chat/map-openai-compatible-finish-reason.ts" -rust = "openai_compatible/convert.rs" +rust = "openai_compatible/chat/map_finish_reason.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/chat/openai-compatible-api-types.ts" -rust = "openai_compatible/types.rs" +rust = "openai_compatible/chat/api_types.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/chat/openai-compatible-chat-language-model-options.ts" -rust = "openai_compatible/config.rs" +rust = "openai_compatible/chat/chat_language_model_options.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/chat/openai-compatible-chat-language-model.ts" -rust = "openai_compatible/chat.rs" +rust = "openai_compatible/chat/chat_language_model.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/chat/openai-compatible-metadata-extractor.ts" -rust = "openai_compatible/config.rs" +rust = "openai_compatible/chat/metadata_extractor.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/chat/openai-compatible-prepare-tools.ts" -rust = "openai_compatible/convert.rs" +rust = "openai_compatible/chat/prepare_tools.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/completion/convert-openai-compatible-completion-usage.ts" -rust = "openai_compatible/completion.rs" +rust = "openai_compatible/completion/convert_completion_usage.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/completion/convert-to-openai-compatible-completion-prompt.ts" -rust = "openai_compatible/completion.rs" +rust = "openai_compatible/completion/convert_to_completion_prompt.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/completion/get-response-metadata.ts" -rust = "openai_compatible/completion.rs" +rust = "openai_compatible/completion/get_response_metadata.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/completion/map-openai-compatible-finish-reason.ts" -rust = "openai_compatible/convert.rs" +rust = "openai_compatible/chat/map_finish_reason.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/completion/openai-compatible-completion-language-model-options.ts" -rust = "openai_compatible/completion.rs" +rust = "openai_compatible/completion/completion_language_model.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/completion/openai-compatible-completion-language-model.ts" -rust = "openai_compatible/completion.rs" +rust = "openai_compatible/completion/completion_language_model.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/embedding/openai-compatible-embedding-model-options.ts" -rust = "openai_compatible/embedding.rs" +rust = "openai_compatible/embedding/embedding_model_options.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/embedding/openai-compatible-embedding-model.ts" -rust = "openai_compatible/embedding.rs" +rust = "openai_compatible/embedding/embedding_model.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/image/openai-compatible-image-model-options.ts" -rust = "openai_compatible/image.rs" +rust = "openai_compatible/image/image_model.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/image/openai-compatible-image-model.ts" -rust = "openai_compatible/image.rs" +rust = "openai_compatible/image/image_model.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/image/openai-compatible-image-settings.ts" -rust = "openai_compatible/image.rs" +rust = "openai_compatible/image/image_model.rs" status = "mapped" [openai_compatible.files.tests] @@ -660,14 +660,14 @@ status = "mapped" [[openai_compatible.files]] upstream = "src/openai-compatible-error.ts" -rust = "openai_compatible/config.rs" +rust = "openai_compatible/error.rs" status = "mapped" [openai_compatible.files.tests] [[openai_compatible.files]] upstream = "src/openai-compatible-provider.ts" -rust = "openai_compatible/mod.rs" +rust = "openai_compatible/provider.rs" status = "mapped" [openai_compatible.files.tests] @@ -678,7 +678,7 @@ ts-only = [ [[openai_compatible.files]] upstream = "src/utils/to-camel-case.ts" -rust = "openai_compatible/convert.rs" +rust = "openai_compatible/utils/to_camel_case.rs" status = "mapped" [openai_compatible.files.tests] diff --git a/aimux-providers/src/decision_support.rs b/aimux-providers/src/decision_support.rs index 0ed1416d7..c82242ad9 100644 --- a/aimux-providers/src/decision_support.rs +++ b/aimux-providers/src/decision_support.rs @@ -79,7 +79,7 @@ pub(crate) fn take_images( } use crate::openai::config::OpenAIModelConfig; -use crate::openai_compatible::config::CompatModelConfig; +use crate::openai_compatible::CompatModelConfig; /// The connection a decision adapter borrows from the provider that made it: /// native OpenAI, or an OpenAI-compatible preset (a local runtime, diff --git a/aimux-providers/src/openai_compatible/types.rs b/aimux-providers/src/openai_compatible/chat/api_types.rs similarity index 99% rename from aimux-providers/src/openai_compatible/types.rs rename to aimux-providers/src/openai_compatible/chat/api_types.rs index 6e182cebf..23dae3b8e 100644 --- a/aimux-providers/src/openai_compatible/types.rs +++ b/aimux-providers/src/openai_compatible/chat/api_types.rs @@ -8,7 +8,6 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; // ── Non-streaming response ── - #[derive(Debug, Deserialize)] pub(crate) struct ChatCompletionResponse { #[serde(default)] diff --git a/aimux-providers/src/openai_compatible/chat.rs b/aimux-providers/src/openai_compatible/chat/chat_language_model.rs similarity index 64% rename from aimux-providers/src/openai_compatible/chat.rs rename to aimux-providers/src/openai_compatible/chat/chat_language_model.rs index 061765656..4c3aebac4 100644 --- a/aimux-providers/src/openai_compatible/chat.rs +++ b/aimux-providers/src/openai_compatible/chat/chat_language_model.rs @@ -1,38 +1,154 @@ -//! The OpenAI-compatible chat language model (`{name}.chat`). +//! The OpenAI-compatible chat language model (`{name}.chat`, +//! `openai-compatible-chat-language-model.ts`). //! -//! The Rust form of `OpenAICompatibleChatLanguageModel`. Request construction -//! lives in [`super::convert`]; vendor differences arrive through the -//! [`ChatSettings`](super::config::ChatSettings) of the model's config. +//! The Rust form of `OpenAICompatibleChatLanguageModel`, with its config +//! ([`ChatSettings`]) and `getArgs` ([`build_request_body`]). Vendor differences +//! arrive as [`ChatSettings`] data; the functions a caller or a vendor package +//! passes (`supportedUrls`, `convertUsage`, `errorStructure`, +//! `metadataExtractor`) are kept as given. +//! +//! The body is built in the AI SDK's order (model, `user`, sampling settings, +//! `response_format`, `stop`, `seed`, the pass-through fields of the provider's +//! own options namespace, then `reasoning_effort`, `verbosity`, `messages`, +//! `tools`, `tool_choice`). -use aimux_core::tool::RawToolCall; use std::collections::{HashMap, HashSet}; - -use async_trait::async_trait; -use serde_json::{Map, Value, json}; +use std::sync::Arc; use aimux_core::error::AiMuxError; use aimux_core::language_model::{LanguageModel, SupportedUrls}; -use aimux_core::options::CallOptions; +use aimux_core::options::{CallOptions, ResponseFormat}; use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source, StreamResult}; use aimux_core::shared::{ RequestInfo, ResponseInfo, SharedProviderMetadata, StreamResponseInfo, provider_namespace, }; use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReason, InputTokenUsage, OutputTokenUsage, ResponseMetadata, Usage}; +use aimux_core::tool::RawToolCall; +use aimux_core::types::{FinishReason, ResponseMetadata, Usage, Warning}; use aimux_provider_utils::{Transformer, generate_id, pipe_through}; +use async_trait::async_trait; +use futures::future::BoxFuture; +use serde_json::{Map, Value, json}; -use super::config::{ChatSettings, CompatModelConfig, StreamMetadataExtractor}; -use super::convert::{ChatBodySpec, RequestBodyResult, build_request_body, parse_finish_reason}; -use super::types::{ChatCompletionResponse, StreamChunk, UsageResponse}; +use crate::openai_compatible::CompatModelConfig; +use crate::openai_compatible::error::{ErrorStructure, default_error_structure}; +use crate::openai_compatible::utils::to_camel_case; use crate::stream::{ ChatToolCalls, Controller, Emit, FINISH_ERROR, FINISH_OTHER, StreamContext, ToolCallDelta, }; +use super::api_types::{ChatCompletionResponse, StreamChunk, UsageResponse}; +use super::chat_language_model_options::{SCHEMA_KEYS, option_string, validate_options}; +use super::convert_chat_usage::usage_from_raw; +use super::convert_to_chat_messages::{MessageSpec, convert_messages}; +use super::map_finish_reason::parse_finish_reason; +use super::metadata_extractor::{MetadataExtractor, StreamMetadataExtractor}; +use super::prepare_tools::prepare_function_tools; + +/// Converts the raw optional token usage of a chat response. +pub type ConvertUsage = Arc) -> Usage + Send + Sync>; + +/// Returns the URLs supported by chat models, read each time a chat model's +/// supported URLs are: `supportedUrls?: () => LanguageModelV4['supportedUrls']` +/// (openai-compatible@3.0.62 `openai-compatible-chat-language-model.ts:84`, +/// `:161-163`), whose value may be a `PromiseLike`. +pub type SupportedUrlsFn = Arc BoxFuture<'static, SupportedUrls> + Send + Sync>; + +/// The chat settings of a compatible endpoint: the AI SDK's +/// `includeUsage` / `supportsStructuredOutputs` / +/// `supportsMultiPartToolContent` / `supportedUrls` / `convertUsage` / +/// `metadataExtractor`, plus the preset capability data of RFC-0036 section +/// 5. +#[derive(Clone)] +pub(crate) struct ChatSettings { + pub include_usage: bool, + pub supports_structured_outputs: bool, + /// When set, only these chat models take structured outputs, whatever + /// `supports_structured_outputs` says. Ids compare exactly; an entry + /// ending in `*` matches every id with that prefix. A preset's + /// `profile.structured_output_models`, the vendor package's per-model + /// `supportsStructuredOutputs`. + pub structured_output_models: Option<&'static [String]>, + /// `supportsMultiPartToolContent`: a `content` tool output is sent as an + /// array of content parts instead of a JSON string. + pub supports_multi_part_tool_content: bool, + /// URL patterns supported by chat models, evaluated when requested. + /// `None`: none. + pub supported_urls: Option, + /// The vendor's or the caller's usage accounting. `None`: the generic + /// OpenAI-shaped usage (`convertOpenAICompatibleChatUsage`). + pub convert_usage: Option, + pub metadata_extractor: Option>, + /// Send `top_k`. The AI SDK baseline never does (it warns and drops it). + pub supports_top_k: bool, + /// Send `tools` / `tool_choice`. + pub supports_tools: bool, + /// Send `response_format`. + pub supports_response_format: bool, + /// The only `max-tokens` key the vendor accepts: `"max_tokens"` or + /// `"max_completion_tokens"`. + pub max_tokens_key: Option<&'static str>, + /// Streaming usage rides in `chunk[key].usage` instead of `chunk.usage`. + pub stream_usage_key: Option, + pub error_structure: ErrorStructure, +} + +impl Default for ChatSettings { + /// The AI SDK baseline. + fn default() -> Self { + Self { + include_usage: false, + supports_structured_outputs: false, + structured_output_models: None, + supports_multi_part_tool_content: false, + supported_urls: None, + convert_usage: None, + metadata_extractor: None, + supports_top_k: false, + supports_tools: true, + supports_response_format: true, + max_tokens_key: None, + stream_usage_key: None, + error_structure: Arc::new(default_error_structure), + } + } +} + +impl ChatSettings { + /// These settings for the chat model `model_id`. + pub(crate) fn for_model(mut self, model_id: &str) -> Self { + if let Some(models) = self.structured_output_models { + self.supports_structured_outputs = models.iter().any(|model| { + model + .strip_suffix('*') + .map_or(model == model_id, |prefix| model_id.starts_with(prefix)) + }); + } + self + } + + /// The URL patterns chat models fetch themselves. + pub(crate) async fn supported_urls(&self) -> SupportedUrls { + match &self.supported_urls { + Some(supported_urls) => supported_urls().await, + None => SupportedUrls::default(), + } + } + + /// The core usage of a chat response's raw `usage` object. + pub(crate) fn convert_usage(&self, raw: Option<&Value>) -> Usage { + match &self.convert_usage { + Some(convert) => convert(raw), + None => usage_from_raw(raw), + } + } +} + /// An OpenAI-compatible chat model. Holds no HTTP client: the request helpers /// of `aimux-provider-utils` use the injected or process-default transport. pub struct OpenAICompatibleChatModel { model_id: String, - pub(super) config: CompatModelConfig, + pub(in crate::openai_compatible) config: CompatModelConfig, } impl OpenAICompatibleChatModel { @@ -66,59 +182,240 @@ impl OpenAICompatibleChatModel { } } -// ── Usage ──────────────────────────────────────────────────────────────────── +/// What the converter needs to know about the provider it builds for. +pub(crate) struct ChatBodySpec<'a> { + /// First segment of the provider string: the providerOptions namespace. + pub provider_options_name: &'a str, + pub chat: &'a ChatSettings, +} -/// Core usage from a parsed OpenAI-shaped `usage` object. -/// -/// `raw` is the provider's original object, kept verbatim in `Usage.raw`. -/// Cache reads come from `prompt_tokens_details`, as in the upstream converter. -pub(crate) fn convert_usage(usage: &UsageResponse, raw: Option<&Value>) -> Usage { - let prompt_tokens = usage.prompt_tokens.unwrap_or(0); - let completion_tokens = usage.completion_tokens.unwrap_or(0); - - let cached = usage - .prompt_tokens_details - .as_ref() - .and_then(|d| d.cached_tokens) - .unwrap_or(0); - - // Saturating: some servers report more cached or reasoning tokens than - // prompt or completion tokens. - let no_cache = prompt_tokens.saturating_sub(cached); - let reasoning_tokens = usage - .completion_tokens_details +/// A request body and what building it produced. +#[derive(Debug, Clone)] +pub(crate) struct RequestBodyResult { + /// The JSON body, before the provider's `transform_request_body`. + pub body: Value, + /// Warnings raised while building it. + pub warnings: Vec, + /// The key provider metadata is reported under. + pub metadata_key: String, +} + +fn namespace<'a>(options: &'a CallOptions, key: &str) -> Option<&'a Map> { + options + .provider_options .as_ref() - .and_then(|d| d.reasoning_tokens) - .unwrap_or(0); - - Usage { - input_tokens: InputTokenUsage { - total: Some(prompt_tokens), - no_cache: Some(no_cache), - cache_read: Some(cached), - cache_write: None, - }, - output_tokens: OutputTokenUsage { - total: Some(completion_tokens), - text: Some(completion_tokens.saturating_sub(reasoning_tokens)), - reasoning: Some(reasoning_tokens), - }, - raw: raw.and_then(Value::as_object).cloned(), + .and_then(|all| all.get(key)) +} + +/// The key provider metadata is reported under (`resolveProviderOptionsKey`): +/// the camelCase name when supplied, otherwise the raw provider name. +fn resolve_metadata_key(name: &str, options: &CallOptions) -> String { + let camel = to_camel_case(name); + if camel != name && namespace(options, &camel).is_some() { + camel + } else { + name.to_string() } } -/// Usage from a raw `usage` value. -pub(crate) fn usage_from_raw(raw: Option<&Value>) -> Usage { - let Some(raw) = raw.filter(|value| !value.is_null()) else { - return Usage::default(); - }; - match serde_json::from_value::(raw.clone()) { - Ok(parsed) => convert_usage(&parsed, Some(raw)), - Err(_) => Usage { - raw: raw.as_object().cloned(), - ..Usage::default() +/// Build the request body (`getArgs`). +/// +/// # Errors +/// +/// `InvalidArgument` for a malformed provider option or a prompt part that has +/// no wire form. +pub(crate) fn build_request_body( + model_id: &str, + options: &CallOptions, + stream: bool, + spec: &ChatBodySpec<'_>, +) -> Result { + let name = spec.provider_options_name; + let camel = to_camel_case(name); + let chat = spec.chat; + let mut warnings = Vec::new(); + + // Resolved provider options: the generic namespace, then the provider's + // own, then its camelCase form; later wins per key. + let mut merged = Map::new(); + if let Some(deprecated) = namespace(options, "openai-compatible") { + validate_options(deprecated, "openai-compatible")?; + warnings.push(Warning::Deprecated { + setting: "providerOptions key 'openai-compatible'".to_string(), + message: "Use 'openaiCompatible' instead.".to_string(), + }); + merged.extend(deprecated.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + for key in ["openaiCompatible", name, camel.as_str()] { + if let Some(object) = namespace(options, key) { + validate_options(object, key)?; + merged.extend(object.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + } + if camel != name && namespace(options, name).is_some() { + warnings.push(Warning::Deprecated { + setting: format!("providerOptions key '{name}'"), + message: format!("Use '{camel}' instead."), + }); + } + + let user = option_string(&merged, name, "user")?; + let reasoning_effort = option_string(&merged, name, "reasoningEffort")?.or_else(|| { + options + .reasoning + .filter(|effort| effort.is_custom()) + .map(|effort| effort.to_string()) + }); + let verbosity = option_string(&merged, name, "textVerbosity")?; + let strict_json_schema = merged + .get("strictJsonSchema") + .and_then(Value::as_bool) + .unwrap_or(true); + + let metadata_key = resolve_metadata_key(name, options); + let mut body = Map::new(); + body.insert("model".into(), json!(model_id)); + if let Some(user) = user { + body.insert("user".into(), json!(user)); + } + if let Some(max_tokens) = options.max_output_tokens { + body.insert( + chat.max_tokens_key.unwrap_or("max_tokens").into(), + json!(max_tokens), + ); + } + for (key, value) in [ + ("temperature", options.temperature), + ("top_p", options.top_p), + ("frequency_penalty", options.frequency_penalty), + ("presence_penalty", options.presence_penalty), + ] { + if let Some(value) = value { + body.insert(key.into(), json!(value)); + } + } + if let Some(top_k) = options.top_k { + if chat.supports_top_k { + body.insert("top_k".into(), json!(top_k)); + } else { + warnings.push(Warning::Unsupported { + feature: "topK".to_string(), + details: None, + }); + } + } + + match &options.response_format { + Some(ResponseFormat::Json { + schema, + name: format_name, + description, + }) if chat.supports_response_format => match schema { + Some(schema) if chat.supports_structured_outputs => { + let mut json_schema = Map::new(); + json_schema.insert("schema".into(), schema.clone()); + json_schema.insert("strict".into(), json!(strict_json_schema)); + json_schema.insert( + "name".into(), + json!(format_name.clone().unwrap_or_else(|| "response".into())), + ); + if let Some(description) = description { + json_schema.insert("description".into(), json!(description)); + } + body.insert( + "response_format".into(), + json!({ "type": "json_schema", "json_schema": json_schema }), + ); + } + Some(_) => { + warnings.push(Warning::Unsupported { + feature: "responseFormat".to_string(), + details: Some( + "JSON response format schema is only supported with structuredOutputs" + .to_string(), + ), + }); + body.insert("response_format".into(), json!({ "type": "json_object" })); + } + None => { + body.insert("response_format".into(), json!({ "type": "json_object" })); + } + }, + Some(ResponseFormat::Json { .. }) => warnings.push(Warning::Unsupported { + feature: "responseFormat".to_string(), + details: Some("response_format is not supported by this provider".to_string()), + }), + Some(ResponseFormat::Text) | None => {} + } + + if let Some(stop) = &options.stop_sequences { + body.insert("stop".into(), json!(stop)); + } + if let Some(seed) = options.seed { + body.insert("seed".into(), json!(seed)); + } + + // Fields of the provider's own namespace that the schema does not know go + // to the body as given (the generic `openaiCompatible` namespace is + // schema-only: unknown fields there are dropped). + for key in [name, camel.as_str()] { + if let Some(object) = namespace(options, key) { + for (field, value) in object { + if !SCHEMA_KEYS.contains(&field.as_str()) { + body.insert(field.clone(), value.clone()); + } + } + } + } + + if let Some(effort) = reasoning_effort { + body.insert("reasoning_effort".into(), json!(effort)); + } + if let Some(verbosity) = verbosity { + body.insert("verbosity".into(), json!(verbosity)); + } + + let messages = convert_messages( + &options.prompt, + &MessageSpec { + metadata_key: &metadata_key, + supports_multi_part_tool_content: chat.supports_multi_part_tool_content, }, + )?; + body.insert("messages".into(), Value::Array(messages)); + + if chat.supports_tools { + let prepared = prepare_function_tools(options); + warnings.extend(prepared.warnings); + if let Some(tools) = prepared.tools { + body.insert("tools".into(), Value::Array(tools)); + } + if let Some(choice) = prepared.tool_choice { + body.insert("tool_choice".into(), choice); + } + } else if options + .tools + .as_ref() + .is_some_and(|tools| !tools.is_empty()) + { + warnings.push(Warning::Unsupported { + feature: "tools".to_string(), + details: Some("tools are not supported by this provider".to_string()), + }); + } + + if stream { + body.insert("stream".into(), json!(true)); + if chat.include_usage { + body.insert("stream_options".into(), json!({ "include_usage": true })); + } } + + Ok(RequestBodyResult { + body: Value::Object(body), + warnings, + metadata_key, + }) } fn prediction_tokens(usage: Option<&UsageResponse>, metadata: &mut Map) { @@ -145,7 +442,7 @@ fn thought_signature(extra_content: Option<&Value>) -> Option { /// (openai-compatible-chat-language-model.ts:588-595, /// openai-compatible-completion-language-model.ts:320-325): no status is /// read from its `code`, numeric or not, so nothing makes it retryable. -pub(super) fn stream_error( +pub(in crate::openai_compatible) fn stream_error( name: &str, error: &Value, url: &str, @@ -174,8 +471,6 @@ pub(super) fn stream_error( ) } -// ── LanguageModel ──────────────────────────────────────────────────────────── - #[async_trait] impl LanguageModel for OpenAICompatibleChatModel { /// `"{name}.chat"`. @@ -362,6 +657,7 @@ impl LanguageModel for OpenAICompatibleChatModel { } const TEXT_ID: &str = "txt-0"; + const REASONING_ID: &str = "reasoning-0"; /// The `TransformStream` of [`OpenAICompatibleChatModel::do_stream`]: turns @@ -713,183 +1009,3 @@ fn content_parts(content: Option<&Value>) -> Vec { }) .collect() } - -/// One `GET {base_url}/models` exchange for discovery: no retry, no -/// recording. -pub(crate) async fn list_models_once( - config: &CompatModelConfig, -) -> Result, AiMuxError> { - #[derive(serde::Deserialize)] - struct ModelsList { - #[serde(default)] - data: Vec, - } - #[derive(serde::Deserialize)] - struct ModelEntry { - id: String, - #[serde(default)] - owned_by: Option, - #[serde(default)] - created: Option, - } - - let headers = config.request_headers(None).await?; - let (url, origin) = config.url("/models")?; - let resp = aimux_provider_utils::get_from_api( - config.with_transport( - aimux_provider_utils::HttpRequest { - url, - headers: headers.list, - ..Default::default() - }, - origin, - headers.fetch_user_agent, - ), - aimux_provider_utils::create_json_response_handler::(), - config.failed_response_handler(), - ) - .await?; - Ok(resp - .value - .data - .into_iter() - .map(|m| aimux_core::model_catalogue::RuntimeModel { - id: m.id, - owned_by: m.owned_by, - created: m.created, - }) - .collect()) -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use serde_json::{Value, json}; - use wiremock::matchers::{method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - use aimux_core::error::AiMuxError; - use aimux_core::language_model::LanguageModel; - use aimux_core::language_model_message::LanguageModelMessage; - use aimux_core::options::CallOptions; - use aimux_provider_utils::ProviderErrorParts; - - use crate::openai_compatible::config::{BaseUrl, ChatSettings}; - use crate::openai_compatible::{Assembly, OpenAICompatibleProvider}; - use crate::shared::Credential; - - fn hello() -> CallOptions { - CallOptions::new(vec![LanguageModelMessage::user_text("hi")]) - } - - fn provider(base_url: BaseUrl, chat: ChatSettings) -> OpenAICompatibleProvider { - OpenAICompatibleProvider::assemble(Assembly { - name: "acme".to_string(), - base_url, - credential: Credential::None, - fixed_headers: Vec::new(), - headers: None, - query_params: None, - fetch: None, - transform_request_body: None, - chat, - package: crate::shared::aisdk_pins::OPENAI_COMPATIBLE, - }) - .unwrap() - } - - async fn serve(server: &MockServer, response: ResponseTemplate) { - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(response) - .mount(server) - .await; - } - - #[tokio::test] - async fn an_error_structure_decides_the_message_and_code() { - let server = MockServer::start().await; - serve( - &server, - ResponseTemplate::new(400).set_body_json(json!({"detail": "bad prompt", "kind": 42})), - ) - .await; - let chat = ChatSettings { - error_structure: Arc::new(|data: &Value| ProviderErrorParts { - message: data["detail"].as_str().unwrap_or_default().to_string(), - provider_code: data["kind"].as_u64().map(|k| k.to_string()), - }), - ..ChatSettings::default() - }; - let error = provider(BaseUrl::Fixed(server.uri()), chat) - .chat("m") - .do_generate(&hello()) - .await - .unwrap_err(); - match error { - AiMuxError::ApiCall(detail) => { - assert_eq!(detail.message, "bad prompt"); - assert_eq!(detail.provider_code.as_deref(), Some("42")); - } - other => panic!("expected ApiCall, got {other:?}"), - } - } - - /// A preset base URL is data the request resolves: creating the provider - /// and the model reads nothing, a request that cannot resolve it fails, - /// and the next request resolves it again. - #[tokio::test] - #[serial_test::serial] - async fn a_lazy_base_url_is_evaluated_per_request_and_its_error_fails_the_request() { - use crate::preset::{AuthMode, ParamSpec, PresetDescriptor}; - - const VAR: &str = "AIMUX_TEST_COMPAT_LAZY_BASE_URL"; - let server = MockServer::start().await; - serve( - &server, - ResponseTemplate::new(200).set_body_json(json!({ - "id": "c", "model": "m", - "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}] - })), - ) - .await; - // SAFETY: the test is `#[serial]` and no other test reads `VAR`. - unsafe { std::env::remove_var(VAR) }; - let descriptor: &'static PresetDescriptor = Box::leak(Box::new(PresetDescriptor { - name: "acme".to_string(), - display: "Acme".to_string(), - base_url: "http://{host}/v1".to_string(), - env_var: String::new(), - auth: AuthMode::None, - base_url_env: Some(VAR.to_string()), - max_tokens_key: None, - convert_usage: None, - params: vec![ParamSpec { - name: "host".to_string(), - env: Vec::new(), - default: None, - derive: None, - }], - user_agent: ("openai-compatible", "3.0.62"), - structured_output_models: None, - })); - let lazy = BaseUrl::Preset { - descriptor, - params: Arc::new(std::collections::HashMap::new()), - }; - let provider = provider(lazy, ChatSettings::default()); - let model = provider.chat("m"); - let error = model.do_generate(&hello()).await.unwrap_err(); - assert!( - matches!(error, AiMuxError::InvalidArgument { .. }), - "{error:?}" - ); - // SAFETY: as above. - unsafe { std::env::set_var(VAR, server.uri()) }; - let result = model.do_generate(&hello()).await; - // SAFETY: as above. - unsafe { std::env::remove_var(VAR) }; - result.expect("resolved on the next request"); - } -} diff --git a/aimux-providers/src/openai_compatible/chat/chat_language_model_options.rs b/aimux-providers/src/openai_compatible/chat/chat_language_model_options.rs new file mode 100644 index 000000000..e57f0e378 --- /dev/null +++ b/aimux-providers/src/openai_compatible/chat/chat_language_model_options.rs @@ -0,0 +1,48 @@ +//! The provider options of the chat model +//! (`openai-compatible-chat-language-model-options.ts`): the keys of +//! `openaiCompatibleLanguageModelChatOptions` and their checks. + +use aimux_core::error::AiMuxError; +use serde_json::{Map, Value}; + +/// Option keys of the generic compatible schema +/// (`openaiCompatibleLanguageModelChatOptions`): consumed by the model, never +/// passed through. +pub(super) const SCHEMA_KEYS: [&str; 4] = [ + "user", + "reasoningEffort", + "textVerbosity", + "strictJsonSchema", +]; + +pub(super) fn option_string( + merged: &Map, + namespace_name: &str, + key: &str, +) -> Result, AiMuxError> { + match merged.get(key) { + None => Ok(None), + Some(Value::String(value)) => Ok(Some(value.clone())), + Some(_) => Err(AiMuxError::invalid_argument(format!( + "invalid provider options for \"{namespace_name}\": `{key}` must be a string" + ))), + } +} + +pub(super) fn validate_options( + options: &Map, + namespace_name: &str, +) -> Result<(), AiMuxError> { + for key in ["user", "reasoningEffort", "textVerbosity"] { + option_string(options, namespace_name, key)?; + } + if options + .get("strictJsonSchema") + .is_some_and(|value| !value.is_boolean()) + { + return Err(AiMuxError::invalid_argument(format!( + "invalid provider options for \"{namespace_name}\": `strictJsonSchema` must be a boolean" + ))); + } + Ok(()) +} diff --git a/aimux-providers/src/openai_compatible/chat/convert_chat_usage.rs b/aimux-providers/src/openai_compatible/chat/convert_chat_usage.rs new file mode 100644 index 000000000..f92574907 --- /dev/null +++ b/aimux-providers/src/openai_compatible/chat/convert_chat_usage.rs @@ -0,0 +1,59 @@ +//! The token usage of a chat response (`convert-openai-compatible-chat-usage.ts`). + +use aimux_core::types::{InputTokenUsage, OutputTokenUsage, Usage}; +use serde_json::Value; + +use super::api_types::UsageResponse; + +/// Core usage from a parsed OpenAI-shaped `usage` object. +/// +/// `raw` is the provider's original object, kept verbatim in `Usage.raw`. +/// Cache reads come from `prompt_tokens_details`, as in the upstream converter. +pub(crate) fn convert_usage(usage: &UsageResponse, raw: Option<&Value>) -> Usage { + let prompt_tokens = usage.prompt_tokens.unwrap_or(0); + let completion_tokens = usage.completion_tokens.unwrap_or(0); + + let cached = usage + .prompt_tokens_details + .as_ref() + .and_then(|d| d.cached_tokens) + .unwrap_or(0); + + // Saturating: some servers report more cached or reasoning tokens than + // prompt or completion tokens. + let no_cache = prompt_tokens.saturating_sub(cached); + let reasoning_tokens = usage + .completion_tokens_details + .as_ref() + .and_then(|d| d.reasoning_tokens) + .unwrap_or(0); + + Usage { + input_tokens: InputTokenUsage { + total: Some(prompt_tokens), + no_cache: Some(no_cache), + cache_read: Some(cached), + cache_write: None, + }, + output_tokens: OutputTokenUsage { + total: Some(completion_tokens), + text: Some(completion_tokens.saturating_sub(reasoning_tokens)), + reasoning: Some(reasoning_tokens), + }, + raw: raw.and_then(Value::as_object).cloned(), + } +} + +/// Usage from a raw `usage` value. +pub(crate) fn usage_from_raw(raw: Option<&Value>) -> Usage { + let Some(raw) = raw.filter(|value| !value.is_null()) else { + return Usage::default(); + }; + match serde_json::from_value::(raw.clone()) { + Ok(parsed) => convert_usage(&parsed, Some(raw)), + Err(_) => Usage { + raw: raw.as_object().cloned(), + ..Usage::default() + }, + } +} diff --git a/aimux-providers/src/openai_compatible/chat/convert_to_chat_messages.rs b/aimux-providers/src/openai_compatible/chat/convert_to_chat_messages.rs new file mode 100644 index 000000000..39ed1d765 --- /dev/null +++ b/aimux-providers/src/openai_compatible/chat/convert_to_chat_messages.rs @@ -0,0 +1,379 @@ +//! The prompt to the chat-completions `messages` +//! (`convert-to-openai-compatible-chat-messages.ts`). + +use aimux_core::error::AiMuxError; +use aimux_core::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, + ToolCallPart, ToolPart, ToolResultContent, ToolResultOutput, ToolResultPart, UserPart, +}; +use aimux_core::shared::{FileBytes, FileData, SharedProviderOptions}; +use aimux_provider_utils::{get_top_level_media_type, resolve_full_media_type}; +use base64::Engine; +use serde_json::{Map, Value, json}; + +pub(crate) struct MessageSpec<'a> { + pub metadata_key: &'a str, + /// Send a `content` tool output as content parts, not a JSON string. + pub supports_multi_part_tool_content: bool, +} + +/// The generic `openaiCompatible` metadata of a message or part +/// (`getOpenAIMetadata`). +fn wire_metadata(provider_options: Option<&SharedProviderOptions>) -> Map { + provider_options + .and_then(|options| options.get("openaiCompatible")) + .cloned() + .unwrap_or_default() +} + +fn with_metadata(mut object: Value, metadata: Map) -> Value { + if let Some(map) = object.as_object_mut() { + map.extend(metadata); + } + object +} + +/// `messages` for the body. +/// +/// # Errors +/// +/// `InvalidArgument` when a part has no wire form (an unsupported media type, +/// an audio or PDF part given by URL). +pub(crate) fn convert_messages( + prompt: &LanguageModelPrompt, + spec: &MessageSpec<'_>, +) -> Result, AiMuxError> { + let mut out = Vec::new(); + for message in prompt { + out.extend(convert_message(message, spec)?); + } + Ok(out) +} + +fn convert_message( + message: &LanguageModelMessage, + spec: &MessageSpec<'_>, +) -> Result, AiMuxError> { + match message { + LanguageModelMessage::System { + content, + provider_options, + } => Ok(vec![with_metadata( + json!({ "role": "system", "content": content }), + wire_metadata(provider_options.as_ref()), + )]), + LanguageModelMessage::User { + content, + provider_options, + } => Ok(vec![convert_user_message( + content, + wire_metadata(provider_options.as_ref()), + )?]), + LanguageModelMessage::Assistant { + content, + provider_options, + } => Ok(vec![convert_assistant_message( + content, + spec, + wire_metadata(provider_options.as_ref()), + )]), + LanguageModelMessage::Tool { content, .. } => content + .iter() + .filter_map(|part| match part { + ToolPart::ToolResult(ToolResultPart { + tool_call_id, + output, + provider_options, + .. + }) => Some((tool_call_id, output, provider_options)), + _ => None, + }) + .map(|(tool_call_id, output, provider_options)| { + // convert-to-openai-compatible-chat-messages.ts:284-321: a + // `content` output is an array of content parts only when + // the provider supports multi-part tool content. + let content = match output { + ToolResultOutput::Content { value } + if spec.supports_multi_part_tool_content => + { + Value::Array( + value + .iter() + .map(convert_tool_content_part) + .collect::>()?, + ) + } + output => crate::openai::convert::tool_result_to_content(output), + }; + Ok(with_metadata( + json!({ + "role": "tool", + "tool_call_id": tool_call_id, + "content": content, + }), + wire_metadata(provider_options.as_ref()), + )) + }) + .collect(), + } +} + +/// One part of a multi-part tool result (`convertToolContentPart`, +/// convert-to-openai-compatible-chat-messages.ts:162-176): a text or file part +/// is converted like a user content part; a custom part has no wire form. +fn convert_tool_content_part(part: &ToolResultContent) -> Result { + match part { + ToolResultContent::Text(text) => Ok(convert_text_part(text)), + ToolResultContent::File(file) => convert_file_part(file), + ToolResultContent::Custom { .. } => Err(AiMuxError::UnsupportedFunctionality( + "custom tool content parts".to_string(), + )), + } +} + +fn convert_user_message( + content: &[UserPart], + message_metadata: Map, +) -> Result { + if let [ + UserPart::Text(TextPart { + text, + provider_options, + }), + ] = content + { + let metadata = wire_metadata(provider_options.as_ref()); + return Ok(with_metadata( + json!({ "role": "user", "content": text }), + metadata, + )); + } + let parts = content + .iter() + .map(convert_user_part) + .collect::, _>>()?; + Ok(with_metadata( + json!({ "role": "user", "content": parts }), + message_metadata, + )) +} + +fn convert_user_part(part: &UserPart) -> Result { + match part { + UserPart::Text(text) => Ok(convert_text_part(text)), + UserPart::File(file) => convert_file_part(file), + } +} + +/// A text content part (`convertToOpenAICompatibleContentPart`). +fn convert_text_part(part: &TextPart) -> Value { + with_metadata( + json!({ "type": "text", "text": part.text }), + wire_metadata(part.provider_options.as_ref()), + ) +} + +/// A file content part (`convertToOpenAICompatibleContentPart`). +fn convert_file_part(file: &FilePart) -> Result { + let wire = match &file.data { + FileData::Data { + data: FileBytes::Binary(data), + } => file_part( + file, + FileSource::Base64(&base64::engine::general_purpose::STANDARD.encode(data)), + )?, + FileData::Data { + data: FileBytes::Base64(data), + } => file_part(file, FileSource::Base64(data))?, + FileData::Url { url, .. } => file_part(file, FileSource::Url(url))?, + FileData::Reference { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "file parts with provider references".to_string(), + )); + } + FileData::Text { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "text file parts".to_string(), + )); + } + }; + Ok(with_metadata( + wire, + wire_metadata(file.provider_options.as_ref()), + )) +} + +enum FileSource<'a> { + Base64(&'a str), + Url(&'a str), +} + +fn file_part(part: &FilePart, source: FileSource<'_>) -> Result { + let media_type = part.media_type.as_str(); + let filename = part.filename.as_deref(); + let unsupported = |what: String| AiMuxError::UnsupportedFunctionality(what); + + match get_top_level_media_type(media_type) { + kind @ ("image" | "video") => { + let url = match source { + FileSource::Url(url) => url.to_string(), + FileSource::Base64(b64) => { + format!("data:{};base64,{}", resolve_full_media_type(part)?, b64) + } + }; + let kind = format!("{kind}_url"); + Ok(json!({ "type": kind, kind: { "url": url } })) + } + "audio" => { + let FileSource::Base64(b64) = source else { + return Err(unsupported("audio file parts with URLs".to_string())); + }; + let full_media_type = resolve_full_media_type(part)?; + let format = match full_media_type.as_str() { + "audio/wav" => "wav", + "audio/mp3" | "audio/mpeg" => "mp3", + other => { + return Err(unsupported(format!("audio media type {other}"))); + } + }; + Ok(json!({ "type": "input_audio", "input_audio": { "data": b64, "format": format } })) + } + "application" => { + let FileSource::Base64(b64) = source else { + return Err(unsupported("PDF file parts with URLs".to_string())); + }; + let full_media_type = resolve_full_media_type(part)?; + if full_media_type != "application/pdf" { + return Err(unsupported(format!( + "file part media type {full_media_type}" + ))); + } + let filename = filename.unwrap_or("document.pdf"); + Ok(json!({ + "type": "file", + "file": { + "filename": filename, + "file_data": format!("data:application/pdf;base64,{b64}"), + } + })) + } + "text" => { + let b64 = match source { + FileSource::Url(url) => return Ok(json!({ "type": "text", "text": url })), + FileSource::Base64(b64) => b64, + }; + let bytes = base64::engine::general_purpose::STANDARD + .decode(b64) + .map_err(|e| unsupported(format!("text file part is not valid base64: {e}")))?; + let text = String::from_utf8_lossy(&bytes); + Ok(json!({ "type": "text", "text": text })) + } + _ => Err(unsupported(format!("file part media type {media_type}"))), + } +} + +fn convert_assistant_message( + content: &[AssistantPart], + spec: &MessageSpec<'_>, + message_metadata: Map, +) -> Value { + let key = spec.metadata_key; + let mut text = String::new(); + let mut reasoning = String::new(); + let mut tool_calls = Vec::new(); + let metadata = message_metadata; + for part in content { + match part { + AssistantPart::Text(TextPart { text: t, .. }) => text.push_str(t), + AssistantPart::Reasoning(ReasoningPart { text: t, .. }) => reasoning.push_str(t), + AssistantPart::ToolCall(ToolCallPart { + tool_call_id, + tool_name, + input, + provider_options, + .. + }) => { + let arguments = input.to_string(); + let mut call = json!({ + "id": tool_call_id, + "type": "function", + "function": { "name": tool_name, "arguments": arguments }, + }); + call = with_metadata(call, wire_metadata(provider_options.as_ref())); + let signature = provider_options.as_ref().and_then(|options| { + options + .get(key) + .and_then(|options| options.get("thoughtSignature")) + .filter(|value| !value.is_null()) + .or_else(|| { + options + .get("google") + .and_then(|options| options.get("thoughtSignature")) + }) + }); + let signature = signature.and_then(|value| match value { + Value::Null | Value::Bool(false) => None, + Value::String(text) if text.is_empty() => None, + Value::Number(number) if number.as_f64() == Some(0.0) => None, + Value::String(text) => Some(text.clone()), + other => Some(signature_string(other)), + }); + if let Some(signature) = signature { + call["extra_content"] = json!({ "google": { "thought_signature": signature } }); + } + tool_calls.push(call); + } + // Provider-executed results and media have no assistant-content + // form on this wire. + _ => {} + } + } + let mut wire = Map::new(); + wire.insert("role".into(), json!("assistant")); + wire.insert( + "content".into(), + if tool_calls.is_empty() || !text.is_empty() { + json!(text) + } else { + Value::Null + }, + ); + if !reasoning.is_empty() { + wire.insert("reasoning_content".into(), json!(reasoning)); + } + if !tool_calls.is_empty() { + wire.insert("tool_calls".into(), Value::Array(tool_calls)); + } + wire.extend(metadata); + Value::Object(wire) +} + +// String(value), as used by the compatible package for thought signatures. +fn signature_string(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + Value::Object(_) => "[object Object]".into(), + Value::Array(values) => values + .iter() + .map(|value| { + if value.is_null() { + String::new() + } else { + signature_string(value) + } + }) + .collect::>() + .join(","), + Value::Number(value) => value.as_f64().map_or_else( + || value.to_string(), + |value| { + if value == 0.0 { + "0".into() + } else { + value.to_string() + } + }, + ), + _ => value.to_string(), + } +} diff --git a/aimux-providers/src/openai_compatible/chat/map_finish_reason.rs b/aimux-providers/src/openai_compatible/chat/map_finish_reason.rs new file mode 100644 index 000000000..53040a47b --- /dev/null +++ b/aimux-providers/src/openai_compatible/chat/map_finish_reason.rs @@ -0,0 +1,21 @@ +//! The finish reason of a chat or completion response +//! (`map-openai-compatible-finish-reason.ts`; the completion package's copy is +//! the same function). + +use aimux_core::types::{FinishReason, FinishReasonUnified}; + +/// Map an OpenAI finish reason string to the unified one, keeping the raw. +#[must_use] +pub(crate) fn parse_finish_reason(raw: &str) -> FinishReason { + let unified = match raw { + "stop" => FinishReasonUnified::Stop, + "length" => FinishReasonUnified::Length, + "tool_calls" | "function_call" => FinishReasonUnified::ToolCalls, + "content_filter" => FinishReasonUnified::ContentFilter, + _ => FinishReasonUnified::Other, + }; + FinishReason { + unified, + raw: Some(raw.to_string()), + } +} diff --git a/aimux-providers/src/openai_compatible/chat/metadata_extractor.rs b/aimux-providers/src/openai_compatible/chat/metadata_extractor.rs new file mode 100644 index 000000000..8f3501cae --- /dev/null +++ b/aimux-providers/src/openai_compatible/chat/metadata_extractor.rs @@ -0,0 +1,24 @@ +//! Vendor metadata of chat responses (`openai-compatible-metadata-extractor.ts`). + +use aimux_core::types::ProviderMetadata; +use serde_json::Value; + +/// Captures vendor-specific metadata from responses (the AI SDK's +/// `MetadataExtractor`). The returned map is merged into `provider_metadata` +/// next to the namespace entry. +pub trait MetadataExtractor: Send + Sync { + /// Metadata of a non-streaming response body. + fn extract_metadata(&self, parsed_body: &Value) -> Option; + + /// A fresh extractor for one streaming response. + fn create_stream_extractor(&self) -> Box; +} + +/// The per-stream half of [`MetadataExtractor`]. +pub trait StreamMetadataExtractor: Send { + /// Called with every parsed chunk (including the one carrying usage). + fn process_chunk(&mut self, parsed_chunk: &Value); + + /// Metadata merged into the finish part's `provider_metadata`. + fn build_metadata(&self) -> Option; +} diff --git a/aimux-providers/src/openai_compatible/chat/mod.rs b/aimux-providers/src/openai_compatible/chat/mod.rs new file mode 100644 index 000000000..452c6eecb --- /dev/null +++ b/aimux-providers/src/openai_compatible/chat/mod.rs @@ -0,0 +1,20 @@ +//! The chat-completions model (`src/chat/`). + +mod api_types; +pub(super) mod chat_language_model; +mod chat_language_model_options; +mod convert_chat_usage; +pub(super) mod convert_to_chat_messages; +mod map_finish_reason; +mod metadata_extractor; +pub(super) mod prepare_tools; + +pub use chat_language_model::{ConvertUsage, OpenAICompatibleChatModel, SupportedUrlsFn}; +pub use metadata_extractor::{MetadataExtractor, StreamMetadataExtractor}; + +pub(crate) use chat_language_model::ChatSettings; +#[cfg(test)] +pub(crate) use chat_language_model::chat_test_stream; +pub(super) use chat_language_model::stream_error; +pub(crate) use convert_chat_usage::usage_from_raw; +pub(super) use map_finish_reason::parse_finish_reason; diff --git a/aimux-providers/src/openai_compatible/chat/prepare_tools.rs b/aimux-providers/src/openai_compatible/chat/prepare_tools.rs new file mode 100644 index 000000000..e692f1874 --- /dev/null +++ b/aimux-providers/src/openai_compatible/chat/prepare_tools.rs @@ -0,0 +1,63 @@ +//! `tools` / `tool_choice` of the chat body (`openai-compatible-prepare-tools.ts`). + +use aimux_core::options::{CallOptions, LanguageModelV4ToolChoice}; +use aimux_core::tool::Tool; +use aimux_core::types::Warning; +use serde_json::{Map, Value, json}; + +/// `tools` / `tool_choice` ready for the body, with the warnings raised while +/// preparing them. +#[derive(Debug, Clone, Default)] +pub(crate) struct PreparedTools { + pub tools: Option>, + pub tool_choice: Option, + pub warnings: Vec, +} + +/// The wire form of a function tool. +pub(crate) fn function_tool_value(tool: &aimux_core::tool::FunctionTool) -> Value { + let mut function = Map::new(); + function.insert("name".into(), json!(tool.name)); + if let Some(description) = &tool.description { + function.insert("description".into(), json!(description)); + } + function.insert("parameters".into(), tool.input_schema.clone()); + if let Some(strict) = tool.strict { + function.insert("strict".into(), json!(strict)); + } + json!({ "type": "function", "function": function }) +} + +/// The wire form of the tool choice. +pub(crate) fn tool_choice_value(choice: &LanguageModelV4ToolChoice) -> Value { + match choice { + LanguageModelV4ToolChoice::Auto => json!("auto"), + LanguageModelV4ToolChoice::None => json!("none"), + LanguageModelV4ToolChoice::Required => json!("required"), + LanguageModelV4ToolChoice::Tool { tool_name } => { + json!({ "type": "function", "function": { "name": tool_name } }) + } + } +} + +/// Function tools only; a provider-defined tool is unsupported by the generic +/// endpoint and warned about (the AI SDK's `prepareTools`). +pub(crate) fn prepare_function_tools(options: &CallOptions) -> PreparedTools { + let mut prepared = PreparedTools::default(); + let Some(tools) = options.tools.as_ref().filter(|tools| !tools.is_empty()) else { + return prepared; + }; + let mut wire = Vec::new(); + for tool in tools { + match tool { + Tool::Function(function) => wire.push(function_tool_value(function)), + Tool::Provider(provider) => prepared.warnings.push(Warning::Unsupported { + feature: format!("provider-defined tool {}", provider.id), + details: None, + }), + } + } + prepared.tools = Some(wire); + prepared.tool_choice = options.tool_choice.as_ref().map(tool_choice_value); + prepared +} diff --git a/aimux-providers/src/openai_compatible/completion.rs b/aimux-providers/src/openai_compatible/completion/completion_language_model.rs similarity index 82% rename from aimux-providers/src/openai_compatible/completion.rs rename to aimux-providers/src/openai_compatible/completion/completion_language_model.rs index 1a63bdf8e..5390b4b1d 100644 --- a/aimux-providers/src/openai_compatible/completion.rs +++ b/aimux-providers/src/openai_compatible/completion/completion_language_model.rs @@ -1,31 +1,32 @@ -//! Text-completion endpoint shared by the native and compatible packages. - -use async_trait::async_trait; -use futures::StreamExt; -use serde::Deserialize; -use serde_json::{Map, Value, json}; +//! The text-completion model (`{name}.completion`, +//! `openai-compatible-completion-language-model.ts`), shared by the native and +//! the compatible packages. use aimux_core::AiMuxError; use aimux_core::language_model::{LanguageModel, SupportedUrls}; -use aimux_core::language_model_message::{AssistantPart, LanguageModelMessage, UserPart}; use aimux_core::options::{CallOptions, ResponseFormat}; -use aimux_core::result::StreamResult; -use aimux_core::result::{GenerateContent, GenerateResult}; +use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; use aimux_core::shared::{RequestInfo, ResponseInfo, StreamResponseInfo, provider_namespace}; use aimux_core::stream_part::StreamPart; -use aimux_core::types::{ - FinishReason, InputTokenUsage, OutputTokenUsage, ResponseMetadata, Usage, Warning, -}; +use aimux_core::types::{FinishReason, Warning}; use aimux_provider_utils::{HttpRequest, Transformer, pipe_through}; +use async_trait::async_trait; +use futures::StreamExt; +use serde::Deserialize; +use serde_json::{Map, Value, json}; +use crate::openai::config::OpenAIModelConfig; +use crate::openai_compatible::CompatModelConfig; +use crate::openai_compatible::chat::parse_finish_reason; +use crate::openai_compatible::utils::to_camel_case; use crate::stream::{Controller, Emit, FINISH_ERROR, FINISH_OTHER, StreamContext}; -use super::config::CompatModelConfig; -use super::convert::{parse_finish_reason, to_camel_case}; -use crate::openai::config::OpenAIModelConfig; +use super::convert_completion_usage::usage; +use super::convert_to_completion_prompt::completion_prompt; +use super::get_response_metadata::metadata; #[derive(Clone)] -pub(super) enum CompletionConfig { +pub(in crate::openai_compatible) enum CompletionConfig { Compatible(CompatModelConfig), Native(OpenAIModelConfig), } @@ -34,7 +35,7 @@ pub(super) enum CompletionConfig { #[derive(Clone)] pub struct OpenAICompatibleCompletionModel { model_id: String, - pub(super) config: CompletionConfig, + pub(in crate::openai_compatible) config: CompletionConfig, } impl OpenAICompatibleCompletionModel { @@ -203,57 +204,11 @@ impl OpenAICompatibleCompletionModel { } } -fn completion_prompt(options: &CallOptions) -> Result { - let mut text = String::new(); - for (index, message) in options.prompt.iter().enumerate() { - match message { - LanguageModelMessage::System { content, .. } if index == 0 => { - text.push_str(content); - text.push_str("\n\n"); - } - LanguageModelMessage::System { .. } => { - return Err(AiMuxError::invalid_prompt( - "Unexpected system message in prompt", - )); - } - LanguageModelMessage::User { content, .. } => { - text.push_str("user:\n"); - for part in content { - if let UserPart::Text(part) = part { - text.push_str(&part.text); - } - } - text.push_str("\n\n"); - } - LanguageModelMessage::Assistant { content, .. } => { - text.push_str("assistant:\n"); - for part in content { - match part { - AssistantPart::Text(part) => text.push_str(&part.text), - AssistantPart::ToolCall(_) => { - return Err(AiMuxError::UnsupportedFunctionality( - "tool-call messages".into(), - )); - } - _ => {} - } - } - text.push_str("\n\n"); - } - LanguageModelMessage::Tool { .. } => { - return Err(AiMuxError::UnsupportedFunctionality("tool messages".into())); - } - } - } - text.push_str("assistant:\n"); - Ok(text) -} - #[derive(Deserialize)] -struct CompletionResponse { - id: Option, - created: Option, - model: Option, +pub(super) struct CompletionResponse { + pub(super) id: Option, + pub(super) created: Option, + pub(super) model: Option, choices: Vec, usage: Option>, } @@ -321,59 +276,6 @@ fn parse_response( Ok(serde_json::from_value(value)?) } -fn metadata(response: &CompletionResponse) -> ResponseMetadata { - ResponseMetadata { - id: response.id.clone(), - timestamp: response - .created - .and_then(|secs| aimux_core::Timestamp::from_unix_seconds(secs as f64)), - model_id: response.model.clone(), - } -} - -fn usage(raw: Option>, native: bool) -> Usage { - let Some(mut raw) = raw else { - return Usage::default(); - }; - if native { - raw.retain(|key, _| { - matches!( - key.as_str(), - "prompt_tokens" | "completion_tokens" | "total_tokens" - ) - }); - } - let input = raw - .get("prompt_tokens") - .and_then(Value::as_u64) - .map(|n| n as u32); - let output = raw - .get("completion_tokens") - .and_then(Value::as_u64) - .map(|n| n as u32); - Usage { - input_tokens: InputTokenUsage { - total: if native { - input - } else { - Some(input.unwrap_or(0)) - }, - no_cache: Some(input.unwrap_or(0)), - ..Default::default() - }, - output_tokens: OutputTokenUsage { - total: if native { - output - } else { - Some(output.unwrap_or(0)) - }, - text: Some(output.unwrap_or(0)), - ..Default::default() - }, - raw: Some(raw), - } -} - #[async_trait] impl LanguageModel for OpenAICompatibleCompletionModel { fn provider(&self) -> &str { @@ -576,7 +478,7 @@ impl Transformer for OpenAICompatibleCompletionStream { self.ctx.request_body.clone(), self.ctx.response_headers.clone(), ), - Some(name) => super::chat::stream_error( + Some(name) => crate::openai_compatible::chat::stream_error( name, error, &self.ctx.url, diff --git a/aimux-providers/src/openai_compatible/completion/convert_completion_usage.rs b/aimux-providers/src/openai_compatible/completion/convert_completion_usage.rs new file mode 100644 index 000000000..7a633d087 --- /dev/null +++ b/aimux-providers/src/openai_compatible/completion/convert_completion_usage.rs @@ -0,0 +1,48 @@ +//! The token usage of a completion response +//! (`convert-openai-compatible-completion-usage.ts`). + +use aimux_core::types::{InputTokenUsage, OutputTokenUsage, Usage}; +use serde_json::{Map, Value}; + +pub(super) fn usage(raw: Option>, native: bool) -> Usage { + let Some(mut raw) = raw else { + return Usage::default(); + }; + if native { + raw.retain(|key, _| { + matches!( + key.as_str(), + "prompt_tokens" | "completion_tokens" | "total_tokens" + ) + }); + } + let input = raw + .get("prompt_tokens") + .and_then(Value::as_u64) + .map(|n| n as u32); + let output = raw + .get("completion_tokens") + .and_then(Value::as_u64) + .map(|n| n as u32); + Usage { + input_tokens: InputTokenUsage { + total: if native { + input + } else { + Some(input.unwrap_or(0)) + }, + no_cache: Some(input.unwrap_or(0)), + ..Default::default() + }, + output_tokens: OutputTokenUsage { + total: if native { + output + } else { + Some(output.unwrap_or(0)) + }, + text: Some(output.unwrap_or(0)), + ..Default::default() + }, + raw: Some(raw), + } +} diff --git a/aimux-providers/src/openai_compatible/completion/convert_to_completion_prompt.rs b/aimux-providers/src/openai_compatible/completion/convert_to_completion_prompt.rs new file mode 100644 index 000000000..ca2e607e4 --- /dev/null +++ b/aimux-providers/src/openai_compatible/completion/convert_to_completion_prompt.rs @@ -0,0 +1,51 @@ +//! The prompt of a text completion (`convert-to-openai-compatible-completion-prompt.ts`). + +use aimux_core::AiMuxError; +use aimux_core::language_model_message::{AssistantPart, LanguageModelMessage, UserPart}; +use aimux_core::options::CallOptions; + +pub(super) fn completion_prompt(options: &CallOptions) -> Result { + let mut text = String::new(); + for (index, message) in options.prompt.iter().enumerate() { + match message { + LanguageModelMessage::System { content, .. } if index == 0 => { + text.push_str(content); + text.push_str("\n\n"); + } + LanguageModelMessage::System { .. } => { + return Err(AiMuxError::invalid_prompt( + "Unexpected system message in prompt", + )); + } + LanguageModelMessage::User { content, .. } => { + text.push_str("user:\n"); + for part in content { + if let UserPart::Text(part) = part { + text.push_str(&part.text); + } + } + text.push_str("\n\n"); + } + LanguageModelMessage::Assistant { content, .. } => { + text.push_str("assistant:\n"); + for part in content { + match part { + AssistantPart::Text(part) => text.push_str(&part.text), + AssistantPart::ToolCall(_) => { + return Err(AiMuxError::UnsupportedFunctionality( + "tool-call messages".into(), + )); + } + _ => {} + } + } + text.push_str("\n\n"); + } + LanguageModelMessage::Tool { .. } => { + return Err(AiMuxError::UnsupportedFunctionality("tool messages".into())); + } + } + } + text.push_str("assistant:\n"); + Ok(text) +} diff --git a/aimux-providers/src/openai_compatible/completion/get_response_metadata.rs b/aimux-providers/src/openai_compatible/completion/get_response_metadata.rs new file mode 100644 index 000000000..c3a88a86c --- /dev/null +++ b/aimux-providers/src/openai_compatible/completion/get_response_metadata.rs @@ -0,0 +1,15 @@ +//! The response metadata of a completion response (`get-response-metadata.ts`). + +use aimux_core::types::ResponseMetadata; + +use super::completion_language_model::CompletionResponse; + +pub(super) fn metadata(response: &CompletionResponse) -> ResponseMetadata { + ResponseMetadata { + id: response.id.clone(), + timestamp: response + .created + .and_then(|secs| aimux_core::Timestamp::from_unix_seconds(secs as f64)), + model_id: response.model.clone(), + } +} diff --git a/aimux-providers/src/openai_compatible/completion/mod.rs b/aimux-providers/src/openai_compatible/completion/mod.rs new file mode 100644 index 000000000..f6f6eccf9 --- /dev/null +++ b/aimux-providers/src/openai_compatible/completion/mod.rs @@ -0,0 +1,10 @@ +//! The text-completion model (`src/completion/`). + +mod completion_language_model; +mod convert_completion_usage; +mod convert_to_completion_prompt; +mod get_response_metadata; + +#[cfg(test)] +pub(in crate::openai_compatible) use completion_language_model::CompletionConfig; +pub use completion_language_model::OpenAICompatibleCompletionModel; diff --git a/aimux-providers/src/openai_compatible/config.rs b/aimux-providers/src/openai_compatible/config.rs deleted file mode 100644 index 5f4089750..000000000 --- a/aimux-providers/src/openai_compatible/config.rs +++ /dev/null @@ -1,436 +0,0 @@ -//! The private per-model configuration of the OpenAI-compatible package. -//! -//! Like the native OpenAI config this is the Rust shape of the object -//! `createOpenAICompatible` hands each model class (`{ provider, url, headers, -//! fetch, ... }`). It has no getters for the credential or the settings that -//! produced it. Where the AI SDK passes `url` and `headers` functions, this -//! config holds data, or a package's own data behind [`ResolveBaseUrl`] / -//! [`ResolveFetchHeaders`]; the functions a caller or a vendor package passes -//! (`supportedUrls`, `convertUsage`, `errorStructure`, `metadataExtractor`, -//! `transformRequestBody`) are kept as given. The shared model never compares -//! a provider name. - -use aimux_core::SourceError; -use futures::future::BoxFuture; -use std::collections::HashMap; -use std::sync::Arc; - -use aimux_core::types::{ProviderMetadata, Usage}; -use serde_json::Value; - -use aimux_core::AiMuxError; -use aimux_core::ErrorCause; -use aimux_core::language_model::SupportedUrls; -use aimux_provider_utils::{ - ExchangeContext, Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse, HeaderMapOpt, - HttpRequest, ProviderErrorParts, combine_headers, normalize_headers, -}; - -use crate::preset::PresetDescriptor; -use crate::shared::ProviderHeaders; - -pub use crate::shared::TransformRequestBody; - -/// Converts the raw optional token usage of a chat response. -pub type ConvertUsage = Arc) -> Usage + Send + Sync>; - -/// Returns the URLs supported by chat models, read each time a chat model's -/// supported URLs are: `supportedUrls?: () => LanguageModelV4['supportedUrls']` -/// (openai-compatible@3.0.62 `openai-compatible-chat-language-model.ts:84`, -/// `:161-163`), whose value may be a `PromiseLike`. -pub type SupportedUrlsFn = Arc BoxFuture<'static, SupportedUrls> + Send + Sync>; - -/// Captures vendor-specific metadata from responses (the AI SDK's -/// `MetadataExtractor`). The returned map is merged into `provider_metadata` -/// next to the namespace entry. -pub trait MetadataExtractor: Send + Sync { - /// Metadata of a non-streaming response body. - fn extract_metadata(&self, parsed_body: &Value) -> Option; - - /// A fresh extractor for one streaming response. - fn create_stream_extractor(&self) -> Box; -} - -/// The per-stream half of [`MetadataExtractor`]. -pub trait StreamMetadataExtractor: Send { - /// Called with every parsed chunk (including the one carrying usage). - fn process_chunk(&mut self, parsed_chunk: &Value); - - /// Metadata merged into the finish part's `provider_metadata`. - fn build_metadata(&self) -> Option; -} - -/// A base URL a package builds from settings read when a request is made -/// (a Vertex AI partner sub-provider's project and location). -pub(crate) trait ResolveBaseUrl: std::fmt::Debug + Send + Sync { - /// The base URL of one request. - fn resolve(&self) -> Result; -} - -/// The headers a package adds in its own fetch wrapper -/// ([`CompatModelConfig::fetch_headers`]). -#[async_trait::async_trait] -pub(crate) trait ResolveFetchHeaders: Send + Sync { - /// The headers of one request. - async fn resolve(&self) -> Result; -} - -/// Where the base URL comes from. -/// -/// A plain provider has a [`Fixed`](Self::Fixed) URL. A preset whose URL is -/// read from the environment or expanded from template parameters is -/// [`Preset`](Self::Preset): it is evaluated per request, so creating the -/// provider (and the preset default instances) never reads the environment -/// and never fails. -#[derive(Clone, Debug)] -pub(crate) enum BaseUrl { - Fixed(String), - Preset { - descriptor: &'static PresetDescriptor, - /// The explicit template parameters of the settings. - params: Arc>, - }, - /// A package's own base URL, resolved when a request is made. - Dynamic(Arc), -} - -impl BaseUrl { - pub(crate) fn resolve(&self) -> Result { - match self { - Self::Fixed(url) => Ok(url.clone()), - Self::Preset { descriptor, params } => { - crate::preset::resolve_base_url(descriptor, params) - } - Self::Dynamic(resolver) => resolver.resolve(), - } - } -} - -/// Maps a provider error payload to the message and code of the API error -/// (the AI SDK's `errorStructure`). -pub(crate) type ErrorStructure = Arc ProviderErrorParts + Send + Sync>; - -/// `{ error: { message, type?, param?, code? } }`, the structure nearly every -/// compatible server answers errors with (the AI SDK's default -/// `errorStructure`). -pub(crate) fn default_error_structure(data: &Value) -> ProviderErrorParts { - let error = data.get("error").unwrap_or(data); - ProviderErrorParts { - message: error - .get("message") - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned(), - provider_code: error.get("code").or_else(|| error.get("type")).and_then( - |value| match value { - Value::String(value) => Some(value.clone()), - Value::Number(value) => Some(value.to_string()), - _ => None, - }, - ), - } -} - -/// The chat settings of a compatible endpoint: the AI SDK's -/// `includeUsage` / `supportsStructuredOutputs` / -/// `supportsMultiPartToolContent` / `supportedUrls` / `convertUsage` / -/// `metadataExtractor`, plus the preset capability data of RFC-0036 section -/// 5. -#[derive(Clone)] -pub(crate) struct ChatSettings { - pub include_usage: bool, - pub supports_structured_outputs: bool, - /// When set, only these chat models take structured outputs, whatever - /// `supports_structured_outputs` says. Ids compare exactly; an entry - /// ending in `*` matches every id with that prefix. A preset's - /// `profile.structured_output_models`, the vendor package's per-model - /// `supportsStructuredOutputs`. - pub structured_output_models: Option<&'static [String]>, - /// `supportsMultiPartToolContent`: a `content` tool output is sent as an - /// array of content parts instead of a JSON string. - pub supports_multi_part_tool_content: bool, - /// URL patterns supported by chat models, evaluated when requested. - /// `None`: none. - pub supported_urls: Option, - /// The vendor's or the caller's usage accounting. `None`: the generic - /// OpenAI-shaped usage (`convertOpenAICompatibleChatUsage`). - pub convert_usage: Option, - pub metadata_extractor: Option>, - /// Send `top_k`. The AI SDK baseline never does (it warns and drops it). - pub supports_top_k: bool, - /// Send `tools` / `tool_choice`. - pub supports_tools: bool, - /// Send `response_format`. - pub supports_response_format: bool, - /// The only `max-tokens` key the vendor accepts: `"max_tokens"` or - /// `"max_completion_tokens"`. - pub max_tokens_key: Option<&'static str>, - /// Streaming usage rides in `chunk[key].usage` instead of `chunk.usage`. - pub stream_usage_key: Option, - pub error_structure: ErrorStructure, -} - -impl Default for ChatSettings { - /// The AI SDK baseline. - fn default() -> Self { - Self { - include_usage: false, - supports_structured_outputs: false, - structured_output_models: None, - supports_multi_part_tool_content: false, - supported_urls: None, - convert_usage: None, - metadata_extractor: None, - supports_top_k: false, - supports_tools: true, - supports_response_format: true, - max_tokens_key: None, - stream_usage_key: None, - error_structure: Arc::new(default_error_structure), - } - } -} - -impl ChatSettings { - /// These settings for the chat model `model_id`. - pub(crate) fn for_model(mut self, model_id: &str) -> Self { - if let Some(models) = self.structured_output_models { - self.supports_structured_outputs = models.iter().any(|model| { - model - .strip_suffix('*') - .map_or(model == model_id, |prefix| model_id.starts_with(prefix)) - }); - } - self - } - - /// The URL patterns chat models fetch themselves. - pub(crate) async fn supported_urls(&self) -> SupportedUrls { - match &self.supported_urls { - Some(supported_urls) => supported_urls().await, - None => SupportedUrls::default(), - } - } - - /// The core usage of a chat response's raw `usage` object. - pub(crate) fn convert_usage(&self, raw: Option<&Value>) -> Usage { - match &self.convert_usage { - Some(convert) => convert(raw), - None => super::chat::usage_from_raw(raw), - } - } -} - -/// What a model needs to talk to the API. -#[derive(Clone)] -pub(crate) struct CompatModelConfig { - /// The identity the model reports: `"{name}.{method}"`. - pub provider: String, - pub base_url: BaseUrl, - /// Appended to every request URL, in this order. - pub query_params: Option>>, - /// Provider headers (credential, user headers), produced on every request. - pub headers: ProviderHeaders, - /// Headers layered over the per-call headers, resolved on every request - /// after `headers`: what an AI SDK package that builds on - /// `createOpenAICompatible` adds in its own fetch wrapper (google-vertex's - /// Node MaaS and xAI providers). Their `user-agent` replaces the whole - /// header, the helper layer's part included ([`FetchUserAgent`]). - pub fetch_headers: Option>, - /// Transport; `None` uses the process default, resolved per request. - pub fetch: Option, - pub transform_request_body: Option, - pub chat: ChatSettings, -} - -impl CompatModelConfig { - /// The providerOptions namespace of this provider: the first segment of - /// the provider string. - pub(crate) fn provider_options_name(&self) -> &str { - self.provider.split('.').next().unwrap_or_default().trim() - } - - /// The full URL of an endpoint path (with the query parameters) and the - /// origin credentialed headers may be sent to. - pub(crate) fn url(&self, path: &str) -> Result<(String, String), AiMuxError> { - let base = self.base_url.resolve()?; - let mut url = - url::Url::parse(&format!("{base}{path}")).map_err(|e| AiMuxError::InvalidArgument { - message: format!("invalid request URL for {}: {e}", self.provider), - cause: Some(ErrorCause::source(&e)), - })?; - if let Some(params) = &self.query_params { - // As the AI SDK does: the configured parameters replace whatever - // query the base URL carried. - url.set_query(None); - if !params.is_empty() { - url.query_pairs_mut() - .extend_pairs(params.iter().map(|(k, v)| (k.as_str(), v.as_str()))); - } - } - Ok((url.to_string(), base)) - } - - /// Resolve the provider headers, layer the per-call headers over them, - /// then the fetch headers (case-insensitive, later wins, `None` removes), - /// and return the list that goes on the wire with the fetch headers' - /// `user-agent`. - pub(crate) async fn request_headers( - &self, - call_headers: Option<&HashMap>, - ) -> Result { - let provider = self.headers.resolve().await?; - let call: HeaderMapOpt = call_headers - .map(|headers| { - headers - .iter() - .map(|(name, value)| (name.clone(), Some(value.clone()))) - .collect() - }) - .unwrap_or_default(); - let fetch = match &self.fetch_headers { - Some(headers) => combine_headers(&[&headers.resolve().await?]), - None => HeaderMapOpt::new(), - }; - let fetch_user_agent = fetch.get("user-agent").cloned().flatten(); - Ok(RequestHeaders { - list: normalize_headers(combine_headers(&[&provider, &call, &fetch])), - fetch_user_agent, - }) - } - - /// An exchange for `path` that inherits the operation's cancellation and - /// recording context and carries this config's transport and credential - /// origin. - pub(crate) fn http_request( - &self, - path: &str, - headers: RequestHeaders, - options: &impl ExchangeContext, - ) -> Result { - let (url, origin) = self.url(path)?; - Ok(self.with_transport( - HttpRequest::new(url, headers.list, options), - origin, - headers.fetch_user_agent, - )) - } - - /// Attach the transport and the credential origin to a request. A fetch - /// headers' `user-agent` is sent as the whole header by the transport. - pub(crate) fn with_transport( - &self, - mut request: HttpRequest, - origin: String, - fetch_user_agent: Option, - ) -> HttpRequest { - request.fetch = match fetch_user_agent { - Some(user_agent) => Some(Arc::new(FetchUserAgent { - inner: self.fetch.clone(), - user_agent, - })), - None => self.fetch.clone(), - }; - request.credentialed_origin = Some(origin); - request - } - - /// Run the provider-level body rewrite, when there is one. - pub(crate) fn transform_body(&self, body: Value) -> Value { - match &self.transform_request_body { - Some(transform) => transform(body), - None => body, - } - } - - /// The failed-response handler of this provider's error structure. - pub(crate) fn failed_response_handler( - &self, - ) -> aimux_provider_utils::ResponseHandler { - let structure = self.chat.error_structure.clone(); - aimux_provider_utils::create_json_error_response_handler(move |data| structure(data)) - } -} - -/// The headers of one request ([`CompatModelConfig::request_headers`]). -pub(crate) struct RequestHeaders { - /// What goes on the wire, before the helper layer adds its - /// `ai-sdk-provider-utils` part. - pub list: Vec<(String, String)>, - /// The `user-agent` of the fetch headers, when they set one. - pub fetch_user_agent: Option, -} - -/// The transport of a request whose fetch headers set `user-agent`: it sends -/// that value as the whole header, over the one the helper layer and the -/// package transport built. google-vertex's Node wrappers spread their -/// headers over `init.headers` after `postToApi` added its part -/// (google-vertex-maas-provider-node.ts:43-49, -/// google-vertex-xai-provider-node.ts:41-47), so nothing of it is left. -struct FetchUserAgent { - /// The package transport (`None`: the process default). - inner: Option, - user_agent: String, -} - -#[async_trait::async_trait] -impl Fetch for FetchUserAgent { - async fn fetch(&self, mut request: FetchRequest) -> Result { - let value = - reqwest::header::HeaderValue::try_from(self.user_agent.as_str()).map_err(|error| { - FetchError::Other(SourceError::with_cause( - "invalid header value for user-agent", - &error, - )) - })?; - request.headers.insert(reqwest::header::USER_AGENT, value); - self.inner - .clone() - .unwrap_or_else(aimux_provider_utils::default_fetch) - .fetch(request) - .await - } -} - -#[cfg(test)] -mod tests { - use super::*; - - static MODELS: std::sync::LazyLock> = - std::sync::LazyLock::new(|| vec!["qwen3.7-plus".to_string(), "kimi-k*".to_string()]); - - fn supports(model_id: &str) -> bool { - ChatSettings { - supports_structured_outputs: true, - structured_output_models: Some(&MODELS), - ..ChatSettings::default() - } - .for_model(model_id) - .supports_structured_outputs - } - - #[test] - fn a_model_list_matches_the_exact_id() { - assert!(supports("qwen3.7-plus")); - assert!(!supports("qwen3.7-plus-2026-09-01")); - assert!(!supports("qwen3.7-plusplus")); - assert!(!supports("qwen3.7")); - } - - #[test] - fn an_entry_ending_in_a_star_matches_a_prefix() { - assert!(supports("kimi-k")); - assert!(supports("kimi-k2.5")); - assert!(!supports("kimi-latest")); - assert!(!supports("kimi")); - } - - #[test] - fn without_a_model_list_the_flag_applies() { - let settings = ChatSettings { - supports_structured_outputs: true, - ..ChatSettings::default() - }; - assert!(settings.for_model("any").supports_structured_outputs); - } -} diff --git a/aimux-providers/src/openai_compatible/convert.rs b/aimux-providers/src/openai_compatible/convert.rs deleted file mode 100644 index 43005077c..000000000 --- a/aimux-providers/src/openai_compatible/convert.rs +++ /dev/null @@ -1,777 +0,0 @@ -//! Call options and prompt to the OpenAI-compatible chat-completions body. -//! -//! The Rust form of `getArgs` and `convertToOpenAICompatibleChatMessages` of -//! `@ai-sdk/openai-compatible`. The body is built in the AI SDK's order (model, -//! `user`, sampling settings, `response_format`, `stop`, `seed`, the -//! pass-through fields of the provider's own options namespace, then -//! `reasoning_effort`, `verbosity`, `messages`, `tools`, `tool_choice`). -//! Compatible endpoint capabilities arrive as [`ChatSettings`] data. - -use aimux_core::shared::SharedProviderOptions; -use base64::Engine; -use serde_json::{Map, Value, json}; - -use aimux_core::error::AiMuxError; -use aimux_core::language_model_message::{ - AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, - ToolCallPart, ToolPart, ToolResultContent, ToolResultOutput, ToolResultPart, UserPart, -}; -use aimux_core::options::{CallOptions, LanguageModelV4ToolChoice, ResponseFormat}; -use aimux_core::shared::{FileBytes, FileData}; -use aimux_core::tool::Tool; -use aimux_core::types::{FinishReason, FinishReasonUnified, Warning}; -use aimux_provider_utils::{get_top_level_media_type, resolve_full_media_type}; - -use super::config::ChatSettings; - -/// Option keys of the generic compatible schema -/// (`openaiCompatibleLanguageModelChatOptions`): consumed by the model, never -/// passed through. -const SCHEMA_KEYS: [&str; 4] = [ - "user", - "reasoningEffort", - "textVerbosity", - "strictJsonSchema", -]; - -/// `tools` / `tool_choice` ready for the body, with the warnings raised while -/// preparing them. -#[derive(Debug, Clone, Default)] -pub(crate) struct PreparedTools { - pub tools: Option>, - pub tool_choice: Option, - pub warnings: Vec, -} - -/// The wire form of a function tool. -pub(crate) fn function_tool_value(tool: &aimux_core::tool::FunctionTool) -> Value { - let mut function = Map::new(); - function.insert("name".into(), json!(tool.name)); - if let Some(description) = &tool.description { - function.insert("description".into(), json!(description)); - } - function.insert("parameters".into(), tool.input_schema.clone()); - if let Some(strict) = tool.strict { - function.insert("strict".into(), json!(strict)); - } - json!({ "type": "function", "function": function }) -} - -/// The wire form of the tool choice. -pub(crate) fn tool_choice_value(choice: &LanguageModelV4ToolChoice) -> Value { - match choice { - LanguageModelV4ToolChoice::Auto => json!("auto"), - LanguageModelV4ToolChoice::None => json!("none"), - LanguageModelV4ToolChoice::Required => json!("required"), - LanguageModelV4ToolChoice::Tool { tool_name } => { - json!({ "type": "function", "function": { "name": tool_name } }) - } - } -} - -/// Function tools only; a provider-defined tool is unsupported by the generic -/// endpoint and warned about (the AI SDK's `prepareTools`). -pub(crate) fn prepare_function_tools(options: &CallOptions) -> PreparedTools { - let mut prepared = PreparedTools::default(); - let Some(tools) = options.tools.as_ref().filter(|tools| !tools.is_empty()) else { - return prepared; - }; - let mut wire = Vec::new(); - for tool in tools { - match tool { - Tool::Function(function) => wire.push(function_tool_value(function)), - Tool::Provider(provider) => prepared.warnings.push(Warning::Unsupported { - feature: format!("provider-defined tool {}", provider.id), - details: None, - }), - } - } - prepared.tools = Some(wire); - prepared.tool_choice = options.tool_choice.as_ref().map(tool_choice_value); - prepared -} - -/// `snake_case` / `kebab-case` provider name to `camelCase` (the AI SDK's -/// `toCamelCase`). -#[must_use] -pub(crate) fn to_camel_case(name: &str) -> String { - let mut out = String::with_capacity(name.len()); - let mut chars = name.chars().peekable(); - while let Some(ch) = chars.next() { - match chars.peek() { - Some(next) if (ch == '_' || ch == '-') && next.is_ascii_lowercase() => { - out.push(next.to_ascii_uppercase()); - chars.next(); - } - _ => out.push(ch), - } - } - out -} - -/// What the converter needs to know about the provider it builds for. -pub(crate) struct ChatBodySpec<'a> { - /// First segment of the provider string: the providerOptions namespace. - pub provider_options_name: &'a str, - pub chat: &'a ChatSettings, -} - -/// A request body and what building it produced. -#[derive(Debug, Clone)] -pub(crate) struct RequestBodyResult { - /// The JSON body, before the provider's `transform_request_body`. - pub body: Value, - /// Warnings raised while building it. - pub warnings: Vec, - /// The key provider metadata is reported under. - pub metadata_key: String, -} - -fn namespace<'a>(options: &'a CallOptions, key: &str) -> Option<&'a Map> { - options - .provider_options - .as_ref() - .and_then(|all| all.get(key)) -} - -/// The key provider metadata is reported under (`resolveProviderOptionsKey`): -/// the camelCase name when supplied, otherwise the raw provider name. -fn resolve_metadata_key(name: &str, options: &CallOptions) -> String { - let camel = to_camel_case(name); - if camel != name && namespace(options, &camel).is_some() { - camel - } else { - name.to_string() - } -} - -fn option_string( - merged: &Map, - namespace_name: &str, - key: &str, -) -> Result, AiMuxError> { - match merged.get(key) { - None => Ok(None), - Some(Value::String(value)) => Ok(Some(value.clone())), - Some(_) => Err(AiMuxError::invalid_argument(format!( - "invalid provider options for \"{namespace_name}\": `{key}` must be a string" - ))), - } -} - -fn validate_options(options: &Map, namespace_name: &str) -> Result<(), AiMuxError> { - for key in ["user", "reasoningEffort", "textVerbosity"] { - option_string(options, namespace_name, key)?; - } - if options - .get("strictJsonSchema") - .is_some_and(|value| !value.is_boolean()) - { - return Err(AiMuxError::invalid_argument(format!( - "invalid provider options for \"{namespace_name}\": `strictJsonSchema` must be a boolean" - ))); - } - Ok(()) -} - -/// Build the request body (`getArgs`). -/// -/// # Errors -/// -/// `InvalidArgument` for a malformed provider option or a prompt part that has -/// no wire form. -pub(crate) fn build_request_body( - model_id: &str, - options: &CallOptions, - stream: bool, - spec: &ChatBodySpec<'_>, -) -> Result { - let name = spec.provider_options_name; - let camel = to_camel_case(name); - let chat = spec.chat; - let mut warnings = Vec::new(); - - // Resolved provider options: the generic namespace, then the provider's - // own, then its camelCase form; later wins per key. - let mut merged = Map::new(); - if let Some(deprecated) = namespace(options, "openai-compatible") { - validate_options(deprecated, "openai-compatible")?; - warnings.push(Warning::Deprecated { - setting: "providerOptions key 'openai-compatible'".to_string(), - message: "Use 'openaiCompatible' instead.".to_string(), - }); - merged.extend(deprecated.iter().map(|(k, v)| (k.clone(), v.clone()))); - } - for key in ["openaiCompatible", name, camel.as_str()] { - if let Some(object) = namespace(options, key) { - validate_options(object, key)?; - merged.extend(object.iter().map(|(k, v)| (k.clone(), v.clone()))); - } - } - if camel != name && namespace(options, name).is_some() { - warnings.push(Warning::Deprecated { - setting: format!("providerOptions key '{name}'"), - message: format!("Use '{camel}' instead."), - }); - } - - let user = option_string(&merged, name, "user")?; - let reasoning_effort = option_string(&merged, name, "reasoningEffort")?.or_else(|| { - options - .reasoning - .filter(|effort| effort.is_custom()) - .map(|effort| effort.to_string()) - }); - let verbosity = option_string(&merged, name, "textVerbosity")?; - let strict_json_schema = merged - .get("strictJsonSchema") - .and_then(Value::as_bool) - .unwrap_or(true); - - let metadata_key = resolve_metadata_key(name, options); - let mut body = Map::new(); - body.insert("model".into(), json!(model_id)); - if let Some(user) = user { - body.insert("user".into(), json!(user)); - } - if let Some(max_tokens) = options.max_output_tokens { - body.insert( - chat.max_tokens_key.unwrap_or("max_tokens").into(), - json!(max_tokens), - ); - } - for (key, value) in [ - ("temperature", options.temperature), - ("top_p", options.top_p), - ("frequency_penalty", options.frequency_penalty), - ("presence_penalty", options.presence_penalty), - ] { - if let Some(value) = value { - body.insert(key.into(), json!(value)); - } - } - if let Some(top_k) = options.top_k { - if chat.supports_top_k { - body.insert("top_k".into(), json!(top_k)); - } else { - warnings.push(Warning::Unsupported { - feature: "topK".to_string(), - details: None, - }); - } - } - - match &options.response_format { - Some(ResponseFormat::Json { - schema, - name: format_name, - description, - }) if chat.supports_response_format => match schema { - Some(schema) if chat.supports_structured_outputs => { - let mut json_schema = Map::new(); - json_schema.insert("schema".into(), schema.clone()); - json_schema.insert("strict".into(), json!(strict_json_schema)); - json_schema.insert( - "name".into(), - json!(format_name.clone().unwrap_or_else(|| "response".into())), - ); - if let Some(description) = description { - json_schema.insert("description".into(), json!(description)); - } - body.insert( - "response_format".into(), - json!({ "type": "json_schema", "json_schema": json_schema }), - ); - } - Some(_) => { - warnings.push(Warning::Unsupported { - feature: "responseFormat".to_string(), - details: Some( - "JSON response format schema is only supported with structuredOutputs" - .to_string(), - ), - }); - body.insert("response_format".into(), json!({ "type": "json_object" })); - } - None => { - body.insert("response_format".into(), json!({ "type": "json_object" })); - } - }, - Some(ResponseFormat::Json { .. }) => warnings.push(Warning::Unsupported { - feature: "responseFormat".to_string(), - details: Some("response_format is not supported by this provider".to_string()), - }), - Some(ResponseFormat::Text) | None => {} - } - - if let Some(stop) = &options.stop_sequences { - body.insert("stop".into(), json!(stop)); - } - if let Some(seed) = options.seed { - body.insert("seed".into(), json!(seed)); - } - - // Fields of the provider's own namespace that the schema does not know go - // to the body as given (the generic `openaiCompatible` namespace is - // schema-only: unknown fields there are dropped). - for key in [name, camel.as_str()] { - if let Some(object) = namespace(options, key) { - for (field, value) in object { - if !SCHEMA_KEYS.contains(&field.as_str()) { - body.insert(field.clone(), value.clone()); - } - } - } - } - - if let Some(effort) = reasoning_effort { - body.insert("reasoning_effort".into(), json!(effort)); - } - if let Some(verbosity) = verbosity { - body.insert("verbosity".into(), json!(verbosity)); - } - - let messages = convert_messages( - &options.prompt, - &MessageSpec { - metadata_key: &metadata_key, - supports_multi_part_tool_content: chat.supports_multi_part_tool_content, - }, - )?; - body.insert("messages".into(), Value::Array(messages)); - - if chat.supports_tools { - let prepared = prepare_function_tools(options); - warnings.extend(prepared.warnings); - if let Some(tools) = prepared.tools { - body.insert("tools".into(), Value::Array(tools)); - } - if let Some(choice) = prepared.tool_choice { - body.insert("tool_choice".into(), choice); - } - } else if options - .tools - .as_ref() - .is_some_and(|tools| !tools.is_empty()) - { - warnings.push(Warning::Unsupported { - feature: "tools".to_string(), - details: Some("tools are not supported by this provider".to_string()), - }); - } - - if stream { - body.insert("stream".into(), json!(true)); - if chat.include_usage { - body.insert("stream_options".into(), json!({ "include_usage": true })); - } - } - - Ok(RequestBodyResult { - body: Value::Object(body), - warnings, - metadata_key, - }) -} - -// ── Messages ──────────────────────────────────────────────────────────────── - -pub(crate) struct MessageSpec<'a> { - pub metadata_key: &'a str, - /// Send a `content` tool output as content parts, not a JSON string. - pub supports_multi_part_tool_content: bool, -} - -/// The generic `openaiCompatible` metadata of a message or part -/// (`getOpenAIMetadata`). -fn wire_metadata(provider_options: Option<&SharedProviderOptions>) -> Map { - provider_options - .and_then(|options| options.get("openaiCompatible")) - .cloned() - .unwrap_or_default() -} - -fn with_metadata(mut object: Value, metadata: Map) -> Value { - if let Some(map) = object.as_object_mut() { - map.extend(metadata); - } - object -} - -/// `messages` for the body. -/// -/// # Errors -/// -/// `InvalidArgument` when a part has no wire form (an unsupported media type, -/// an audio or PDF part given by URL). -pub(crate) fn convert_messages( - prompt: &LanguageModelPrompt, - spec: &MessageSpec<'_>, -) -> Result, AiMuxError> { - let mut out = Vec::new(); - for message in prompt { - out.extend(convert_message(message, spec)?); - } - Ok(out) -} - -fn convert_message( - message: &LanguageModelMessage, - spec: &MessageSpec<'_>, -) -> Result, AiMuxError> { - match message { - LanguageModelMessage::System { - content, - provider_options, - } => Ok(vec![with_metadata( - json!({ "role": "system", "content": content }), - wire_metadata(provider_options.as_ref()), - )]), - LanguageModelMessage::User { - content, - provider_options, - } => Ok(vec![convert_user_message( - content, - wire_metadata(provider_options.as_ref()), - )?]), - LanguageModelMessage::Assistant { - content, - provider_options, - } => Ok(vec![convert_assistant_message( - content, - spec, - wire_metadata(provider_options.as_ref()), - )]), - LanguageModelMessage::Tool { content, .. } => content - .iter() - .filter_map(|part| match part { - ToolPart::ToolResult(ToolResultPart { - tool_call_id, - output, - provider_options, - .. - }) => Some((tool_call_id, output, provider_options)), - _ => None, - }) - .map(|(tool_call_id, output, provider_options)| { - // convert-to-openai-compatible-chat-messages.ts:284-321: a - // `content` output is an array of content parts only when - // the provider supports multi-part tool content. - let content = match output { - ToolResultOutput::Content { value } - if spec.supports_multi_part_tool_content => - { - Value::Array( - value - .iter() - .map(convert_tool_content_part) - .collect::>()?, - ) - } - output => crate::openai::convert::tool_result_to_content(output), - }; - Ok(with_metadata( - json!({ - "role": "tool", - "tool_call_id": tool_call_id, - "content": content, - }), - wire_metadata(provider_options.as_ref()), - )) - }) - .collect(), - } -} - -/// One part of a multi-part tool result (`convertToolContentPart`, -/// convert-to-openai-compatible-chat-messages.ts:162-176): a text or file part -/// is converted like a user content part; a custom part has no wire form. -fn convert_tool_content_part(part: &ToolResultContent) -> Result { - match part { - ToolResultContent::Text(text) => Ok(convert_text_part(text)), - ToolResultContent::File(file) => convert_file_part(file), - ToolResultContent::Custom { .. } => Err(AiMuxError::UnsupportedFunctionality( - "custom tool content parts".to_string(), - )), - } -} - -fn convert_user_message( - content: &[UserPart], - message_metadata: Map, -) -> Result { - if let [ - UserPart::Text(TextPart { - text, - provider_options, - }), - ] = content - { - let metadata = wire_metadata(provider_options.as_ref()); - return Ok(with_metadata( - json!({ "role": "user", "content": text }), - metadata, - )); - } - let parts = content - .iter() - .map(convert_user_part) - .collect::, _>>()?; - Ok(with_metadata( - json!({ "role": "user", "content": parts }), - message_metadata, - )) -} - -fn convert_user_part(part: &UserPart) -> Result { - match part { - UserPart::Text(text) => Ok(convert_text_part(text)), - UserPart::File(file) => convert_file_part(file), - } -} - -/// A text content part (`convertToOpenAICompatibleContentPart`). -fn convert_text_part(part: &TextPart) -> Value { - with_metadata( - json!({ "type": "text", "text": part.text }), - wire_metadata(part.provider_options.as_ref()), - ) -} - -/// A file content part (`convertToOpenAICompatibleContentPart`). -fn convert_file_part(file: &FilePart) -> Result { - let wire = match &file.data { - FileData::Data { - data: FileBytes::Binary(data), - } => file_part( - file, - FileSource::Base64(&base64::engine::general_purpose::STANDARD.encode(data)), - )?, - FileData::Data { - data: FileBytes::Base64(data), - } => file_part(file, FileSource::Base64(data))?, - FileData::Url { url, .. } => file_part(file, FileSource::Url(url))?, - FileData::Reference { .. } => { - return Err(AiMuxError::UnsupportedFunctionality( - "file parts with provider references".to_string(), - )); - } - FileData::Text { .. } => { - return Err(AiMuxError::UnsupportedFunctionality( - "text file parts".to_string(), - )); - } - }; - Ok(with_metadata( - wire, - wire_metadata(file.provider_options.as_ref()), - )) -} - -enum FileSource<'a> { - Base64(&'a str), - Url(&'a str), -} - -fn file_part(part: &FilePart, source: FileSource<'_>) -> Result { - let media_type = part.media_type.as_str(); - let filename = part.filename.as_deref(); - let unsupported = |what: String| AiMuxError::UnsupportedFunctionality(what); - - match get_top_level_media_type(media_type) { - kind @ ("image" | "video") => { - let url = match source { - FileSource::Url(url) => url.to_string(), - FileSource::Base64(b64) => { - format!("data:{};base64,{}", resolve_full_media_type(part)?, b64) - } - }; - let kind = format!("{kind}_url"); - Ok(json!({ "type": kind, kind: { "url": url } })) - } - "audio" => { - let FileSource::Base64(b64) = source else { - return Err(unsupported("audio file parts with URLs".to_string())); - }; - let full_media_type = resolve_full_media_type(part)?; - let format = match full_media_type.as_str() { - "audio/wav" => "wav", - "audio/mp3" | "audio/mpeg" => "mp3", - other => { - return Err(unsupported(format!("audio media type {other}"))); - } - }; - Ok(json!({ "type": "input_audio", "input_audio": { "data": b64, "format": format } })) - } - "application" => { - let FileSource::Base64(b64) = source else { - return Err(unsupported("PDF file parts with URLs".to_string())); - }; - let full_media_type = resolve_full_media_type(part)?; - if full_media_type != "application/pdf" { - return Err(unsupported(format!( - "file part media type {full_media_type}" - ))); - } - let filename = filename.unwrap_or("document.pdf"); - Ok(json!({ - "type": "file", - "file": { - "filename": filename, - "file_data": format!("data:application/pdf;base64,{b64}"), - } - })) - } - "text" => { - let b64 = match source { - FileSource::Url(url) => return Ok(json!({ "type": "text", "text": url })), - FileSource::Base64(b64) => b64, - }; - let bytes = base64::engine::general_purpose::STANDARD - .decode(b64) - .map_err(|e| unsupported(format!("text file part is not valid base64: {e}")))?; - let text = String::from_utf8_lossy(&bytes); - Ok(json!({ "type": "text", "text": text })) - } - _ => Err(unsupported(format!("file part media type {media_type}"))), - } -} - -fn convert_assistant_message( - content: &[AssistantPart], - spec: &MessageSpec<'_>, - message_metadata: Map, -) -> Value { - let key = spec.metadata_key; - let mut text = String::new(); - let mut reasoning = String::new(); - let mut tool_calls = Vec::new(); - let metadata = message_metadata; - for part in content { - match part { - AssistantPart::Text(TextPart { text: t, .. }) => text.push_str(t), - AssistantPart::Reasoning(ReasoningPart { text: t, .. }) => reasoning.push_str(t), - AssistantPart::ToolCall(ToolCallPart { - tool_call_id, - tool_name, - input, - provider_options, - .. - }) => { - let arguments = input.to_string(); - let mut call = json!({ - "id": tool_call_id, - "type": "function", - "function": { "name": tool_name, "arguments": arguments }, - }); - call = with_metadata(call, wire_metadata(provider_options.as_ref())); - let signature = provider_options.as_ref().and_then(|options| { - options - .get(key) - .and_then(|options| options.get("thoughtSignature")) - .filter(|value| !value.is_null()) - .or_else(|| { - options - .get("google") - .and_then(|options| options.get("thoughtSignature")) - }) - }); - let signature = signature.and_then(|value| match value { - Value::Null | Value::Bool(false) => None, - Value::String(text) if text.is_empty() => None, - Value::Number(number) if number.as_f64() == Some(0.0) => None, - Value::String(text) => Some(text.clone()), - other => Some(signature_string(other)), - }); - if let Some(signature) = signature { - call["extra_content"] = json!({ "google": { "thought_signature": signature } }); - } - tool_calls.push(call); - } - // Provider-executed results and media have no assistant-content - // form on this wire. - _ => {} - } - } - let mut wire = Map::new(); - wire.insert("role".into(), json!("assistant")); - wire.insert( - "content".into(), - if tool_calls.is_empty() || !text.is_empty() { - json!(text) - } else { - Value::Null - }, - ); - if !reasoning.is_empty() { - wire.insert("reasoning_content".into(), json!(reasoning)); - } - if !tool_calls.is_empty() { - wire.insert("tool_calls".into(), Value::Array(tool_calls)); - } - wire.extend(metadata); - Value::Object(wire) -} - -/// Map an OpenAI finish reason string to the unified one, keeping the raw. -#[must_use] -pub(crate) fn parse_finish_reason(raw: &str) -> FinishReason { - let unified = match raw { - "stop" => FinishReasonUnified::Stop, - "length" => FinishReasonUnified::Length, - "tool_calls" | "function_call" => FinishReasonUnified::ToolCalls, - "content_filter" => FinishReasonUnified::ContentFilter, - _ => FinishReasonUnified::Other, - }; - FinishReason { - unified, - raw: Some(raw.to_string()), - } -} - -// String(value), as used by the compatible package for thought signatures. -fn signature_string(value: &Value) -> String { - match value { - Value::String(value) => value.clone(), - Value::Object(_) => "[object Object]".into(), - Value::Array(values) => values - .iter() - .map(|value| { - if value.is_null() { - String::new() - } else { - signature_string(value) - } - }) - .collect::>() - .join(","), - Value::Number(value) => value.as_f64().map_or_else( - || value.to_string(), - |value| { - if value == 0.0 { - "0".into() - } else { - value.to_string() - } - }, - ), - _ => value.to_string(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn camel_case_follows_the_ai_sdk() { - assert_eq!(to_camel_case("acme"), "acme"); - assert_eq!(to_camel_case("my_provider"), "myProvider"); - assert_eq!(to_camel_case("my-provider"), "myProvider"); - assert_eq!( - to_camel_case("vertex_ai_openai_models"), - "vertexAiOpenaiModels" - ); - assert_eq!(to_camel_case("a_1"), "a_1"); - } -} diff --git a/aimux-providers/src/openai_compatible/embedding.rs b/aimux-providers/src/openai_compatible/embedding/embedding_model.rs similarity index 67% rename from aimux-providers/src/openai_compatible/embedding.rs rename to aimux-providers/src/openai_compatible/embedding/embedding_model.rs index 810ff34be..ba73a1e98 100644 --- a/aimux-providers/src/openai_compatible/embedding.rs +++ b/aimux-providers/src/openai_compatible/embedding/embedding_model.rs @@ -1,27 +1,26 @@ -//! The OpenAI-compatible embedding model (`{name}.embedding`). +//! The OpenAI-compatible embedding model (`{name}.embedding`, +//! `openai-compatible-embedding-model.ts`). //! //! The Rust form of `OpenAICompatibleEmbeddingModel`: `POST {base_url}/embeddings` //! with `encoding_format: "float"`, the `dimensions` and `user` options read //! from the compatible namespaces and then the provider's own. -use async_trait::async_trait; -use serde_json::{Map, Value, json}; - use aimux_core::ErrorCause; use aimux_core::embedding_model::{ EmbeddingCallOptions, EmbeddingModel, EmbeddingResponse, EmbeddingResult, EmbeddingUsage, }; use aimux_core::error::AiMuxError; +use async_trait::async_trait; +use serde_json::{Map, Value, json}; -use aimux_core::shared::Warning; +use crate::openai_compatible::CompatModelConfig; -use super::config::CompatModelConfig; -use super::convert::to_camel_case; +use super::embedding_model_options::embedding_options; /// An OpenAI-compatible embedding model. pub struct OpenAICompatibleEmbeddingModel { model_id: String, - pub(super) config: CompatModelConfig, + pub(in crate::openai_compatible) config: CompatModelConfig, } impl OpenAICompatibleEmbeddingModel { @@ -30,60 +29,6 @@ impl OpenAICompatibleEmbeddingModel { } } -/// Validate each namespace before merging, as upstream's provider-options schema does. -fn embedding_options( - options: &EmbeddingCallOptions, - name: &str, - warnings: &mut Vec, -) -> Result, AiMuxError> { - let mut merged = Map::new(); - for key in ["openai-compatible", "openaiCompatible", name] { - let Some(object) = options - .provider_options - .as_ref() - .and_then(|all| all.get(key)) - else { - continue; - }; - for (setting, valid) in [ - ("dimensions", Value::is_number as fn(&Value) -> bool), - ("user", Value::is_string), - ] { - if let Some(value) = object.get(setting) { - if !valid(value) { - return Err(AiMuxError::invalid_argument(format!( - "invalid providerOptions.{key}.{setting}" - ))); - } - merged.insert(setting.to_string(), value.clone()); - } - } - } - if options - .provider_options - .as_ref() - .is_some_and(|all| all.contains_key("openai-compatible")) - { - warnings.push(Warning::Deprecated { - setting: "providerOptions key 'openai-compatible'".into(), - message: "Use 'openaiCompatible' instead.".into(), - }); - } - let camel = to_camel_case(name); - if camel != name - && options - .provider_options - .as_ref() - .is_some_and(|all| all.contains_key(name)) - { - warnings.push(Warning::Deprecated { - setting: format!("providerOptions key '{name}'"), - message: format!("Use '{camel}' instead."), - }); - } - Ok(merged) -} - #[async_trait] impl EmbeddingModel for OpenAICompatibleEmbeddingModel { fn provider(&self) -> &str { diff --git a/aimux-providers/src/openai_compatible/embedding/embedding_model_options.rs b/aimux-providers/src/openai_compatible/embedding/embedding_model_options.rs new file mode 100644 index 000000000..fb8561027 --- /dev/null +++ b/aimux-providers/src/openai_compatible/embedding/embedding_model_options.rs @@ -0,0 +1,63 @@ +//! The provider options of the embedding model +//! (`openai-compatible-embedding-model-options.ts`). + +use aimux_core::embedding_model::EmbeddingCallOptions; +use aimux_core::error::AiMuxError; +use aimux_core::shared::Warning; +use serde_json::{Map, Value}; + +use crate::openai_compatible::utils::to_camel_case; + +/// Validate each namespace before merging, as upstream's provider-options schema does. +pub(super) fn embedding_options( + options: &EmbeddingCallOptions, + name: &str, + warnings: &mut Vec, +) -> Result, AiMuxError> { + let mut merged = Map::new(); + for key in ["openai-compatible", "openaiCompatible", name] { + let Some(object) = options + .provider_options + .as_ref() + .and_then(|all| all.get(key)) + else { + continue; + }; + for (setting, valid) in [ + ("dimensions", Value::is_number as fn(&Value) -> bool), + ("user", Value::is_string), + ] { + if let Some(value) = object.get(setting) { + if !valid(value) { + return Err(AiMuxError::invalid_argument(format!( + "invalid providerOptions.{key}.{setting}" + ))); + } + merged.insert(setting.to_string(), value.clone()); + } + } + } + if options + .provider_options + .as_ref() + .is_some_and(|all| all.contains_key("openai-compatible")) + { + warnings.push(Warning::Deprecated { + setting: "providerOptions key 'openai-compatible'".into(), + message: "Use 'openaiCompatible' instead.".into(), + }); + } + let camel = to_camel_case(name); + if camel != name + && options + .provider_options + .as_ref() + .is_some_and(|all| all.contains_key(name)) + { + warnings.push(Warning::Deprecated { + setting: format!("providerOptions key '{name}'"), + message: format!("Use '{camel}' instead."), + }); + } + Ok(merged) +} diff --git a/aimux-providers/src/openai_compatible/embedding/mod.rs b/aimux-providers/src/openai_compatible/embedding/mod.rs new file mode 100644 index 000000000..92be76d6c --- /dev/null +++ b/aimux-providers/src/openai_compatible/embedding/mod.rs @@ -0,0 +1,6 @@ +//! The embedding model (`src/embedding/`). + +mod embedding_model; +mod embedding_model_options; + +pub use embedding_model::OpenAICompatibleEmbeddingModel; diff --git a/aimux-providers/src/openai_compatible/error.rs b/aimux-providers/src/openai_compatible/error.rs new file mode 100644 index 000000000..4aedf1365 --- /dev/null +++ b/aimux-providers/src/openai_compatible/error.rs @@ -0,0 +1,31 @@ +//! The error structure of compatible servers (`openai-compatible-error.ts`). + +use std::sync::Arc; + +use aimux_provider_utils::ProviderErrorParts; +use serde_json::Value; + +/// Maps a provider error payload to the message and code of the API error +/// (the AI SDK's `errorStructure`). +pub(crate) type ErrorStructure = Arc ProviderErrorParts + Send + Sync>; + +/// `{ error: { message, type?, param?, code? } }`, the structure nearly every +/// compatible server answers errors with (the AI SDK's default +/// `errorStructure`). +pub(crate) fn default_error_structure(data: &Value) -> ProviderErrorParts { + let error = data.get("error").unwrap_or(data); + ProviderErrorParts { + message: error + .get("message") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + provider_code: error.get("code").or_else(|| error.get("type")).and_then( + |value| match value { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + _ => None, + }, + ), + } +} diff --git a/aimux-providers/src/openai_compatible/image.rs b/aimux-providers/src/openai_compatible/image/image_model.rs similarity index 97% rename from aimux-providers/src/openai_compatible/image.rs rename to aimux-providers/src/openai_compatible/image/image_model.rs index 55f399f78..3ff4ef932 100644 --- a/aimux-providers/src/openai_compatible/image.rs +++ b/aimux-providers/src/openai_compatible/image/image_model.rs @@ -1,4 +1,5 @@ -//! The OpenAI-compatible image model (`{name}.image`). +//! The OpenAI-compatible image model (`{name}.image`, +//! `openai-compatible-image-model.ts`). //! //! The Rust form of `OpenAICompatibleImageModel`: //! @@ -7,9 +8,6 @@ //! //! Provider-name options are forwarded to the endpoint as given. -use async_trait::async_trait; -use serde_json::{Map, Value, json}; - use aimux_core::ErrorCause; use aimux_core::error::AiMuxError; use aimux_core::image_model::{ @@ -18,14 +16,16 @@ use aimux_core::image_model::{ }; use aimux_core::shared::Warning; use aimux_provider_utils::{HttpBody, MultipartForm}; +use async_trait::async_trait; +use serde_json::{Map, Value, json}; -use super::config::CompatModelConfig; -use super::convert::to_camel_case; +use crate::openai_compatible::CompatModelConfig; +use crate::openai_compatible::utils::to_camel_case; /// An OpenAI-compatible image generation / editing model. pub struct OpenAICompatibleImageModel { model_id: String, - pub(super) config: CompatModelConfig, + pub(in crate::openai_compatible) config: CompatModelConfig, } impl OpenAICompatibleImageModel { diff --git a/aimux-providers/src/openai_compatible/image/mod.rs b/aimux-providers/src/openai_compatible/image/mod.rs new file mode 100644 index 000000000..0a4ab421b --- /dev/null +++ b/aimux-providers/src/openai_compatible/image/mod.rs @@ -0,0 +1,5 @@ +//! The image model (`src/image/`). + +mod image_model; + +pub use image_model::OpenAICompatibleImageModel; diff --git a/aimux-providers/src/openai_compatible/mod.rs b/aimux-providers/src/openai_compatible/mod.rs index 853f4ff07..4e1d9a792 100644 --- a/aimux-providers/src/openai_compatible/mod.rs +++ b/aimux-providers/src/openai_compatible/mod.rs @@ -27,342 +27,29 @@ pub(crate) mod chat; pub mod completion; -pub(crate) mod config; -pub(crate) mod convert; pub mod embedding; +mod error; pub mod image; -mod types; +mod provider; +mod utils; -pub use chat::OpenAICompatibleChatModel; -pub use completion::OpenAICompatibleCompletionModel; -pub use config::{ - ConvertUsage, MetadataExtractor, StreamMetadataExtractor, SupportedUrlsFn, TransformRequestBody, +pub use chat::{ + ConvertUsage, MetadataExtractor, OpenAICompatibleChatModel, StreamMetadataExtractor, + SupportedUrlsFn, }; +pub use completion::OpenAICompatibleCompletionModel; pub use embedding::OpenAICompatibleEmbeddingModel; pub use image::OpenAICompatibleImageModel; +pub use provider::{ + OpenAICompatibleProvider, OpenAICompatibleProviderSettings, create_openai_compatible, +}; -use std::collections::HashMap; -use std::sync::Arc; - -use futures::future::BoxFuture; - -use aimux_core::embedding_model::EmbeddingModel; -use aimux_core::error::AiMuxError; -use aimux_core::image_model::ImageModel; -use aimux_core::language_model::LanguageModel; -use aimux_core::model_catalogue::RuntimeModel; -use aimux_core::provider::{Provider, ProviderDiscovery}; -use aimux_provider_utils::{FetchFunction, HeaderMapOpt, Resolvable, validate_base_url}; - -use crate::shared::aisdk_pins::Package; -use crate::shared::{AuthScheme, Credential, ProviderHeaders}; -use config::{BaseUrl, ChatSettings, CompatModelConfig}; - -/// Settings of [`create_openai_compatible`] (the AI SDK's -/// `OpenAICompatibleProviderSettings`). -#[derive(Clone, Default)] -pub struct OpenAICompatibleProviderSettings { - /// The provider name: the first segment of every model's `provider()` - /// string and the providerOptions namespace. Preserved as supplied. - pub name: String, - /// Base URL for the API calls. Required, `http(s)` with a host; a trailing - /// slash is removed. - pub base_url: String, - /// The API key. `None` or `""` sends no `Authorization` header; - /// a non-empty value is sent as `Bearer `. - pub api_key: Option, - /// Extra headers on every request. A `None` value removes the header, - /// including the `Authorization` one. Per-call headers win over these. - pub headers: Option, - /// Query parameters appended to every request URL (sorted by name). - pub query_params: Option>, - /// The transport: a mock, a signing decorator, a proxy-aware client. - /// `None` uses the process default, resolved per request. - pub fetch: Option, - /// Ask streaming responses to include usage (`stream_options.include_usage`). - pub include_usage: Option, - /// Whether the chat endpoint accepts `json_schema` response formats; when - /// not, a schema degrades to `json_object` with a warning. - pub supports_structured_outputs: Option, - /// Whether chat tool results may carry multi-part content: a `content` - /// tool output is then sent as an array of content parts instead of a - /// JSON string. Default `false`. - pub supports_multi_part_tool_content: Option, - /// Rewrites each chat JSON request body once, after it is serialized and - /// before it is sent. - pub transform_request_body: Option, - /// Extracts metadata from chat responses and streaming chunks. - pub metadata_extractor: Option>, - /// URL patterns supported by chat models, evaluated when requested. - pub supported_urls: Option, - /// Custom chat token accounting. - pub convert_usage: Option, -} - -impl std::fmt::Debug for OpenAICompatibleProviderSettings { - /// Never prints the key or header values. - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("OpenAICompatibleProviderSettings") - .field("name", &self.name) - .field("base_url", &self.base_url) - .field("api_key", &self.api_key.is_some()) - .field( - "headers", - &self.headers.as_ref().map(std::collections::HashMap::len), - ) - .field("query_params", &self.query_params) - .field("fetch", &self.fetch.is_some()) - .field("include_usage", &self.include_usage) - .field( - "supports_structured_outputs", - &self.supports_structured_outputs, - ) - .field( - "supports_multi_part_tool_content", - &self.supports_multi_part_tool_content, - ) - .field( - "transform_request_body", - &self.transform_request_body.is_some(), - ) - .field("metadata_extractor", &self.metadata_extractor.is_some()) - .field("supported_urls", &self.supported_urls.is_some()) - .field("convert_usage", &self.convert_usage.is_some()) - .finish() - } -} - -/// Create an OpenAI-compatible provider. -/// -/// # Errors -/// -/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` -/// URL with a host. The key is not read -/// here. -pub fn create_openai_compatible( - settings: OpenAICompatibleProviderSettings, -) -> Result { - let base_url = validate_base_url(&settings.base_url)?; - let credential = settings - .api_key - .clone() - .filter(|key| !key.is_empty()) - .map_or(Credential::None, |key| { - Credential::Explicit(Resolvable::Value(key)) - }); - let headers = ProviderHeaders::bearer(credential, Vec::new(), settings.headers.clone()); - build( - settings, - BaseUrl::Fixed(base_url), - headers, - None, - crate::shared::aisdk_pins::OPENAI_COMPATIBLE, - ) -} - -/// [`create_openai_compatible`] for a package that builds on it with its own -/// base URL source and provider headers (the AI SDK's sub-providers that -/// call `createOpenAICompatible`). `settings.base_url`, `api_key` and -/// `headers` are not read; the user-agent part of `package` (the -/// openai-compatible version the calling package depends on) is added to -/// `headers`. `fetch_headers` go over the per-call headers, as headers a -/// package adds in its own fetch wrapper do. -pub(crate) fn build( - settings: OpenAICompatibleProviderSettings, - base_url: BaseUrl, - headers: ProviderHeaders, - fetch_headers: Option>, - package: Package, -) -> Result { - let defaults = ChatSettings::default(); - let mut provider = OpenAICompatibleProvider::assemble(Assembly { - name: settings.name, - base_url, - credential: Credential::None, - fixed_headers: Vec::new(), - headers: None, - query_params: settings.query_params, - fetch: settings.fetch, - transform_request_body: settings.transform_request_body, - package, - chat: ChatSettings { - include_usage: settings.include_usage.unwrap_or(false), - supports_structured_outputs: settings.supports_structured_outputs.unwrap_or(false), - supports_multi_part_tool_content: settings - .supports_multi_part_tool_content - .unwrap_or(false), - supported_urls: settings.supported_urls, - convert_usage: settings.convert_usage, - metadata_extractor: settings.metadata_extractor, - ..defaults - }, - })?; - provider.headers = headers.with_user_agent(package.name, package.version); - provider.fetch_headers = fetch_headers; - Ok(provider) -} - -/// Everything a compatible provider is made of. The public factory fills it -/// from the settings; presets fill it from their registry row. -pub(crate) struct Assembly { - pub name: String, - pub base_url: BaseUrl, - pub credential: Credential, - /// Headers sent before the user's (organization, project, ...). - pub fixed_headers: Vec<(String, String)>, - pub headers: Option, - pub query_params: Option>, - pub fetch: Option, - pub transform_request_body: Option, - pub chat: ChatSettings, - /// The package the user-agent names: its `ai-sdk-{name}/{version}` part - /// in the provider headers, and its provider-utils in the transport. - pub package: Package, -} - -/// An OpenAI-compatible provider (the AI SDK's `OpenAICompatibleProvider`). -/// Cheap to take models out of; it holds no HTTP client. -pub struct OpenAICompatibleProvider { - name: String, - base_url: BaseUrl, - query_params: Option>>, - headers: ProviderHeaders, - fetch_headers: Option>, - fetch: Option, - transform_request_body: Option, - chat: ChatSettings, -} - -impl OpenAICompatibleProvider { - pub(crate) fn assemble(assembly: Assembly) -> Result { - let query_params = assembly.query_params.map(|params| { - let mut pairs: Vec<(String, String)> = params.into_iter().collect(); - pairs.sort(); - Arc::new(pairs) - }); - Ok(Self { - name: assembly.name, - base_url: assembly.base_url, - query_params, - headers: ProviderHeaders { - credential: assembly.credential, - scheme: AuthScheme::Bearer, - fixed: assembly.fixed_headers, - user: assembly.headers, - user_agent: Some((assembly.package.name, assembly.package.version)), - }, - fetch_headers: None, - fetch: Some(crate::shared::package_fetch( - assembly.fetch, - assembly.package, - )), - transform_request_body: assembly.transform_request_body, - chat: assembly.chat, - }) - } - - fn model_config(&self, method: &str) -> CompatModelConfig { - CompatModelConfig { - provider: format!("{}.{method}", self.name), - base_url: self.base_url.clone(), - query_params: self.query_params.clone(), - headers: self.headers.clone(), - fetch_headers: self.fetch_headers.clone(), - fetch: self.fetch.clone(), - transform_request_body: if method == "chat" { - self.transform_request_body.clone() - } else { - None - }, - chat: self.chat.clone(), - } - } - - /// A chat-completions model; `provider()` is `"{name}.chat"`. - #[must_use] - pub fn chat(&self, model_id: &str) -> OpenAICompatibleChatModel { - let mut config = self.model_config("chat"); - config.chat = config.chat.for_model(model_id); - OpenAICompatibleChatModel::from_config(model_id.to_string(), config) - } - - /// A text-completion model; `provider()` is `"{name}.completion"`. - #[must_use] - pub fn completion(&self, model_id: &str) -> OpenAICompatibleCompletionModel { - OpenAICompatibleCompletionModel::from_config( - model_id.to_string(), - self.model_config("completion"), - ) - } - - /// An embedding model; `provider()` is `"{name}.embedding"`. - #[must_use] - pub fn embedding(&self, model_id: &str) -> OpenAICompatibleEmbeddingModel { - OpenAICompatibleEmbeddingModel::from_config( - model_id.to_string(), - self.model_config("embedding"), - ) - } - - /// An image model; `provider()` is `"{name}.image"`. - #[must_use] - pub fn image(&self, model_id: &str) -> OpenAICompatibleImageModel { - OpenAICompatibleImageModel::from_config(model_id.to_string(), self.model_config("image")) - } - - /// The provider as a function: the default language model for an id, the - /// chat model. The AI SDK's callable provider. - #[must_use] - pub fn call(&self, model_id: &str) -> Arc { - Arc::new(self.chat(model_id)) - } -} - -impl Provider for OpenAICompatibleProvider { - fn discovery(&self) -> Option<&dyn ProviderDiscovery> { - Some(self) - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(self.call(model_id)) - } - - fn embedding_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Arc::new(self.embedding(model_id))) - } - - fn image_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Arc::new(self.image(model_id))) - } - - /// Native decisions of the official runtimes (Ollama, llama.cpp, LocalAI, - /// Laya, vLLM, SGLang, Cloudflare); any other preset offers none. - fn decision_model( - &self, - model_id: &str, - ) -> Option, AiMuxError>> { - if !crate::runtime_decision::supported(&self.name) { - return None; - } - Some( - crate::runtime_decision::RuntimeDecisionModel::new( - &self.name, - model_id, - self.model_config("decision"), - ) - .map(|model| Arc::new(model) as Arc), - ) - } -} +pub use crate::shared::TransformRequestBody; -impl ProviderDiscovery for OpenAICompatibleProvider { - /// `GET {base_url}/models`: one exchange, no retry. Without a key no - /// `Authorization` header is sent. - fn list_models(&self) -> BoxFuture<'_, Result, AiMuxError>> { - let config = self.model_config("models"); - Box::pin(async move { chat::list_models_once(&config).await }) - } -} +pub(crate) use chat::ChatSettings; +pub(crate) use provider::{ + Assembly, BaseUrl, CompatModelConfig, ResolveBaseUrl, ResolveFetchHeaders, build, +}; #[cfg(test)] mod tests; diff --git a/aimux-providers/src/openai_compatible/provider.rs b/aimux-providers/src/openai_compatible/provider.rs new file mode 100644 index 000000000..2f312ef4c --- /dev/null +++ b/aimux-providers/src/openai_compatible/provider.rs @@ -0,0 +1,608 @@ +//! The OpenAI-compatible provider (`openai-compatible-provider.ts`): +//! [`create_openai_compatible`], its settings, and the configuration every +//! model of the provider gets (the AI SDK's `getCommonModelConfig`). +//! +//! Like the native OpenAI config, [`CompatModelConfig`] is the Rust shape of the +//! object `createOpenAICompatible` hands each model class (`{ provider, url, +//! headers, fetch, ... }`). It has no getters for the credential or the settings +//! that produced it. Where the AI SDK passes `url` and `headers` functions, it +//! holds data, or a package's own data behind [`ResolveBaseUrl`] / +//! [`ResolveFetchHeaders`]. The shared model never compares a provider name. + +use std::collections::HashMap; +use std::sync::Arc; + +use aimux_core::ErrorCause; +use aimux_core::SourceError; +use aimux_core::embedding_model::EmbeddingModel; +use aimux_core::error::AiMuxError; +use aimux_core::image_model::ImageModel; +use aimux_core::language_model::LanguageModel; +use aimux_core::model_catalogue::RuntimeModel; +use aimux_core::provider::{Provider, ProviderDiscovery}; +use aimux_provider_utils::{ + ExchangeContext, Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse, HeaderMapOpt, + HttpRequest, Resolvable, combine_headers, normalize_headers, validate_base_url, +}; +use futures::future::BoxFuture; +use serde_json::Value; + +use crate::preset::PresetDescriptor; +use crate::shared::aisdk_pins::Package; +use crate::shared::{AuthScheme, Credential, ProviderHeaders}; + +use super::chat::{ChatSettings, ConvertUsage, MetadataExtractor, SupportedUrlsFn}; +use super::{ + OpenAICompatibleChatModel, OpenAICompatibleCompletionModel, OpenAICompatibleEmbeddingModel, + OpenAICompatibleImageModel, TransformRequestBody, +}; + +/// Settings of [`create_openai_compatible`] (the AI SDK's +/// `OpenAICompatibleProviderSettings`). +#[derive(Clone, Default)] +pub struct OpenAICompatibleProviderSettings { + /// The provider name: the first segment of every model's `provider()` + /// string and the providerOptions namespace. Preserved as supplied. + pub name: String, + /// Base URL for the API calls. Required, `http(s)` with a host; a trailing + /// slash is removed. + pub base_url: String, + /// The API key. `None` or `""` sends no `Authorization` header; + /// a non-empty value is sent as `Bearer `. + pub api_key: Option, + /// Extra headers on every request. A `None` value removes the header, + /// including the `Authorization` one. Per-call headers win over these. + pub headers: Option, + /// Query parameters appended to every request URL (sorted by name). + pub query_params: Option>, + /// The transport: a mock, a signing decorator, a proxy-aware client. + /// `None` uses the process default, resolved per request. + pub fetch: Option, + /// Ask streaming responses to include usage (`stream_options.include_usage`). + pub include_usage: Option, + /// Whether the chat endpoint accepts `json_schema` response formats; when + /// not, a schema degrades to `json_object` with a warning. + pub supports_structured_outputs: Option, + /// Whether chat tool results may carry multi-part content: a `content` + /// tool output is then sent as an array of content parts instead of a + /// JSON string. Default `false`. + pub supports_multi_part_tool_content: Option, + /// Rewrites each chat JSON request body once, after it is serialized and + /// before it is sent. + pub transform_request_body: Option, + /// Extracts metadata from chat responses and streaming chunks. + pub metadata_extractor: Option>, + /// URL patterns supported by chat models, evaluated when requested. + pub supported_urls: Option, + /// Custom chat token accounting. + pub convert_usage: Option, +} + +impl std::fmt::Debug for OpenAICompatibleProviderSettings { + /// Never prints the key or header values. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OpenAICompatibleProviderSettings") + .field("name", &self.name) + .field("base_url", &self.base_url) + .field("api_key", &self.api_key.is_some()) + .field( + "headers", + &self.headers.as_ref().map(std::collections::HashMap::len), + ) + .field("query_params", &self.query_params) + .field("fetch", &self.fetch.is_some()) + .field("include_usage", &self.include_usage) + .field( + "supports_structured_outputs", + &self.supports_structured_outputs, + ) + .field( + "supports_multi_part_tool_content", + &self.supports_multi_part_tool_content, + ) + .field( + "transform_request_body", + &self.transform_request_body.is_some(), + ) + .field("metadata_extractor", &self.metadata_extractor.is_some()) + .field("supported_urls", &self.supported_urls.is_some()) + .field("convert_usage", &self.convert_usage.is_some()) + .finish() + } +} + +/// Create an OpenAI-compatible provider. +/// +/// # Errors +/// +/// Returns `AiMuxError::invalid_argument` when `base_url` is not an `http(s)` +/// URL with a host. The key is not read +/// here. +pub fn create_openai_compatible( + settings: OpenAICompatibleProviderSettings, +) -> Result { + let base_url = validate_base_url(&settings.base_url)?; + let credential = settings + .api_key + .clone() + .filter(|key| !key.is_empty()) + .map_or(Credential::None, |key| { + Credential::Explicit(Resolvable::Value(key)) + }); + let headers = ProviderHeaders::bearer(credential, Vec::new(), settings.headers.clone()); + build( + settings, + BaseUrl::Fixed(base_url), + headers, + None, + crate::shared::aisdk_pins::OPENAI_COMPATIBLE, + ) +} + +/// [`create_openai_compatible`] for a package that builds on it with its own +/// base URL source and provider headers (the AI SDK's sub-providers that +/// call `createOpenAICompatible`). `settings.base_url`, `api_key` and +/// `headers` are not read; the user-agent part of `package` (the +/// openai-compatible version the calling package depends on) is added to +/// `headers`. `fetch_headers` go over the per-call headers, as headers a +/// package adds in its own fetch wrapper do. +pub(crate) fn build( + settings: OpenAICompatibleProviderSettings, + base_url: BaseUrl, + headers: ProviderHeaders, + fetch_headers: Option>, + package: Package, +) -> Result { + let defaults = ChatSettings::default(); + let mut provider = OpenAICompatibleProvider::assemble(Assembly { + name: settings.name, + base_url, + credential: Credential::None, + fixed_headers: Vec::new(), + headers: None, + query_params: settings.query_params, + fetch: settings.fetch, + transform_request_body: settings.transform_request_body, + package, + chat: ChatSettings { + include_usage: settings.include_usage.unwrap_or(false), + supports_structured_outputs: settings.supports_structured_outputs.unwrap_or(false), + supports_multi_part_tool_content: settings + .supports_multi_part_tool_content + .unwrap_or(false), + supported_urls: settings.supported_urls, + convert_usage: settings.convert_usage, + metadata_extractor: settings.metadata_extractor, + ..defaults + }, + })?; + provider.headers = headers.with_user_agent(package.name, package.version); + provider.fetch_headers = fetch_headers; + Ok(provider) +} + +/// Everything a compatible provider is made of. The public factory fills it +/// from the settings; presets fill it from their registry row. +pub(crate) struct Assembly { + pub name: String, + pub base_url: BaseUrl, + pub credential: Credential, + /// Headers sent before the user's (organization, project, ...). + pub fixed_headers: Vec<(String, String)>, + pub headers: Option, + pub query_params: Option>, + pub fetch: Option, + pub transform_request_body: Option, + pub chat: ChatSettings, + /// The package the user-agent names: its `ai-sdk-{name}/{version}` part + /// in the provider headers, and its provider-utils in the transport. + pub package: Package, +} + +/// An OpenAI-compatible provider (the AI SDK's `OpenAICompatibleProvider`). +/// Cheap to take models out of; it holds no HTTP client. +pub struct OpenAICompatibleProvider { + name: String, + base_url: BaseUrl, + query_params: Option>>, + headers: ProviderHeaders, + fetch_headers: Option>, + fetch: Option, + transform_request_body: Option, + chat: ChatSettings, +} + +impl OpenAICompatibleProvider { + pub(crate) fn assemble(assembly: Assembly) -> Result { + let query_params = assembly.query_params.map(|params| { + let mut pairs: Vec<(String, String)> = params.into_iter().collect(); + pairs.sort(); + Arc::new(pairs) + }); + Ok(Self { + name: assembly.name, + base_url: assembly.base_url, + query_params, + headers: ProviderHeaders { + credential: assembly.credential, + scheme: AuthScheme::Bearer, + fixed: assembly.fixed_headers, + user: assembly.headers, + user_agent: Some((assembly.package.name, assembly.package.version)), + }, + fetch_headers: None, + fetch: Some(crate::shared::package_fetch( + assembly.fetch, + assembly.package, + )), + transform_request_body: assembly.transform_request_body, + chat: assembly.chat, + }) + } + + fn model_config(&self, method: &str) -> CompatModelConfig { + CompatModelConfig { + provider: format!("{}.{method}", self.name), + base_url: self.base_url.clone(), + query_params: self.query_params.clone(), + headers: self.headers.clone(), + fetch_headers: self.fetch_headers.clone(), + fetch: self.fetch.clone(), + transform_request_body: if method == "chat" { + self.transform_request_body.clone() + } else { + None + }, + chat: self.chat.clone(), + } + } + + /// A chat-completions model; `provider()` is `"{name}.chat"`. + #[must_use] + pub fn chat(&self, model_id: &str) -> OpenAICompatibleChatModel { + let mut config = self.model_config("chat"); + config.chat = config.chat.for_model(model_id); + OpenAICompatibleChatModel::from_config(model_id.to_string(), config) + } + + /// A text-completion model; `provider()` is `"{name}.completion"`. + #[must_use] + pub fn completion(&self, model_id: &str) -> OpenAICompatibleCompletionModel { + OpenAICompatibleCompletionModel::from_config( + model_id.to_string(), + self.model_config("completion"), + ) + } + + /// An embedding model; `provider()` is `"{name}.embedding"`. + #[must_use] + pub fn embedding(&self, model_id: &str) -> OpenAICompatibleEmbeddingModel { + OpenAICompatibleEmbeddingModel::from_config( + model_id.to_string(), + self.model_config("embedding"), + ) + } + + /// An image model; `provider()` is `"{name}.image"`. + #[must_use] + pub fn image(&self, model_id: &str) -> OpenAICompatibleImageModel { + OpenAICompatibleImageModel::from_config(model_id.to_string(), self.model_config("image")) + } + + /// The provider as a function: the default language model for an id, the + /// chat model. The AI SDK's callable provider. + #[must_use] + pub fn call(&self, model_id: &str) -> Arc { + Arc::new(self.chat(model_id)) + } +} + +impl Provider for OpenAICompatibleProvider { + fn discovery(&self) -> Option<&dyn ProviderDiscovery> { + Some(self) + } + + fn language_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(self.call(model_id)) + } + + fn embedding_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.embedding(model_id))) + } + + fn image_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.image(model_id))) + } + + /// Native decisions of the official runtimes (Ollama, llama.cpp, LocalAI, + /// Laya, vLLM, SGLang, Cloudflare); any other preset offers none. + fn decision_model( + &self, + model_id: &str, + ) -> Option, AiMuxError>> { + if !crate::runtime_decision::supported(&self.name) { + return None; + } + Some( + crate::runtime_decision::RuntimeDecisionModel::new( + &self.name, + model_id, + self.model_config("decision"), + ) + .map(|model| Arc::new(model) as Arc), + ) + } +} + +impl ProviderDiscovery for OpenAICompatibleProvider { + /// `GET {base_url}/models`: one exchange, no retry. Without a key no + /// `Authorization` header is sent. + fn list_models(&self) -> BoxFuture<'_, Result, AiMuxError>> { + let config = self.model_config("models"); + Box::pin(async move { list_models_once(&config).await }) + } +} + +/// A base URL a package builds from settings read when a request is made +/// (a Vertex AI partner sub-provider's project and location). +pub(crate) trait ResolveBaseUrl: std::fmt::Debug + Send + Sync { + /// The base URL of one request. + fn resolve(&self) -> Result; +} + +/// The headers a package adds in its own fetch wrapper +/// ([`CompatModelConfig::fetch_headers`]). +#[async_trait::async_trait] +pub(crate) trait ResolveFetchHeaders: Send + Sync { + /// The headers of one request. + async fn resolve(&self) -> Result; +} + +/// Where the base URL comes from. +/// +/// A plain provider has a [`Fixed`](Self::Fixed) URL. A preset whose URL is +/// read from the environment or expanded from template parameters is +/// [`Preset`](Self::Preset): it is evaluated per request, so creating the +/// provider (and the preset default instances) never reads the environment +/// and never fails. +#[derive(Clone, Debug)] +pub(crate) enum BaseUrl { + Fixed(String), + Preset { + descriptor: &'static PresetDescriptor, + /// The explicit template parameters of the settings. + params: Arc>, + }, + /// A package's own base URL, resolved when a request is made. + Dynamic(Arc), +} + +impl BaseUrl { + pub(crate) fn resolve(&self) -> Result { + match self { + Self::Fixed(url) => Ok(url.clone()), + Self::Preset { descriptor, params } => { + crate::preset::resolve_base_url(descriptor, params) + } + Self::Dynamic(resolver) => resolver.resolve(), + } + } +} + +/// What a model needs to talk to the API. +#[derive(Clone)] +pub(crate) struct CompatModelConfig { + /// The identity the model reports: `"{name}.{method}"`. + pub provider: String, + pub base_url: BaseUrl, + /// Appended to every request URL, in this order. + pub query_params: Option>>, + /// Provider headers (credential, user headers), produced on every request. + pub headers: ProviderHeaders, + /// Headers layered over the per-call headers, resolved on every request + /// after `headers`: what an AI SDK package that builds on + /// `createOpenAICompatible` adds in its own fetch wrapper (google-vertex's + /// Node MaaS and xAI providers). Their `user-agent` replaces the whole + /// header, the helper layer's part included ([`FetchUserAgent`]). + pub fetch_headers: Option>, + /// Transport; `None` uses the process default, resolved per request. + pub fetch: Option, + pub transform_request_body: Option, + pub chat: ChatSettings, +} + +impl CompatModelConfig { + /// The providerOptions namespace of this provider: the first segment of + /// the provider string. + pub(crate) fn provider_options_name(&self) -> &str { + self.provider.split('.').next().unwrap_or_default().trim() + } + + /// The full URL of an endpoint path (with the query parameters) and the + /// origin credentialed headers may be sent to. + pub(crate) fn url(&self, path: &str) -> Result<(String, String), AiMuxError> { + let base = self.base_url.resolve()?; + let mut url = + url::Url::parse(&format!("{base}{path}")).map_err(|e| AiMuxError::InvalidArgument { + message: format!("invalid request URL for {}: {e}", self.provider), + cause: Some(ErrorCause::source(&e)), + })?; + if let Some(params) = &self.query_params { + // As the AI SDK does: the configured parameters replace whatever + // query the base URL carried. + url.set_query(None); + if !params.is_empty() { + url.query_pairs_mut() + .extend_pairs(params.iter().map(|(k, v)| (k.as_str(), v.as_str()))); + } + } + Ok((url.to_string(), base)) + } + + /// Resolve the provider headers, layer the per-call headers over them, + /// then the fetch headers (case-insensitive, later wins, `None` removes), + /// and return the list that goes on the wire with the fetch headers' + /// `user-agent`. + pub(crate) async fn request_headers( + &self, + call_headers: Option<&HashMap>, + ) -> Result { + let provider = self.headers.resolve().await?; + let call: HeaderMapOpt = call_headers + .map(|headers| { + headers + .iter() + .map(|(name, value)| (name.clone(), Some(value.clone()))) + .collect() + }) + .unwrap_or_default(); + let fetch = match &self.fetch_headers { + Some(headers) => combine_headers(&[&headers.resolve().await?]), + None => HeaderMapOpt::new(), + }; + let fetch_user_agent = fetch.get("user-agent").cloned().flatten(); + Ok(RequestHeaders { + list: normalize_headers(combine_headers(&[&provider, &call, &fetch])), + fetch_user_agent, + }) + } + + /// An exchange for `path` that inherits the operation's cancellation and + /// recording context and carries this config's transport and credential + /// origin. + pub(crate) fn http_request( + &self, + path: &str, + headers: RequestHeaders, + options: &impl ExchangeContext, + ) -> Result { + let (url, origin) = self.url(path)?; + Ok(self.with_transport( + HttpRequest::new(url, headers.list, options), + origin, + headers.fetch_user_agent, + )) + } + + /// Attach the transport and the credential origin to a request. A fetch + /// headers' `user-agent` is sent as the whole header by the transport. + pub(crate) fn with_transport( + &self, + mut request: HttpRequest, + origin: String, + fetch_user_agent: Option, + ) -> HttpRequest { + request.fetch = match fetch_user_agent { + Some(user_agent) => Some(Arc::new(FetchUserAgent { + inner: self.fetch.clone(), + user_agent, + })), + None => self.fetch.clone(), + }; + request.credentialed_origin = Some(origin); + request + } + + /// Run the provider-level body rewrite, when there is one. + pub(crate) fn transform_body(&self, body: Value) -> Value { + match &self.transform_request_body { + Some(transform) => transform(body), + None => body, + } + } + + /// The failed-response handler of this provider's error structure. + pub(crate) fn failed_response_handler( + &self, + ) -> aimux_provider_utils::ResponseHandler { + let structure = self.chat.error_structure.clone(); + aimux_provider_utils::create_json_error_response_handler(move |data| structure(data)) + } +} + +/// The headers of one request ([`CompatModelConfig::request_headers`]). +pub(crate) struct RequestHeaders { + /// What goes on the wire, before the helper layer adds its + /// `ai-sdk-provider-utils` part. + pub list: Vec<(String, String)>, + /// The `user-agent` of the fetch headers, when they set one. + pub fetch_user_agent: Option, +} + +/// The transport of a request whose fetch headers set `user-agent`: it sends +/// that value as the whole header, over the one the helper layer and the +/// package transport built. google-vertex's Node wrappers spread their +/// headers over `init.headers` after `postToApi` added its part +/// (google-vertex-maas-provider-node.ts:43-49, +/// google-vertex-xai-provider-node.ts:41-47), so nothing of it is left. +struct FetchUserAgent { + /// The package transport (`None`: the process default). + inner: Option, + user_agent: String, +} + +#[async_trait::async_trait] +impl Fetch for FetchUserAgent { + async fn fetch(&self, mut request: FetchRequest) -> Result { + let value = + reqwest::header::HeaderValue::try_from(self.user_agent.as_str()).map_err(|error| { + FetchError::Other(SourceError::with_cause( + "invalid header value for user-agent", + &error, + )) + })?; + request.headers.insert(reqwest::header::USER_AGENT, value); + self.inner + .clone() + .unwrap_or_else(aimux_provider_utils::default_fetch) + .fetch(request) + .await + } +} + +/// One `GET {base_url}/models` exchange for discovery: no retry, no +/// recording. +pub(crate) async fn list_models_once( + config: &CompatModelConfig, +) -> Result, AiMuxError> { + #[derive(serde::Deserialize)] + struct ModelsList { + #[serde(default)] + data: Vec, + } + #[derive(serde::Deserialize)] + struct ModelEntry { + id: String, + #[serde(default)] + owned_by: Option, + #[serde(default)] + created: Option, + } + + let headers = config.request_headers(None).await?; + let (url, origin) = config.url("/models")?; + let resp = aimux_provider_utils::get_from_api( + config.with_transport( + aimux_provider_utils::HttpRequest { + url, + headers: headers.list, + ..Default::default() + }, + origin, + headers.fetch_user_agent, + ), + aimux_provider_utils::create_json_response_handler::(), + config.failed_response_handler(), + ) + .await?; + Ok(resp + .value + .data + .into_iter() + .map(|m| aimux_core::model_catalogue::RuntimeModel { + id: m.id, + owned_by: m.owned_by, + created: m.created, + }) + .collect()) +} diff --git a/aimux-providers/src/openai_compatible/tests/chat/chat_language_model.rs b/aimux-providers/src/openai_compatible/tests/chat/chat_language_model.rs index b8f4b5cd4..d8cc05f76 100644 --- a/aimux-providers/src/openai_compatible/tests/chat/chat_language_model.rs +++ b/aimux-providers/src/openai_compatible/tests/chat/chat_language_model.rs @@ -13,7 +13,7 @@ use aimux_core::shared::{FileBytes, FileData, provider_namespace}; use aimux_core::stream_part::StreamPart; use aimux_core::types::ProviderMetadata; -use crate::openai_compatible::config::{BaseUrl, ChatSettings, CompatModelConfig}; +use crate::openai_compatible::{BaseUrl, ChatSettings, CompatModelConfig}; use crate::openai_compatible::{ MetadataExtractor, OpenAICompatibleChatModel, OpenAICompatibleProvider, OpenAICompatibleProviderSettings, StreamMetadataExtractor, create_openai_compatible, @@ -4549,3 +4549,601 @@ mod transform_request_body { assert_eq!(request_body.get("custom_field"), None); } } + +// ── Older aimux tests (previously the unit tests of `openai_compatible/config.rs`) ── + +mod chat_settings { + use crate::openai_compatible::ChatSettings; + + static MODELS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| vec!["qwen3.7-plus".to_string(), "kimi-k*".to_string()]); + + fn supports(model_id: &str) -> bool { + ChatSettings { + supports_structured_outputs: true, + structured_output_models: Some(&MODELS), + ..ChatSettings::default() + } + .for_model(model_id) + .supports_structured_outputs + } + + #[test] + fn a_model_list_matches_the_exact_id() { + assert!(supports("qwen3.7-plus")); + assert!(!supports("qwen3.7-plus-2026-09-01")); + assert!(!supports("qwen3.7-plusplus")); + assert!(!supports("qwen3.7")); + } + + #[test] + fn an_entry_ending_in_a_star_matches_a_prefix() { + assert!(supports("kimi-k")); + assert!(supports("kimi-k2.5")); + assert!(!supports("kimi-latest")); + assert!(!supports("kimi")); + } + + #[test] + fn without_a_model_list_the_flag_applies() { + let settings = ChatSettings { + supports_structured_outputs: true, + ..ChatSettings::default() + }; + assert!(settings.for_model("any").supports_structured_outputs); + } +} + +// ── Older aimux tests (previously the unit tests of `openai_compatible/chat.rs`) ── + +mod error_structure_and_base_url { + use std::sync::Arc; + + use serde_json::{Value, json}; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + use aimux_core::error::AiMuxError; + use aimux_core::language_model::LanguageModel; + use aimux_core::language_model_message::LanguageModelMessage; + use aimux_core::options::CallOptions; + use aimux_provider_utils::ProviderErrorParts; + + use crate::openai_compatible::{Assembly, BaseUrl, ChatSettings, OpenAICompatibleProvider}; + use crate::shared::Credential; + + fn hello() -> CallOptions { + CallOptions::new(vec![LanguageModelMessage::user_text("hi")]) + } + + fn provider(base_url: BaseUrl, chat: ChatSettings) -> OpenAICompatibleProvider { + OpenAICompatibleProvider::assemble(Assembly { + name: "acme".to_string(), + base_url, + credential: Credential::None, + fixed_headers: Vec::new(), + headers: None, + query_params: None, + fetch: None, + transform_request_body: None, + chat, + package: crate::shared::aisdk_pins::OPENAI_COMPATIBLE, + }) + .unwrap() + } + + async fn serve(server: &MockServer, response: ResponseTemplate) { + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(response) + .mount(server) + .await; + } + + #[tokio::test] + async fn an_error_structure_decides_the_message_and_code() { + let server = MockServer::start().await; + serve( + &server, + ResponseTemplate::new(400).set_body_json(json!({"detail": "bad prompt", "kind": 42})), + ) + .await; + let chat = ChatSettings { + error_structure: Arc::new(|data: &Value| ProviderErrorParts { + message: data["detail"].as_str().unwrap_or_default().to_string(), + provider_code: data["kind"].as_u64().map(|k| k.to_string()), + }), + ..ChatSettings::default() + }; + let error = provider(BaseUrl::Fixed(server.uri()), chat) + .chat("m") + .do_generate(&hello()) + .await + .unwrap_err(); + match error { + AiMuxError::ApiCall(detail) => { + assert_eq!(detail.message, "bad prompt"); + assert_eq!(detail.provider_code.as_deref(), Some("42")); + } + other => panic!("expected ApiCall, got {other:?}"), + } + } + + /// A preset base URL is data the request resolves: creating the provider + /// and the model reads nothing, a request that cannot resolve it fails, + /// and the next request resolves it again. + #[tokio::test] + #[serial_test::serial] + async fn a_lazy_base_url_is_evaluated_per_request_and_its_error_fails_the_request() { + use crate::preset::{AuthMode, ParamSpec, PresetDescriptor}; + + const VAR: &str = "AIMUX_TEST_COMPAT_LAZY_BASE_URL"; + let server = MockServer::start().await; + serve( + &server, + ResponseTemplate::new(200).set_body_json(json!({ + "id": "c", "model": "m", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}] + })), + ) + .await; + // SAFETY: the test is `#[serial]` and no other test reads `VAR`. + unsafe { std::env::remove_var(VAR) }; + let descriptor: &'static PresetDescriptor = Box::leak(Box::new(PresetDescriptor { + name: "acme".to_string(), + display: "Acme".to_string(), + base_url: "http://{host}/v1".to_string(), + env_var: String::new(), + auth: AuthMode::None, + base_url_env: Some(VAR.to_string()), + max_tokens_key: None, + convert_usage: None, + params: vec![ParamSpec { + name: "host".to_string(), + env: Vec::new(), + default: None, + derive: None, + }], + user_agent: ("openai-compatible", "3.0.62"), + structured_output_models: None, + })); + let lazy = BaseUrl::Preset { + descriptor, + params: Arc::new(std::collections::HashMap::new()), + }; + let provider = provider(lazy, ChatSettings::default()); + let model = provider.chat("m"); + let error = model.do_generate(&hello()).await.unwrap_err(); + assert!( + matches!(error, AiMuxError::InvalidArgument { .. }), + "{error:?}" + ); + // SAFETY: as above. + unsafe { std::env::set_var(VAR, server.uri()) }; + let result = model.do_generate(&hello()).await; + // SAFETY: as above. + unsafe { std::env::remove_var(VAR) }; + result.expect("resolved on the next request"); + } +} + +// ── Older ports (previously `tests/openai_compatible_upstream_e2e_test.rs`) ── +// +// End-to-end subset of the pinned openai-compatible upstream tests. + +mod upstream_e2e { + use std::sync::Arc; + + use aimux_core::AiMuxError; + use aimux_core::language_model::LanguageModel; + use aimux_core::language_model_message::LanguageModelMessage; + use aimux_core::options::CallOptions; + use aimux_core::result::GenerateContent; + use aimux_core::shared::provider_namespace; + use aimux_core::stream_part::StreamPart; + use aimux_core::tool::FunctionTool; + use aimux_core::types::FinishReasonUnified; + use futures::StreamExt; + use serde_json::{Value, json}; + + use crate::openai_compatible::tests::support::*; + use crate::openai_compatible::{ + OpenAICompatibleProvider, OpenAICompatibleProviderSettings, create_openai_compatible, + }; + use crate::test_support::mock_fetch::{Canned, MockFetch}; + + /// partial: `should use camelCase metadata key when camelCase provider options are used`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. + /// partial: `should extract detailed token usage when available`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. + #[tokio::test] + async fn chat_generate_metadata_and_usage() { + let usage = json!({"prompt_tokens":20,"completion_tokens":30,"total_tokens":50, + "prompt_tokens_details":{"cached_tokens":5}, + "completion_tokens_details":{"reasoning_tokens":10,"accepted_prediction_tokens":15,"rejected_prediction_tokens":5}}); + let fetch = MockFetch::new(vec![Canned::json(&chat_response( + json!({"content":"Hello!"}), + usage.clone(), + ))]); + let mut opts = options(); + opts.provider_options = Some( + provider_namespace("testProvider", json!({"reasoningEffort":"high"})) + .expect("provider metadata must be an object"), + ); + let result = provider(&fetch) + .chat("test-model") + .do_generate(&opts) + .await + .unwrap(); + let expected = json!({"model":"test-model","messages":[{"role":"user","content":"Hello"}],"reasoning_effort":"high"}); + assert_request(&fetch, "/chat/completions", expected.clone()); + assert_eq!(result.request.unwrap().body, Some(expected)); + assert_eq!(result.content.len(), 1); + assert!( + matches!(&result.content[0], GenerateContent::Text { text, .. } if text == "Hello!") + ); + assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); + assert_eq!(result.finish_reason.raw.as_deref(), Some("stop")); + assert_eq!(result.usage.input_tokens.total, Some(20)); + assert_eq!(result.usage.input_tokens.no_cache, Some(15)); + assert_eq!(result.usage.input_tokens.cache_read, Some(5)); + assert_eq!(result.usage.input_tokens.cache_write, None); + assert_eq!(result.usage.output_tokens.total, Some(30)); + assert_eq!(result.usage.output_tokens.text, Some(20)); + assert_eq!(result.usage.output_tokens.reasoning, Some(10)); + assert_eq!(result.usage.raw, usage.as_object().cloned()); + let metadata = result.provider_metadata.unwrap(); + assert_eq!( + metadata, + provider_namespace( + "testProvider", + json!({"acceptedPredictionTokens":15,"rejectedPredictionTokens":5}) + ) + .expect("provider metadata must be an object") + ); + assert!(!metadata.contains_key("test-provider")); + let response = result.response.unwrap(); + assert_eq!(response.id.as_deref(), Some("chatcmpl-test")); + assert_eq!(response.model_id.as_deref(), Some("test-model")); + assert_eq!( + response.headers.unwrap()["content-type"], + "application/json" + ); + assert!(result.warnings.is_empty()); + } + + /// partial: `should stream reasoning content before text deltas`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. + #[tokio::test] + async fn chat_stream_reasoning_before_text() { + let delta = |value: Value| { + json!({"id":"chatcmpl-test","created":1711357598, + "model":"test-model","choices":[{"index":0,"delta":value,"finish_reason":null}]}) + }; + let fetch = MockFetch::new(vec![stream_response(&[ + delta(json!({"role":"assistant","content":"","reasoning_content":"Let me think"})), + delta(json!({"content":"","reasoning_content":" about this"})), + delta(json!({"content":"Here's"})), + delta(json!({"content":" my response"})), + json!({"choices":[{"index":0,"delta":{},"finish_reason":"stop"}], + "usage":{"prompt_tokens":18,"completion_tokens":439}}), + ])]); + let result = provider(&fetch) + .chat("test-model") + .do_stream(&options()) + .await + .unwrap(); + assert_request(&fetch, "/chat/completions", stream_request()); + assert_eq!(result.request.unwrap().body, Some(stream_request())); + let parts: Vec<_> = result.stream.map(|part| part.unwrap()).collect().await; + assert_eq!(parts.len(), 11); + assert!(matches!(&parts[0], StreamPart::StreamStart { warnings } if warnings.is_empty())); + assert!( + matches!(&parts[1], StreamPart::ResponseMetadata(metadata) if metadata.id.as_deref() == Some("chatcmpl-test") && metadata.model_id.as_deref() == Some("test-model")) + ); + assert!(matches!(&parts[2], StreamPart::ReasoningStart { id, .. } if id == "reasoning-0")); + assert!(matches!(&parts[5], StreamPart::ReasoningEnd { id, .. } if id == "reasoning-0")); + assert!(matches!(&parts[6], StreamPart::TextStart { id, .. } if id == "txt-0")); + assert!(matches!(&parts[9], StreamPart::TextEnd { id, .. } if id == "txt-0")); + let deltas: Vec<_> = parts + .iter() + .filter_map(|part| match part { + StreamPart::ReasoningDelta { id, delta, .. } => { + Some(("reasoning", id.as_str(), delta.as_str())) + } + StreamPart::TextDelta { id, delta, .. } => { + Some(("text", id.as_str(), delta.as_str())) + } + _ => None, + }) + .collect(); + assert_eq!( + deltas, + vec![ + ("reasoning", "reasoning-0", "Let me think"), + ("reasoning", "reasoning-0", " about this"), + ("text", "txt-0", "Here's"), + ("text", "txt-0", " my response") + ] + ); + let StreamPart::Finish { + finish_reason, + usage, + provider_metadata, + } = &parts[10] + else { + panic!("expected finish"); + }; + assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); + assert_eq!(usage.input_tokens.total, Some(18)); + assert_eq!(usage.input_tokens.no_cache, Some(18)); + assert_eq!(usage.input_tokens.cache_read, Some(0)); + assert_eq!(usage.output_tokens.total, Some(439)); + assert_eq!(usage.output_tokens.text, Some(439)); + assert_eq!(usage.output_tokens.reasoning, Some(0)); + assert_eq!( + provider_metadata.as_ref().unwrap(), + &provider_namespace("test-provider", json!({})) + .expect("provider metadata must be an object") + ); + } + + /// partial: `should parse thought signature from extra_content and include in providerMetadata`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. + #[tokio::test] + async fn chat_tool_call_preserves_arguments_and_signature() { + let fetch = MockFetch::new(vec![Canned::json(&chat_response( + json!({"tool_calls":[{ + "id":"function-call-1","type":"function", + "function":{"name":"check_flight","arguments":"{\"flight\":\"AA100\"}"}, + "extra_content":{"google":{"thought_signature":""}}}]}), + Value::Null, + ))]); + let schema = json!({"type":"object","properties":{"flight":{"type":"string"}}, + "required":["flight"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}); + let mut opts = options(); + opts.tools = Some(vec![ + FunctionTool::new("check_flight", schema.clone()).into(), + ]); + let result = provider(&fetch) + .chat("test-model") + .do_generate(&opts) + .await + .unwrap(); + assert_request( + &fetch, + "/chat/completions", + json!({"model":"test-model", + "messages":[{"role":"user","content":"Hello"}], + "tools":[{"type":"function","function":{"name":"check_flight","parameters":schema}}]}), + ); + assert_eq!(result.content.len(), 1); + assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); + assert_eq!(result.finish_reason.raw.as_deref(), Some("stop")); + let GenerateContent::ToolCall(call) = &result.content[0] else { + panic!("expected tool call"); + }; + assert_eq!(call.tool_call_id, "function-call-1"); + assert_eq!(call.tool_name, "check_flight"); + assert_eq!(call.input, "{\"flight\":\"AA100\"}"); + assert_eq!( + call.provider_metadata.as_ref().unwrap(), + &provider_namespace("test-provider", json!({"thoughtSignature":""})) + .expect("provider metadata must be an object") + ); + } + + /// partial: `should preserve structured error stream parts`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. + #[tokio::test] + async fn chat_stream_preserves_structured_error() { + let fetch = MockFetch::new(vec![stream_response(&[json!({"error":{ + "message":"Context length exceeded","code":"CONTEXT_LENGTH_EXCEEDED"}})])]); + let result = provider(&fetch) + .chat("test-model") + .do_stream(&options()) + .await + .unwrap(); + assert_request(&fetch, "/chat/completions", stream_request()); + let parts: Vec<_> = result.stream.map(|part| part.unwrap()).collect().await; + assert_eq!(parts.len(), 3); + assert!(matches!(&parts[0], StreamPart::StreamStart { warnings } if warnings.is_empty())); + let StreamPart::Error { + error: AiMuxError::ApiCall(error), + } = &parts[1] + else { + panic!("expected structured API error"); + }; + assert_eq!(error.message, "Context length exceeded"); + assert_eq!( + error.provider_code.as_deref(), + Some("CONTEXT_LENGTH_EXCEEDED") + ); + let StreamPart::Finish { + finish_reason, + usage, + provider_metadata, + } = &parts[2] + else { + panic!("expected finish"); + }; + assert_eq!(finish_reason.unified, FinishReasonUnified::Error); + assert_eq!(finish_reason.raw, None); + assert_eq!(usage.input_tokens.total, None); + assert_eq!(usage.output_tokens.total, None); + assert_eq!(usage.raw, None); + assert_eq!( + provider_metadata.as_ref().unwrap(), + &provider_namespace("test-provider", json!({})) + .expect("provider metadata must be an object") + ); + } + + /// Upstream: @ai-sdk/openai-compatible 3.0.62 + /// src/chat/openai-compatible-chat-language-model.ts:588-595 passes an error + /// chunk's `error` on as it is: even a numeric `code` that looks like an HTTP + /// status is no status, so the error is not retryable. + #[tokio::test] + async fn chat_stream_error_takes_no_status_from_a_numeric_code() { + for (code, provider_code) in [ + (json!(429), "429"), + (json!(503), "503"), + (json!("429"), "429"), + ] { + let payload = json!({"message": "Slow down", "code": code}); + let fetch = MockFetch::new(vec![stream_response(&[json!({ "error": payload })])]); + let parts: Vec<_> = provider(&fetch) + .chat("test-model") + .do_stream(&options()) + .await + .unwrap() + .stream + .map(|part| part.unwrap()) + .collect() + .await; + let error = parts + .iter() + .find_map(|part| match part { + StreamPart::Error { + error: AiMuxError::ApiCall(error), + } => Some(error), + _ => None, + }) + .expect("expected an API error part"); + assert_eq!(error.message, "Slow down", "{code}"); + assert_eq!( + error.provider_code.as_deref(), + Some(provider_code), + "{code}" + ); + assert_eq!(error.status_code, None, "{code}"); + assert!(!error.is_retryable, "{code}"); + assert_eq!(error.data.as_ref(), Some(&payload), "{code}"); + } + } + + /// A tool result whose output is `content` (a text part and a base64 image + /// part), as the AI SDK passes it to the provider. + fn multi_part_tool_prompt( + extra: Option, + ) -> CallOptions { + use aimux_core::language_model_message::{ + FilePart, TextPart, ToolPart, ToolResultContent, ToolResultOutput, ToolResultPart, + }; + use aimux_core::shared::{FileBytes, FileData}; + let mut value = vec![ + ToolResultContent::Text(TextPart { + text: "The weather is sunny".into(), + provider_options: None, + }), + ToolResultContent::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64("AAECAw==".into()), + }, + media_type: "image/png".into(), + filename: None, + provider_options: None, + }), + ]; + value.extend(extra); + CallOptions::new(vec![ + LanguageModelMessage::user_text("Weather?"), + LanguageModelMessage::Tool { + content: vec![ToolPart::ToolResult(ToolResultPart { + tool_call_id: "call-1".into(), + tool_name: "weather".into(), + output: ToolResultOutput::Content { value }, + provider_options: None, + })], + provider_options: None, + }, + ]) + } + + fn multi_part_provider( + fetch: &Arc, + supported: Option, + ) -> OpenAICompatibleProvider { + create_openai_compatible(OpenAICompatibleProviderSettings { + name: "test-provider".into(), + base_url: BASE_URL.into(), + fetch: Some(fetch.transport()), + supports_multi_part_tool_content: supported, + ..Default::default() + }) + .unwrap() + } + + /// partial: `should pass multi-part tool content when supported`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. + /// partial: `should convert multi-part tool content when supported`; full port in src/openai_compatible/tests/chat/convert_to_chat_messages.rs. + /// partial: `should stringify multi-part tool content by default`; full port in src/openai_compatible/tests/chat/convert_to_chat_messages.rs. + /// Upstream: @ai-sdk/openai-compatible 3.0.62 + /// src/chat/convert-to-openai-compatible-chat-messages.ts:303-306 sends a + /// `content` tool output as an array of content parts + /// (`convertToolContentPart`, :162-176) when `supportsMultiPartToolContent` + /// (openai-compatible-provider.ts:94-100, :183) is set, and as + /// `JSON.stringify(output.value)` otherwise (the default, `false`). + #[tokio::test] + async fn chat_multi_part_tool_content_follows_the_setting() { + let response = || { + Canned::json(&chat_response( + json!({"role":"assistant","content":"ok"}), + json!({"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}), + )) + }; + let fetch = MockFetch::new(vec![response(), response(), response()]); + let tool_message = |index: usize| fetch.seen()[index].json_body()["messages"][1].clone(); + + multi_part_provider(&fetch, Some(true)) + .chat("test-model") + .do_generate(&multi_part_tool_prompt(None)) + .await + .unwrap(); + assert_eq!( + tool_message(0), + json!({ + "role": "tool", + "tool_call_id": "call-1", + "content": [ + {"type": "text", "text": "The weather is sunny"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAECAw=="}} + ] + }) + ); + + for (index, supported) in [(1, None), (2, Some(false))] { + multi_part_provider(&fetch, supported) + .chat("test-model") + .do_generate(&multi_part_tool_prompt(None)) + .await + .unwrap(); + let message = tool_message(index); + let content: Value = + serde_json::from_str(message["content"].as_str().expect("a JSON string")).unwrap(); + assert_eq!( + content, + json!([ + {"type": "text", "text": "The weather is sunny"}, + {"type": "file", "mediaType": "image/png", "data": {"type": "data", "data": "AAECAw=="}} + ]), + "{supported:?}" + ); + } + } + + /// Upstream: convert-to-openai-compatible-chat-messages.ts:166-170 rejects a + /// custom part in multi-part tool content. + #[tokio::test] + async fn chat_multi_part_tool_content_rejects_a_custom_part() { + let fetch = MockFetch::new(Vec::new()); + let error = multi_part_provider(&fetch, Some(true)) + .chat("test-model") + .do_generate(&multi_part_tool_prompt(Some( + aimux_core::language_model_message::ToolResultContent::Custom { + provider_options: None, + }, + ))) + .await + .unwrap_err(); + assert!( + matches!(&error, AiMuxError::UnsupportedFunctionality(what) if what == "custom tool content parts"), + "{error:?}" + ); + assert!(fetch.seen().is_empty()); + } +} diff --git a/aimux-providers/src/openai_compatible/tests/chat/convert_to_chat_messages.rs b/aimux-providers/src/openai_compatible/tests/chat/convert_to_chat_messages.rs index 85419b9d2..43f421748 100644 --- a/aimux-providers/src/openai_compatible/tests/chat/convert_to_chat_messages.rs +++ b/aimux-providers/src/openai_compatible/tests/chat/convert_to_chat_messages.rs @@ -4,7 +4,7 @@ use aimux_core::error::AiMuxError; use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt, UserPart}; use aimux_core::shared::{FileBytes, FileData}; -use crate::openai_compatible::convert::{MessageSpec, convert_messages}; +use crate::openai_compatible::chat::convert_to_chat_messages::{MessageSpec, convert_messages}; /// A prompt in its AI SDK JSON form. fn prompt(value: Value) -> LanguageModelPrompt { diff --git a/aimux-providers/src/openai_compatible/tests/chat/prepare_tools.rs b/aimux-providers/src/openai_compatible/tests/chat/prepare_tools.rs index ddc522b8a..8d2f9f148 100644 --- a/aimux-providers/src/openai_compatible/tests/chat/prepare_tools.rs +++ b/aimux-providers/src/openai_compatible/tests/chat/prepare_tools.rs @@ -2,7 +2,7 @@ use serde_json::{Map, Value, json}; use aimux_core::options::CallOptions; -use crate::openai_compatible::convert::{PreparedTools, prepare_function_tools}; +use crate::openai_compatible::chat::prepare_tools::{PreparedTools, prepare_function_tools}; /// `prepareTools({ tools, toolChoice })`: the Rust form reads both from the /// call options, given here in their AI SDK JSON form. diff --git a/aimux-providers/src/openai_compatible/tests/completion/completion_language_model.rs b/aimux-providers/src/openai_compatible/tests/completion/completion_language_model.rs index e6f5d3780..744854ce8 100644 --- a/aimux-providers/src/openai_compatible/tests/completion/completion_language_model.rs +++ b/aimux-providers/src/openai_compatible/tests/completion/completion_language_model.rs @@ -15,7 +15,7 @@ use aimux_core::result::StreamResult; use aimux_core::stream_part::StreamPart; use crate::openai_compatible::completion::{CompletionConfig, OpenAICompatibleCompletionModel}; -use crate::openai_compatible::config::{BaseUrl, ChatSettings, CompatModelConfig}; +use crate::openai_compatible::{BaseUrl, ChatSettings, CompatModelConfig}; use crate::openai_compatible::{ OpenAICompatibleProvider, OpenAICompatibleProviderSettings, create_openai_compatible, }; @@ -920,3 +920,50 @@ mod do_stream { } } } + +// ── Older ports (previously `tests/openai_compatible_upstream_e2e_test.rs`) ── +// +// End-to-end subset of the pinned openai-compatible upstream tests. + +mod upstream_e2e { + use aimux_core::AiMuxError; + use aimux_core::language_model::LanguageModel; + use aimux_core::stream_part::StreamPart; + use futures::StreamExt; + use serde_json::json; + + use crate::openai_compatible::tests::support::*; + use crate::test_support::mock_fetch::MockFetch; + + /// Upstream: @ai-sdk/openai-compatible 3.0.62 + /// src/completion/openai-compatible-completion-language-model.ts:320-324 + /// passes an error chunk on as it is (`error: value.error`): unlike + /// @ai-sdk/openai, no status is inferred from its `code`/`type`. + #[tokio::test] + async fn completion_stream_error_infers_no_status() { + let fetch = MockFetch::new(vec![stream_response(&[json!({"error":{ + "message":"Slow down","type":"requests","code":"rate_limit_exceeded"}})])]); + let parts: Vec<_> = provider(&fetch) + .completion("test-model") + .do_stream(&options()) + .await + .unwrap() + .stream + .map(|part| part.unwrap()) + .collect() + .await; + let error = parts + .iter() + .find_map(|part| match part { + StreamPart::Error { + error: AiMuxError::ApiCall(error), + } => Some(error), + _ => None, + }) + .expect("expected an API error part"); + assert_eq!(error.message, "Slow down"); + assert_eq!(error.provider_code.as_deref(), Some("rate_limit_exceeded")); + assert_eq!(error.status_code, None); + assert!(!error.is_retryable); + } +} diff --git a/aimux-providers/src/openai_compatible/tests/embedding/embedding_model.rs b/aimux-providers/src/openai_compatible/tests/embedding/embedding_model.rs index e7e521d34..500bf833b 100644 --- a/aimux-providers/src/openai_compatible/tests/embedding/embedding_model.rs +++ b/aimux-providers/src/openai_compatible/tests/embedding/embedding_model.rs @@ -360,3 +360,51 @@ mod do_embed { ); } } + +// ── Older ports (previously `tests/openai_compatible_upstream_e2e_test.rs`) ── +// +// End-to-end subset of the pinned openai-compatible upstream tests. + +mod upstream_e2e { + use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; + use serde_json::json; + + use crate::openai_compatible::tests::support::*; + use crate::test_support::mock_fetch::{Canned, MockFetch}; + + /// partial: `should extract embedding`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. + /// partial: `should pass the model and the values`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. + #[tokio::test] + async fn embedding_request_and_result() { + let body = json!({"object":"list","data":[ + {"object":"embedding","index":0,"embedding":[0.1,0.2,0.3,0.4,0.5]}, + {"object":"embedding","index":1,"embedding":[0.6,0.7,0.8,0.9,1.0]}], + "model":"test-embedding","usage":{"prompt_tokens":8,"total_tokens":8}}); + let fetch = MockFetch::new(vec![Canned::json(&body)]); + let mut opts = EmbeddingCallOptions::new("sunny day at the beach"); + opts.values.push("rainy day in the city".into()); + let result = provider(&fetch) + .embedding("test-embedding") + .do_embed(&opts) + .await + .unwrap(); + assert_request( + &fetch, + "/embeddings", + json!({"model":"test-embedding", + "input":["sunny day at the beach","rainy day in the city"],"encoding_format":"float"}), + ); + assert_eq!( + result.embeddings, + vec![vec![0.1, 0.2, 0.3, 0.4, 0.5], vec![0.6, 0.7, 0.8, 0.9, 1.0]] + ); + assert_eq!(result.usage.unwrap().tokens, 8); + let response = result.response.unwrap(); + assert_eq!(response.body, Some(body)); + assert_eq!( + response.headers.unwrap()["content-type"], + "application/json" + ); + assert!(result.warnings.is_empty()); + } +} diff --git a/aimux-providers/src/openai_compatible/tests/image/image_model.rs b/aimux-providers/src/openai_compatible/tests/image/image_model.rs index 8f5e2cab1..ec1131fc8 100644 --- a/aimux-providers/src/openai_compatible/tests/image/image_model.rs +++ b/aimux-providers/src/openai_compatible/tests/image/image_model.rs @@ -15,7 +15,8 @@ use aimux_core::shared::{AspectRatio, Size}; use aimux_provider_utils::{HeaderMapOpt, ProviderErrorParts}; use crate::openai_compatible::OpenAICompatibleImageModel; -use crate::openai_compatible::config::{BaseUrl, ChatSettings, CompatModelConfig, ErrorStructure}; +use crate::openai_compatible::error::ErrorStructure; +use crate::openai_compatible::{BaseUrl, ChatSettings, CompatModelConfig}; use crate::shared::{Credential, ProviderHeaders}; use crate::test_support::mock_fetch::{Canned, RouteFetch, Seen}; @@ -843,3 +844,87 @@ mod open_ai_compatible_image_model { } } } + +// ── Older ports (previously `tests/openai_compatible_upstream_e2e_test.rs`) ── +// +// End-to-end subset of the pinned openai-compatible upstream tests. + +mod upstream_e2e { + use aimux_core::AiMuxError; + use aimux_core::image_model::{ImageCallOptions, ImageModel, ImageOutputs}; + use aimux_core::shared::Size; + use serde_json::json; + + use crate::openai_compatible::tests::support::*; + use crate::test_support::mock_fetch::{Canned, MockFetch}; + + const IMAGE_PROMPT: &str = "A photorealistic astronaut riding a horse"; + + fn image_options() -> ImageCallOptions { + let mut opts = ImageCallOptions::new(IMAGE_PROMPT); + opts.size = Some(Size::new(1024, 1024)); + opts + } + + /// partial: `should return the raw b64_json content`; full port in src/openai_compatible/tests/image/image_model.rs. + /// partial: `should map the usage object reported by the provider`; full port in src/openai_compatible/tests/image/image_model.rs. + #[tokio::test] + async fn image_generate_raw_content_and_usage() { + let fetch = MockFetch::new(vec![Canned::json(&json!({"data":[ + {"b64_json":"test1234"},{"b64_json":"test5678"}], + "usage":{"input_tokens":12,"output_tokens":4,"total_tokens":16}}))]); + let mut opts = image_options(); + opts.n = 2; + let result = provider(&fetch) + .image("test-image") + .do_generate(&opts) + .await + .unwrap(); + assert_request( + &fetch, + "/images/generations", + json!({"model":"test-image", + "prompt":IMAGE_PROMPT,"n":2,"size":"1024x1024"}), + ); + let ImageOutputs::Base64(images) = result.images else { + panic!("expected base64 images"); + }; + assert_eq!(images, vec!["test1234", "test5678"]); + let usage = result.usage.unwrap(); + assert_eq!(usage.input_tokens, Some(12)); + assert_eq!(usage.output_tokens, Some(4)); + assert_eq!(usage.total_tokens, Some(16)); + assert_eq!(result.response.model_id.as_deref(), Some("test-image")); + assert_eq!( + result.response.headers.unwrap()["content-type"], + "application/json" + ); + assert!(result.warnings.is_empty()); + } + + /// partial: `should handle API errors with default error structure`; full port in src/openai_compatible/tests/image/image_model.rs. + #[tokio::test] + async fn image_generate_api_error() { + let mut canned = Canned::json(&json!({"error":{"message":"Invalid prompt content", + "type":"invalid_request_error","param":null,"code":null}})); + canned.status = 400; + let fetch = MockFetch::new(vec![canned]); + let error = provider(&fetch) + .image("test-image") + .do_generate(&image_options()) + .await + .unwrap_err(); + assert_request( + &fetch, + "/images/generations", + json!({"model":"test-image", + "prompt":IMAGE_PROMPT,"n":1,"size":"1024x1024"}), + ); + let AiMuxError::ApiCall(error) = error else { + panic!("expected API error"); + }; + assert_eq!(error.message, "Invalid prompt content"); + assert_eq!(error.status_code, Some(400)); + assert_eq!(error.url, format!("{BASE_URL}/images/generations")); + } +} diff --git a/aimux-providers/src/openai_compatible/tests/mod.rs b/aimux-providers/src/openai_compatible/tests/mod.rs index 2b4c89c6c..5bedea63b 100644 --- a/aimux-providers/src/openai_compatible/tests/mod.rs +++ b/aimux-providers/src/openai_compatible/tests/mod.rs @@ -3,4 +3,5 @@ mod completion; mod embedding; mod image; mod provider; +mod support; mod utils; diff --git a/aimux-providers/src/openai_compatible/tests/provider.rs b/aimux-providers/src/openai_compatible/tests/provider.rs index 29205aebf..037dd7908 100644 --- a/aimux-providers/src/openai_compatible/tests/provider.rs +++ b/aimux-providers/src/openai_compatible/tests/provider.rs @@ -18,8 +18,8 @@ use aimux_core::language_model_message::LanguageModelMessage; use aimux_core::options::CallOptions; use aimux_core::types::ProviderMetadata; +use crate::openai_compatible::CompatModelConfig; use crate::openai_compatible::completion::{CompletionConfig, OpenAICompatibleCompletionModel}; -use crate::openai_compatible::config::CompatModelConfig; use crate::openai_compatible::{ MetadataExtractor, OpenAICompatibleProvider, OpenAICompatibleProviderSettings, StreamMetadataExtractor, create_openai_compatible, @@ -411,3 +411,904 @@ mod open_ai_compatible_provider { } } } + +// ── Older ports (previously `tests/openai_compatible_factory_test.rs`) ── +// +// `create_openai_compatible` against the recorded AI SDK behavior. +// +// `fixtures/aisdk/openai-compatible/*.json` hold what +// `@ai-sdk/openai-compatible`'s `createOpenAICompatible` sent over a mocked +// fetch for a fixed input: URL, headers, body and the model's `provider` +// string. Each fixture is replayed here through an injected +// [`Fetch`] (the factory's `fetch` setting), so the test also proves the +// transport is the one the settings name. The expected request comes from the +// fixture; the inputs are rebuilt from the fixture's recorded `sdk.input`. +// +// The recorded `authorization` is redacted, so the key the case used is +// substituted back. The user-agent aimux sends is checked against the +// recorded one (package and provider-utils parts). + +mod recorded_fixtures { + use std::collections::{BTreeMap, HashMap, VecDeque}; + use std::path::PathBuf; + use std::sync::{Arc, Mutex}; + + use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; + use aimux_core::language_model::LanguageModel; + use aimux_core::language_model_message::{ + AssistantPart, LanguageModelMessage, LanguageModelPrompt, TextPart, + }; + use aimux_core::options::CallOptions; + use aimux_core::result::{GenerateContent, GenerateResult}; + use aimux_core::stream_part::StreamPart; + use aimux_core::tool::{FunctionTool, LanguageModelV4ToolChoice, Tool}; + use aimux_core::types::FinishReasonUnified; + use aimux_provider_utils::{ + Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse, HeaderMapOpt, + }; + use async_trait::async_trait; + use bytes::Bytes; + use futures::StreamExt; + use reqwest::StatusCode; + use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; + use serde_json::{Value, json}; + + use crate::openai_compatible::{ + OpenAICompatibleProvider, OpenAICompatibleProviderSettings, create_openai_compatible, + }; + + // ── injected transport ─────────────────────────────────────────────────────── + + const KEY: &str = "sk-test-fixture"; + + const ENV_KEY: &str = "sk-env-should-not-be-used"; + + #[derive(Clone)] + struct Canned { + status: u16, + headers: Vec<(String, String)>, + body: Vec, + } + + impl Canned { + fn json(body: &Value) -> Self { + Self { + status: 200, + headers: vec![("content-type".into(), "application/json".into())], + body: serde_json::to_vec(body).unwrap(), + } + } + } + + /// One request as the transport saw it. + #[derive(Clone, Debug)] + struct Seen { + method: String, + url: String, + /// Lower-cased names (the `http` crate normalizes them). + headers: BTreeMap, + body: Vec, + } + + impl Seen { + fn json_body(&self) -> Value { + serde_json::from_slice(&self.body).expect("request body is JSON") + } + } + + #[derive(Default)] + struct MockFetch { + canned: Mutex>, + seen: Mutex>, + } + + impl MockFetch { + fn new(canned: Vec) -> Arc { + Arc::new(Self { + canned: Mutex::new(canned.into()), + seen: Mutex::default(), + }) + } + + fn seen(&self) -> Vec { + self.seen.lock().unwrap().clone() + } + + fn transport(self: &Arc) -> FetchFunction { + self.clone() + } + } + + #[async_trait] + impl Fetch for MockFetch { + async fn fetch(&self, request: FetchRequest) -> Result { + self.seen.lock().unwrap().push(Seen { + method: request.method.to_string(), + url: request.url.to_string(), + headers: request + .headers + .iter() + .map(|(name, value)| { + ( + name.as_str().to_string(), + value.to_str().expect("ASCII header").to_string(), + ) + }) + .collect(), + body: request.body.to_vec(), + }); + let canned = self + .canned + .lock() + .unwrap() + .pop_front() + .ok_or_else(|| FetchError::Other("no canned response left".into()))?; + let mut headers = HeaderMap::new(); + for (name, value) in &canned.headers { + headers.insert( + HeaderName::try_from(name.as_str()).unwrap(), + HeaderValue::try_from(value.as_str()).unwrap(), + ); + } + Ok(FetchResponse::from_bytes( + StatusCode::from_u16(canned.status).unwrap(), + headers, + request.url, + Bytes::from(canned.body), + )) + } + } + + /// The settings every Groq-named fixture was recorded with + /// (`scripts/aisdk-fixtures/cases/openai-compatible.mjs`). + fn groq_settings(mock: &Arc) -> OpenAICompatibleProviderSettings { + OpenAICompatibleProviderSettings { + name: "groq".to_string(), + base_url: "https://api.groq.com/openai/v1".to_string(), + api_key: Some(KEY.to_string()), + fetch: Some(mock.transport()), + ..Default::default() + } + } + + /// Sets (or removes) an environment variable and restores it on drop. Tests + /// that use it are `#[serial]`. + struct EnvVar { + name: &'static str, + saved: Option, + } + + impl EnvVar { + fn set(name: &'static str, value: Option<&str>) -> Self { + let saved = std::env::var(name).ok(); + unsafe { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + Self { name, saved } + } + } + + impl Drop for EnvVar { + fn drop(&mut self) { + unsafe { + match &self.saved { + Some(value) => std::env::set_var(self.name, value), + None => std::env::remove_var(self.name), + } + } + } + } + + // ── fixtures ───────────────────────────────────────────────────────────────── + + /// Every fixture under `fixtures/aisdk/openai/`; the directory listing is + /// compared to this so a new fixture cannot go unreplayed. + const FIXTURES: [&str; 7] = [ + "chat-basic", + "chat-no-api-key", + "chat-provider-options-generic-key-unknown", + "chat-provider-options-namespace", + "chat-query-params-and-headers", + "chat-stream-basic", + "embedding-basic", + ]; + + fn fixtures_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fixtures/aisdk/openai-compatible") + } + + struct Fixture { + name: &'static str, + json: Value, + } + + impl Fixture { + fn load(name: &'static str) -> Self { + assert!(FIXTURES.contains(&name), "unlisted fixture {name}"); + let path = fixtures_dir().join(format!("{name}.json")); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())); + Self { + name, + json: serde_json::from_str(&text).expect("fixture is JSON"), + } + } + + fn provider_string(&self) -> &str { + self.json["model"]["provider"].as_str().unwrap() + } + + fn model_id(&self) -> &str { + self.json["model"]["modelId"].as_str().unwrap() + } + + fn sdk_input(&self) -> &Value { + &self.json["sdk"]["input"] + } + + /// The transport answering with the recorded response (or a plain chat + /// completion for the case that never reached the wire). + fn mock(&self) -> Arc { + let response = &self.json["response"]; + if response.is_null() { + return MockFetch::new(vec![Canned::json(&json!({ + "id": "chatcmpl-unused", "object": "chat.completion", "created": 1, + "model": "gpt-4o", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "x"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} + }))]); + } + let body = match &response["body"] { + Value::String(text) => text.clone().into_bytes(), + other => serde_json::to_vec(other).unwrap(), + }; + MockFetch::new(vec![Canned { + status: response["status"].as_u64().unwrap() as u16, + headers: response["headers"] + .as_object() + .unwrap() + .iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string())) + .collect(), + body, + }]) + } + + /// The recorded request must equal what went out: method, URL, headers + /// (names case-insensitively; user-agent checked against the recording) and body. + /// `authorization` is the value the redacted recording stands for, or + /// `None` when the recording carries no `authorization` header at all. + fn assert_request(&self, seen: &Seen, authorization: Option<&str>) { + let recorded = &self.json["request"]; + assert_eq!( + seen.method, + recorded["method"].as_str().unwrap(), + "{}", + self.name + ); + assert_eq!(seen.url, recorded["url"].as_str().unwrap(), "{}", self.name); + + let mut expected: BTreeMap = recorded["headers"] + .as_object() + .unwrap() + .iter() + .map(|(k, v)| (k.to_ascii_lowercase(), v.as_str().unwrap().to_string())) + .collect(); + // The user-agent is checked against the recording, not overwritten: + // aimux sends `ai-sdk-openai-compatible/ ai-sdk-provider-utils/`; every + // recording carries the same provider-utils part, and a provider-layer + // recording (not made through `ai`) starts with the same package part. + let recorded_ua = expected.remove("user-agent").expect("recorded user-agent"); + let mut sent_headers = seen.headers.clone(); + let sent_ua = sent_headers.remove("user-agent").expect("sent user-agent"); + let (package_ua, utils_ua) = sent_ua + .split_once(' ') + .expect("package and provider-utils user-agent parts"); + assert!( + package_ua.starts_with("ai-sdk-openai-compatible/") + && utils_ua.starts_with("ai-sdk-provider-utils/"), + "{}: sent user-agent {sent_ua:?}", + self.name + ); + assert!( + recorded_ua.contains(&format!(" {utils_ua} ")), + "{}: recorded user-agent {recorded_ua:?} lacks {utils_ua:?}", + self.name + ); + if recorded_ua.starts_with("ai-sdk-openai-compatible/") { + assert!( + recorded_ua.starts_with(&format!("{package_ua} ")), + "{}: recorded user-agent {recorded_ua:?} does not start with {package_ua:?}", + self.name + ); + } + match authorization { + Some(value) => { + assert_eq!( + expected.get("authorization").map(String::as_str), + Some(""), + "{}: the recording redacts the key", + self.name + ); + expected.insert("authorization".to_string(), value.to_string()); + } + None => assert!( + !expected.contains_key("authorization"), + "{}: the recording carries no authorization", + self.name + ), + } + assert_eq!(sent_headers, expected, "{}: headers", self.name); + + assert_eq!(seen.json_body(), recorded["body"], "{}: body", self.name); + } + + /// The call options the recording was made with. + fn call_options(&self) -> CallOptions { + call_options_from(self.sdk_input()) + } + } + + fn message(role: &str, text: &str) -> LanguageModelMessage { + match role { + "system" => LanguageModelMessage::System { + content: text.to_string(), + provider_options: None, + }, + "user" => LanguageModelMessage::user_text(text), + "assistant" => LanguageModelMessage::Assistant { + content: vec![AssistantPart::Text(TextPart { + text: text.to_string(), + provider_options: None, + })], + provider_options: None, + }, + other => unreachable!("fixture has no {other} messages"), + } + } + + /// Rebuild aimux call options from a recorded `sdk.input` (the subset the + /// OpenAI fixtures use). + fn call_options_from(input: &Value) -> CallOptions { + let mut prompt: LanguageModelPrompt = Vec::new(); + if let Some(system) = input.get("system").and_then(Value::as_str) { + prompt.push(message("system", system)); + } + if let Some(text) = input.get("prompt").and_then(Value::as_str) { + prompt.push(message("user", text)); + } + for entry in input + .get("messages") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + prompt.push(message( + entry["role"].as_str().unwrap(), + entry["content"].as_str().unwrap(), + )); + } + let mut options = CallOptions::new(prompt); + options.temperature = input.get("temperature").and_then(Value::as_f64); + options.top_p = input.get("topP").and_then(Value::as_f64); + options.max_output_tokens = input + .get("maxOutputTokens") + .and_then(Value::as_u64) + .map(|v| v as u32); + options.seed = input.get("seed").and_then(Value::as_u64); + options.stop_sequences = input + .get("stopSequences") + .and_then(Value::as_array) + .map(|a| a.iter().map(|v| v.as_str().unwrap().to_string()).collect()); + if let Some(tools) = input.get("tools").and_then(Value::as_object) { + options.tools = Some( + tools + .iter() + .map(|(name, tool)| { + let mut function = + FunctionTool::new(name.clone(), tool["inputSchema"].clone()); + if let Some(description) = tool["description"].as_str() { + function = function.with_description(description); + } + Tool::Function(function) + }) + .collect(), + ); + } + options.tool_choice = match input.get("toolChoice").and_then(Value::as_str) { + Some("required") => Some(LanguageModelV4ToolChoice::Required), + Some("none") => Some(LanguageModelV4ToolChoice::None), + Some("auto") => Some(LanguageModelV4ToolChoice::Auto), + None => options + .tools + .as_ref() + .filter(|tools| !tools.is_empty()) + .map(|_| LanguageModelV4ToolChoice::Auto), + Some(other) => panic!("unmapped toolChoice {other}"), + }; + options.provider_options = input + .get("providerOptions") + .map(|o| serde_json::from_value(o.clone()).unwrap()); + options.headers = input.get("headers").and_then(Value::as_object).map(|h| { + h.iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string())) + .collect() + }); + options + } + + fn generated_text(result: &GenerateResult) -> String { + result + .content + .iter() + .filter_map(|part| match part { + GenerateContent::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect() + } + + /// Result fields every recorded `generateText` case pins. + fn assert_generate_result(fixture: &Fixture, result: &GenerateResult) { + let recorded = &fixture.json["result"]; + assert_eq!( + generated_text(result), + recorded["text"].as_str().unwrap(), + "{}: text", + fixture.name + ); + assert_eq!( + result.response.as_ref().and_then(|r| r.id.as_deref()), + recorded["response"]["id"].as_str(), + "{}: response id", + fixture.name + ); + assert_eq!( + result.response.as_ref().and_then(|r| r.model_id.as_deref()), + recorded["response"]["modelId"].as_str(), + "{}: response model", + fixture.name + ); + assert_eq!( + result.usage.input_tokens.total, + recorded["usage"]["inputTokens"].as_u64().map(|v| v as _), + "{}: input tokens", + fixture.name + ); + assert_eq!( + result.usage.output_tokens.total, + recorded["usage"]["outputTokens"].as_u64().map(|v| v as _), + "{}: output tokens", + fixture.name + ); + let unified = match recorded["finishReason"].as_str().unwrap() { + "stop" => FinishReasonUnified::Stop, + "tool-calls" => FinishReasonUnified::ToolCalls, + other => panic!("unmapped finish reason {other}"), + }; + assert_eq!( + result.finish_reason.unified, unified, + "{}: finish", + fixture.name + ); + if let Some(raw) = recorded["rawFinishReason"].as_str() { + assert_eq!( + result.finish_reason.raw.as_deref(), + Some(raw), + "{}: raw finish", + fixture.name + ); + } + } + + fn bearer(key: &str) -> String { + format!("Bearer {key}") + } + + fn opt_map(entries: &[(&str, Option<&str>)]) -> HeaderMapOpt { + entries + .iter() + .map(|(k, v)| ((*k).to_string(), v.map(str::to_string))) + .collect() + } + + // ── the fixtures ───────────────────────────────────────────────────────────── + + #[test] + fn every_fixture_is_replayed() { + let mut on_disk: Vec = std::fs::read_dir(fixtures_dir()) + .expect("fixtures/aisdk/openai-compatible exists") + .map(|entry| { + let name = entry.unwrap().file_name().into_string().unwrap(); + name.strip_suffix(".json") + .unwrap_or_else(|| panic!("unexpected file {name}")) + .to_string() + }) + .collect(); + on_disk.sort(); + assert_eq!( + on_disk, FIXTURES, + "add a test for the new fixture and list it" + ); + } + + /// Metadata is reported under the provider name, with the namespace entry + /// present even when empty. + fn assert_metadata_key(fixture: &Fixture, result: &GenerateResult, key: &str) { + assert_eq!( + fixture.json["result"]["providerMetadata"], + json!({ key: {} }), + "{}: recorded", + fixture.name + ); + assert_eq!( + serde_json::to_value(result.provider_metadata.as_ref().unwrap()).unwrap(), + json!({ key: {} }), + "{}: metadata key", + fixture.name + ); + } + + /// Replay a `generateText` fixture recorded with the Groq-named provider. + async fn replay_groq_chat(name: &'static str) -> GenerateResult { + let fixture = Fixture::load(name); + let mock = fixture.mock(); + let provider = create_openai_compatible(groq_settings(&mock)).unwrap(); + let model = provider.chat(fixture.model_id()); + assert_eq!(model.provider(), "groq.chat"); + assert_eq!( + model.provider(), + fixture.provider_string(), + "{name}: provider()" + ); + assert_eq!(model.model_id(), fixture.model_id()); + + let result = model.do_generate(&fixture.call_options()).await.unwrap(); + + let seen = mock.seen(); + assert_eq!(seen.len(), 1, "{name}: exactly one request"); + fixture.assert_request(&seen[0], Some(&bearer(KEY))); + assert_generate_result(&fixture, &result); + assert_metadata_key(&fixture, &result, "groq"); + result + } + + #[tokio::test] + async fn chat_basic() { + replay_groq_chat("chat-basic").await; + } + + /// partial: `should include provider-specific options`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. + #[tokio::test] + async fn chat_provider_options_namespace() { + // `groq.foo` passes through at the top level; `openaiCompatible.user` is a + // schema field. + replay_groq_chat("chat-provider-options-namespace").await; + let fixture = Fixture::load("chat-provider-options-namespace"); + assert_eq!( + fixture.json["observations"], + json!({"bodyHasFooAtTopLevel": true, "bodyUser": "u1", "providerIsGroqChat": true}) + ); + } + + #[tokio::test] + async fn unknown_fields_of_the_generic_namespace_are_not_forwarded() { + replay_groq_chat("chat-provider-options-generic-key-unknown").await; + let fixture = Fixture::load("chat-provider-options-generic-key-unknown"); + assert_eq!( + fixture.json["observations"]["bodyHasExtraField"], + json!(false) + ); + } + + /// partial: `should pass headers`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. + /// partial: `should create provider with correct configuration`; full port in src/openai_compatible/tests/provider.rs. + #[tokio::test] + async fn chat_query_params_and_headers() { + let fixture = Fixture::load("chat-query-params-and-headers"); + let mock = fixture.mock(); + // The recording was made with headers { "x-a": "1", "X-B": "2" } and + // queryParams { "api-version": "1" }. + let provider = create_openai_compatible(OpenAICompatibleProviderSettings { + headers: Some(opt_map(&[("x-a", Some("1")), ("X-B", Some("2"))])), + query_params: Some(HashMap::from([( + "api-version".to_string(), + "1".to_string(), + )])), + ..groq_settings(&mock) + }) + .unwrap(); + let model = provider.chat(fixture.model_id()); + + let result = model.do_generate(&fixture.call_options()).await.unwrap(); + + let seen = mock.seen(); + fixture.assert_request(&seen[0], Some(&bearer(KEY))); + assert_generate_result(&fixture, &result); + // Case-insensitive override: the call's `x-b` replaced the provider's `X-B`. + assert_eq!(seen[0].headers["x-b"], "3"); + let sent: Vec<&String> = seen[0] + .headers + .keys() + .filter(|n| n.starts_with("x-")) + .collect(); + assert_eq!(sent, ["x-a", "x-b", "x-c"]); + assert_eq!( + fixture.json["observations"]["headerNamesAsSent"], + json!(["x-a", "x-b", "x-c"]) + ); + } + + /// partial: `should create headers without authorization when no apiKey provided`; full port in src/openai_compatible/tests/provider.rs. + #[tokio::test] + #[serial_test::serial] + async fn chat_without_an_api_key_sends_no_authorization() { + let fixture = Fixture::load("chat-no-api-key"); + let mock = fixture.mock(); + // A key in the environment must not leak in either. + let _env = EnvVar::set("OPENAI_API_KEY", Some(ENV_KEY)); + let provider = create_openai_compatible(OpenAICompatibleProviderSettings { + name: "local".to_string(), + base_url: "http://localhost:1234/v1".to_string(), + fetch: Some(mock.transport()), + ..Default::default() + }) + .expect("no key is needed to create the provider"); + let model = provider.chat(fixture.model_id()); + assert_eq!(model.provider(), "local.chat"); + assert_eq!(model.provider(), fixture.provider_string()); + + let result = model.do_generate(&fixture.call_options()).await.unwrap(); + + let seen = mock.seen(); + assert_eq!(seen.len(), 1); + fixture.assert_request(&seen[0], None); + assert!(!seen[0].headers.contains_key("authorization")); + assert_eq!( + fixture.json["observations"]["hasAuthorizationHeader"], + json!(false) + ); + assert_generate_result(&fixture, &result); + assert_metadata_key(&fixture, &result, "local"); + } + + /// `supportedUrls` is read through the settings function each time, and its + /// value may be async (openai-compatible@3.0.62 + /// `openai-compatible-chat-language-model.ts:84`, `:161-163`); completion + /// models get none (`openai-compatible-provider.ts:189-193`). + #[tokio::test] + async fn chat_supported_urls_come_from_the_async_settings_function() { + let mock = MockFetch::new(Vec::new()); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = calls.clone(); + let provider = create_openai_compatible(OpenAICompatibleProviderSettings { + supported_urls: Some(Arc::new(move || { + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Box::pin(async { + tokio::task::yield_now().await; + aimux_core::language_model::SupportedUrls(HashMap::from([( + "image/*".to_string(), + vec![regex::Regex::new("^https://").unwrap()], + )])) + }) + })), + ..groq_settings(&mock) + }) + .unwrap(); + let chat = provider.chat("m"); + for _ in 0..2 { + let urls = chat.supported_urls().await; + assert_eq!(urls.0.keys().collect::>(), ["image/*"]); + } + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2); + assert!(provider.completion("m").supported_urls().await.is_empty()); + assert!(mock.seen().is_empty()); + } + + #[tokio::test] + async fn chat_stream_basic() { + let fixture = Fixture::load("chat-stream-basic"); + let mock = fixture.mock(); + let model = create_openai_compatible(groq_settings(&mock)) + .unwrap() + .chat(fixture.model_id()); + assert_eq!(model.provider(), fixture.provider_string()); + + let result = model.do_stream(&fixture.call_options()).await.unwrap(); + let parts: Vec = result + .stream + .map(|part| part.expect("no stream error")) + .collect() + .await; + + // Unlike the native OpenAI package, the baseline asks for no + // `stream_options` unless `include_usage` is set: the recorded body has + // none. (The usage chunk is read anyway when the server sends it.) The + // recording's user-agent names the provider package. + assert!( + fixture.json["request"]["headers"]["user-agent"] + .as_str() + .unwrap() + .starts_with("ai-sdk-openai-compatible/"), + "the stream case is a provider-layer recording" + ); + let seen = mock.seen(); + assert_eq!(seen.len(), 1); + fixture.assert_request(&seen[0], Some(&bearer(KEY))); + assert!(seen[0].json_body().get("stream_options").is_none()); + + let text: String = parts + .iter() + .filter_map(|part| match part { + StreamPart::TextDelta { delta, .. } => Some(delta.as_str()), + _ => None, + }) + .collect(); + assert_eq!(text, "Hello world"); + let (reason, usage, metadata) = parts + .iter() + .find_map(|part| match part { + StreamPart::Finish { + finish_reason, + usage, + provider_metadata, + } => Some((finish_reason, usage, provider_metadata)), + _ => None, + }) + .expect("a finish part"); + assert_eq!(reason.unified, FinishReasonUnified::Stop); + assert_eq!(usage.input_tokens.total, Some(12)); + assert_eq!(usage.output_tokens.total, Some(2)); + assert_eq!( + serde_json::to_value(metadata.as_ref().unwrap()).unwrap(), + json!({"groq": {}}) + ); + } + + /// partial: `should extract embedding`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. + /// partial: `should extract usage`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. + /// partial: `should pass the model and the values`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. + #[tokio::test] + async fn embedding_basic() { + let fixture = Fixture::load("embedding-basic"); + let mock = fixture.mock(); + let model = create_openai_compatible(groq_settings(&mock)) + .unwrap() + .embedding(fixture.model_id()); + assert_eq!(model.provider(), "groq.embedding"); + assert_eq!(model.provider(), fixture.provider_string()); + + let values: Vec = fixture.sdk_input()["values"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect(); + let options = EmbeddingCallOptions { + values, + ..EmbeddingCallOptions::new("") + }; + let result = model.do_embed(&options).await.unwrap(); + + fixture.assert_request(&mock.seen()[0], Some(&bearer(KEY))); + let recorded: Vec> = + serde_json::from_value(fixture.json["result"]["embeddings"].clone()).unwrap(); + assert_eq!(result.embeddings, recorded); + assert_eq!( + result.usage.map(|u| u.tokens), + fixture.json["result"]["usage"]["tokens"] + .as_u64() + .map(|v| v as u32) + ); + } + + fn _assert_provider_is_shareable() { + fn shareable() {} + shareable::(); + } + + // ── compat request body key order ──────────────────────────────────────────── + + /// One chat request with `options`; returns the body bytes as sent. + async fn sent_chat_body(options: CallOptions) -> String { + let mock = MockFetch::new(vec![Canned::json(&json!({ + "id": "chatcmpl-order", "object": "chat.completion", "created": 1, + "model": "m", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "x"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} + }))]); + let provider = create_openai_compatible(groq_settings(&mock)).unwrap(); + provider.chat("m").do_generate(&options).await.unwrap(); + let seen = mock.seen(); + assert_eq!(seen.len(), 1); + String::from_utf8(seen[0].body.clone()).unwrap() + } + + /// Byte offset of top-level key `key` in a compact JSON object. + fn key_at(body: &str, key: &str) -> usize { + body.find(&format!("\"{key}\":")) + .unwrap_or_else(|| panic!("{key} missing in {body}")) + } + + /// A user "hi" prompt with a `weather` tool, `toolChoice: auto` and the given + /// `groq` provider options. + fn tool_call_options(groq: Value) -> CallOptions { + let mut options = CallOptions::new(vec![LanguageModelMessage::user_text("hi")]); + options.temperature = Some(0.5); + options.tools = Some(vec![Tool::Function(FunctionTool::new( + "weather".to_string(), + json!({"type": "object", "properties": {}}), + ))]); + options.tool_choice = Some(LanguageModelV4ToolChoice::Auto); + options.provider_options = Some(serde_json::from_value(json!({ "groq": groq })).unwrap()); + options + } + + /// The body bytes follow openai-compatible@3.0.62 + /// `openai-compatible-chat-language-model.ts:270-325` (the alignment doc's + /// "compat 请求体顺序"): model, user → sampling, response_format, stop, seed + /// → unknown provider-namespace fields → reasoning_effort, verbosity → + /// messages, tools, tool_choice. `serde_json::Value` ignores order, so this + /// asserts on the bytes. Ported from li's fork PR #35 (f9c9653b). + #[tokio::test] + async fn compat_body_keys_follow_the_documented_order() { + let mut options = tool_call_options(json!({ + "zzzUnknown": 1, + "user": "u1", + "reasoningEffort": "low", + "textVerbosity": "low" + })); + options.stop_sequences = Some(vec!["END".to_string()]); + options.seed = Some(7); + let body = sent_chat_body(options).await; + let order = [ + "model", + "user", + "temperature", + "stop", + "seed", + "zzzUnknown", + "reasoning_effort", + "verbosity", + "messages", + "tools", + "tool_choice", + ]; + let at: Vec = order.iter().map(|k| key_at(&body, k)).collect(); + for (i, pair) in at.windows(2).enumerate() { + assert!( + pair[0] < pair[1], + "{} must come before {}: {body}", + order[i], + order[i + 1] + ); + } + } + + /// An unknown field may override earlier fields (sampling), but not the + /// fields assigned after it: `messages` / `tools` / `tool_choice` from the + /// provider namespace are replaced by the real ones (the same spread order). + #[tokio::test] + async fn unknown_fields_override_sampling_but_not_messages_or_tools() { + let body = sent_chat_body(tool_call_options(json!({ + "temperature": 0.9, + "messages": "bogus", + "tools": "bogus", + "tool_choice": "bogus" + }))) + .await; + let sent: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(sent["temperature"], json!(0.9), "{body}"); + assert_eq!(sent["messages"][0]["role"], json!("user"), "{body}"); + assert_eq!( + sent["tools"][0]["function"]["name"], + json!("weather"), + "{body}" + ); + assert_eq!(sent["tool_choice"], json!("auto"), "{body}"); + assert!(!body.contains("bogus"), "{body}"); + } +} diff --git a/aimux-providers/src/openai_compatible/tests/support.rs b/aimux-providers/src/openai_compatible/tests/support.rs new file mode 100644 index 000000000..2dbc96a99 --- /dev/null +++ b/aimux-providers/src/openai_compatible/tests/support.rs @@ -0,0 +1,61 @@ +//! The harness of the older end-to-end ports (previously +//! `tests/openai_compatible_upstream_e2e_test.rs`), shared by their parts in +//! `chat/`, `completion/`, `embedding/` and `image/`. + +use std::sync::Arc; + +use aimux_core::language_model_message::LanguageModelMessage; +use aimux_core::options::CallOptions; +use serde_json::{Value, json}; + +use crate::openai_compatible::{ + OpenAICompatibleProvider, OpenAICompatibleProviderSettings, create_openai_compatible, +}; +use crate::test_support::mock_fetch::{Canned, MockFetch}; + +pub(super) const BASE_URL: &str = "https://my.api.com/v1"; + +pub(super) fn provider(fetch: &Arc) -> OpenAICompatibleProvider { + create_openai_compatible(OpenAICompatibleProviderSettings { + name: "test-provider".into(), + base_url: BASE_URL.into(), + fetch: Some(fetch.transport()), + ..Default::default() + }) + .unwrap() +} + +pub(super) fn options() -> CallOptions { + CallOptions::new(vec![LanguageModelMessage::user_text("Hello")]) +} + +pub(super) fn chat_response(message: Value, usage: Value) -> Value { + json!({"id":"chatcmpl-test", "created":1711115037, "model":"test-model", + "choices":[{"index":0,"message":message,"finish_reason":"stop"}], + "usage":usage}) +} + +pub(super) fn stream_response(values: &[Value]) -> Canned { + let mut body = values + .iter() + .map(|value| format!("data: {value}\n\n")) + .collect::(); + body.push_str("data: [DONE]\n\n"); + Canned { + status: 200, + headers: vec![("content-type".into(), "text/event-stream".into())], + body: body.into_bytes(), + } +} + +pub(super) fn assert_request(fetch: &MockFetch, path: &str, body: Value) { + let requests = fetch.seen(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].url, format!("{BASE_URL}{path}")); + assert_eq!(requests[0].json_body(), body); +} + +pub(super) fn stream_request() -> Value { + json!({"model":"test-model","messages":[{"role":"user","content":"Hello"}],"stream":true}) +} diff --git a/aimux-providers/src/openai_compatible/tests/utils/to_camel_case.rs b/aimux-providers/src/openai_compatible/tests/utils/to_camel_case.rs index 9f1f703d2..00a8450ed 100644 --- a/aimux-providers/src/openai_compatible/tests/utils/to_camel_case.rs +++ b/aimux-providers/src/openai_compatible/tests/utils/to_camel_case.rs @@ -3,10 +3,11 @@ use serde_json::{Value, json}; use aimux_core::options::CallOptions; use aimux_core::types::Warning; -use crate::openai_compatible::config::ChatSettings; -use crate::openai_compatible::convert::{ - ChatBodySpec, RequestBodyResult, build_request_body, to_camel_case, +use crate::openai_compatible::ChatSettings; +use crate::openai_compatible::chat::chat_language_model::{ + ChatBodySpec, RequestBodyResult, build_request_body, }; +use crate::openai_compatible::utils::to_camel_case; /// `resolveProviderOptionsKey` and `warnIfDeprecatedProviderOptionsKey` have /// no standalone Rust form: both run inside `build_request_body` (the @@ -214,3 +215,21 @@ mod warn_if_deprecated_provider_options_key { assert_eq!(warnings_json(&warnings), json!([])); } } + +// ── Older aimux tests (previously the unit tests of `openai_compatible/convert.rs`) ── + +mod camel_case { + use crate::openai_compatible::utils::to_camel_case; + + #[test] + fn camel_case_follows_the_ai_sdk() { + assert_eq!(to_camel_case("acme"), "acme"); + assert_eq!(to_camel_case("my_provider"), "myProvider"); + assert_eq!(to_camel_case("my-provider"), "myProvider"); + assert_eq!( + to_camel_case("vertex_ai_openai_models"), + "vertexAiOpenaiModels" + ); + assert_eq!(to_camel_case("a_1"), "a_1"); + } +} diff --git a/aimux-providers/src/openai_compatible/utils/mod.rs b/aimux-providers/src/openai_compatible/utils/mod.rs new file mode 100644 index 000000000..01075a880 --- /dev/null +++ b/aimux-providers/src/openai_compatible/utils/mod.rs @@ -0,0 +1,5 @@ +//! Helpers (`src/utils/`). + +mod to_camel_case; + +pub(crate) use to_camel_case::to_camel_case; diff --git a/aimux-providers/src/openai_compatible/utils/to_camel_case.rs b/aimux-providers/src/openai_compatible/utils/to_camel_case.rs new file mode 100644 index 000000000..5a76c4bf0 --- /dev/null +++ b/aimux-providers/src/openai_compatible/utils/to_camel_case.rs @@ -0,0 +1,19 @@ +//! `toCamelCase` (`to-camel-case.ts`). + +/// `snake_case` / `kebab-case` provider name to `camelCase` (the AI SDK's +/// `toCamelCase`). +#[must_use] +pub(crate) fn to_camel_case(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + let mut chars = name.chars().peekable(); + while let Some(ch) = chars.next() { + match chars.peek() { + Some(next) if (ch == '_' || ch == '-') && next.is_ascii_lowercase() => { + out.push(next.to_ascii_uppercase()); + chars.next(); + } + _ => out.push(ch), + } + } + out +} diff --git a/aimux-providers/src/preset.rs b/aimux-providers/src/preset.rs index b38836a71..edf85d70c 100644 --- a/aimux-providers/src/preset.rs +++ b/aimux-providers/src/preset.rs @@ -37,9 +37,8 @@ use aimux_core::types::Usage; use aimux_provider_utils::{FetchFunction, HeaderMapOpt, Resolvable, validate_base_url}; use crate::openai_compatible::chat::usage_from_raw; -use crate::openai_compatible::config::{BaseUrl, ChatSettings}; use crate::openai_compatible::{ - Assembly, ConvertUsage, OpenAICompatibleProvider, TransformRequestBody, + Assembly, BaseUrl, ChatSettings, ConvertUsage, OpenAICompatibleProvider, TransformRequestBody, }; use crate::shared::Credential; diff --git a/aimux-providers/src/runtime_decision.rs b/aimux-providers/src/runtime_decision.rs index 019b3970d..046bb4ae5 100644 --- a/aimux-providers/src/runtime_decision.rs +++ b/aimux-providers/src/runtime_decision.rs @@ -12,7 +12,7 @@ use crate::decision_support::{ self as support, DecisionConfig, DecisionImage, DecisionProbabilitySource, candidate_count, invalid, provider_metadata, response_info, }; -use crate::openai_compatible::config::CompatModelConfig; +use crate::openai_compatible::CompatModelConfig; pub(crate) fn supported(provider: &str) -> bool { matches!( diff --git a/aimux-providers/src/vertex/partner_provider.rs b/aimux-providers/src/vertex/partner_provider.rs index c9b7781c5..68372f9a8 100644 --- a/aimux-providers/src/vertex/partner_provider.rs +++ b/aimux-providers/src/vertex/partner_provider.rs @@ -26,9 +26,9 @@ use aimux_provider_utils::{ load_setting, validate_base_url, }; -use crate::openai_compatible::config::{BaseUrl, ResolveBaseUrl, ResolveFetchHeaders}; use crate::openai_compatible::{ - OpenAICompatibleChatModel, OpenAICompatibleProvider, OpenAICompatibleProviderSettings, + BaseUrl, OpenAICompatibleChatModel, OpenAICompatibleProvider, OpenAICompatibleProviderSettings, + ResolveBaseUrl, ResolveFetchHeaders, }; use super::auth::{GoogleAuth, GoogleAuthOptions}; diff --git a/aimux-providers/tests/openai_compatible_factory_test.rs b/aimux-providers/tests/openai_compatible_factory_test.rs deleted file mode 100644 index 44da42f92..000000000 --- a/aimux-providers/tests/openai_compatible_factory_test.rs +++ /dev/null @@ -1,893 +0,0 @@ -//! `create_openai_compatible` against the recorded AI SDK behavior. -//! -//! `fixtures/aisdk/openai-compatible/*.json` hold what -//! `@ai-sdk/openai-compatible`'s `createOpenAICompatible` sent over a mocked -//! fetch for a fixed input: URL, headers, body and the model's `provider` -//! string. Each fixture is replayed here through an injected -//! [`Fetch`] (the factory's `fetch` setting), so the test also proves the -//! transport is the one the settings name. The expected request comes from the -//! fixture; the inputs are rebuilt from the fixture's recorded `sdk.input`. -//! -//! The recorded `authorization` is redacted, so the key the case used is -//! substituted back. The user-agent aimux sends is checked against the -//! recorded one (package and provider-utils parts). - -use std::collections::{BTreeMap, HashMap, VecDeque}; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; - -use async_trait::async_trait; -use bytes::Bytes; -use futures::StreamExt; -use reqwest::StatusCode; -use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; -use serde_json::{Value, json}; - -use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{ - AssistantPart, LanguageModelMessage, LanguageModelPrompt, TextPart, -}; -use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::{FunctionTool, LanguageModelV4ToolChoice, Tool}; -use aimux_core::types::FinishReasonUnified; -use aimux_provider_utils::{ - Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse, HeaderMapOpt, -}; -use aimux_providers::openai_compatible::{ - OpenAICompatibleProvider, OpenAICompatibleProviderSettings, create_openai_compatible, -}; - -// ── injected transport ─────────────────────────────────────────────────────── - -const KEY: &str = "sk-test-fixture"; - -const ENV_KEY: &str = "sk-env-should-not-be-used"; - -#[derive(Clone)] -struct Canned { - status: u16, - headers: Vec<(String, String)>, - body: Vec, -} - -impl Canned { - fn json(body: &Value) -> Self { - Self { - status: 200, - headers: vec![("content-type".into(), "application/json".into())], - body: serde_json::to_vec(body).unwrap(), - } - } -} - -/// One request as the transport saw it. -#[derive(Clone, Debug)] -struct Seen { - method: String, - url: String, - /// Lower-cased names (the `http` crate normalizes them). - headers: BTreeMap, - body: Vec, -} - -impl Seen { - fn json_body(&self) -> Value { - serde_json::from_slice(&self.body).expect("request body is JSON") - } -} - -#[derive(Default)] -struct MockFetch { - canned: Mutex>, - seen: Mutex>, -} - -impl MockFetch { - fn new(canned: Vec) -> Arc { - Arc::new(Self { - canned: Mutex::new(canned.into()), - seen: Mutex::default(), - }) - } - - fn seen(&self) -> Vec { - self.seen.lock().unwrap().clone() - } - - fn transport(self: &Arc) -> FetchFunction { - self.clone() - } -} - -#[async_trait] -impl Fetch for MockFetch { - async fn fetch(&self, request: FetchRequest) -> Result { - self.seen.lock().unwrap().push(Seen { - method: request.method.to_string(), - url: request.url.to_string(), - headers: request - .headers - .iter() - .map(|(name, value)| { - ( - name.as_str().to_string(), - value.to_str().expect("ASCII header").to_string(), - ) - }) - .collect(), - body: request.body.to_vec(), - }); - let canned = self - .canned - .lock() - .unwrap() - .pop_front() - .ok_or_else(|| FetchError::Other("no canned response left".into()))?; - let mut headers = HeaderMap::new(); - for (name, value) in &canned.headers { - headers.insert( - HeaderName::try_from(name.as_str()).unwrap(), - HeaderValue::try_from(value.as_str()).unwrap(), - ); - } - Ok(FetchResponse::from_bytes( - StatusCode::from_u16(canned.status).unwrap(), - headers, - request.url, - Bytes::from(canned.body), - )) - } -} - -/// The settings every Groq-named fixture was recorded with -/// (`scripts/aisdk-fixtures/cases/openai-compatible.mjs`). -fn groq_settings(mock: &Arc) -> OpenAICompatibleProviderSettings { - OpenAICompatibleProviderSettings { - name: "groq".to_string(), - base_url: "https://api.groq.com/openai/v1".to_string(), - api_key: Some(KEY.to_string()), - fetch: Some(mock.transport()), - ..Default::default() - } -} - -/// Sets (or removes) an environment variable and restores it on drop. Tests -/// that use it are `#[serial]`. -struct EnvVar { - name: &'static str, - saved: Option, -} - -impl EnvVar { - fn set(name: &'static str, value: Option<&str>) -> Self { - let saved = std::env::var(name).ok(); - unsafe { - match value { - Some(value) => std::env::set_var(name, value), - None => std::env::remove_var(name), - } - } - Self { name, saved } - } -} - -impl Drop for EnvVar { - fn drop(&mut self) { - unsafe { - match &self.saved { - Some(value) => std::env::set_var(self.name, value), - None => std::env::remove_var(self.name), - } - } - } -} - -// ── fixtures ───────────────────────────────────────────────────────────────── - -/// Every fixture under `fixtures/aisdk/openai/`; the directory listing is -/// compared to this so a new fixture cannot go unreplayed. -const FIXTURES: [&str; 7] = [ - "chat-basic", - "chat-no-api-key", - "chat-provider-options-generic-key-unknown", - "chat-provider-options-namespace", - "chat-query-params-and-headers", - "chat-stream-basic", - "embedding-basic", -]; - -fn fixtures_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fixtures/aisdk/openai-compatible") -} - -struct Fixture { - name: &'static str, - json: Value, -} - -impl Fixture { - fn load(name: &'static str) -> Self { - assert!(FIXTURES.contains(&name), "unlisted fixture {name}"); - let path = fixtures_dir().join(format!("{name}.json")); - let text = std::fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())); - Self { - name, - json: serde_json::from_str(&text).expect("fixture is JSON"), - } - } - - fn provider_string(&self) -> &str { - self.json["model"]["provider"].as_str().unwrap() - } - - fn model_id(&self) -> &str { - self.json["model"]["modelId"].as_str().unwrap() - } - - fn sdk_input(&self) -> &Value { - &self.json["sdk"]["input"] - } - - /// The transport answering with the recorded response (or a plain chat - /// completion for the case that never reached the wire). - fn mock(&self) -> Arc { - let response = &self.json["response"]; - if response.is_null() { - return MockFetch::new(vec![Canned::json(&json!({ - "id": "chatcmpl-unused", "object": "chat.completion", "created": 1, - "model": "gpt-4o", - "choices": [{"index": 0, "message": {"role": "assistant", "content": "x"}, "finish_reason": "stop"}], - "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} - }))]); - } - let body = match &response["body"] { - Value::String(text) => text.clone().into_bytes(), - other => serde_json::to_vec(other).unwrap(), - }; - MockFetch::new(vec![Canned { - status: response["status"].as_u64().unwrap() as u16, - headers: response["headers"] - .as_object() - .unwrap() - .iter() - .map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string())) - .collect(), - body, - }]) - } - - /// The recorded request must equal what went out: method, URL, headers - /// (names case-insensitively; user-agent checked against the recording) and body. - /// `authorization` is the value the redacted recording stands for, or - /// `None` when the recording carries no `authorization` header at all. - fn assert_request(&self, seen: &Seen, authorization: Option<&str>) { - let recorded = &self.json["request"]; - assert_eq!( - seen.method, - recorded["method"].as_str().unwrap(), - "{}", - self.name - ); - assert_eq!(seen.url, recorded["url"].as_str().unwrap(), "{}", self.name); - - let mut expected: BTreeMap = recorded["headers"] - .as_object() - .unwrap() - .iter() - .map(|(k, v)| (k.to_ascii_lowercase(), v.as_str().unwrap().to_string())) - .collect(); - // The user-agent is checked against the recording, not overwritten: - // aimux sends `ai-sdk-openai-compatible/ ai-sdk-provider-utils/`; every - // recording carries the same provider-utils part, and a provider-layer - // recording (not made through `ai`) starts with the same package part. - let recorded_ua = expected.remove("user-agent").expect("recorded user-agent"); - let mut sent_headers = seen.headers.clone(); - let sent_ua = sent_headers.remove("user-agent").expect("sent user-agent"); - let (package_ua, utils_ua) = sent_ua - .split_once(' ') - .expect("package and provider-utils user-agent parts"); - assert!( - package_ua.starts_with("ai-sdk-openai-compatible/") - && utils_ua.starts_with("ai-sdk-provider-utils/"), - "{}: sent user-agent {sent_ua:?}", - self.name - ); - assert!( - recorded_ua.contains(&format!(" {utils_ua} ")), - "{}: recorded user-agent {recorded_ua:?} lacks {utils_ua:?}", - self.name - ); - if recorded_ua.starts_with("ai-sdk-openai-compatible/") { - assert!( - recorded_ua.starts_with(&format!("{package_ua} ")), - "{}: recorded user-agent {recorded_ua:?} does not start with {package_ua:?}", - self.name - ); - } - match authorization { - Some(value) => { - assert_eq!( - expected.get("authorization").map(String::as_str), - Some(""), - "{}: the recording redacts the key", - self.name - ); - expected.insert("authorization".to_string(), value.to_string()); - } - None => assert!( - !expected.contains_key("authorization"), - "{}: the recording carries no authorization", - self.name - ), - } - assert_eq!(sent_headers, expected, "{}: headers", self.name); - - assert_eq!(seen.json_body(), recorded["body"], "{}: body", self.name); - } - - /// The call options the recording was made with. - fn call_options(&self) -> CallOptions { - call_options_from(self.sdk_input()) - } -} - -fn message(role: &str, text: &str) -> LanguageModelMessage { - match role { - "system" => LanguageModelMessage::System { - content: text.to_string(), - provider_options: None, - }, - "user" => LanguageModelMessage::user_text(text), - "assistant" => LanguageModelMessage::Assistant { - content: vec![AssistantPart::Text(TextPart { - text: text.to_string(), - provider_options: None, - })], - provider_options: None, - }, - other => unreachable!("fixture has no {other} messages"), - } -} - -/// Rebuild aimux call options from a recorded `sdk.input` (the subset the -/// OpenAI fixtures use). -fn call_options_from(input: &Value) -> CallOptions { - let mut prompt: LanguageModelPrompt = Vec::new(); - if let Some(system) = input.get("system").and_then(Value::as_str) { - prompt.push(message("system", system)); - } - if let Some(text) = input.get("prompt").and_then(Value::as_str) { - prompt.push(message("user", text)); - } - for entry in input - .get("messages") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - prompt.push(message( - entry["role"].as_str().unwrap(), - entry["content"].as_str().unwrap(), - )); - } - let mut options = CallOptions::new(prompt); - options.temperature = input.get("temperature").and_then(Value::as_f64); - options.top_p = input.get("topP").and_then(Value::as_f64); - options.max_output_tokens = input - .get("maxOutputTokens") - .and_then(Value::as_u64) - .map(|v| v as u32); - options.seed = input.get("seed").and_then(Value::as_u64); - options.stop_sequences = input - .get("stopSequences") - .and_then(Value::as_array) - .map(|a| a.iter().map(|v| v.as_str().unwrap().to_string()).collect()); - if let Some(tools) = input.get("tools").and_then(Value::as_object) { - options.tools = Some( - tools - .iter() - .map(|(name, tool)| { - let mut function = FunctionTool::new(name.clone(), tool["inputSchema"].clone()); - if let Some(description) = tool["description"].as_str() { - function = function.with_description(description); - } - Tool::Function(function) - }) - .collect(), - ); - } - options.tool_choice = match input.get("toolChoice").and_then(Value::as_str) { - Some("required") => Some(LanguageModelV4ToolChoice::Required), - Some("none") => Some(LanguageModelV4ToolChoice::None), - Some("auto") => Some(LanguageModelV4ToolChoice::Auto), - None => options - .tools - .as_ref() - .filter(|tools| !tools.is_empty()) - .map(|_| LanguageModelV4ToolChoice::Auto), - Some(other) => panic!("unmapped toolChoice {other}"), - }; - options.provider_options = input - .get("providerOptions") - .map(|o| serde_json::from_value(o.clone()).unwrap()); - options.headers = input.get("headers").and_then(Value::as_object).map(|h| { - h.iter() - .map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string())) - .collect() - }); - options -} - -fn generated_text(result: &GenerateResult) -> String { - result - .content - .iter() - .filter_map(|part| match part { - GenerateContent::Text { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect() -} - -/// Result fields every recorded `generateText` case pins. -fn assert_generate_result(fixture: &Fixture, result: &GenerateResult) { - let recorded = &fixture.json["result"]; - assert_eq!( - generated_text(result), - recorded["text"].as_str().unwrap(), - "{}: text", - fixture.name - ); - assert_eq!( - result.response.as_ref().and_then(|r| r.id.as_deref()), - recorded["response"]["id"].as_str(), - "{}: response id", - fixture.name - ); - assert_eq!( - result.response.as_ref().and_then(|r| r.model_id.as_deref()), - recorded["response"]["modelId"].as_str(), - "{}: response model", - fixture.name - ); - assert_eq!( - result.usage.input_tokens.total, - recorded["usage"]["inputTokens"].as_u64().map(|v| v as _), - "{}: input tokens", - fixture.name - ); - assert_eq!( - result.usage.output_tokens.total, - recorded["usage"]["outputTokens"].as_u64().map(|v| v as _), - "{}: output tokens", - fixture.name - ); - let unified = match recorded["finishReason"].as_str().unwrap() { - "stop" => FinishReasonUnified::Stop, - "tool-calls" => FinishReasonUnified::ToolCalls, - other => panic!("unmapped finish reason {other}"), - }; - assert_eq!( - result.finish_reason.unified, unified, - "{}: finish", - fixture.name - ); - if let Some(raw) = recorded["rawFinishReason"].as_str() { - assert_eq!( - result.finish_reason.raw.as_deref(), - Some(raw), - "{}: raw finish", - fixture.name - ); - } -} - -fn bearer(key: &str) -> String { - format!("Bearer {key}") -} -fn opt_map(entries: &[(&str, Option<&str>)]) -> HeaderMapOpt { - entries - .iter() - .map(|(k, v)| ((*k).to_string(), v.map(str::to_string))) - .collect() -} - -// ── the fixtures ───────────────────────────────────────────────────────────── - -#[test] -fn every_fixture_is_replayed() { - let mut on_disk: Vec = std::fs::read_dir(fixtures_dir()) - .expect("fixtures/aisdk/openai-compatible exists") - .map(|entry| { - let name = entry.unwrap().file_name().into_string().unwrap(); - name.strip_suffix(".json") - .unwrap_or_else(|| panic!("unexpected file {name}")) - .to_string() - }) - .collect(); - on_disk.sort(); - assert_eq!( - on_disk, FIXTURES, - "add a test for the new fixture and list it" - ); -} - -/// Metadata is reported under the provider name, with the namespace entry -/// present even when empty. -fn assert_metadata_key(fixture: &Fixture, result: &GenerateResult, key: &str) { - assert_eq!( - fixture.json["result"]["providerMetadata"], - json!({ key: {} }), - "{}: recorded", - fixture.name - ); - assert_eq!( - serde_json::to_value(result.provider_metadata.as_ref().unwrap()).unwrap(), - json!({ key: {} }), - "{}: metadata key", - fixture.name - ); -} - -/// Replay a `generateText` fixture recorded with the Groq-named provider. -async fn replay_groq_chat(name: &'static str) -> GenerateResult { - let fixture = Fixture::load(name); - let mock = fixture.mock(); - let provider = create_openai_compatible(groq_settings(&mock)).unwrap(); - let model = provider.chat(fixture.model_id()); - assert_eq!(model.provider(), "groq.chat"); - assert_eq!( - model.provider(), - fixture.provider_string(), - "{name}: provider()" - ); - assert_eq!(model.model_id(), fixture.model_id()); - - let result = model.do_generate(&fixture.call_options()).await.unwrap(); - - let seen = mock.seen(); - assert_eq!(seen.len(), 1, "{name}: exactly one request"); - fixture.assert_request(&seen[0], Some(&bearer(KEY))); - assert_generate_result(&fixture, &result); - assert_metadata_key(&fixture, &result, "groq"); - result -} - -#[tokio::test] -async fn chat_basic() { - replay_groq_chat("chat-basic").await; -} - -/// partial: `should include provider-specific options`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. -#[tokio::test] -async fn chat_provider_options_namespace() { - // `groq.foo` passes through at the top level; `openaiCompatible.user` is a - // schema field. - replay_groq_chat("chat-provider-options-namespace").await; - let fixture = Fixture::load("chat-provider-options-namespace"); - assert_eq!( - fixture.json["observations"], - json!({"bodyHasFooAtTopLevel": true, "bodyUser": "u1", "providerIsGroqChat": true}) - ); -} - -#[tokio::test] -async fn unknown_fields_of_the_generic_namespace_are_not_forwarded() { - replay_groq_chat("chat-provider-options-generic-key-unknown").await; - let fixture = Fixture::load("chat-provider-options-generic-key-unknown"); - assert_eq!( - fixture.json["observations"]["bodyHasExtraField"], - json!(false) - ); -} - -/// partial: `should pass headers`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. -/// partial: `should create provider with correct configuration`; full port in src/openai_compatible/tests/provider.rs. -#[tokio::test] -async fn chat_query_params_and_headers() { - let fixture = Fixture::load("chat-query-params-and-headers"); - let mock = fixture.mock(); - // The recording was made with headers { "x-a": "1", "X-B": "2" } and - // queryParams { "api-version": "1" }. - let provider = create_openai_compatible(OpenAICompatibleProviderSettings { - headers: Some(opt_map(&[("x-a", Some("1")), ("X-B", Some("2"))])), - query_params: Some(HashMap::from([( - "api-version".to_string(), - "1".to_string(), - )])), - ..groq_settings(&mock) - }) - .unwrap(); - let model = provider.chat(fixture.model_id()); - - let result = model.do_generate(&fixture.call_options()).await.unwrap(); - - let seen = mock.seen(); - fixture.assert_request(&seen[0], Some(&bearer(KEY))); - assert_generate_result(&fixture, &result); - // Case-insensitive override: the call's `x-b` replaced the provider's `X-B`. - assert_eq!(seen[0].headers["x-b"], "3"); - let sent: Vec<&String> = seen[0] - .headers - .keys() - .filter(|n| n.starts_with("x-")) - .collect(); - assert_eq!(sent, ["x-a", "x-b", "x-c"]); - assert_eq!( - fixture.json["observations"]["headerNamesAsSent"], - json!(["x-a", "x-b", "x-c"]) - ); -} - -/// partial: `should create headers without authorization when no apiKey provided`; full port in src/openai_compatible/tests/provider.rs. -#[tokio::test] -async fn chat_without_an_api_key_sends_no_authorization() { - let fixture = Fixture::load("chat-no-api-key"); - let mock = fixture.mock(); - // A key in the environment must not leak in either. - let _env = EnvVar::set("OPENAI_API_KEY", Some(ENV_KEY)); - let provider = create_openai_compatible(OpenAICompatibleProviderSettings { - name: "local".to_string(), - base_url: "http://localhost:1234/v1".to_string(), - fetch: Some(mock.transport()), - ..Default::default() - }) - .expect("no key is needed to create the provider"); - let model = provider.chat(fixture.model_id()); - assert_eq!(model.provider(), "local.chat"); - assert_eq!(model.provider(), fixture.provider_string()); - - let result = model.do_generate(&fixture.call_options()).await.unwrap(); - - let seen = mock.seen(); - assert_eq!(seen.len(), 1); - fixture.assert_request(&seen[0], None); - assert!(!seen[0].headers.contains_key("authorization")); - assert_eq!( - fixture.json["observations"]["hasAuthorizationHeader"], - json!(false) - ); - assert_generate_result(&fixture, &result); - assert_metadata_key(&fixture, &result, "local"); -} - -/// `supportedUrls` is read through the settings function each time, and its -/// value may be async (openai-compatible@3.0.62 -/// `openai-compatible-chat-language-model.ts:84`, `:161-163`); completion -/// models get none (`openai-compatible-provider.ts:189-193`). -#[tokio::test] -async fn chat_supported_urls_come_from_the_async_settings_function() { - let mock = MockFetch::new(Vec::new()); - let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let counter = calls.clone(); - let provider = create_openai_compatible(OpenAICompatibleProviderSettings { - supported_urls: Some(Arc::new(move || { - counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Box::pin(async { - tokio::task::yield_now().await; - aimux_core::language_model::SupportedUrls(HashMap::from([( - "image/*".to_string(), - vec![regex::Regex::new("^https://").unwrap()], - )])) - }) - })), - ..groq_settings(&mock) - }) - .unwrap(); - let chat = provider.chat("m"); - for _ in 0..2 { - let urls = chat.supported_urls().await; - assert_eq!(urls.0.keys().collect::>(), ["image/*"]); - } - assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2); - assert!(provider.completion("m").supported_urls().await.is_empty()); - assert!(mock.seen().is_empty()); -} - -#[tokio::test] -async fn chat_stream_basic() { - let fixture = Fixture::load("chat-stream-basic"); - let mock = fixture.mock(); - let model = create_openai_compatible(groq_settings(&mock)) - .unwrap() - .chat(fixture.model_id()); - assert_eq!(model.provider(), fixture.provider_string()); - - let result = model.do_stream(&fixture.call_options()).await.unwrap(); - let parts: Vec = result - .stream - .map(|part| part.expect("no stream error")) - .collect() - .await; - - // Unlike the native OpenAI package, the baseline asks for no - // `stream_options` unless `include_usage` is set: the recorded body has - // none. (The usage chunk is read anyway when the server sends it.) The - // recording's user-agent names the provider package. - assert!( - fixture.json["request"]["headers"]["user-agent"] - .as_str() - .unwrap() - .starts_with("ai-sdk-openai-compatible/"), - "the stream case is a provider-layer recording" - ); - let seen = mock.seen(); - assert_eq!(seen.len(), 1); - fixture.assert_request(&seen[0], Some(&bearer(KEY))); - assert!(seen[0].json_body().get("stream_options").is_none()); - - let text: String = parts - .iter() - .filter_map(|part| match part { - StreamPart::TextDelta { delta, .. } => Some(delta.as_str()), - _ => None, - }) - .collect(); - assert_eq!(text, "Hello world"); - let (reason, usage, metadata) = parts - .iter() - .find_map(|part| match part { - StreamPart::Finish { - finish_reason, - usage, - provider_metadata, - } => Some((finish_reason, usage, provider_metadata)), - _ => None, - }) - .expect("a finish part"); - assert_eq!(reason.unified, FinishReasonUnified::Stop); - assert_eq!(usage.input_tokens.total, Some(12)); - assert_eq!(usage.output_tokens.total, Some(2)); - assert_eq!( - serde_json::to_value(metadata.as_ref().unwrap()).unwrap(), - json!({"groq": {}}) - ); -} - -/// partial: `should extract embedding`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. -/// partial: `should extract usage`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. -/// partial: `should pass the model and the values`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. -#[tokio::test] -async fn embedding_basic() { - let fixture = Fixture::load("embedding-basic"); - let mock = fixture.mock(); - let model = create_openai_compatible(groq_settings(&mock)) - .unwrap() - .embedding(fixture.model_id()); - assert_eq!(model.provider(), "groq.embedding"); - assert_eq!(model.provider(), fixture.provider_string()); - - let values: Vec = fixture.sdk_input()["values"] - .as_array() - .unwrap() - .iter() - .map(|v| v.as_str().unwrap().to_string()) - .collect(); - let options = EmbeddingCallOptions { - values, - ..EmbeddingCallOptions::new("") - }; - let result = model.do_embed(&options).await.unwrap(); - - fixture.assert_request(&mock.seen()[0], Some(&bearer(KEY))); - let recorded: Vec> = - serde_json::from_value(fixture.json["result"]["embeddings"].clone()).unwrap(); - assert_eq!(result.embeddings, recorded); - assert_eq!( - result.usage.map(|u| u.tokens), - fixture.json["result"]["usage"]["tokens"] - .as_u64() - .map(|v| v as u32) - ); -} - -fn _assert_provider_is_shareable() { - fn shareable() {} - shareable::(); -} - -// ── compat request body key order ──────────────────────────────────────────── - -/// One chat request with `options`; returns the body bytes as sent. -async fn sent_chat_body(options: CallOptions) -> String { - let mock = MockFetch::new(vec![Canned::json(&json!({ - "id": "chatcmpl-order", "object": "chat.completion", "created": 1, - "model": "m", - "choices": [{"index": 0, "message": {"role": "assistant", "content": "x"}, "finish_reason": "stop"}], - "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} - }))]); - let provider = create_openai_compatible(groq_settings(&mock)).unwrap(); - provider.chat("m").do_generate(&options).await.unwrap(); - let seen = mock.seen(); - assert_eq!(seen.len(), 1); - String::from_utf8(seen[0].body.clone()).unwrap() -} - -/// Byte offset of top-level key `key` in a compact JSON object. -fn key_at(body: &str, key: &str) -> usize { - body.find(&format!("\"{key}\":")) - .unwrap_or_else(|| panic!("{key} missing in {body}")) -} - -/// A user "hi" prompt with a `weather` tool, `toolChoice: auto` and the given -/// `groq` provider options. -fn tool_call_options(groq: Value) -> CallOptions { - let mut options = CallOptions::new(vec![LanguageModelMessage::user_text("hi")]); - options.temperature = Some(0.5); - options.tools = Some(vec![Tool::Function(FunctionTool::new( - "weather".to_string(), - json!({"type": "object", "properties": {}}), - ))]); - options.tool_choice = Some(LanguageModelV4ToolChoice::Auto); - options.provider_options = Some(serde_json::from_value(json!({ "groq": groq })).unwrap()); - options -} - -/// The body bytes follow openai-compatible@3.0.62 -/// `openai-compatible-chat-language-model.ts:270-325` (the alignment doc's -/// "compat 请求体顺序"): model, user → sampling, response_format, stop, seed -/// → unknown provider-namespace fields → reasoning_effort, verbosity → -/// messages, tools, tool_choice. `serde_json::Value` ignores order, so this -/// asserts on the bytes. Ported from li's fork PR #35 (f9c9653b). -#[tokio::test] -async fn compat_body_keys_follow_the_documented_order() { - let mut options = tool_call_options(json!({ - "zzzUnknown": 1, - "user": "u1", - "reasoningEffort": "low", - "textVerbosity": "low" - })); - options.stop_sequences = Some(vec!["END".to_string()]); - options.seed = Some(7); - let body = sent_chat_body(options).await; - let order = [ - "model", - "user", - "temperature", - "stop", - "seed", - "zzzUnknown", - "reasoning_effort", - "verbosity", - "messages", - "tools", - "tool_choice", - ]; - let at: Vec = order.iter().map(|k| key_at(&body, k)).collect(); - for (i, pair) in at.windows(2).enumerate() { - assert!( - pair[0] < pair[1], - "{} must come before {}: {body}", - order[i], - order[i + 1] - ); - } -} - -/// An unknown field may override earlier fields (sampling), but not the -/// fields assigned after it: `messages` / `tools` / `tool_choice` from the -/// provider namespace are replaced by the real ones (the same spread order). -#[tokio::test] -async fn unknown_fields_override_sampling_but_not_messages_or_tools() { - let body = sent_chat_body(tool_call_options(json!({ - "temperature": 0.9, - "messages": "bogus", - "tools": "bogus", - "tool_choice": "bogus" - }))) - .await; - let sent: Value = serde_json::from_str(&body).unwrap(); - assert_eq!(sent["temperature"], json!(0.9), "{body}"); - assert_eq!(sent["messages"][0]["role"], json!("user"), "{body}"); - assert_eq!( - sent["tools"][0]["function"]["name"], - json!("weather"), - "{body}" - ); - assert_eq!(sent["tool_choice"], json!("auto"), "{body}"); - assert!(!body.contains("bogus"), "{body}"); -} diff --git a/aimux-providers/tests/openai_compatible_upstream_e2e_test.rs b/aimux-providers/tests/openai_compatible_upstream_e2e_test.rs deleted file mode 100644 index 2a3ccaa0a..000000000 --- a/aimux-providers/tests/openai_compatible_upstream_e2e_test.rs +++ /dev/null @@ -1,599 +0,0 @@ -//! End-to-end subset of the pinned openai-compatible upstream tests. - -#[path = "common/mock_fetch.rs"] -mod mock_fetch; - -use std::sync::Arc; - -use aimux_core::AiMuxError; -use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; -use aimux_core::image_model::{ImageCallOptions, ImageModel, ImageOutputs}; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::LanguageModelMessage; -use aimux_core::options::CallOptions; -use aimux_core::result::GenerateContent; -use aimux_core::shared::{Size, provider_namespace}; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::FunctionTool; -use aimux_core::types::FinishReasonUnified; -use aimux_providers::openai_compatible::{ - OpenAICompatibleProvider, OpenAICompatibleProviderSettings, create_openai_compatible, -}; -use futures::StreamExt; -use mock_fetch::{Canned, MockFetch}; -use serde_json::{Value, json}; - -const BASE_URL: &str = "https://my.api.com/v1"; -const IMAGE_PROMPT: &str = "A photorealistic astronaut riding a horse"; - -fn provider(fetch: &Arc) -> OpenAICompatibleProvider { - create_openai_compatible(OpenAICompatibleProviderSettings { - name: "test-provider".into(), - base_url: BASE_URL.into(), - fetch: Some(fetch.transport()), - ..Default::default() - }) - .unwrap() -} - -fn options() -> CallOptions { - CallOptions::new(vec![LanguageModelMessage::user_text("Hello")]) -} - -fn chat_response(message: Value, usage: Value) -> Value { - json!({"id":"chatcmpl-test", "created":1711115037, "model":"test-model", - "choices":[{"index":0,"message":message,"finish_reason":"stop"}], - "usage":usage}) -} - -fn stream_response(values: &[Value]) -> Canned { - let mut body = values - .iter() - .map(|value| format!("data: {value}\n\n")) - .collect::(); - body.push_str("data: [DONE]\n\n"); - Canned { - status: 200, - headers: vec![("content-type".into(), "text/event-stream".into())], - body: body.into_bytes(), - } -} - -fn assert_request(fetch: &MockFetch, path: &str, body: Value) { - let requests = fetch.seen(); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].method, "POST"); - assert_eq!(requests[0].url, format!("{BASE_URL}{path}")); - assert_eq!(requests[0].json_body(), body); -} - -fn stream_request() -> Value { - json!({"model":"test-model","messages":[{"role":"user","content":"Hello"}],"stream":true}) -} - -/// partial: `should use camelCase metadata key when camelCase provider options are used`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. -/// partial: `should extract detailed token usage when available`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. -#[tokio::test] -async fn chat_generate_metadata_and_usage() { - let usage = json!({"prompt_tokens":20,"completion_tokens":30,"total_tokens":50, - "prompt_tokens_details":{"cached_tokens":5}, - "completion_tokens_details":{"reasoning_tokens":10,"accepted_prediction_tokens":15,"rejected_prediction_tokens":5}}); - let fetch = MockFetch::new(vec![Canned::json(&chat_response( - json!({"content":"Hello!"}), - usage.clone(), - ))]); - let mut opts = options(); - opts.provider_options = Some( - provider_namespace("testProvider", json!({"reasoningEffort":"high"})) - .expect("provider metadata must be an object"), - ); - let result = provider(&fetch) - .chat("test-model") - .do_generate(&opts) - .await - .unwrap(); - let expected = json!({"model":"test-model","messages":[{"role":"user","content":"Hello"}],"reasoning_effort":"high"}); - assert_request(&fetch, "/chat/completions", expected.clone()); - assert_eq!(result.request.unwrap().body, Some(expected)); - assert_eq!(result.content.len(), 1); - assert!(matches!(&result.content[0], GenerateContent::Text { text, .. } if text == "Hello!")); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.finish_reason.raw.as_deref(), Some("stop")); - assert_eq!(result.usage.input_tokens.total, Some(20)); - assert_eq!(result.usage.input_tokens.no_cache, Some(15)); - assert_eq!(result.usage.input_tokens.cache_read, Some(5)); - assert_eq!(result.usage.input_tokens.cache_write, None); - assert_eq!(result.usage.output_tokens.total, Some(30)); - assert_eq!(result.usage.output_tokens.text, Some(20)); - assert_eq!(result.usage.output_tokens.reasoning, Some(10)); - assert_eq!(result.usage.raw, usage.as_object().cloned()); - let metadata = result.provider_metadata.unwrap(); - assert_eq!( - metadata, - provider_namespace( - "testProvider", - json!({"acceptedPredictionTokens":15,"rejectedPredictionTokens":5}) - ) - .expect("provider metadata must be an object") - ); - assert!(!metadata.contains_key("test-provider")); - let response = result.response.unwrap(); - assert_eq!(response.id.as_deref(), Some("chatcmpl-test")); - assert_eq!(response.model_id.as_deref(), Some("test-model")); - assert_eq!( - response.headers.unwrap()["content-type"], - "application/json" - ); - assert!(result.warnings.is_empty()); -} - -/// partial: `should stream reasoning content before text deltas`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. -#[tokio::test] -async fn chat_stream_reasoning_before_text() { - let delta = |value: Value| { - json!({"id":"chatcmpl-test","created":1711357598, - "model":"test-model","choices":[{"index":0,"delta":value,"finish_reason":null}]}) - }; - let fetch = MockFetch::new(vec![stream_response(&[ - delta(json!({"role":"assistant","content":"","reasoning_content":"Let me think"})), - delta(json!({"content":"","reasoning_content":" about this"})), - delta(json!({"content":"Here's"})), - delta(json!({"content":" my response"})), - json!({"choices":[{"index":0,"delta":{},"finish_reason":"stop"}], - "usage":{"prompt_tokens":18,"completion_tokens":439}}), - ])]); - let result = provider(&fetch) - .chat("test-model") - .do_stream(&options()) - .await - .unwrap(); - assert_request(&fetch, "/chat/completions", stream_request()); - assert_eq!(result.request.unwrap().body, Some(stream_request())); - let parts: Vec<_> = result.stream.map(|part| part.unwrap()).collect().await; - assert_eq!(parts.len(), 11); - assert!(matches!(&parts[0], StreamPart::StreamStart { warnings } if warnings.is_empty())); - assert!( - matches!(&parts[1], StreamPart::ResponseMetadata(metadata) if metadata.id.as_deref() == Some("chatcmpl-test") && metadata.model_id.as_deref() == Some("test-model")) - ); - assert!(matches!(&parts[2], StreamPart::ReasoningStart { id, .. } if id == "reasoning-0")); - assert!(matches!(&parts[5], StreamPart::ReasoningEnd { id, .. } if id == "reasoning-0")); - assert!(matches!(&parts[6], StreamPart::TextStart { id, .. } if id == "txt-0")); - assert!(matches!(&parts[9], StreamPart::TextEnd { id, .. } if id == "txt-0")); - let deltas: Vec<_> = parts - .iter() - .filter_map(|part| match part { - StreamPart::ReasoningDelta { id, delta, .. } => { - Some(("reasoning", id.as_str(), delta.as_str())) - } - StreamPart::TextDelta { id, delta, .. } => Some(("text", id.as_str(), delta.as_str())), - _ => None, - }) - .collect(); - assert_eq!( - deltas, - vec![ - ("reasoning", "reasoning-0", "Let me think"), - ("reasoning", "reasoning-0", " about this"), - ("text", "txt-0", "Here's"), - ("text", "txt-0", " my response") - ] - ); - let StreamPart::Finish { - finish_reason, - usage, - provider_metadata, - } = &parts[10] - else { - panic!("expected finish"); - }; - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(usage.input_tokens.total, Some(18)); - assert_eq!(usage.input_tokens.no_cache, Some(18)); - assert_eq!(usage.input_tokens.cache_read, Some(0)); - assert_eq!(usage.output_tokens.total, Some(439)); - assert_eq!(usage.output_tokens.text, Some(439)); - assert_eq!(usage.output_tokens.reasoning, Some(0)); - assert_eq!( - provider_metadata.as_ref().unwrap(), - &provider_namespace("test-provider", json!({})) - .expect("provider metadata must be an object") - ); -} - -/// partial: `should parse thought signature from extra_content and include in providerMetadata`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. -#[tokio::test] -async fn chat_tool_call_preserves_arguments_and_signature() { - let fetch = MockFetch::new(vec![Canned::json(&chat_response( - json!({"tool_calls":[{ - "id":"function-call-1","type":"function", - "function":{"name":"check_flight","arguments":"{\"flight\":\"AA100\"}"}, - "extra_content":{"google":{"thought_signature":""}}}]}), - Value::Null, - ))]); - let schema = json!({"type":"object","properties":{"flight":{"type":"string"}}, - "required":["flight"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}); - let mut opts = options(); - opts.tools = Some(vec![ - FunctionTool::new("check_flight", schema.clone()).into(), - ]); - let result = provider(&fetch) - .chat("test-model") - .do_generate(&opts) - .await - .unwrap(); - assert_request( - &fetch, - "/chat/completions", - json!({"model":"test-model", - "messages":[{"role":"user","content":"Hello"}], - "tools":[{"type":"function","function":{"name":"check_flight","parameters":schema}}]}), - ); - assert_eq!(result.content.len(), 1); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.finish_reason.raw.as_deref(), Some("stop")); - let GenerateContent::ToolCall(call) = &result.content[0] else { - panic!("expected tool call"); - }; - assert_eq!(call.tool_call_id, "function-call-1"); - assert_eq!(call.tool_name, "check_flight"); - assert_eq!(call.input, "{\"flight\":\"AA100\"}"); - assert_eq!( - call.provider_metadata.as_ref().unwrap(), - &provider_namespace("test-provider", json!({"thoughtSignature":""})) - .expect("provider metadata must be an object") - ); -} - -/// partial: `should preserve structured error stream parts`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. -#[tokio::test] -async fn chat_stream_preserves_structured_error() { - let fetch = MockFetch::new(vec![stream_response(&[json!({"error":{ - "message":"Context length exceeded","code":"CONTEXT_LENGTH_EXCEEDED"}})])]); - let result = provider(&fetch) - .chat("test-model") - .do_stream(&options()) - .await - .unwrap(); - assert_request(&fetch, "/chat/completions", stream_request()); - let parts: Vec<_> = result.stream.map(|part| part.unwrap()).collect().await; - assert_eq!(parts.len(), 3); - assert!(matches!(&parts[0], StreamPart::StreamStart { warnings } if warnings.is_empty())); - let StreamPart::Error { - error: AiMuxError::ApiCall(error), - } = &parts[1] - else { - panic!("expected structured API error"); - }; - assert_eq!(error.message, "Context length exceeded"); - assert_eq!( - error.provider_code.as_deref(), - Some("CONTEXT_LENGTH_EXCEEDED") - ); - let StreamPart::Finish { - finish_reason, - usage, - provider_metadata, - } = &parts[2] - else { - panic!("expected finish"); - }; - assert_eq!(finish_reason.unified, FinishReasonUnified::Error); - assert_eq!(finish_reason.raw, None); - assert_eq!(usage.input_tokens.total, None); - assert_eq!(usage.output_tokens.total, None); - assert_eq!(usage.raw, None); - assert_eq!( - provider_metadata.as_ref().unwrap(), - &provider_namespace("test-provider", json!({})) - .expect("provider metadata must be an object") - ); -} - -/// Upstream: @ai-sdk/openai-compatible 3.0.62 -/// src/chat/openai-compatible-chat-language-model.ts:588-595 passes an error -/// chunk's `error` on as it is: even a numeric `code` that looks like an HTTP -/// status is no status, so the error is not retryable. -#[tokio::test] -async fn chat_stream_error_takes_no_status_from_a_numeric_code() { - for (code, provider_code) in [ - (json!(429), "429"), - (json!(503), "503"), - (json!("429"), "429"), - ] { - let payload = json!({"message": "Slow down", "code": code}); - let fetch = MockFetch::new(vec![stream_response(&[json!({ "error": payload })])]); - let parts: Vec<_> = provider(&fetch) - .chat("test-model") - .do_stream(&options()) - .await - .unwrap() - .stream - .map(|part| part.unwrap()) - .collect() - .await; - let error = parts - .iter() - .find_map(|part| match part { - StreamPart::Error { - error: AiMuxError::ApiCall(error), - } => Some(error), - _ => None, - }) - .expect("expected an API error part"); - assert_eq!(error.message, "Slow down", "{code}"); - assert_eq!( - error.provider_code.as_deref(), - Some(provider_code), - "{code}" - ); - assert_eq!(error.status_code, None, "{code}"); - assert!(!error.is_retryable, "{code}"); - assert_eq!(error.data.as_ref(), Some(&payload), "{code}"); - } -} - -/// Upstream: @ai-sdk/openai-compatible 3.0.62 -/// src/completion/openai-compatible-completion-language-model.ts:320-324 -/// passes an error chunk on as it is (`error: value.error`): unlike -/// @ai-sdk/openai, no status is inferred from its `code`/`type`. -#[tokio::test] -async fn completion_stream_error_infers_no_status() { - let fetch = MockFetch::new(vec![stream_response(&[json!({"error":{ - "message":"Slow down","type":"requests","code":"rate_limit_exceeded"}})])]); - let parts: Vec<_> = provider(&fetch) - .completion("test-model") - .do_stream(&options()) - .await - .unwrap() - .stream - .map(|part| part.unwrap()) - .collect() - .await; - let error = parts - .iter() - .find_map(|part| match part { - StreamPart::Error { - error: AiMuxError::ApiCall(error), - } => Some(error), - _ => None, - }) - .expect("expected an API error part"); - assert_eq!(error.message, "Slow down"); - assert_eq!(error.provider_code.as_deref(), Some("rate_limit_exceeded")); - assert_eq!(error.status_code, None); - assert!(!error.is_retryable); -} - -/// partial: `should extract embedding`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. -/// partial: `should pass the model and the values`; full port in src/openai_compatible/tests/embedding/embedding_model.rs. -#[tokio::test] -async fn embedding_request_and_result() { - let body = json!({"object":"list","data":[ - {"object":"embedding","index":0,"embedding":[0.1,0.2,0.3,0.4,0.5]}, - {"object":"embedding","index":1,"embedding":[0.6,0.7,0.8,0.9,1.0]}], - "model":"test-embedding","usage":{"prompt_tokens":8,"total_tokens":8}}); - let fetch = MockFetch::new(vec![Canned::json(&body)]); - let mut opts = EmbeddingCallOptions::new("sunny day at the beach"); - opts.values.push("rainy day in the city".into()); - let result = provider(&fetch) - .embedding("test-embedding") - .do_embed(&opts) - .await - .unwrap(); - assert_request( - &fetch, - "/embeddings", - json!({"model":"test-embedding", - "input":["sunny day at the beach","rainy day in the city"],"encoding_format":"float"}), - ); - assert_eq!( - result.embeddings, - vec![vec![0.1, 0.2, 0.3, 0.4, 0.5], vec![0.6, 0.7, 0.8, 0.9, 1.0]] - ); - assert_eq!(result.usage.unwrap().tokens, 8); - let response = result.response.unwrap(); - assert_eq!(response.body, Some(body)); - assert_eq!( - response.headers.unwrap()["content-type"], - "application/json" - ); - assert!(result.warnings.is_empty()); -} - -fn image_options() -> ImageCallOptions { - let mut opts = ImageCallOptions::new(IMAGE_PROMPT); - opts.size = Some(Size::new(1024, 1024)); - opts -} - -/// partial: `should return the raw b64_json content`; full port in src/openai_compatible/tests/image/image_model.rs. -/// partial: `should map the usage object reported by the provider`; full port in src/openai_compatible/tests/image/image_model.rs. -#[tokio::test] -async fn image_generate_raw_content_and_usage() { - let fetch = MockFetch::new(vec![Canned::json(&json!({"data":[ - {"b64_json":"test1234"},{"b64_json":"test5678"}], - "usage":{"input_tokens":12,"output_tokens":4,"total_tokens":16}}))]); - let mut opts = image_options(); - opts.n = 2; - let result = provider(&fetch) - .image("test-image") - .do_generate(&opts) - .await - .unwrap(); - assert_request( - &fetch, - "/images/generations", - json!({"model":"test-image", - "prompt":IMAGE_PROMPT,"n":2,"size":"1024x1024"}), - ); - let ImageOutputs::Base64(images) = result.images else { - panic!("expected base64 images"); - }; - assert_eq!(images, vec!["test1234", "test5678"]); - let usage = result.usage.unwrap(); - assert_eq!(usage.input_tokens, Some(12)); - assert_eq!(usage.output_tokens, Some(4)); - assert_eq!(usage.total_tokens, Some(16)); - assert_eq!(result.response.model_id.as_deref(), Some("test-image")); - assert_eq!( - result.response.headers.unwrap()["content-type"], - "application/json" - ); - assert!(result.warnings.is_empty()); -} - -/// partial: `should handle API errors with default error structure`; full port in src/openai_compatible/tests/image/image_model.rs. -#[tokio::test] -async fn image_generate_api_error() { - let mut canned = Canned::json(&json!({"error":{"message":"Invalid prompt content", - "type":"invalid_request_error","param":null,"code":null}})); - canned.status = 400; - let fetch = MockFetch::new(vec![canned]); - let error = provider(&fetch) - .image("test-image") - .do_generate(&image_options()) - .await - .unwrap_err(); - assert_request( - &fetch, - "/images/generations", - json!({"model":"test-image", - "prompt":IMAGE_PROMPT,"n":1,"size":"1024x1024"}), - ); - let AiMuxError::ApiCall(error) = error else { - panic!("expected API error"); - }; - assert_eq!(error.message, "Invalid prompt content"); - assert_eq!(error.status_code, Some(400)); - assert_eq!(error.url, format!("{BASE_URL}/images/generations")); -} - -/// A tool result whose output is `content` (a text part and a base64 image -/// part), as the AI SDK passes it to the provider. -fn multi_part_tool_prompt( - extra: Option, -) -> CallOptions { - use aimux_core::language_model_message::{ - FilePart, TextPart, ToolPart, ToolResultContent, ToolResultOutput, ToolResultPart, - }; - use aimux_core::shared::{FileBytes, FileData}; - let mut value = vec![ - ToolResultContent::Text(TextPart { - text: "The weather is sunny".into(), - provider_options: None, - }), - ToolResultContent::File(FilePart { - data: FileData::Data { - data: FileBytes::Base64("AAECAw==".into()), - }, - media_type: "image/png".into(), - filename: None, - provider_options: None, - }), - ]; - value.extend(extra); - CallOptions::new(vec![ - LanguageModelMessage::user_text("Weather?"), - LanguageModelMessage::Tool { - content: vec![ToolPart::ToolResult(ToolResultPart { - tool_call_id: "call-1".into(), - tool_name: "weather".into(), - output: ToolResultOutput::Content { value }, - provider_options: None, - })], - provider_options: None, - }, - ]) -} - -fn multi_part_provider( - fetch: &Arc, - supported: Option, -) -> OpenAICompatibleProvider { - create_openai_compatible(OpenAICompatibleProviderSettings { - name: "test-provider".into(), - base_url: BASE_URL.into(), - fetch: Some(fetch.transport()), - supports_multi_part_tool_content: supported, - ..Default::default() - }) - .unwrap() -} - -/// partial: `should pass multi-part tool content when supported`; full port in src/openai_compatible/tests/chat/chat_language_model.rs. -/// partial: `should convert multi-part tool content when supported`; full port in src/openai_compatible/tests/chat/convert_to_chat_messages.rs. -/// partial: `should stringify multi-part tool content by default`; full port in src/openai_compatible/tests/chat/convert_to_chat_messages.rs. -/// Upstream: @ai-sdk/openai-compatible 3.0.62 -/// src/chat/convert-to-openai-compatible-chat-messages.ts:303-306 sends a -/// `content` tool output as an array of content parts -/// (`convertToolContentPart`, :162-176) when `supportsMultiPartToolContent` -/// (openai-compatible-provider.ts:94-100, :183) is set, and as -/// `JSON.stringify(output.value)` otherwise (the default, `false`). -#[tokio::test] -async fn chat_multi_part_tool_content_follows_the_setting() { - let response = || { - Canned::json(&chat_response( - json!({"role":"assistant","content":"ok"}), - json!({"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}), - )) - }; - let fetch = MockFetch::new(vec![response(), response(), response()]); - let tool_message = |index: usize| fetch.seen()[index].json_body()["messages"][1].clone(); - - multi_part_provider(&fetch, Some(true)) - .chat("test-model") - .do_generate(&multi_part_tool_prompt(None)) - .await - .unwrap(); - assert_eq!( - tool_message(0), - json!({ - "role": "tool", - "tool_call_id": "call-1", - "content": [ - {"type": "text", "text": "The weather is sunny"}, - {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAECAw=="}} - ] - }) - ); - - for (index, supported) in [(1, None), (2, Some(false))] { - multi_part_provider(&fetch, supported) - .chat("test-model") - .do_generate(&multi_part_tool_prompt(None)) - .await - .unwrap(); - let message = tool_message(index); - let content: Value = - serde_json::from_str(message["content"].as_str().expect("a JSON string")).unwrap(); - assert_eq!( - content, - json!([ - {"type": "text", "text": "The weather is sunny"}, - {"type": "file", "mediaType": "image/png", "data": {"type": "data", "data": "AAECAw=="}} - ]), - "{supported:?}" - ); - } -} - -/// Upstream: convert-to-openai-compatible-chat-messages.ts:166-170 rejects a -/// custom part in multi-part tool content. -#[tokio::test] -async fn chat_multi_part_tool_content_rejects_a_custom_part() { - let fetch = MockFetch::new(Vec::new()); - let error = multi_part_provider(&fetch, Some(true)) - .chat("test-model") - .do_generate(&multi_part_tool_prompt(Some( - aimux_core::language_model_message::ToolResultContent::Custom { - provider_options: None, - }, - ))) - .await - .unwrap_err(); - assert!( - matches!(&error, AiMuxError::UnsupportedFunctionality(what) if what == "custom tool content parts"), - "{error:?}" - ); - assert!(fetch.seen().is_empty()); -} diff --git a/scripts/check_provider_boundaries.sh b/scripts/check_provider_boundaries.sh index 72b71d382..2f4bfc3b1 100755 --- a/scripts/check_provider_boundaries.sh +++ b/scripts/check_provider_boundaries.sh @@ -143,9 +143,14 @@ rule4_hits="$( violation 'provider-name comparison in a shared protocol package (aimux-providers/src/{openai,openai_compatible,anthropic,google,bedrock,cohere})' "$rule4_hits" # `"groq"` / `"deepseek"` literals belong to their own packages and the registry. +# Two vendor test files may name them: the OpenAI evaluation test checks that +# another preset has no decision model, and the openai-compatible fixtures were +# recorded with a provider named `groq`. rule4b_hits="$( - grep -rnE '"(groq|deepseek)"' \ - aimux-providers/src/openai aimux-providers/src/openai_compatible --include='*.rs' || true + { grep -rnE '"(groq|deepseek)"' \ + aimux-providers/src/openai aimux-providers/src/openai_compatible --include='*.rs' || true; } | + grep -vE '^aimux-providers/src/(openai/tests/evaluation|openai_compatible/tests/provider)\.rs:' || + true )" violation 'groq / deepseek literal in the shared OpenAI or compatible package' "$rule4b_hits"