- 1
use futures::StreamExt; - 2
use serde_json::Value; - 3
use tokio_util::sync::CancellationToken; - 4
- 5
use crate::Provider; - 6
use crate::error::LlmError; - 7
use crate::gate::ProviderGate; - 8
use crate::sse::SseDecoder; - 9
use crate::stream::{EventStream, StreamEvent, channel}; - 10
use crate::types::{ - 11
AssistantMessage, ChatRequest, ContentBlock, Message, Role, StopReason, ToolDefinition, Usage, - 12
}; - 13
- 14
pub const OPENAI_DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; - 15
- 16
#[derive(Debug, Clone, Default)] - 17
pub struct OpenAiConfig { - 18
pub api_key: String, - 19
pub base_url: String, - 20
/// When true, and the request carries `ChatRequest::cache`, send - 21
/// `prompt_cache_key` so the provider can route repeat traffic to the - 22
/// same cache-warm backend. - 23
pub cache_key: bool, - 24
/// When true, also send OpenRouter's `session_id` alongside - 25
/// `prompt_cache_key` — OpenRouter accepts both and uses `session_id` - 26
/// to pin the upstream that holds the cache. - 27
pub openrouter: bool, - 28
} - 29
- 30
/// Batch transcription through the OpenAI-compatible `/audio/transcriptions` - 31
/// endpoint. The model is supplied by discovery/configuration; this adapter - 32
/// deliberately has no baked-in model catalogue or default. - 33
pub async fn transcribe( - 34
config: &OpenAiConfig, - 35
audio: &[u8], - 36
mime: &str, - 37
model: &str, - 38
cancel: &CancellationToken, - 39
) -> Result<String, LlmError> { - 40
if audio.is_empty() || mime.trim().is_empty() || model.trim().is_empty() { - 41
return Err(LlmError::InvalidRequest( - 42
"audio, mime, and model are required".into(), - 43
)); - 44
} - 45
if cancel.is_cancelled() { - 46
return Err(LlmError::Aborted { partial: None }); - 47
} - 48
let filename = if mime.contains("wav") { - 49
"audio.wav" - 50
} else if mime.contains("mpeg") || mime.contains("mp3") { - 51
"audio.mp3" - 52
} else if mime.contains("ogg") { - 53
"audio.ogg" - 54
} else if mime.contains("oga") { - 55
"audio.oga" - 56
} else if mime.contains("webm") { - 57
"audio.webm" - 58
} else if mime.contains("mp4") || mime.contains("m4a") { - 59
"audio.m4a" - 60
} else if mime.contains("flac") { - 61
"audio.flac" - 62
} else { - 63
"audio.bin" - 64
}; - 65
let part = reqwest::multipart::Part::bytes(audio.to_vec()) - 66
.file_name(filename) - 67
.mime_str(mime) - 68
.map_err(|e| LlmError::InvalidRequest(e.to_string()))?; - 69
let form = reqwest::multipart::Form::new() - 70
.part("file", part) - 71
.text("model", model.to_string()); - 72
let url = format!( - 73
"{}/audio/transcriptions", - 74
config.base_url.trim_end_matches('/') - 75
); - 76
let response = tokio::select! { - 77
_ = cancel.cancelled() => return Err(LlmError::Aborted { partial: None }), - 78
result = reqwest::Client::new().post(url).bearer_auth(&config.api_key).multipart(form).send() => result.map_err(|e| LlmError::Network(e.to_string()))?, - 79
}; - 80
let status = response.status().as_u16(); - 81
let value: Value = response - 82
.json() - 83
.await - 84
.map_err(|e| LlmError::Parse(e.to_string()))?; - 85
if status >= 400 { - 86
return Err(LlmError::InvalidRequest(format!( - 87
"transcription provider returned HTTP {status}: {value}" - 88
))); - 89
} - 90
let text = value - 91
.get("text") - 92
.and_then(Value::as_str) - 93
.unwrap_or("") - 94
.trim() - 95
.to_string(); - 96
Ok(text) - 97
} - 98
- 99
/// Synthesize speech through an OpenAI-compatible `/audio/speech` endpoint. - 100
/// The model is always explicit and the response is bounded before it is - 101
/// materialized, so a misbehaving provider cannot exhaust the process. - 102
pub async fn speak( - 103
config: &OpenAiConfig, - 104
text: &str, - 105
model: &str, - 106
voice: Option<&str>, - 107
format: &str, - 108
cancel: &CancellationToken, - 109
) -> Result<Vec<u8>, LlmError> { - 110
if text.trim().is_empty() || model.trim().is_empty() || format.trim().is_empty() { - 111
return Err(LlmError::InvalidRequest( - 112
"text, model, and format are required".into(), - 113
)); - 114
} - 115
if cancel.is_cancelled() { - 116
return Err(LlmError::Aborted { partial: None }); - 117
} - 118
let mut body = serde_json::json!({ - 119
"model": model, - 120
"input": text, - 121
"response_format": format, - 122
}); - 123
if let Some(voice) = voice.filter(|v| !v.trim().is_empty()) { - 124
body["voice"] = Value::String(voice.to_string()); - 125
} - 126
let url = format!("{}/audio/speech", config.base_url.trim_end_matches('/')); - 127
let response = tokio::select! { - 128
_ = cancel.cancelled() => return Err(LlmError::Aborted { partial: None }), - 129
result = reqwest::Client::new().post(url).bearer_auth(&config.api_key).json(&body).send() => result.map_err(|e| LlmError::Network(e.to_string()))?, - 130
}; - 131
let status = response.status().as_u16(); - 132
if status >= 400 { - 133
let body = response.text().await.unwrap_or_default(); - 134
return Err(map_status_error(status, &body, None)); - 135
} - 136
const MAX_AUDIO_BYTES: usize = 16 * 1024 * 1024; - 137
if response - 138
.content_length() - 139
.is_some_and(|n| n > MAX_AUDIO_BYTES as u64) - 140
{ - 141
return Err(LlmError::InvalidRequest( - 142
"provider audio exceeds 16 MiB".into(), - 143
)); - 144
} - 145
let mut output = Vec::new(); - 146
let mut stream = response.bytes_stream(); - 147
while let Some(chunk) = tokio::select! { - 148
_ = cancel.cancelled() => return Err(LlmError::Aborted { partial: None }), - 149
chunk = stream.next() => chunk, - 150
} { - 151
let chunk = chunk.map_err(|e| LlmError::Network(e.to_string()))?; - 152
if output.len().saturating_add(chunk.len()) > MAX_AUDIO_BYTES { - 153
return Err(LlmError::InvalidRequest( - 154
"provider audio exceeds 16 MiB".into(), - 155
)); - 156
} - 157
output.extend_from_slice(&chunk); - 158
} - 159
if output.is_empty() { - 160
return Err(LlmError::Parse("provider returned empty audio".into())); - 161
} - 162
Ok(output) - 163
} - 164
- 165
#[derive(Clone)] - 166
pub struct OpenAiCompletionsProvider { - 167
http: reqwest::Client, - 168
config: OpenAiConfig, - 169
gate: ProviderGate, - 170
} - 171
- 172
impl OpenAiCompletionsProvider { - 173
pub fn new(config: OpenAiConfig) -> Result<Self, LlmError> { - 174
let http = reqwest::Client::builder() - 175
.connect_timeout(std::time::Duration::from_secs(30)) - 176
.build() - 177
.map_err(|e| LlmError::Network(e.to_string()))?; - 178
Ok(OpenAiCompletionsProvider { - 179
http, - 180
gate: ProviderGate::new(&config.base_url, &config.api_key), - 181
config, - 182
}) - 183
} - 184
} - 185
- 186
pub fn build_body(config: &OpenAiConfig, request: &ChatRequest) -> Result<Value, LlmError> { - 187
// Chat Completions carries no reasoning-item channel, so `Thinking` - 188
// blocks are dropped unconditionally by `append_message` below — there - 189
// is no turn boundary to compute here (contrast Anthropic/Google, which - 190
// must replay signed thinking within the current turn). - 191
let mut messages: Vec<Value> = Vec::with_capacity(request.messages.len() + 1); - 192
if let Some(system) = &request.system { - 193
messages.push(serde_json::json!({"role": "system", "content": system})); - 194
} - 195
for m in &request.messages { - 196
append_message(&mut messages, m)?; - 197
} - 198
- 199
let mut body = serde_json::json!({ - 200
"model": request.model, - 201
"messages": messages, - 202
"stream": true, - 203
"stream_options": {"include_usage": true}, - 204
}); - 205
if let Some(cache) = &request.cache { - 206
if config.cache_key { - 207
body["prompt_cache_key"] = serde_json::json!(cache.session_key); - 208
} - 209
if config.openrouter { - 210
body["session_id"] = serde_json::json!(cache.session_key); - 211
} - 212
} - 213
if !request.tools.is_empty() { - 214
let tools: Vec<Value> = request - 215
.tools - 216
.iter() - 217
.map(|t: &ToolDefinition| { - 218
serde_json::json!({ - 219
"type": "function", - 220
"function": { - 221
"name": t.name, - 222
"description": t.description, - 223
"parameters": t.parameters, - 224
}, - 225
}) - 226
}) - 227
.collect(); - 228
body["tools"] = Value::Array(tools); - 229
} - 230
Ok(body) - 231
} - 232
- 233
fn append_message(out: &mut Vec<Value>, m: &Message) -> Result<(), LlmError> { - 234
match m.role { - 235
Role::User => { - 236
let mut text_parts: Vec<&str> = Vec::new(); - 237
let mut image_parts: Vec<String> = Vec::new(); - 238
let mut tool_results: Vec<&ContentBlock> = Vec::new(); - 239
for b in &m.content { - 240
match b { - 241
ContentBlock::Text { text } => text_parts.push(text), - 242
ContentBlock::Image { source } => { - 243
// Data URLs are the transport-agnostic form for the - 244
// chat-completions API. - 245
image_parts - 246
.push(format!("data:{};base64,{}", source.media_type, source.data)); - 247
} - 248
ContentBlock::ToolResult { .. } => tool_results.push(b), - 249
ContentBlock::ToolUse { .. } => { - 250
return Err(LlmError::InvalidRequest( - 251
"tool_use blocks must appear in assistant messages".into(), - 252
)); - 253
} - 254
ContentBlock::Thinking { .. } => {} - 255
// Only the Anthropic adapter understands server-side - 256
// tool search; every other adapter skips this opaque - 257
// block entirely (docs/design/68 §5/§12). - 258
ContentBlock::Provider { .. } => {} - 259
} - 260
} - 261
for r in tool_results { - 262
let ContentBlock::ToolResult { - 263
tool_use_id, - 264
content, - 265
.. - 266
} = r - 267
else { - 268
unreachable!() - 269
}; - 270
out.push(serde_json::json!({ - 271
"role": "tool", - 272
"tool_call_id": tool_use_id, - 273
"content": content, - 274
})); - 275
} - 276
if !image_parts.is_empty() || !text_parts.is_empty() { - 277
let content = if image_parts.is_empty() { - 278
serde_json::json!(text_parts.join("\n")) - 279
} else { - 280
let mut parts: Vec<Value> = text_parts - 281
.iter() - 282
.map(|t| serde_json::json!({"type": "text", "text": t})) - 283
.collect(); - 284
for url in &image_parts { - 285
parts.push(serde_json::json!({ - 286
"type": "image_url", - 287
"image_url": {"url": url} - 288
})); - 289
} - 290
serde_json::json!(parts) - 291
}; - 292
out.push(serde_json::json!({ - 293
"role": "user", - 294
"content": content, - 295
})); - 296
} - 297
} - 298
Role::Assistant => { - 299
let mut text = String::new(); - 300
let mut tool_calls: Vec<Value> = Vec::new(); - 301
for b in &m.content { - 302
match b { - 303
ContentBlock::Text { text: t } => { - 304
if !text.is_empty() { - 305
text.push('\n'); - 306
} - 307
text.push_str(t); - 308
} - 309
ContentBlock::ToolUse { id, name, input } => { - 310
tool_calls.push(serde_json::json!({ - 311
"id": id, - 312
"type": "function", - 313
"function": { - 314
"name": name, - 315
"arguments": serde_json::to_string(input) - 316
.map_err(|e| LlmError::Parse(e.to_string()))?, - 317
}, - 318
})); - 319
} - 320
ContentBlock::Thinking { .. } - 321
| ContentBlock::ToolResult { .. } - 322
| ContentBlock::Image { .. } - 323
| ContentBlock::Provider { .. } => {} - 324
} - 325
} - 326
let mut msg = serde_json::json!({"role": "assistant"}); - 327
if !text.is_empty() || tool_calls.is_empty() { - 328
msg["content"] = Value::String(text); - 329
} else { - 330
msg["content"] = Value::Null; - 331
} - 332
if !tool_calls.is_empty() { - 333
msg["tool_calls"] = Value::Array(tool_calls); - 334
} - 335
out.push(msg); - 336
} - 337
} - 338
Ok(()) - 339
} - 340
- 341
fn map_status_error(status: u16, body: &str, retry_after: Option<u64>) -> LlmError { - 342
let message = serde_json::from_str::<Value>(body) - 343
.ok() - 344
.and_then(|v| { - 345
v.pointer("/error/message") - 346
.or_else(|| v.pointer("/message")) - 347
.and_then(|m| m.as_str().map(String::from)) - 348
}) - 349
.unwrap_or_else(|| body.chars().take(500).collect()); - 350
- 351
match status { - 352
401 | 403 => LlmError::Auth(message), - 353
400 => match LlmError::classify_400(message.clone()) { - 354
over_length @ LlmError::Context(_) => over_length, - 355
_ => LlmError::invalid_request_for_endpoint("/v1/chat/completions", message), - 356
}, - 357
404 | 413 | 422 => LlmError::invalid_request_for_endpoint("/v1/chat/completions", message), - 358
429 => LlmError::RateLimit { - 359
message, - 360
retry_after_secs: retry_after, - 361
}, - 362
503 | 529 => LlmError::Overloaded(message), - 363
_ => LlmError::Api { status, message }, - 364
} - 365
} - 366
- 367
struct Accumulator { - 368
message: AssistantMessage, - 369
saw_end: bool, - 370
tool_pos: std::collections::HashMap<usize, usize>, - 371
raw_json: std::collections::HashMap<usize, String>, - 372
} - 373
- 374
impl Accumulator { - 375
fn new(model: &str) -> Self { - 376
Accumulator { - 377
message: AssistantMessage::empty(model), - 378
saw_end: false, - 379
tool_pos: std::collections::HashMap::new(), - 380
raw_json: std::collections::HashMap::new(), - 381
} - 382
} - 383
- 384
fn convert(&mut self, data: &str) -> Result<Option<StreamEvent>, LlmError> { - 385
let v: Value = serde_json::from_str(data) - 386
.map_err(|e| LlmError::Parse(format!("bad chunk json: {e}")))?; - 387
- 388
if let Some(usage) = v.get("usage").filter(|u| !u.is_null()) { - 389
// OpenAI's `prompt_tokens` is the WHOLE prompt, cache hits - 390
// included (`prompt_tokens_details.cached_tokens` is a subset - 391
// of it, not an addition to it). Normalized `input_tokens` is - 392
// only the non-cached remainder, matching every other adapter - 393
// (docs/design/68-context-engine.md §1) — `Usage::prompt_tokens()` - 394
// reconstructs the original total. - 395
let prompt_tokens = usage - 396
.get("prompt_tokens") - 397
.and_then(|x| x.as_u64()) - 398
.unwrap_or(0); - 399
let cached_tokens = usage - 400
.get("prompt_tokens_details") - 401
.and_then(|d| d.get("cached_tokens")) - 402
.and_then(|x| x.as_u64()) - 403
.unwrap_or(0); - 404
self.message.usage = Usage { - 405
input_tokens: prompt_tokens.saturating_sub(cached_tokens), - 406
output_tokens: usage - 407
.get("completion_tokens") - 408
.and_then(|x| x.as_u64()) - 409
.unwrap_or(0), - 410
cache_read_input_tokens: (cached_tokens > 0).then_some(cached_tokens), - 411
cache_creation_input_tokens: None, - 412
..Default::default() - 413
}; - 414
} - 415
- 416
let Some(choice) = v.get("choices").and_then(|c| c.get(0)) else { - 417
return Ok(None); - 418
}; - 419
- 420
#[allow(clippy::dbg_macro)] - 421
if std::env::var_os("VAK_LLM_DEBUG").is_some() { - 422
eprintln!( - 423
"[vak-llm] frame finish={:?} delta_keys={:?} content_len={}", - 424
choice.get("finish_reason"), - 425
choice.get("delta").map(|d| d - 426
.as_object() - 427
.map(|o| o.keys().cloned().collect::<Vec<_>>()) - 428
.unwrap_or_default()), - 429
self.message.content.len() - 430
); - 431
} - 432
if let Some(finish) = choice.get("finish_reason").and_then(|f| f.as_str()) { - 433
// Accumulated tool_use blocks are ground truth: some compat - 434
// endpoints close tool-call turns with finish reasons outside - 435
// the canonical set (e.g. plain "stop"). Trusting the label - 436
// would strand a dangling tool_use and kill the run. - 437
let has_tool_use = self - 438
.message - 439
.content - 440
.iter() - 441
.any(|b| matches!(b, ContentBlock::ToolUse { .. })); - 442
self.message.stop_reason = if has_tool_use { - 443
StopReason::ToolUse - 444
} else { - 445
match finish { - 446
"tool_calls" | "function_call" => StopReason::ToolUse, - 447
"length" => StopReason::MaxTokens, - 448
_ => StopReason::EndTurn, - 449
} - 450
}; - 451
self.saw_end = true; - 452
return Ok(Some(StreamEvent::End { - 453
message: self.message.clone(), - 454
})); - 455
} - 456
- 457
let Some(delta) = choice.get("delta") else { - 458
return Ok(None); - 459
}; - 460
- 461
let mut event = None; - 462
if let Some(text) = delta - 463
.get("content") - 464
.and_then(|c| c.as_str()) - 465
.filter(|t| !t.is_empty()) - 466
{ - 467
append_text_block(&mut self.message.content, text); - 468
event = Some(StreamEvent::TextDelta { - 469
delta: text.to_string(), - 470
partial: self.message.clone(), - 471
}); - 472
} - 473
- 474
if let Some(calls) = delta.get("tool_calls").and_then(|c| c.as_array()) { - 475
for call in calls { - 476
let idx = call.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize; - 477
let pos = match self.tool_pos.get(&idx) { - 478
Some(&p) => p, - 479
None => { - 480
self.message.content.push(ContentBlock::ToolUse { - 481
id: String::new(), - 482
name: String::new(), - 483
input: Value::Object(Default::default()), - 484
}); - 485
let p = self.message.content.len() - 1; - 486
self.tool_pos.insert(idx, p); - 487
p - 488
} - 489
}; - 490
if let ContentBlock::ToolUse { id, name, .. } = &mut self.message.content[pos] { - 491
if let Some(new_id) = call.get("id").and_then(|i| i.as_str()) { - 492
*id = new_id.to_string(); - 493
} - 494
if let Some(fname) = call.pointer("/function/name").and_then(|n| n.as_str()) { - 495
*name = fname.to_string(); - 496
} - 497
if let Some(args) = call.pointer("/function/arguments").and_then(|a| a.as_str()) - 498
{ - 499
let raw = self.raw_json.entry(idx).or_default(); - 500
raw.push_str(args); - 501
let parsed: Value = - 502
serde_json::from_str(raw).unwrap_or(Value::Object(Default::default())); - 503
if let ContentBlock::ToolUse { input, .. } = &mut self.message.content[pos] - 504
{ - 505
*input = parsed; - 506
} - 507
} - 508
} - 509
if event.is_none() - 510
&& let Some(ContentBlock::ToolUse { id, name, .. }) = - 511
self.message.content.get(pos) - 512
{ - 513
event = Some(StreamEvent::ToolUseStart { - 514
index: pos, - 515
id: id.clone(), - 516
name: name.clone(), - 517
partial: self.message.clone(), - 518
}); - 519
} - 520
} - 521
} - 522
- 523
Ok(event) - 524
} - 525
} - 526
- 527
fn append_text_block(content: &mut Vec<ContentBlock>, text: &str) { - 528
if let Some(ContentBlock::Text { text: last }) = content.last_mut() { - 529
last.push_str(text); - 530
return; - 531
} - 532
content.push(ContentBlock::text(text)); - 533
} - 534
- 535
#[async_trait::async_trait] - 536
impl Provider for OpenAiCompletionsProvider { - 537
fn name(&self) -> &str { - 538
"openai-completions" - 539
} - 540
- 541
fn circuit_key(&self) -> String { - 542
crate::gate::route_identity(self.name(), &self.config.base_url, &self.config.api_key) - 543
} - 544
- 545
async fn stream( - 546
&self, - 547
request: ChatRequest, - 548
cancel: CancellationToken, - 549
) -> Result<EventStream, LlmError> { - 550
let provider_permit = self.gate.acquire(&cancel).await?; - 551
let url = format!( - 552
"{}/chat/completions", - 553
self.config.base_url.trim_end_matches('/') - 554
); - 555
let body = build_body(&self.config, &request)?; - 556
let send_fut = self - 557
.http - 558
.post(&url) - 559
.bearer_auth(&self.config.api_key) - 560
.json(&body) - 561
.send(); - 562
let response = tokio::select! { - 563
_ = cancel.cancelled() => return Err(LlmError::Aborted { partial: None }), - 564
r = send_fut => match r { - 565
Ok(r) => r, - 566
Err(e) => return Err(LlmError::Network(e.to_string())), - 567
}, - 568
}; - 569
- 570
let status = response.status(); - 571
if !status.is_success() { - 572
let retry_after = response - 573
.headers() - 574
.get("retry-after") - 575
.and_then(|value| value.to_str().ok()) - 576
.and_then(|value| value.parse::<u64>().ok()); - 577
let text = response.text().await.unwrap_or_default(); - 578
return Err(map_status_error(status.as_u16(), &text, retry_after)); - 579
} - 580
- 581
let model = request.model.clone(); - 582
let (mut sink, stream_rx) = channel(256); - 583
let mut byte_stream = response.bytes_stream(); - 584
let mut decoder = SseDecoder::new(); - 585
let mut acc = Accumulator::new(&model); - 586
- 587
tokio::spawn(async move { - 588
loop { - 589
tokio::select! { - 590
_ = cancel.cancelled() => { - 591
let partial = (!acc.message.content.is_empty()).then(|| Box::new(acc.message.clone())); - 592
sink.close_error(LlmError::Aborted { partial }).await; - 593
return; - 594
} - 595
chunk = byte_stream.next() => { - 596
match chunk { - 597
Some(Ok(bytes)) => { - 598
decoder.push(&bytes); - 599
while let Some(frame) = decoder.next_frame() { - 600
let data = frame.data.trim(); - 601
if data == "[DONE]" { - 602
// Content is ground truth here as - 603
// everywhere else: endpoints that - 604
// skip finish_reason entirely (seen - 605
// on opencode-zen) still owe the loop - 606
// a ToolUse signal when tool calls - 607
// were streamed. - 608
let has_tool_use = acc.message.content.iter().any( - 609
|b| matches!(b, ContentBlock::ToolUse { .. }), - 610
); - 611
if has_tool_use { - 612
acc.message.stop_reason = StopReason::ToolUse; - 613
} - 614
sink.close_message(acc.message.clone()).await; - 615
return; - 616
} - 617
match acc.convert(data) { - 618
Ok(Some(event)) => sink.push(event), - 619
Ok(None) => {} - 620
Err(e) => { - 621
sink.close_error(e).await; - 622
return; - 623
} - 624
} - 625
} - 626
} - 627
Some(Err(e)) => { - 628
sink.close_error(LlmError::Network(e.to_string())).await; - 629
return; - 630
} - 631
None => { - 632
// OpenAI-compatible proxies sometimes end the - 633
// body after the last content chunk without - 634
// [DONE]/finish_reason. A clean close with - 635
// content is de facto completion (same as the - 636
// [DONE] branch); empty content fails closed. - 637
if acc.saw_end || !acc.message.content.is_empty() { - 638
// Content is ground truth here too: a - 639
// stream ending right after tool-call - 640
// deltas must surface ToolUse, or the - 641
// dangling call aborts the run. - 642
let has_tool_use = acc.message.content.iter().any( - 643
|b| matches!(b, ContentBlock::ToolUse { .. }), - 644
); - 645
if has_tool_use { - 646
acc.message.stop_reason = StopReason::ToolUse; - 647
} - 648
sink.close_message(acc.message.clone()).await; - 649
} else { - 650
sink.close_error(LlmError::Parse( - 651
"stream closed before finish_reason".into(), - 652
)).await; - 653
} - 654
return; - 655
} - 656
} - 657
} - 658
} - 659
} - 660
}); - 661
- 662
Ok(stream_rx.with_guard(provider_permit)) - 663
} - 664
} - 665
- 666
#[cfg(test)] - 667
mod build_body_tests { - 668
#![allow(clippy::unwrap_used, clippy::expect_used)] - 669
use super::*; - 670
use crate::types::CacheHints; - 671
- 672
fn req_with_cache() -> ChatRequest { - 673
let mut req = ChatRequest::new("gpt-5.6"); - 674
req.messages = vec![Message::user_text("hi")]; - 675
req.cache = Some(CacheHints { - 676
session_key: "sess-1".into(), - 677
breakpoints: Vec::new(), - 678
}); - 679
req - 680
} - 681
- 682
#[test] - 683
fn cache_key_off_sends_neither_hint() { - 684
let config = OpenAiConfig::default(); - 685
let body = build_body(&config, &req_with_cache()).unwrap(); - 686
assert!(body.get("prompt_cache_key").is_none()); - 687
assert!(body.get("session_id").is_none()); - 688
} - 689
- 690
#[test] - 691
fn cache_key_on_sends_prompt_cache_key_only() { - 692
let config = OpenAiConfig { - 693
cache_key: true, - 694
..Default::default() - 695
}; - 696
let body = build_body(&config, &req_with_cache()).unwrap(); - 697
assert_eq!(body["prompt_cache_key"], "sess-1"); - 698
assert!(body.get("session_id").is_none()); - 699
} - 700
- 701
#[test] - 702
fn openrouter_flag_adds_session_id_alongside_prompt_cache_key() { - 703
let config = OpenAiConfig { - 704
cache_key: true, - 705
openrouter: true, - 706
..Default::default() - 707
}; - 708
let body = build_body(&config, &req_with_cache()).unwrap(); - 709
assert_eq!(body["prompt_cache_key"], "sess-1"); - 710
assert_eq!(body["session_id"], "sess-1"); - 711
} - 712
- 713
#[test] - 714
fn no_cache_hint_on_request_sends_nothing_even_when_enabled() { - 715
let config = OpenAiConfig { - 716
api_key: String::new(), - 717
base_url: String::new(), - 718
cache_key: true, - 719
openrouter: true, - 720
}; - 721
let mut req = ChatRequest::new("gpt-5.6"); - 722
req.messages = vec![Message::user_text("hi")]; - 723
let body = build_body(&config, &req).unwrap(); - 724
assert!(body.get("prompt_cache_key").is_none()); - 725
assert!(body.get("session_id").is_none()); - 726
} - 727
} - 728
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.