liter_llm 1.0.0.pre.rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +239 -0
- data/ext/liter_llm_rb/extconf.rb +65 -0
- data/ext/liter_llm_rb/native/.cargo/config.toml +23 -0
- data/ext/liter_llm_rb/native/Cargo.lock +3713 -0
- data/ext/liter_llm_rb/native/Cargo.toml +32 -0
- data/ext/liter_llm_rb/native/build.rs +15 -0
- data/ext/liter_llm_rb/native/src/lib.rs +1079 -0
- data/lib/liter_llm.rb +8 -0
- data/sig/liter_llm.rbs +416 -0
- data/vendor/Cargo.toml +54 -0
- data/vendor/liter-llm/Cargo.toml +92 -0
- data/vendor/liter-llm/README.md +252 -0
- data/vendor/liter-llm/schemas/pricing.json +40 -0
- data/vendor/liter-llm/schemas/providers.json +1662 -0
- data/vendor/liter-llm/src/auth/azure_ad.rs +264 -0
- data/vendor/liter-llm/src/auth/bedrock_sts.rs +353 -0
- data/vendor/liter-llm/src/auth/mod.rs +68 -0
- data/vendor/liter-llm/src/auth/vertex_oauth.rs +353 -0
- data/vendor/liter-llm/src/client/config.rs +351 -0
- data/vendor/liter-llm/src/client/managed.rs +622 -0
- data/vendor/liter-llm/src/client/mod.rs +864 -0
- data/vendor/liter-llm/src/cost.rs +212 -0
- data/vendor/liter-llm/src/error.rs +190 -0
- data/vendor/liter-llm/src/http/eventstream.rs +860 -0
- data/vendor/liter-llm/src/http/mod.rs +12 -0
- data/vendor/liter-llm/src/http/request.rs +438 -0
- data/vendor/liter-llm/src/http/retry.rs +72 -0
- data/vendor/liter-llm/src/http/streaming.rs +289 -0
- data/vendor/liter-llm/src/lib.rs +37 -0
- data/vendor/liter-llm/src/provider/anthropic.rs +2250 -0
- data/vendor/liter-llm/src/provider/azure.rs +579 -0
- data/vendor/liter-llm/src/provider/bedrock.rs +1543 -0
- data/vendor/liter-llm/src/provider/cohere.rs +654 -0
- data/vendor/liter-llm/src/provider/custom.rs +404 -0
- data/vendor/liter-llm/src/provider/google_ai.rs +281 -0
- data/vendor/liter-llm/src/provider/mistral.rs +188 -0
- data/vendor/liter-llm/src/provider/mod.rs +616 -0
- data/vendor/liter-llm/src/provider/vertex.rs +1504 -0
- data/vendor/liter-llm/src/tests.rs +1425 -0
- data/vendor/liter-llm/src/tokenizer.rs +281 -0
- data/vendor/liter-llm/src/tower/budget.rs +599 -0
- data/vendor/liter-llm/src/tower/cache.rs +502 -0
- data/vendor/liter-llm/src/tower/cache_opendal.rs +270 -0
- data/vendor/liter-llm/src/tower/cooldown.rs +231 -0
- data/vendor/liter-llm/src/tower/cost.rs +404 -0
- data/vendor/liter-llm/src/tower/fallback.rs +121 -0
- data/vendor/liter-llm/src/tower/health.rs +219 -0
- data/vendor/liter-llm/src/tower/hooks.rs +369 -0
- data/vendor/liter-llm/src/tower/mod.rs +77 -0
- data/vendor/liter-llm/src/tower/rate_limit.rs +300 -0
- data/vendor/liter-llm/src/tower/router.rs +436 -0
- data/vendor/liter-llm/src/tower/service.rs +181 -0
- data/vendor/liter-llm/src/tower/tests.rs +539 -0
- data/vendor/liter-llm/src/tower/tests_common.rs +252 -0
- data/vendor/liter-llm/src/tower/tracing.rs +209 -0
- data/vendor/liter-llm/src/tower/types.rs +170 -0
- data/vendor/liter-llm/src/types/audio.rs +52 -0
- data/vendor/liter-llm/src/types/batch.rs +77 -0
- data/vendor/liter-llm/src/types/chat.rs +214 -0
- data/vendor/liter-llm/src/types/common.rs +244 -0
- data/vendor/liter-llm/src/types/embedding.rs +84 -0
- data/vendor/liter-llm/src/types/files.rs +58 -0
- data/vendor/liter-llm/src/types/image.rs +40 -0
- data/vendor/liter-llm/src/types/mod.rs +27 -0
- data/vendor/liter-llm/src/types/models.rs +21 -0
- data/vendor/liter-llm/src/types/moderation.rs +80 -0
- data/vendor/liter-llm/src/types/ocr.rs +87 -0
- data/vendor/liter-llm/src/types/rerank.rs +46 -0
- data/vendor/liter-llm/src/types/responses.rs +55 -0
- data/vendor/liter-llm/src/types/search.rs +45 -0
- data/vendor/liter-llm/tests/contract.rs +332 -0
- data/vendor/liter-llm-ffi/Cargo.toml +30 -0
- data/vendor/liter-llm-ffi/build.rs +66 -0
- data/vendor/liter-llm-ffi/cbindgen.toml +60 -0
- data/vendor/liter-llm-ffi/liter_llm.h +850 -0
- data/vendor/liter-llm-ffi/src/lib.rs +2488 -0
- metadata +286 -0
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
use std::borrow::Cow;
|
|
2
|
+
|
|
3
|
+
use crate::error::{LiterLlmError, Result};
|
|
4
|
+
use crate::provider::Provider;
|
|
5
|
+
|
|
6
|
+
/// Azure OpenAI provider.
|
|
7
|
+
///
|
|
8
|
+
/// Differences from the OpenAI-compatible baseline:
|
|
9
|
+
/// - Auth uses `api-key` instead of `Authorization: Bearer`.
|
|
10
|
+
/// - The base URL is **required** and must be supplied via the
|
|
11
|
+
/// `AZURE_OPENAI_ENDPOINT` environment variable (or `AZURE_ENDPOINT`), in the
|
|
12
|
+
/// format `https://{resource}.openai.azure.com`. Azure has no single shared
|
|
13
|
+
/// endpoint — each customer has a unique resource URL. Failing to supply
|
|
14
|
+
/// `base_url` will produce a clear [`LiterLlmError::BadRequest`] at
|
|
15
|
+
/// construction time via [`AzureProvider::validate`].
|
|
16
|
+
/// - The URL embeds the deployment name rather than sending it in the request
|
|
17
|
+
/// body; see [`AzureProvider::build_url`].
|
|
18
|
+
/// - The API version is configurable via `AZURE_API_VERSION` (default:
|
|
19
|
+
/// `2025-02-01-preview`).
|
|
20
|
+
///
|
|
21
|
+
/// # URL Format
|
|
22
|
+
///
|
|
23
|
+
/// ```text
|
|
24
|
+
/// {base_url}/openai/deployments/{deployment}{endpoint_path}?api-version={api_version}
|
|
25
|
+
/// ```
|
|
26
|
+
///
|
|
27
|
+
/// # Configuration
|
|
28
|
+
///
|
|
29
|
+
/// ```rust,ignore
|
|
30
|
+
/// // Set environment variables before constructing the client:
|
|
31
|
+
/// // AZURE_OPENAI_ENDPOINT=https://my-resource.openai.azure.com
|
|
32
|
+
/// // AZURE_API_VERSION=2024-10-21 (optional)
|
|
33
|
+
/// let config = ClientConfigBuilder::new("your-azure-api-key").build();
|
|
34
|
+
/// let client = DefaultClient::new(config, Some("azure/gpt-4"))?;
|
|
35
|
+
/// ```
|
|
36
|
+
pub struct AzureProvider {
|
|
37
|
+
/// Customer-specific resource URL, e.g. `https://my-resource.openai.azure.com`.
|
|
38
|
+
///
|
|
39
|
+
/// Empty string when no environment variable is set; `validate()` surfaces
|
|
40
|
+
/// this as a [`LiterLlmError::BadRequest`] before any request is attempted.
|
|
41
|
+
base_url: String,
|
|
42
|
+
/// Azure REST API version query parameter, e.g. `2024-10-21`.
|
|
43
|
+
api_version: String,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
impl AzureProvider {
|
|
47
|
+
/// Construct an [`AzureProvider`], reading configuration from environment
|
|
48
|
+
/// variables.
|
|
49
|
+
///
|
|
50
|
+
/// - `AZURE_OPENAI_ENDPOINT` (or `AZURE_ENDPOINT` as a fallback): the
|
|
51
|
+
/// customer resource URL in the form `https://{resource}.openai.azure.com`.
|
|
52
|
+
/// Trailing slashes are stripped.
|
|
53
|
+
/// - `AZURE_API_VERSION`: optional API version string (default:
|
|
54
|
+
/// `2025-02-01-preview`).
|
|
55
|
+
#[must_use]
|
|
56
|
+
pub fn new() -> Self {
|
|
57
|
+
let base_url = std::env::var("AZURE_OPENAI_ENDPOINT")
|
|
58
|
+
.or_else(|_| std::env::var("AZURE_ENDPOINT"))
|
|
59
|
+
.unwrap_or_default()
|
|
60
|
+
.trim_end_matches('/')
|
|
61
|
+
.to_owned();
|
|
62
|
+
|
|
63
|
+
let api_version = std::env::var("AZURE_API_VERSION").unwrap_or_else(|_| "2025-02-01-preview".to_owned());
|
|
64
|
+
|
|
65
|
+
Self { base_url, api_version }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
impl Default for AzureProvider {
|
|
70
|
+
fn default() -> Self {
|
|
71
|
+
Self::new()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
impl Provider for AzureProvider {
|
|
76
|
+
fn name(&self) -> &str {
|
|
77
|
+
"azure"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// Returns the customer resource base URL (empty string when unconfigured).
|
|
81
|
+
///
|
|
82
|
+
/// An empty return value causes [`AzureProvider::validate`] to fail at
|
|
83
|
+
/// construction time with a descriptive error, so the HTTP layer never
|
|
84
|
+
/// receives a malformed URL.
|
|
85
|
+
fn base_url(&self) -> &str {
|
|
86
|
+
&self.base_url
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
|
|
90
|
+
// Azure uses `api-key`, not `Authorization: Bearer`.
|
|
91
|
+
Some((Cow::Borrowed("api-key"), Cow::Borrowed(api_key)))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
fn matches_model(&self, model: &str) -> bool {
|
|
95
|
+
model.starts_with("azure/")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
|
|
99
|
+
model.strip_prefix("azure/").unwrap_or(model)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// Validate that a base URL is present.
|
|
103
|
+
///
|
|
104
|
+
/// Azure requires a customer-specific resource URL. This check runs at
|
|
105
|
+
/// [`DefaultClient::new`] time, surfacing misconfiguration immediately
|
|
106
|
+
/// rather than on the first request — covering `list_models` as well.
|
|
107
|
+
fn validate(&self) -> Result<()> {
|
|
108
|
+
if self.base_url.is_empty() {
|
|
109
|
+
return Err(LiterLlmError::BadRequest {
|
|
110
|
+
message: "Azure OpenAI requires a base URL. \
|
|
111
|
+
Set AZURE_OPENAI_ENDPOINT=https://{resource}.openai.azure.com \
|
|
112
|
+
(or AZURE_ENDPOINT as a fallback)."
|
|
113
|
+
.into(),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
Ok(())
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// Build the Azure deployment URL.
|
|
120
|
+
///
|
|
121
|
+
/// Azure embeds the deployment name in the URL rather than the request body:
|
|
122
|
+
///
|
|
123
|
+
/// ```text
|
|
124
|
+
/// {base_url}/openai/deployments/{deployment}{endpoint_path}?api-version={api_version}
|
|
125
|
+
/// ```
|
|
126
|
+
///
|
|
127
|
+
/// When `base_url` is empty (misconfigured), returns a clearly-broken URL
|
|
128
|
+
/// that will fail at the HTTP layer; `validate()` normally catches this
|
|
129
|
+
/// before any request is fired.
|
|
130
|
+
fn build_url(&self, endpoint_path: &str, model: &str) -> String {
|
|
131
|
+
if self.base_url.is_empty() {
|
|
132
|
+
// validate() should have caught this; return a broken URL so the
|
|
133
|
+
// HTTP layer surfaces a clear connection error rather than silently
|
|
134
|
+
// hitting the wrong endpoint.
|
|
135
|
+
return endpoint_path.to_owned();
|
|
136
|
+
}
|
|
137
|
+
// If the base URL already contains the deployments path (e.g. it was
|
|
138
|
+
// supplied pre-formatted), avoid duplicating it.
|
|
139
|
+
if self.base_url.contains("/openai/deployments/") {
|
|
140
|
+
return format!("{}{}?api-version={}", self.base_url, endpoint_path, self.api_version);
|
|
141
|
+
}
|
|
142
|
+
format!(
|
|
143
|
+
"{}/openai/deployments/{}{}?api-version={}",
|
|
144
|
+
self.base_url, model, endpoint_path, self.api_version
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// Transform the request body for Azure OpenAI.
|
|
149
|
+
///
|
|
150
|
+
/// - Removes `model` from the body (Azure routes via URL deployment name).
|
|
151
|
+
/// - Handles O-series models (o1, o3, o4): removes `temperature`, `top_p`,
|
|
152
|
+
/// and `stream` (for o1) since Azure rejects them for reasoning models.
|
|
153
|
+
/// - Maps `reasoning_effort` for O-series models.
|
|
154
|
+
///
|
|
155
|
+
/// [`build_url`]: AzureProvider::build_url
|
|
156
|
+
fn transform_request(&self, body: &mut serde_json::Value) -> Result<()> {
|
|
157
|
+
if let Some(obj) = body.as_object_mut() {
|
|
158
|
+
// Capture the model name before removing it for O-series detection.
|
|
159
|
+
let model_name = obj.get("model").and_then(|m| m.as_str()).unwrap_or("").to_owned();
|
|
160
|
+
|
|
161
|
+
obj.remove("model");
|
|
162
|
+
|
|
163
|
+
// O-series model handling (o1, o3, o4).
|
|
164
|
+
if is_o_series_model(&model_name) {
|
|
165
|
+
// Azure rejects temperature and top_p for O-series reasoning models.
|
|
166
|
+
obj.remove("temperature");
|
|
167
|
+
obj.remove("top_p");
|
|
168
|
+
|
|
169
|
+
// o1 models do not support streaming in some Azure API versions.
|
|
170
|
+
if model_name == "o1" || model_name.starts_with("o1-") || model_name.starts_with("o1.") {
|
|
171
|
+
obj.remove("stream");
|
|
172
|
+
obj.remove("stream_options");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
Ok(())
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/// Transform the Azure response.
|
|
180
|
+
///
|
|
181
|
+
/// Azure responses are OpenAI-compatible. When content filtering is
|
|
182
|
+
/// triggered, the response includes `content_filter_results` in choices
|
|
183
|
+
/// and `finish_reason: "content_filter"`. This maps correctly to the
|
|
184
|
+
/// canonical [`FinishReason::ContentFilter`] variant already, so no
|
|
185
|
+
/// transformation is needed for normal responses.
|
|
186
|
+
///
|
|
187
|
+
/// For blocked responses where the choice has no `message` content but
|
|
188
|
+
/// does have `content_filter_results`, we ensure the response still has
|
|
189
|
+
/// a valid structure.
|
|
190
|
+
fn transform_response(&self, body: &mut serde_json::Value) -> Result<()> {
|
|
191
|
+
// Azure content filtering: check each choice for filter results.
|
|
192
|
+
if let Some(choices) = body.pointer("/choices").and_then(|c| c.as_array()) {
|
|
193
|
+
for choice in choices {
|
|
194
|
+
if let Some(filter_results) = choice.get("content_filter_results") {
|
|
195
|
+
// If any filter category has `filtered: true` and finish_reason
|
|
196
|
+
// is already "content_filter", the response maps correctly.
|
|
197
|
+
// Check for a missing message on blocked responses.
|
|
198
|
+
let is_filtered = choice.get("finish_reason").and_then(|fr| fr.as_str()) == Some("content_filter");
|
|
199
|
+
|
|
200
|
+
if is_filtered && choice.get("message").is_none() {
|
|
201
|
+
// Inject a minimal message so downstream deserialization
|
|
202
|
+
// does not fail on a missing `message` field.
|
|
203
|
+
if let Some(choices_arr) = body.get_mut("choices").and_then(|c| c.as_array_mut())
|
|
204
|
+
&& let Some(choice_obj) = choices_arr.first_mut().and_then(|c| c.as_object_mut())
|
|
205
|
+
{
|
|
206
|
+
choice_obj.insert(
|
|
207
|
+
"message".to_owned(),
|
|
208
|
+
serde_json::json!({
|
|
209
|
+
"role": "assistant",
|
|
210
|
+
"content": null,
|
|
211
|
+
"refusal": "Content filtered by Azure content safety."
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Preserve filter_results metadata: Azure already includes it
|
|
219
|
+
// in the response and callers can inspect it via raw JSON.
|
|
220
|
+
let _ = filter_results;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
Ok(())
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/// Return `true` when the model name looks like an O-series reasoning model.
|
|
229
|
+
///
|
|
230
|
+
/// Matches: `o1`, `o1-preview`, `o1-mini`, `o3`, `o3-mini`, `o4`, `o4-mini`,
|
|
231
|
+
/// and any variant with a dot suffix (e.g. `o3.5`).
|
|
232
|
+
fn is_o_series_model(model: &str) -> bool {
|
|
233
|
+
// Match "o1", "o3", "o4" exactly or with a separator (-, .)
|
|
234
|
+
for prefix in &["o1", "o3", "o4"] {
|
|
235
|
+
if model == *prefix {
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
if let Some(rest) = model.strip_prefix(prefix)
|
|
239
|
+
&& (rest.starts_with('-') || rest.starts_with('.'))
|
|
240
|
+
{
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
false
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Unit tests ───────────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
#[cfg(test)]
|
|
250
|
+
mod tests {
|
|
251
|
+
use serde_json::json;
|
|
252
|
+
|
|
253
|
+
use super::*;
|
|
254
|
+
|
|
255
|
+
/// Construct a provider with an explicit base URL and api version, bypassing
|
|
256
|
+
/// env-var reading. Use this in tests to avoid clobbering real env state.
|
|
257
|
+
fn make_provider(base_url: &str, api_version: &str) -> AzureProvider {
|
|
258
|
+
AzureProvider {
|
|
259
|
+
base_url: base_url.to_owned(),
|
|
260
|
+
api_version: api_version.to_owned(),
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#[test]
|
|
265
|
+
fn build_url_embeds_deployment_name() {
|
|
266
|
+
let provider = make_provider("https://myresource.openai.azure.com", "2024-10-21");
|
|
267
|
+
let url = provider.build_url("/chat/completions", "gpt-4");
|
|
268
|
+
assert_eq!(
|
|
269
|
+
url,
|
|
270
|
+
"https://myresource.openai.azure.com/openai/deployments/gpt-4/chat/completions?api-version=2024-10-21"
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#[test]
|
|
275
|
+
fn build_url_includes_api_version_query_param() {
|
|
276
|
+
let provider = make_provider("https://example.openai.azure.com", "2025-01-01");
|
|
277
|
+
let url = provider.build_url("/chat/completions", "gpt-4o");
|
|
278
|
+
assert!(url.contains("?api-version=2025-01-01"), "url = {url}");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
#[test]
|
|
282
|
+
fn build_url_embeddings_endpoint() {
|
|
283
|
+
let provider = make_provider("https://myresource.openai.azure.com", "2024-10-21");
|
|
284
|
+
let url = provider.build_url("/embeddings", "text-embedding-3-large");
|
|
285
|
+
assert_eq!(
|
|
286
|
+
url,
|
|
287
|
+
"https://myresource.openai.azure.com/openai/deployments/text-embedding-3-large/embeddings?api-version=2024-10-21"
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#[test]
|
|
292
|
+
fn build_url_with_trailing_slash_stripped() {
|
|
293
|
+
// Simulate construction with a pre-stripped base_url (new() handles this).
|
|
294
|
+
let provider = make_provider("https://myresource.openai.azure.com", "2024-10-21");
|
|
295
|
+
let url = provider.build_url("/chat/completions", "gpt-4");
|
|
296
|
+
// Should not contain double slashes.
|
|
297
|
+
assert!(!url.contains("//openai"), "double slash in url: {url}");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#[test]
|
|
301
|
+
fn build_url_already_contains_deployments_path() {
|
|
302
|
+
// When base_url already contains /openai/deployments/{name}, do not
|
|
303
|
+
// insert the path fragment a second time.
|
|
304
|
+
let provider = make_provider(
|
|
305
|
+
"https://myresource.openai.azure.com/openai/deployments/gpt-4",
|
|
306
|
+
"2025-02-01-preview",
|
|
307
|
+
);
|
|
308
|
+
let url = provider.build_url("/chat/completions", "gpt-4");
|
|
309
|
+
assert!(
|
|
310
|
+
!url.contains("deployments/gpt-4/openai/deployments"),
|
|
311
|
+
"deployment path must not be doubled: {url}"
|
|
312
|
+
);
|
|
313
|
+
assert!(
|
|
314
|
+
url.contains("/openai/deployments/gpt-4/chat/completions"),
|
|
315
|
+
"url should contain the deployment path: {url}"
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#[test]
|
|
320
|
+
fn build_url_empty_base_returns_fallback() {
|
|
321
|
+
let provider = make_provider("", "2024-10-21");
|
|
322
|
+
let url = provider.build_url("/chat/completions", "gpt-4");
|
|
323
|
+
// Falls back to just the endpoint path — clearly broken, not a valid URL.
|
|
324
|
+
assert_eq!(url, "/chat/completions");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
#[test]
|
|
328
|
+
fn transform_request_removes_model_field() {
|
|
329
|
+
let provider = make_provider("https://myresource.openai.azure.com", "2024-10-21");
|
|
330
|
+
let mut body = json!({
|
|
331
|
+
"model": "gpt-4",
|
|
332
|
+
"messages": [{"role": "user", "content": "hello"}],
|
|
333
|
+
"temperature": 0.7
|
|
334
|
+
});
|
|
335
|
+
provider.transform_request(&mut body).expect("transform should succeed");
|
|
336
|
+
assert!(body.get("model").is_none(), "model should be removed from body");
|
|
337
|
+
// Other fields must be preserved.
|
|
338
|
+
assert!(body.get("messages").is_some());
|
|
339
|
+
assert!(body.get("temperature").is_some());
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
#[test]
|
|
343
|
+
fn transform_request_non_object_body_is_noop() {
|
|
344
|
+
let provider = make_provider("https://myresource.openai.azure.com", "2024-10-21");
|
|
345
|
+
let mut body = json!("not an object");
|
|
346
|
+
// Must not panic or return an error.
|
|
347
|
+
assert!(provider.transform_request(&mut body).is_ok());
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
#[test]
|
|
351
|
+
fn validate_fails_when_base_url_is_empty() {
|
|
352
|
+
let provider = make_provider("", "2024-10-21");
|
|
353
|
+
let err = provider.validate().expect_err("should fail with empty base_url");
|
|
354
|
+
let msg = err.to_string();
|
|
355
|
+
assert!(
|
|
356
|
+
msg.contains("Azure OpenAI"),
|
|
357
|
+
"error message should mention Azure: {msg}"
|
|
358
|
+
);
|
|
359
|
+
assert!(
|
|
360
|
+
msg.contains("AZURE_OPENAI_ENDPOINT"),
|
|
361
|
+
"error message should mention env var: {msg}"
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
#[test]
|
|
366
|
+
fn validate_succeeds_when_base_url_is_set() {
|
|
367
|
+
let provider = make_provider("https://myresource.openai.azure.com", "2024-10-21");
|
|
368
|
+
assert!(provider.validate().is_ok());
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
#[test]
|
|
372
|
+
fn explicit_base_url_and_api_version_are_stored() {
|
|
373
|
+
// Test the constructor's field assignment directly, bypassing env vars
|
|
374
|
+
// to avoid thread-unsafe env mutation in parallel test runs.
|
|
375
|
+
let provider = make_provider("https://test.openai.azure.com", "2099-01-01");
|
|
376
|
+
assert_eq!(provider.base_url, "https://test.openai.azure.com");
|
|
377
|
+
assert_eq!(provider.api_version, "2099-01-01");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
#[test]
|
|
381
|
+
fn default_api_version_is_preview() {
|
|
382
|
+
// Verify the default api_version matches what `new()` would set when
|
|
383
|
+
// the AZURE_API_VERSION env var is absent.
|
|
384
|
+
let provider = make_provider("https://test.openai.azure.com", "2025-02-01-preview");
|
|
385
|
+
assert_eq!(provider.api_version, "2025-02-01-preview");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
#[test]
|
|
389
|
+
fn strip_model_prefix_removes_azure_prefix() {
|
|
390
|
+
let provider = make_provider("https://myresource.openai.azure.com", "2024-10-21");
|
|
391
|
+
assert_eq!(provider.strip_model_prefix("azure/gpt-4"), "gpt-4");
|
|
392
|
+
assert_eq!(provider.strip_model_prefix("gpt-4"), "gpt-4");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
#[test]
|
|
396
|
+
fn matches_model_only_for_azure_prefix() {
|
|
397
|
+
let provider = make_provider("https://myresource.openai.azure.com", "2024-10-21");
|
|
398
|
+
assert!(provider.matches_model("azure/gpt-4"));
|
|
399
|
+
assert!(provider.matches_model("azure/gpt-4o-mini"));
|
|
400
|
+
assert!(!provider.matches_model("gpt-4"));
|
|
401
|
+
assert!(!provider.matches_model("openai/gpt-4"));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
#[test]
|
|
405
|
+
fn auth_header_uses_api_key_scheme() {
|
|
406
|
+
let provider = make_provider("https://myresource.openai.azure.com", "2024-10-21");
|
|
407
|
+
let (name, _value) = provider.auth_header("test-key").expect("should return Some");
|
|
408
|
+
assert_eq!(name.as_ref(), "api-key");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── O-series model handling ──────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
#[test]
|
|
414
|
+
fn is_o_series_model_detection() {
|
|
415
|
+
assert!(super::is_o_series_model("o1"));
|
|
416
|
+
assert!(super::is_o_series_model("o1-preview"));
|
|
417
|
+
assert!(super::is_o_series_model("o1-mini"));
|
|
418
|
+
assert!(super::is_o_series_model("o3"));
|
|
419
|
+
assert!(super::is_o_series_model("o3-mini"));
|
|
420
|
+
assert!(super::is_o_series_model("o3.5"));
|
|
421
|
+
assert!(super::is_o_series_model("o4"));
|
|
422
|
+
assert!(super::is_o_series_model("o4-mini"));
|
|
423
|
+
|
|
424
|
+
assert!(!super::is_o_series_model("gpt-4"));
|
|
425
|
+
assert!(!super::is_o_series_model("gpt-4o"));
|
|
426
|
+
assert!(!super::is_o_series_model("o2"));
|
|
427
|
+
assert!(!super::is_o_series_model("opt-1"));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
#[test]
|
|
431
|
+
fn transform_request_o_series_removes_temperature_and_top_p() {
|
|
432
|
+
let provider = make_provider("https://myresource.openai.azure.com", "2024-10-21");
|
|
433
|
+
let mut body = json!({
|
|
434
|
+
"model": "o3-mini",
|
|
435
|
+
"messages": [{"role": "user", "content": "hello"}],
|
|
436
|
+
"temperature": 0.7,
|
|
437
|
+
"top_p": 0.9,
|
|
438
|
+
"reasoning_effort": "high"
|
|
439
|
+
});
|
|
440
|
+
provider.transform_request(&mut body).expect("transform should succeed");
|
|
441
|
+
|
|
442
|
+
// model removed (standard Azure behavior)
|
|
443
|
+
assert!(body.get("model").is_none());
|
|
444
|
+
// temperature and top_p removed for O-series
|
|
445
|
+
assert!(
|
|
446
|
+
body.get("temperature").is_none(),
|
|
447
|
+
"temperature should be removed for O-series"
|
|
448
|
+
);
|
|
449
|
+
assert!(body.get("top_p").is_none(), "top_p should be removed for O-series");
|
|
450
|
+
// reasoning_effort preserved
|
|
451
|
+
assert_eq!(body.get("reasoning_effort").unwrap(), "high");
|
|
452
|
+
// messages preserved
|
|
453
|
+
assert!(body.get("messages").is_some());
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
#[test]
|
|
457
|
+
fn transform_request_o1_removes_stream() {
|
|
458
|
+
let provider = make_provider("https://myresource.openai.azure.com", "2024-10-21");
|
|
459
|
+
let mut body = json!({
|
|
460
|
+
"model": "o1-preview",
|
|
461
|
+
"messages": [{"role": "user", "content": "hello"}],
|
|
462
|
+
"stream": true,
|
|
463
|
+
"stream_options": {"include_usage": true},
|
|
464
|
+
"temperature": 0.5
|
|
465
|
+
});
|
|
466
|
+
provider.transform_request(&mut body).expect("transform should succeed");
|
|
467
|
+
|
|
468
|
+
assert!(body.get("stream").is_none(), "stream should be removed for o1");
|
|
469
|
+
assert!(
|
|
470
|
+
body.get("stream_options").is_none(),
|
|
471
|
+
"stream_options should be removed for o1"
|
|
472
|
+
);
|
|
473
|
+
assert!(
|
|
474
|
+
body.get("temperature").is_none(),
|
|
475
|
+
"temperature should be removed for O-series"
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
#[test]
|
|
480
|
+
fn transform_request_o3_keeps_stream() {
|
|
481
|
+
let provider = make_provider("https://myresource.openai.azure.com", "2024-10-21");
|
|
482
|
+
let mut body = json!({
|
|
483
|
+
"model": "o3-mini",
|
|
484
|
+
"messages": [{"role": "user", "content": "hello"}],
|
|
485
|
+
"stream": true
|
|
486
|
+
});
|
|
487
|
+
provider.transform_request(&mut body).expect("transform should succeed");
|
|
488
|
+
|
|
489
|
+
// o3 supports streaming, stream should be kept
|
|
490
|
+
assert!(body.get("stream").is_some(), "stream should remain for o3");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
#[test]
|
|
494
|
+
fn transform_request_non_o_series_keeps_all_params() {
|
|
495
|
+
let provider = make_provider("https://myresource.openai.azure.com", "2024-10-21");
|
|
496
|
+
let mut body = json!({
|
|
497
|
+
"model": "gpt-4",
|
|
498
|
+
"messages": [{"role": "user", "content": "hello"}],
|
|
499
|
+
"temperature": 0.7,
|
|
500
|
+
"top_p": 0.9,
|
|
501
|
+
"stream": true
|
|
502
|
+
});
|
|
503
|
+
provider.transform_request(&mut body).expect("transform should succeed");
|
|
504
|
+
|
|
505
|
+
assert!(body.get("model").is_none(), "model should be removed");
|
|
506
|
+
assert!(
|
|
507
|
+
body.get("temperature").is_some(),
|
|
508
|
+
"temperature should be kept for non-O-series"
|
|
509
|
+
);
|
|
510
|
+
assert!(body.get("top_p").is_some(), "top_p should be kept for non-O-series");
|
|
511
|
+
assert!(body.get("stream").is_some(), "stream should be kept for non-O-series");
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ── Content filtering response handling ──────────────────────────────────
|
|
515
|
+
|
|
516
|
+
#[test]
|
|
517
|
+
fn transform_response_passthrough_normal() {
|
|
518
|
+
let provider = make_provider("https://myresource.openai.azure.com", "2024-10-21");
|
|
519
|
+
let mut body = json!({
|
|
520
|
+
"id": "chatcmpl-123",
|
|
521
|
+
"object": "chat.completion",
|
|
522
|
+
"choices": [{
|
|
523
|
+
"index": 0,
|
|
524
|
+
"message": {"role": "assistant", "content": "Hello!"},
|
|
525
|
+
"finish_reason": "stop"
|
|
526
|
+
}]
|
|
527
|
+
});
|
|
528
|
+
let original = body.clone();
|
|
529
|
+
provider
|
|
530
|
+
.transform_response(&mut body)
|
|
531
|
+
.expect("transform should succeed");
|
|
532
|
+
assert_eq!(body, original, "normal responses should pass through unchanged");
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
#[test]
|
|
536
|
+
fn transform_response_content_filter_with_message() {
|
|
537
|
+
let provider = make_provider("https://myresource.openai.azure.com", "2024-10-21");
|
|
538
|
+
let mut body = json!({
|
|
539
|
+
"id": "chatcmpl-123",
|
|
540
|
+
"choices": [{
|
|
541
|
+
"index": 0,
|
|
542
|
+
"message": {"role": "assistant", "content": ""},
|
|
543
|
+
"finish_reason": "content_filter",
|
|
544
|
+
"content_filter_results": {
|
|
545
|
+
"hate": {"filtered": true, "severity": "high"}
|
|
546
|
+
}
|
|
547
|
+
}]
|
|
548
|
+
});
|
|
549
|
+
provider
|
|
550
|
+
.transform_response(&mut body)
|
|
551
|
+
.expect("transform should succeed");
|
|
552
|
+
// Message already present, so no injection needed.
|
|
553
|
+
assert_eq!(body["choices"][0]["finish_reason"], "content_filter");
|
|
554
|
+
assert!(body["choices"][0]["message"].is_object());
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
#[test]
|
|
558
|
+
fn transform_response_content_filter_blocked_no_message() {
|
|
559
|
+
let provider = make_provider("https://myresource.openai.azure.com", "2024-10-21");
|
|
560
|
+
let mut body = json!({
|
|
561
|
+
"id": "chatcmpl-123",
|
|
562
|
+
"choices": [{
|
|
563
|
+
"index": 0,
|
|
564
|
+
"finish_reason": "content_filter",
|
|
565
|
+
"content_filter_results": {
|
|
566
|
+
"hate": {"filtered": true, "severity": "high"}
|
|
567
|
+
}
|
|
568
|
+
}]
|
|
569
|
+
});
|
|
570
|
+
provider
|
|
571
|
+
.transform_response(&mut body)
|
|
572
|
+
.expect("transform should succeed");
|
|
573
|
+
// Should inject a minimal message for blocked responses.
|
|
574
|
+
let message = &body["choices"][0]["message"];
|
|
575
|
+
assert_eq!(message["role"], "assistant");
|
|
576
|
+
assert!(message["content"].is_null());
|
|
577
|
+
assert!(message["refusal"].as_str().unwrap().contains("Content filtered"));
|
|
578
|
+
}
|
|
579
|
+
}
|