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.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +239 -0
  3. data/ext/liter_llm_rb/extconf.rb +65 -0
  4. data/ext/liter_llm_rb/native/.cargo/config.toml +23 -0
  5. data/ext/liter_llm_rb/native/Cargo.lock +3713 -0
  6. data/ext/liter_llm_rb/native/Cargo.toml +32 -0
  7. data/ext/liter_llm_rb/native/build.rs +15 -0
  8. data/ext/liter_llm_rb/native/src/lib.rs +1079 -0
  9. data/lib/liter_llm.rb +8 -0
  10. data/sig/liter_llm.rbs +416 -0
  11. data/vendor/Cargo.toml +54 -0
  12. data/vendor/liter-llm/Cargo.toml +92 -0
  13. data/vendor/liter-llm/README.md +252 -0
  14. data/vendor/liter-llm/schemas/pricing.json +40 -0
  15. data/vendor/liter-llm/schemas/providers.json +1662 -0
  16. data/vendor/liter-llm/src/auth/azure_ad.rs +264 -0
  17. data/vendor/liter-llm/src/auth/bedrock_sts.rs +353 -0
  18. data/vendor/liter-llm/src/auth/mod.rs +68 -0
  19. data/vendor/liter-llm/src/auth/vertex_oauth.rs +353 -0
  20. data/vendor/liter-llm/src/client/config.rs +351 -0
  21. data/vendor/liter-llm/src/client/managed.rs +622 -0
  22. data/vendor/liter-llm/src/client/mod.rs +864 -0
  23. data/vendor/liter-llm/src/cost.rs +212 -0
  24. data/vendor/liter-llm/src/error.rs +190 -0
  25. data/vendor/liter-llm/src/http/eventstream.rs +860 -0
  26. data/vendor/liter-llm/src/http/mod.rs +12 -0
  27. data/vendor/liter-llm/src/http/request.rs +438 -0
  28. data/vendor/liter-llm/src/http/retry.rs +72 -0
  29. data/vendor/liter-llm/src/http/streaming.rs +289 -0
  30. data/vendor/liter-llm/src/lib.rs +37 -0
  31. data/vendor/liter-llm/src/provider/anthropic.rs +2250 -0
  32. data/vendor/liter-llm/src/provider/azure.rs +579 -0
  33. data/vendor/liter-llm/src/provider/bedrock.rs +1543 -0
  34. data/vendor/liter-llm/src/provider/cohere.rs +654 -0
  35. data/vendor/liter-llm/src/provider/custom.rs +404 -0
  36. data/vendor/liter-llm/src/provider/google_ai.rs +281 -0
  37. data/vendor/liter-llm/src/provider/mistral.rs +188 -0
  38. data/vendor/liter-llm/src/provider/mod.rs +616 -0
  39. data/vendor/liter-llm/src/provider/vertex.rs +1504 -0
  40. data/vendor/liter-llm/src/tests.rs +1425 -0
  41. data/vendor/liter-llm/src/tokenizer.rs +281 -0
  42. data/vendor/liter-llm/src/tower/budget.rs +599 -0
  43. data/vendor/liter-llm/src/tower/cache.rs +502 -0
  44. data/vendor/liter-llm/src/tower/cache_opendal.rs +270 -0
  45. data/vendor/liter-llm/src/tower/cooldown.rs +231 -0
  46. data/vendor/liter-llm/src/tower/cost.rs +404 -0
  47. data/vendor/liter-llm/src/tower/fallback.rs +121 -0
  48. data/vendor/liter-llm/src/tower/health.rs +219 -0
  49. data/vendor/liter-llm/src/tower/hooks.rs +369 -0
  50. data/vendor/liter-llm/src/tower/mod.rs +77 -0
  51. data/vendor/liter-llm/src/tower/rate_limit.rs +300 -0
  52. data/vendor/liter-llm/src/tower/router.rs +436 -0
  53. data/vendor/liter-llm/src/tower/service.rs +181 -0
  54. data/vendor/liter-llm/src/tower/tests.rs +539 -0
  55. data/vendor/liter-llm/src/tower/tests_common.rs +252 -0
  56. data/vendor/liter-llm/src/tower/tracing.rs +209 -0
  57. data/vendor/liter-llm/src/tower/types.rs +170 -0
  58. data/vendor/liter-llm/src/types/audio.rs +52 -0
  59. data/vendor/liter-llm/src/types/batch.rs +77 -0
  60. data/vendor/liter-llm/src/types/chat.rs +214 -0
  61. data/vendor/liter-llm/src/types/common.rs +244 -0
  62. data/vendor/liter-llm/src/types/embedding.rs +84 -0
  63. data/vendor/liter-llm/src/types/files.rs +58 -0
  64. data/vendor/liter-llm/src/types/image.rs +40 -0
  65. data/vendor/liter-llm/src/types/mod.rs +27 -0
  66. data/vendor/liter-llm/src/types/models.rs +21 -0
  67. data/vendor/liter-llm/src/types/moderation.rs +80 -0
  68. data/vendor/liter-llm/src/types/ocr.rs +87 -0
  69. data/vendor/liter-llm/src/types/rerank.rs +46 -0
  70. data/vendor/liter-llm/src/types/responses.rs +55 -0
  71. data/vendor/liter-llm/src/types/search.rs +45 -0
  72. data/vendor/liter-llm/tests/contract.rs +332 -0
  73. data/vendor/liter-llm-ffi/Cargo.toml +30 -0
  74. data/vendor/liter-llm-ffi/build.rs +66 -0
  75. data/vendor/liter-llm-ffi/cbindgen.toml +60 -0
  76. data/vendor/liter-llm-ffi/liter_llm.h +850 -0
  77. data/vendor/liter-llm-ffi/src/lib.rs +2488 -0
  78. 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
+ }