Skip to content

Commit 50119e8

Browse files
authored
Merge pull request #76 from Blankeos/oai-compat-fix
feat: fix API path for OpenAI-compatible providers
2 parents 065a898 + dcab9c8 commit 50119e8

25 files changed

Lines changed: 470 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ Changelog entries are grouped by type, with the following types:
2525
- Added embedding support to `OpenRouter` provider (delegates to OpenAI)
2626
- Added embedding support to `TogetherAI` provider (delegates to OpenAI)
2727
- Added embedding support to `Vercel` provider (delegates to OpenAI)
28+
- Added `openai_compatible` provider. (#76) by @Blankeos
29+
30+
### Changed
2831

2932
## [0.4.0] - 2026-01-24
3033

Cargo.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ keywords = ["ai", "sdk", "aisdk", "ai-sdk-rs", "openai"]
1515
categories = ["api-bindings", "asynchronous"]
1616

1717
[features]
18-
full = ["prompt", "openai", "openaichatcompletions", "anthropic", "groq", "vercel", "google", "openrouter", "deepseek", "amazon-bedrock", "togetherai", "xai"]
18+
full = ["prompt", "openai", "openaicompatible", "openaichatcompletions", "anthropic", "groq", "vercel", "google", "openrouter", "deepseek", "amazon-bedrock", "togetherai", "xai"]
1919
openai = []
20+
openaicompatible = ["openaichatcompletions"]
2021
openaichatcompletions = []
2122
anthropic = []
2223
groq = ["openaichatcompletions"]
@@ -70,6 +71,11 @@ name = "openai_tests"
7071
path = "tests/provider/openai_tests.rs"
7172
required-features = ["openai", "test-access"]
7273

74+
[[test]]
75+
name = "openai_compatible_tests"
76+
path = "tests/provider/openai_compatible_tests.rs"
77+
required-features = ["openaicompatible", "test-access"]
78+
7379
[[test]]
7480
name = "anthropic_tests"
7581
path = "tests/provider/anthropic_tests.rs"

src/core/client.rs

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! This module provides the client for interacting with the AI providers.
22
//! It is a thin wrapper around the `reqwest` crate.
33
4+
use crate::core::utils::join_url;
45
use crate::error::{Error, Result};
56
use futures::Stream;
67
use futures::StreamExt;
@@ -24,13 +25,7 @@ pub(crate) trait LanguageModelClient {
2425
async fn send(&self, base_url: impl IntoUrl) -> Result<Self::Response> {
2526
let client = reqwest::Client::new();
2627

27-
let base_url = base_url
28-
.into_url()
29-
.map_err(|_| Error::InvalidInput("Invalid base URL".into()))?;
30-
31-
let url = base_url
32-
.join(&self.path())
33-
.map_err(|_| Error::InvalidInput("Failed to join base URL and path".into()))?;
28+
let url = join_url(base_url, &self.path())?;
3429

3530
let max_retries = 5;
3631
let mut retry_count = 0;
@@ -95,13 +90,7 @@ pub(crate) trait LanguageModelClient {
9590
{
9691
let client = reqwest::Client::new();
9792

98-
let base_url = base_url
99-
.into_url()
100-
.map_err(|_| Error::InvalidInput("Invalid base URL".into()))?;
101-
102-
let url = base_url
103-
.join(&self.path())
104-
.map_err(|_| Error::InvalidInput("Failed to join base URL and path".into()))?;
93+
let url = join_url(base_url, &self.path())?;
10594

10695
// Establish the event source stream directly
10796
// Note: Status code errors (including 429) will be surfaced as stream events

src/core/utils.rs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
//! Helper functions for `aisdk`
22
3+
use reqwest::{IntoUrl, Url};
4+
35
use crate::{
4-
Error,
6+
Error, Result,
57
core::{Message, language_model::LanguageModelOptions, messages::TaggedMessage},
68
};
79

@@ -118,6 +120,38 @@ pub(crate) fn validate_base_url(s: &str) -> crate::error::Result<String> {
118120
Ok(url.to_string())
119121
}
120122

123+
/// Joins a base URL with a path, handling trailing/leading slashes automatically.
124+
///
125+
/// This function normalizes the URL components to ensure proper joining:
126+
/// - Strips trailing slashes from base_url
127+
/// - Strips leading slashes from path
128+
/// - Joins them with a single slash
129+
///
130+
/// # Examples
131+
///
132+
/// ```ignore
133+
/// // All of these produce "https://api.example.com/v1/chat/completions"
134+
/// join_url("https://api.example.com/v1", "chat/completions");
135+
/// join_url("https://api.example.com/v1/", "chat/completions");
136+
/// join_url("https://api.example.com/v1", "/chat/completions");
137+
/// join_url("https://api.example.com/v1/", "/chat/completions");
138+
/// ```
139+
pub(crate) fn join_url(base_url: impl IntoUrl, path: &str) -> Result<Url> {
140+
let base_url = base_url
141+
.into_url()
142+
.map_err(|_| Error::InvalidInput("Invalid base URL".into()))?;
143+
144+
// Normalize: strip trailing slashes from base, strip leading slashes from path
145+
let base_str = base_url.as_str().trim_end_matches('/');
146+
let path_str = path.trim_start_matches('/');
147+
148+
// Join with a single slash
149+
let full_url = format!("{}/{}", base_str, path_str);
150+
151+
Url::parse(&full_url)
152+
.map_err(|_| Error::InvalidInput("Failed to join base URL and path".into()))
153+
}
154+
121155
#[cfg(test)]
122156
mod tests {
123157
use super::*;
@@ -145,4 +179,19 @@ mod tests {
145179
fn test_sum_options_both_none() {
146180
assert_eq!(sum_options(None, None), None);
147181
}
182+
183+
#[test]
184+
fn test_join_url() {
185+
let url = join_url("https://api.example.com/v1", "chat/completions").unwrap();
186+
assert_eq!(url.as_str(), "https://api.example.com/v1/chat/completions");
187+
188+
let url = join_url("https://api.example.com/v1/", "chat/completions").unwrap();
189+
assert_eq!(url.as_str(), "https://api.example.com/v1/chat/completions");
190+
191+
let url = join_url("https://api.example.com/v1", "/chat/completions").unwrap();
192+
assert_eq!(url.as_str(), "https://api.example.com/v1/chat/completions");
193+
194+
let url = join_url("https://api.example.com/v1/", "/chat/completions").unwrap();
195+
assert_eq!(url.as_str(), "https://api.example.com/v1/chat/completions");
196+
}
148197
}

src/providers/amazon_bedrock/settings.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ impl Default for AmazonBedrockProviderSettings {
2222
fn default() -> Self {
2323
Self {
2424
provider_name: "AmazonBedrock".to_string(),
25-
base_url: "https://bedrock-runtime.us-east-1.amazonaws.com/openai/".to_string(),
25+
base_url: "https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1".to_string(),
2626
api_key: std::env::var("BEDROCK_API_KEY").unwrap_or_default(),
2727
}
2828
}

src/providers/deepseek/settings.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ impl Default for DeepSeekProviderSettings {
2121
fn default() -> Self {
2222
Self {
2323
provider_name: "DeepSeek".to_string(),
24-
base_url: "https://api.deepseek.com/v1/".to_string(),
24+
base_url: "https://api.deepseek.com/v1".to_string(),
2525
api_key: std::env::var("DEEPSEEK_API_KEY").unwrap_or_default(),
2626
}
2727
}

src/providers/groq/settings.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ impl Default for GroqProviderSettings {
2121
fn default() -> Self {
2222
Self {
2323
provider_name: "Groq".to_string(),
24-
base_url: "https://api.groq.com/openai/".to_string(),
24+
base_url: "https://api.groq.com/openai/v1".to_string(),
2525
api_key: std::env::var("GROQ_API_KEY").unwrap_or_default(),
2626
}
2727
}

src/providers/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ pub mod openai;
66
#[cfg(feature = "openai")]
77
pub use openai::OpenAI;
88

9+
// Public OpenAI-compatible provider
10+
#[cfg(feature = "openaicompatible")]
11+
pub mod openai_compatible;
12+
#[cfg(feature = "openaicompatible")]
13+
pub use openai_compatible::OpenAICompatible;
14+
915
#[cfg(feature = "anthropic")]
1016
pub mod anthropic;
1117
#[cfg(feature = "anthropic")]

src/providers/openai_chat_completions/client/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ impl<M: ModelName> LanguageModelClient for OpenAIChatCompletions<M> {
1717
type StreamEvent = ChatCompletionsStreamEvent;
1818

1919
fn path(&self) -> String {
20-
"v1/chat/completions".to_string()
20+
"chat/completions".to_string()
2121
}
2222

2323
fn method(&self) -> reqwest::Method {

src/providers/openai_chat_completions/client/types.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,10 @@ pub(crate) struct Delta {
294294
#[serde(skip_serializing_if = "Option::is_none")]
295295
pub content: Option<String>,
296296

297+
/// Reasoning content for reasoning models (e.g., OpenAI o1, DeepSeek R1)
298+
#[serde(skip_serializing_if = "Option::is_none")]
299+
pub reasoning_content: Option<String>,
300+
297301
#[serde(skip_serializing_if = "Option::is_none")]
298302
pub tool_calls: Option<Vec<DeltaToolCall>>,
299303
}

0 commit comments

Comments
 (0)