Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1453,6 +1453,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
xAI, Anthropic, Google and DeepSeek converters resolve file references with
the second. A missing DeepSeek reference now names the providers the
reference has, as the AI SDK's `NoSuchProviderReferenceError` does.
- `aimux_provider_utils::is_valid_hostname_part`, as `@ai-sdk/provider-utils`
5.0.55 `isValidHostnamePart`, and `aimux_provider_utils::base_url_setting`,
the explicit-or-environment base URL of a provider (RFC-0046 R1). They move
out of `aimux-providers`; the Azure, Amazon Bedrock, Anthropic AWS and
Google Vertex host checks and the OpenAI and Anthropic base URLs use them
unchanged.
- `aimux_provider_utils::parse_provider_options`, as `@ai-sdk/provider-utils`
5.0.55 `parseProviderOptions` (RFC-0046 R1): one provider's
`providerOptions` namespace deserialized into its options type, `None`
Expand Down
31 changes: 31 additions & 0 deletions aimux-provider-utils/src/api_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,34 @@ pub fn load_optional_setting(
None => std::env::var(environment_variable_name).ok(),
}
}

/// The base URL of a native package: the explicit setting, else the
/// environment variable `var` read when the provider is created (the AI SDK's
/// `loadOptionalSetting`), else `default`.
///
/// An invalid explicit value fails the factory (`Err` from the outer
/// result). An invalid environment value does not: the AI SDK does not
/// validate it when the provider is created and the first request fails, so
/// the inner `Err` is kept and reported by every request of that provider.
/// Creating a default provider therefore never fails on the environment.
///
/// # Errors
///
/// The outer `Err` is `normalize`'s error for an invalid explicit value.
pub fn base_url_setting(
explicit: Option<&str>,
var: &str,
default: &str,
normalize: impl Fn(&str) -> Result<String, AiMuxError>,
) -> Result<Result<String, AiMuxError>, AiMuxError> {
if let Some(url) = explicit {
return normalize(url).map(Ok);
}
Ok(match load_optional_setting(None, var) {
Some(url) => normalize(&url).map_err(|error| AiMuxError::InvalidArgument {
message: format!("{var}: {error}"),
cause: Some(error.into()),
}),
None => Ok(default.to_string()),
})
}
19 changes: 19 additions & 0 deletions aimux-provider-utils/src/is_valid_hostname_part.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//! `isValidHostnamePart` (is-valid-hostname-part.ts).

/// Whether `part` is one ASCII DNS label: 1 to 63 letters, digits or hyphens,
/// not starting or ending with a hyphen.
///
/// Check a resource name, region or location with this before putting it into
/// a generated hostname: values such as `evil.example.com/#` or
/// `user@localhost:8080/#` would change the request destination.
#[must_use]
pub fn is_valid_hostname_part(part: &str) -> bool {
let bytes = part.as_bytes();
!bytes.is_empty()
&& bytes.len() <= 63
&& bytes
.iter()
.all(|b| b.is_ascii_alphanumeric() || *b == b'-')
&& bytes[0] != b'-'
&& bytes[bytes.len() - 1] != b'-'
}
4 changes: 3 additions & 1 deletion aimux-provider-utils/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub mod handle_fetch_error;
pub mod headers;
pub mod http;
pub mod inject_json_instruction;
pub mod is_valid_hostname_part;
pub mod logging;
pub mod map_reasoning_to_provider;
pub mod media_type;
Expand All @@ -50,7 +51,7 @@ pub mod url;
pub mod ws;

pub use aimux_core::download_guard::same_origin;
pub use api_key::{load_api_key, load_optional_setting, load_setting};
pub use api_key::{base_url_setting, load_api_key, load_optional_setting, load_setting};
pub use convert_image_model_file_to_data_uri::convert_image_model_file_to_data_uri;
pub use convert_inline_file_data_to_uint8_array::convert_inline_file_data_to_uint8_array;
pub use create_language_model_response_metadata::create_language_model_response_metadata;
Expand All @@ -69,6 +70,7 @@ pub use headers::{
};
pub use http::{ExchangeContext, HttpBody, HttpRequest, sleep_or_abort};
pub use inject_json_instruction::{inject_json_instruction, inject_json_instruction_into_messages};
pub use is_valid_hostname_part::is_valid_hostname_part;
pub use logging::{init_logging, redact_error_context};
pub use map_reasoning_to_provider::{
DEFAULT_MIN_REASONING_BUDGET, DEFAULT_REASONING_BUDGET_PERCENTAGES,
Expand Down
41 changes: 41 additions & 0 deletions aimux-provider-utils/src/tests/is_valid_hostname_part.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//! Rust translation of `packages/provider-utils/src/is-valid-hostname-part.test.ts`
//! (@ai-sdk/provider-utils). Each `it.each` table is one test that checks every
//! row.

use crate::is_valid_hostname_part;

/// upstream: @ai-sdk/provider-utils src/is-valid-hostname-part.test.ts › isValidHostnamePart › accepts %j
#[test]
fn accepts() {
let max = "a".repeat(63);
for value in ["a", "0", "us-east-1", "MY-resource", "global", max.as_str()] {
assert!(is_valid_hostname_part(value), "should accept {value:?}");
}
}

/// upstream: @ai-sdk/provider-utils src/is-valid-hostname-part.test.ts › isValidHostnamePart › rejects %j
#[test]
fn rejects() {
let too_long = "a".repeat(64);
for value in [
"",
too_long.as_str(),
"-a",
"a-",
"a.b",
"a_b",
"é",
"user@internal:8080/#",
"evil.example.com/#",
"169.254.169.254:80/x#",
"us-east-1/../..",
"us east 1",
"a\n",
"a\r",
"a\t",
"a\0",
"%61",
] {
assert!(!is_valid_hostname_part(value), "should reject {value:?}");
}
}
1 change: 1 addition & 0 deletions aimux-provider-utils/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod convert_image_model_file_to_data_uri;
mod convert_inline_file_data_to_uint8_array;
mod create_provider_stream_error;
mod create_tool_name_mapping;
mod is_valid_hostname_part;
mod map_reasoning_to_provider;
mod parse_provider_options;
mod resolve_provider_reference;
Expand Down
2 changes: 1 addition & 1 deletion aimux-providers/src/anthropic/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ impl std::fmt::Debug for AnthropicProviderSettings {
pub fn create_anthropic(
settings: AnthropicProviderSettings,
) -> Result<AnthropicProvider, AiMuxError> {
let base_url = crate::shared::base_url_setting(
let base_url = aimux_provider_utils::base_url_setting(
settings.base_url.as_deref(),
"ANTHROPIC_BASE_URL",
DEFAULT_BASE_URL,
Expand Down
6 changes: 2 additions & 4 deletions aimux-providers/src/anthropic_aws/fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,10 @@ use async_trait::async_trait;
use aimux_core::error::AiMuxError;
use aimux_provider_utils::{
AwsCredentials, Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse, HeaderMapOpt,
Resolvable, SigV4Fetch, SigV4Signing, combine_headers, default_fetch, load_optional_setting,
load_setting,
Resolvable, SigV4Fetch, SigV4Signing, combine_headers, default_fetch, is_valid_hostname_part,
load_optional_setting, load_setting,
};

use crate::shared::is_valid_hostname_part;

use super::AnthropicAwsAuth;

/// The SigV4 service name of Claude Platform on AWS.
Expand Down
4 changes: 2 additions & 2 deletions aimux-providers/src/bedrock/sigv4_fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ use super::provider::{
ACCESS_KEY_ID_ENV_VAR, BEARER_TOKEN_ENV_VAR, REGION_ENV_VAR, SECRET_ACCESS_KEY_ENV_VAR,
SESSION_TOKEN_ENV_VAR,
};
use crate::shared::is_valid_hostname_part;
use aimux_core::error::AiMuxError;
use aimux_provider_utils::{
AwsCredentials, Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse, Resolvable,
SigV4Fetch, default_fetch, load_optional_setting, load_setting, validate_base_url,
SigV4Fetch, default_fetch, is_valid_hostname_part, load_optional_setting, load_setting,
validate_base_url,
};
use async_trait::async_trait;

Expand Down
4 changes: 2 additions & 2 deletions aimux-providers/src/openai/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ use std::collections::HashMap;

use serde_json::Value;

use crate::shared::{ProviderHeaders, is_valid_hostname_part};
use crate::shared::ProviderHeaders;
use aimux_core::language_model::SupportedUrls;
use aimux_core::{AiMuxError, ErrorCause};
use aimux_provider_utils::{
ExchangeContext, FetchFunction, HeaderMapOpt, HttpRequest, Resolvable, combine_headers,
load_setting, normalize_headers, without_trailing_slash,
is_valid_hostname_part, load_setting, normalize_headers, without_trailing_slash,
};

use super::responses::ResponsesProfile;
Expand Down
2 changes: 1 addition & 1 deletion aimux-providers/src/openai/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ impl std::fmt::Debug for OpenAIProviderSettings {
/// loaded per request, and an invalid `OPENAI_BASE_URL` is reported by each
/// request, not here.
pub fn create_openai(settings: OpenAIProviderSettings) -> Result<OpenAIProvider, AiMuxError> {
let base_url = crate::shared::base_url_setting(
let base_url = aimux_provider_utils::base_url_setting(
settings.base_url.as_deref(),
"OPENAI_BASE_URL",
DEFAULT_BASE_URL,
Expand Down
42 changes: 0 additions & 42 deletions aimux-providers/src/shared/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,48 +30,6 @@ use aimux_provider_utils::{
with_user_agent_suffix,
};

/// A host part (a Vertex location, an Azure resource name) is one DNS label: letters, digits and hyphens, not starting or
/// ending with a hyphen (`isValidHostnamePart`).
pub(crate) fn is_valid_hostname_part(part: &str) -> bool {
let bytes = part.as_bytes();
!bytes.is_empty()
&& bytes.len() <= 63
&& bytes
.iter()
.all(|b| b.is_ascii_alphanumeric() || *b == b'-')
&& bytes[0] != b'-'
&& bytes[bytes.len() - 1] != b'-'
}

/// The base URL of a native package: the explicit setting, else the
/// environment variable `var` read when the provider is created (the AI SDK's
/// `loadOptionalSetting`), else `default`.
///
/// An invalid explicit value fails the factory (`Err` from the outer
/// result). An invalid environment value does not: the AI SDK does not
/// validate it when the provider is created and the first request fails, so
/// the inner `Err` is kept and reported by every request of that provider.
/// Creating a default provider therefore never fails on the environment.
pub(crate) fn base_url_setting(
explicit: Option<&str>,
var: &str,
default: &str,
normalize: impl Fn(&str) -> Result<String, AiMuxError>,
) -> Result<Result<String, AiMuxError>, AiMuxError> {
if let Some(url) = explicit {
return normalize(url).map(Ok);
}
Ok(
match aimux_provider_utils::load_optional_setting(None, var) {
Some(url) => normalize(&url).map_err(|error| AiMuxError::InvalidArgument {
message: format!("{var}: {error}"),
cause: Some(error.into()),
}),
None => Ok(default.to_string()),
},
)
}

/// A provider-level rewrite of every JSON request body, called once after the
/// body is serialized and before it is sent.
pub type TransformRequestBody = Arc<dyn Fn(Value) -> Value + Send + Sync>;
Expand Down
5 changes: 2 additions & 3 deletions aimux-providers/src/vertex/provider_base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ use crate::google::options::Namespace;
use crate::google::{GoogleModel, GoogleSpeechModel};
use crate::shared::{
Endpoint, EndpointConfig, ResolveEndpoint, SupportedUrlsSource, TransformRequestBody,
is_valid_hostname_part,
};
use aimux_core::embedding_model::EmbeddingModel;
use aimux_core::error::AiMuxError;
Expand All @@ -26,8 +25,8 @@ use aimux_core::speech_model::SpeechModel;
use aimux_core::transcription_model::TranscriptionModel;
use aimux_core::video_model::VideoModel;
use aimux_provider_utils::{
FetchFunction, HeaderMapOpt, Resolvable, combine_headers, load_setting, normalize_headers,
with_user_agent_suffix,
FetchFunction, HeaderMapOpt, Resolvable, combine_headers, is_valid_hostname_part, load_setting,
normalize_headers, with_user_agent_suffix,
};
use futures::future::BoxFuture;
use std::sync::{Arc, OnceLock};
Expand Down
3 changes: 2 additions & 1 deletion aimux-providers/src/vertex/transcription_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ use serde::Deserialize;
use serde_json::{Value, json};

use crate::google::options::Namespace;
use crate::shared::{EndpointConfig, is_valid_hostname_part};
use crate::shared::EndpointConfig;
use aimux_core::error::AiMuxError;
use aimux_core::shared::Warning;
use aimux_core::transcription_model::{
AudioInput, TranscriptionCallOptions, TranscriptionModel, TranscriptionRequest,
TranscriptionResponse, TranscriptionResult, TranscriptionSegment,
};
use aimux_provider_utils::is_valid_hostname_part;

use super::provider_base::ProjectLocationSource;

Expand Down
Loading