Skip to main content

provider_core/
http_transport.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3//! Transport-independent, injected HTTP seam for provider request specifications.
4
5use crate::anthropic_chat::{
6    AnthropicBodyWriteState, AnthropicFailure, AnthropicRequestSpec, AnthropicSseParser,
7    AnthropicStreamError, AnthropicStreamEvent, AnthropicTransportFailureSignal,
8    normalize_http_response as normalize_anthropic_response,
9    normalize_transport_failure as normalize_anthropic_transport_failure,
10};
11use crate::gemini_chat::{
12    GeminiBodyWriteState, GeminiFailure, GeminiRequestSpec, GeminiSseParser, GeminiStreamError,
13    GeminiStreamEvent, GeminiTransportFailureSignal,
14    normalize_http_response as normalize_gemini_response,
15    normalize_transport_failure as normalize_gemini_transport_failure,
16};
17use crate::local_credential_store::LocalCredentialStore;
18use crate::ollama_chat::{
19    BodyWriteState, OllamaAuthentication, OllamaFailure, OllamaNdjsonParser, OllamaReplayGuard,
20    OllamaRequestSpec, OllamaStreamError, OllamaStreamEvent, ReplayDecision,
21    TransportFailureSignal, normalize_http_response as normalize_ollama_response,
22    normalize_transport_failure as normalize_ollama_transport_failure,
23};
24use crate::openai_compat_chat::{
25    BodyWriteState as OpenAiCompatBodyWriteState, OpenAiCompatFailure, OpenAiCompatRequestSpec,
26    OpenAiCompatSseParser, OpenAiCompatStreamError, OpenAiCompatStreamEvent,
27    TransportFailureSignal as OpenAiCompatTransportFailureSignal,
28    normalize_http_response as normalize_openai_compat_response,
29    normalize_transport_failure as normalize_openai_compat_transport_failure,
30};
31use crate::openrouter_chat::{
32    OpenRouterFailure, OpenRouterRequestSpec, OpenRouterSseParser, OpenRouterStreamError,
33    OpenRouterStreamEvent, normalize_http_response as normalize_openrouter_response,
34    normalize_transport_failure as normalize_openrouter_transport_failure,
35};
36use futures_util::StreamExt;
37use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
38use router_protocol::AttemptId;
39use std::fmt;
40use std::future::Future;
41#[cfg(unix)]
42use std::path::Path;
43use std::path::{Component, PathBuf};
44use std::pin::Pin;
45use std::sync::Arc;
46use std::sync::atomic::{AtomicBool, Ordering};
47use std::time::Duration;
48#[cfg(unix)]
49use std::{
50    fs::{File, OpenOptions},
51    io::{Read, Write},
52    os::unix::{
53        fs::{DirBuilderExt, MetadataExt, OpenOptionsExt},
54        process::CommandExt,
55    },
56    process::{Child, Command, Stdio},
57    sync::atomic::AtomicU64,
58    thread,
59    time::{Instant, SystemTime, UNIX_EPOCH},
60};
61use tokio::sync::Notify;
62use zeroize::Zeroizing;
63
64pub use crate::ollama_chat::NetworkPolicy;
65
66const MAX_RESPONSE_CHUNKS: usize = 4_096;
67const MAX_REQUEST_BODY_BYTES: usize = 4 * 1024 * 1024;
68const MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
69const MAX_ERROR_BODY_BYTES: usize = 32 * 1024;
70const MAX_REQUEST_ID_BYTES: usize = 128;
71const MAX_RESOLVED_SECRET_BYTES: usize = 16 * 1024;
72const MAX_KEYCHAIN_COMPONENT_BYTES: usize = 255;
73#[cfg(unix)]
74const MAX_SECRET_HELPER_BYTES: usize = 4 * 1024 * 1024;
75const SECRET_HELPER_TIMEOUT: Duration = Duration::from_secs(5);
76const SECRET_HELPER_POLL_INTERVAL: Duration = Duration::from_millis(5);
77const FIXED_USER_AGENT: &str = "steadymux-provider-core/0.1";
78#[cfg(unix)]
79static SECRET_HELPER_STAGE_COUNTER: AtomicU64 = AtomicU64::new(0);
80
81/// A resolved credential that can only be exposed deliberately at the dispatch edge.
82pub struct ResolvedSecret(Zeroizing<Vec<u8>>);
83
84impl ResolvedSecret {
85    pub fn new(value: impl Into<Vec<u8>>) -> Result<Self, SecretResolutionError> {
86        Self::from_zeroizing(Zeroizing::new(value.into()))
87    }
88
89    fn from_zeroizing(value: Zeroizing<Vec<u8>>) -> Result<Self, SecretResolutionError> {
90        if value.is_empty()
91            || value.len() > MAX_RESOLVED_SECRET_BYTES
92            || value.contains(&b'\r')
93            || value.contains(&b'\n')
94        {
95            return Err(SecretResolutionError);
96        }
97        Ok(Self(value))
98    }
99
100    pub fn expose_for_dispatch(&self) -> &[u8] {
101        self.0.as_slice()
102    }
103}
104
105impl fmt::Debug for ResolvedSecret {
106    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
107        formatter.write_str("ResolvedSecret([REDACTED])")
108    }
109}
110
111#[derive(Clone, Copy, Debug, PartialEq, Eq)]
112pub struct SecretResolutionError;
113
114impl fmt::Display for SecretResolutionError {
115    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
116        formatter.write_str("provider credential resolution failed")
117    }
118}
119
120impl std::error::Error for SecretResolutionError {}
121
122pub trait SecretResolver: Send + Sync {
123    fn resolve(&self, secret_ref: &str) -> Result<ResolvedSecret, SecretResolutionError>;
124}
125
126enum ParsedSecretReference<'a> {
127    MacOsKeychain { service: &'a str, account: &'a str },
128    LocalFile,
129    Command,
130    Environment { name: &'a str },
131}
132
133impl fmt::Debug for ParsedSecretReference<'_> {
134    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
135        match self {
136            Self::MacOsKeychain { .. } => formatter.write_str("MacOsKeychain([REDACTED])"),
137            Self::LocalFile => formatter.write_str("LocalFile([REDACTED])"),
138            Self::Command => formatter.write_str("Command([REDACTED])"),
139            Self::Environment { .. } => formatter.write_str("Environment([REDACTED])"),
140        }
141    }
142}
143
144fn parse_secret_reference(
145    secret_ref: &str,
146) -> Result<ParsedSecretReference<'_>, SecretResolutionError> {
147    if let Some(value) = secret_ref.strip_prefix("keychain:") {
148        let (service, account) = value.split_once('/').ok_or(SecretResolutionError)?;
149        if account.contains('/')
150            || !valid_keychain_component(service)
151            || !valid_keychain_component(account)
152        {
153            return Err(SecretResolutionError);
154        }
155        return Ok(ParsedSecretReference::MacOsKeychain { service, account });
156    }
157
158    if let Some(value) = secret_ref.strip_prefix("env:") {
159        if !valid_environment_name(value) {
160            return Err(SecretResolutionError);
161        }
162        return Ok(ParsedSecretReference::Environment { name: value });
163    }
164
165    if let Some(value) = secret_ref.strip_prefix("local-file-v1:") {
166        if value.len() != 32
167            || !value
168                .bytes()
169                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
170        {
171            return Err(SecretResolutionError);
172        }
173        return Ok(ParsedSecretReference::LocalFile);
174    }
175
176    command_secret_path(secret_ref)?;
177    Ok(ParsedSecretReference::Command)
178}
179
180fn valid_environment_name(value: &str) -> bool {
181    !value.is_empty()
182        && value.len() <= 256
183        && value
184            .bytes()
185            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
186}
187
188fn valid_keychain_component(value: &str) -> bool {
189    !value.is_empty()
190        && value.len() <= MAX_KEYCHAIN_COMPONENT_BYTES
191        && value.bytes().all(|byte| {
192            byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':' | b'@' | b'+')
193        })
194}
195
196/// Resolves an approved platform-native credential reference or the existing
197/// owner-selected command helper reference. Keychain references fail closed on
198/// platforms other than macOS.
199#[derive(Clone, Copy, Default)]
200pub struct ProductionSecretResolver {
201    command: CommandSecretResolver,
202}
203
204impl fmt::Debug for ProductionSecretResolver {
205    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
206        formatter
207            .debug_struct("ProductionSecretResolver")
208            .field("credential", &"[REDACTED]")
209            .finish()
210    }
211}
212
213impl ProductionSecretResolver {
214    pub fn supports_reference(secret_ref: &str) -> bool {
215        match parse_secret_reference(secret_ref) {
216            Ok(ParsedSecretReference::MacOsKeychain { .. }) => cfg!(target_os = "macos"),
217            Ok(ParsedSecretReference::LocalFile) => cfg!(target_os = "macos"),
218            Ok(ParsedSecretReference::Environment { .. }) => true,
219            Ok(ParsedSecretReference::Command) => {
220                CommandSecretResolver::supports_reference(secret_ref)
221            }
222            Err(_) => false,
223        }
224    }
225
226    /// Validates the reference form without reading a credential. Command
227    /// references retain their existing filesystem ownership checks.
228    pub fn validate_reference(secret_ref: &str) -> Result<(), SecretResolutionError> {
229        match parse_secret_reference(secret_ref)? {
230            ParsedSecretReference::MacOsKeychain { .. } => {
231                #[cfg(target_os = "macos")]
232                {
233                    Ok(())
234                }
235                #[cfg(not(target_os = "macos"))]
236                {
237                    Err(SecretResolutionError)
238                }
239            }
240            ParsedSecretReference::LocalFile => {
241                #[cfg(target_os = "macos")]
242                {
243                    Ok(())
244                }
245                #[cfg(not(target_os = "macos"))]
246                {
247                    Err(SecretResolutionError)
248                }
249            }
250            ParsedSecretReference::Environment { .. } => Ok(()),
251            ParsedSecretReference::Command => CommandSecretResolver::validate_reference(secret_ref),
252        }
253    }
254}
255
256impl SecretResolver for ProductionSecretResolver {
257    fn resolve(&self, secret_ref: &str) -> Result<ResolvedSecret, SecretResolutionError> {
258        match parse_secret_reference(secret_ref)? {
259            ParsedSecretReference::MacOsKeychain { service, account } => {
260                resolve_macos_keychain_secret(service, account)
261            }
262            ParsedSecretReference::LocalFile => {
263                let value = LocalCredentialStore::production()
264                    .and_then(|store| store.resolve(secret_ref))
265                    .map_err(|_| SecretResolutionError)?;
266                ResolvedSecret::from_zeroizing(value)
267            }
268            ParsedSecretReference::Environment { name } => {
269                let value = std::env::var(name).map_err(|_| SecretResolutionError)?;
270                ResolvedSecret::from_zeroizing(Zeroizing::new(value.into_bytes()))
271            }
272            ParsedSecretReference::Command => self.command.resolve(secret_ref),
273        }
274    }
275}
276
277#[cfg(target_os = "macos")]
278fn resolve_macos_keychain_secret(
279    service: &str,
280    account: &str,
281) -> Result<ResolvedSecret, SecretResolutionError> {
282    use security_framework::item::SearchResult;
283
284    let mut options = local_data_protection_keychain_search(service, account);
285    options.load_data(true);
286    let mut results = options.search().map_err(|_| SecretResolutionError)?;
287    if results.len() != 1 {
288        return Err(SecretResolutionError);
289    }
290    let SearchResult::Data(value) = results.pop().ok_or(SecretResolutionError)? else {
291        return Err(SecretResolutionError);
292    };
293    ResolvedSecret::from_zeroizing(Zeroizing::new(value))
294}
295
296#[cfg(not(target_os = "macos"))]
297fn resolve_macos_keychain_secret(
298    _service: &str,
299    _account: &str,
300) -> Result<ResolvedSecret, SecretResolutionError> {
301    Err(SecretResolutionError)
302}
303
304/// Creates or replaces a validated generic-password entry in the local,
305/// non-synchronizing macOS Data Protection Keychain. The legacy login
306/// Keychain is not searched or mutated, so an older file-Keychain item with
307/// the same service/account may coexist until an explicit migration removes
308/// it. The credential is accepted only in memory and is never included in
309/// diagnostics.
310#[cfg(target_os = "macos")]
311pub fn upsert_macos_keychain_secret(
312    secret_ref: &str,
313    value: &[u8],
314) -> Result<(), SecretResolutionError> {
315    let ParsedSecretReference::MacOsKeychain { service, account } =
316        parse_secret_reference(secret_ref)?
317    else {
318        return Err(SecretResolutionError);
319    };
320    let value = ResolvedSecret::new(value.to_vec())?;
321    security_framework::passwords::set_generic_password_options(
322        value.expose_for_dispatch(),
323        local_data_protection_password_options(service, account),
324    )
325    .map_err(|_| SecretResolutionError)
326}
327
328/// Verifies that one exact local Data Protection Keychain item can be read
329/// without authentication UI. The resolved value remains zeroizing, is
330/// dropped immediately, and is never returned to the caller.
331#[cfg(target_os = "macos")]
332pub fn verify_macos_keychain_secret(secret_ref: &str) -> Result<(), SecretResolutionError> {
333    let ParsedSecretReference::MacOsKeychain { service, account } =
334        parse_secret_reference(secret_ref)?
335    else {
336        return Err(SecretResolutionError);
337    };
338    let secret = resolve_macos_keychain_secret(service, account)?;
339    drop(secret);
340    Ok(())
341}
342
343/// Deletes only the matching local Data Protection Keychain item. A legacy
344/// login-Keychain item with the same service/account remains untouched.
345/// Missing entries and platform errors remain content-free.
346#[cfg(target_os = "macos")]
347pub fn delete_macos_keychain_secret(secret_ref: &str) -> Result<(), SecretResolutionError> {
348    let ParsedSecretReference::MacOsKeychain { service, account } =
349        parse_secret_reference(secret_ref)?
350    else {
351        return Err(SecretResolutionError);
352    };
353    local_data_protection_keychain_search(service, account)
354        .delete()
355        .map_err(|_| SecretResolutionError)
356}
357
358#[cfg(target_os = "macos")]
359fn local_data_protection_password_options(
360    service: &str,
361    account: &str,
362) -> security_framework::passwords::PasswordOptions {
363    let mut options =
364        security_framework::passwords::PasswordOptions::new_generic_password(service, account);
365    options.use_protected_keychain();
366    options.set_access_synchronized(Some(false));
367    options
368}
369
370#[cfg(target_os = "macos")]
371fn local_data_protection_keychain_search(
372    service: &str,
373    account: &str,
374) -> security_framework::item::ItemSearchOptions {
375    use security_framework::item::{ItemClass, ItemSearchOptions};
376
377    let mut options = ItemSearchOptions::new();
378    options
379        .ignore_legacy_keychains()
380        .class(ItemClass::generic_password())
381        .service(service)
382        .account(account)
383        .cloud_sync(Some(false))
384        // Normal runtime reads are non-interactive. An ACL that would require
385        // UI fails closed instead of presenting a recurring system prompt or
386        // indefinitely holding an in-flight daemon request.
387        .skip_authenticated_items(true);
388    options
389}
390
391/// Resolves only an owner-selected executable referenced as
392/// `command:/absolute/path`. The helper receives no arguments, stdin, stderr,
393/// inherited environment, or shell interpretation. All failure diagnostics are
394/// deliberately content-free.
395#[derive(Clone, Copy)]
396pub struct CommandSecretResolver {
397    timeout: Duration,
398}
399
400impl Default for CommandSecretResolver {
401    fn default() -> Self {
402        Self {
403            timeout: SECRET_HELPER_TIMEOUT,
404        }
405    }
406}
407
408impl fmt::Debug for CommandSecretResolver {
409    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
410        formatter
411            .debug_struct("CommandSecretResolver")
412            .field("credential", &"[REDACTED]")
413            .field("timeout", &self.timeout)
414            .finish()
415    }
416}
417
418impl CommandSecretResolver {
419    pub fn supports_reference(secret_ref: &str) -> bool {
420        command_secret_path(secret_ref).is_ok() && cfg!(unix)
421    }
422
423    /// Performs the same redacted path/ownership/permission validation used
424    /// immediately before execution, without starting the helper.
425    pub fn validate_reference(secret_ref: &str) -> Result<(), SecretResolutionError> {
426        #[cfg(unix)]
427        {
428            open_secret_helper_source(secret_ref).map(|_| ())
429        }
430        #[cfg(not(unix))]
431        {
432            let _ = secret_ref;
433            Err(SecretResolutionError)
434        }
435    }
436
437    #[cfg(test)]
438    const fn with_timeout(timeout: Duration) -> Self {
439        Self { timeout }
440    }
441}
442
443impl SecretResolver for CommandSecretResolver {
444    fn resolve(&self, secret_ref: &str) -> Result<ResolvedSecret, SecretResolutionError> {
445        #[cfg(unix)]
446        {
447            resolve_command_secret(secret_ref, self.timeout)
448        }
449        #[cfg(not(unix))]
450        {
451            let _ = (secret_ref, self.timeout);
452            Err(SecretResolutionError)
453        }
454    }
455}
456
457fn command_secret_path(secret_ref: &str) -> Result<PathBuf, SecretResolutionError> {
458    let value = secret_ref
459        .strip_prefix("command:")
460        .ok_or(SecretResolutionError)?;
461    let path = PathBuf::from(value);
462    if value.is_empty()
463        || value.len() > 4096
464        || value.chars().any(char::is_control)
465        || !path.is_absolute()
466        || path
467            .components()
468            .any(|component| !matches!(component, Component::RootDir | Component::Normal(_)))
469    {
470        return Err(SecretResolutionError);
471    }
472    Ok(path)
473}
474
475#[cfg(unix)]
476fn resolve_command_secret(
477    secret_ref: &str,
478    timeout: Duration,
479) -> Result<ResolvedSecret, SecretResolutionError> {
480    resolve_command_secret_with_hook(secret_ref, timeout, |_| Ok(()))
481}
482
483#[cfg(unix)]
484fn resolve_command_secret_with_hook<F>(
485    secret_ref: &str,
486    timeout: Duration,
487    after_source_open: F,
488) -> Result<ResolvedSecret, SecretResolutionError>
489where
490    F: FnOnce(&Path) -> Result<(), SecretResolutionError>,
491{
492    let (source_path, mut source) = open_secret_helper_source(secret_ref)?;
493    after_source_open(&source_path)?;
494    let source_bytes = read_bounded_source(&mut source)?;
495    let staged = StagedSecretHelper::create(&source_path, &source_bytes)?;
496    staged.verify(&source_bytes)?;
497    let mut command = Command::new(&staged.path);
498    command
499        .env_clear()
500        .stdin(Stdio::null())
501        .stderr(Stdio::null())
502        .stdout(Stdio::piped())
503        .process_group(0);
504    let mut child = SecretHelperChild(command.spawn().map_err(|_| SecretResolutionError)?);
505    staged.verify(&source_bytes)?;
506    let mut stdout = child.0.stdout.take().ok_or(SecretResolutionError)?;
507    rustix::fs::fcntl_setfl(&stdout, rustix::fs::OFlags::NONBLOCK)
508        .map_err(|_| SecretResolutionError)?;
509    let started = Instant::now();
510    let mut value = Zeroizing::new(Vec::with_capacity(256));
511    let status = loop {
512        read_bounded_nonblocking(&mut stdout, &mut value)?;
513        if let Some(status) = child.0.try_wait().map_err(|_| SecretResolutionError)? {
514            read_bounded_nonblocking(&mut stdout, &mut value)?;
515            break status;
516        }
517        if started.elapsed() >= timeout {
518            return Err(SecretResolutionError);
519        }
520        thread::sleep(SECRET_HELPER_POLL_INTERVAL);
521    };
522    if !status.success() {
523        return Err(SecretResolutionError);
524    }
525    if value.ends_with(b"\r\n") {
526        let trimmed_len = value.len() - 2;
527        value.truncate(trimmed_len);
528    } else if value.ends_with(b"\n") {
529        value.pop();
530    }
531    drop(stdout);
532    drop(child);
533    staged.cleanup(&source_bytes)?;
534    ResolvedSecret::from_zeroizing(value)
535}
536
537#[cfg(unix)]
538struct SecretHelperChild(Child);
539
540#[cfg(unix)]
541impl Drop for SecretHelperChild {
542    fn drop(&mut self) {
543        let process_group = rustix::process::Pid::from_child(&self.0);
544        let _ = rustix::process::kill_process_group(process_group, rustix::process::Signal::KILL);
545        let _ = self.0.kill();
546        let _ = self.0.wait();
547    }
548}
549
550#[cfg(unix)]
551fn validate_safe_ancestry(
552    path: &Path,
553    final_directory_owner_only: bool,
554) -> Result<(), SecretResolutionError> {
555    let expected_uid = rustix::process::geteuid().as_raw();
556    let mut cursor = PathBuf::new();
557    for component in path.components() {
558        cursor.push(component.as_os_str());
559        let metadata = std::fs::symlink_metadata(&cursor).map_err(|_| SecretResolutionError)?;
560        let owner_is_trusted = metadata.uid() == 0 || metadata.uid() == expected_uid;
561        if metadata.file_type().is_symlink()
562            || !metadata.is_dir()
563            || !owner_is_trusted
564            || metadata.mode() & 0o022 != 0
565            || (cursor == path
566                && final_directory_owner_only
567                && (metadata.uid() != expected_uid || metadata.mode() & 0o777 != 0o700))
568        {
569            return Err(SecretResolutionError);
570        }
571    }
572    Ok(())
573}
574
575#[cfg(unix)]
576#[derive(Clone, Copy, Debug, PartialEq, Eq)]
577struct SecretHelperIdentity {
578    device: u64,
579    inode: u64,
580    uid: u32,
581    mode: u32,
582    length: u64,
583    modified_seconds: i64,
584    modified_nanoseconds: i64,
585    changed_seconds: i64,
586    changed_nanoseconds: i64,
587}
588
589#[cfg(unix)]
590#[derive(Clone, Copy, Debug, PartialEq, Eq)]
591struct StableNodeIdentity {
592    device: u64,
593    inode: u64,
594    uid: u32,
595    mode: u32,
596}
597
598#[cfg(unix)]
599fn stable_node_identity(metadata: &std::fs::Metadata) -> StableNodeIdentity {
600    StableNodeIdentity {
601        device: metadata.dev(),
602        inode: metadata.ino(),
603        uid: metadata.uid(),
604        mode: metadata.mode(),
605    }
606}
607
608#[cfg(unix)]
609fn metadata_identity(metadata: &std::fs::Metadata) -> SecretHelperIdentity {
610    SecretHelperIdentity {
611        device: metadata.dev(),
612        inode: metadata.ino(),
613        uid: metadata.uid(),
614        mode: metadata.mode(),
615        length: metadata.len(),
616        modified_seconds: metadata.mtime(),
617        modified_nanoseconds: metadata.mtime_nsec(),
618        changed_seconds: metadata.ctime(),
619        changed_nanoseconds: metadata.ctime_nsec(),
620    }
621}
622
623#[cfg(unix)]
624fn open_secret_helper_source(secret_ref: &str) -> Result<(PathBuf, File), SecretResolutionError> {
625    let path = command_secret_path(secret_ref)?;
626    let parent = path.parent().ok_or(SecretResolutionError)?;
627    validate_safe_ancestry(parent, false)?;
628    let mut options = OpenOptions::new();
629    options
630        .read(true)
631        .custom_flags((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits() as i32);
632    let file = options.open(&path).map_err(|_| SecretResolutionError)?;
633    let metadata = file.metadata().map_err(|_| SecretResolutionError)?;
634    validate_source_metadata(&metadata)?;
635    Ok((path, file))
636}
637
638#[cfg(unix)]
639fn validate_source_metadata(metadata: &std::fs::Metadata) -> Result<(), SecretResolutionError> {
640    if !metadata.is_file()
641        || metadata.uid() != rustix::process::geteuid().as_raw()
642        || metadata.nlink() != 1
643        || metadata.mode() & 0o7777 != 0o700
644        || metadata.len() == 0
645        || metadata.len() > MAX_SECRET_HELPER_BYTES as u64
646    {
647        return Err(SecretResolutionError);
648    }
649    Ok(())
650}
651
652#[cfg(unix)]
653fn read_bounded_source(source: &mut File) -> Result<Vec<u8>, SecretResolutionError> {
654    let metadata = source.metadata().map_err(|_| SecretResolutionError)?;
655    validate_source_metadata(&metadata)?;
656    let mut bytes = Vec::with_capacity(metadata.len() as usize);
657    Read::by_ref(source)
658        .take(MAX_SECRET_HELPER_BYTES as u64 + 1)
659        .read_to_end(&mut bytes)
660        .map_err(|_| SecretResolutionError)?;
661    let final_metadata = source.metadata().map_err(|_| SecretResolutionError)?;
662    if bytes.len() > MAX_SECRET_HELPER_BYTES
663        || bytes.len() as u64 != metadata.len()
664        || metadata.dev() != final_metadata.dev()
665        || metadata.ino() != final_metadata.ino()
666        || metadata.uid() != final_metadata.uid()
667        || metadata.mode() != final_metadata.mode()
668        || metadata.len() != final_metadata.len()
669        || metadata.mtime() != final_metadata.mtime()
670        || metadata.mtime_nsec() != final_metadata.mtime_nsec()
671    {
672        return Err(SecretResolutionError);
673    }
674    Ok(bytes)
675}
676
677#[cfg(unix)]
678#[derive(Debug)]
679struct StagedSecretHelper {
680    parent: PathBuf,
681    directory: PathBuf,
682    path: PathBuf,
683    directory_identity: StableNodeIdentity,
684    file_identity: Option<SecretHelperIdentity>,
685}
686
687#[cfg(unix)]
688impl StagedSecretHelper {
689    fn create(source_path: &Path, bytes: &[u8]) -> Result<Self, SecretResolutionError> {
690        let parent = source_path
691            .parent()
692            .ok_or(SecretResolutionError)?
693            .to_owned();
694        validate_safe_ancestry(&parent, false)?;
695        let counter = SECRET_HELPER_STAGE_COUNTER.fetch_add(1, Ordering::Relaxed);
696        let nonce = SystemTime::now()
697            .duration_since(UNIX_EPOCH)
698            .map_err(|_| SecretResolutionError)?
699            .as_nanos();
700        let directory = parent.join(format!(
701            ".steadymux-secret-helper-{}-{counter}-{nonce}",
702            std::process::id()
703        ));
704        std::fs::DirBuilder::new()
705            .mode(0o700)
706            .create(&directory)
707            .map_err(|_| SecretResolutionError)?;
708        let directory_metadata =
709            std::fs::symlink_metadata(&directory).map_err(|_| SecretResolutionError)?;
710        let directory_identity = stable_node_identity(&directory_metadata);
711        let path = directory.join("helper");
712        let mut cleanup = Self {
713            parent,
714            directory,
715            path,
716            directory_identity,
717            file_identity: None,
718        };
719        let result = (|| {
720            validate_safe_ancestry(&cleanup.directory, true)?;
721            sync_directory(&cleanup.parent)?;
722            let mut options = OpenOptions::new();
723            options
724                .write(true)
725                .create_new(true)
726                .mode(0o700)
727                .custom_flags(
728                    (rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits() as i32,
729                );
730            let mut file = options
731                .open(&cleanup.path)
732                .map_err(|_| SecretResolutionError)?;
733            file.write_all(bytes).map_err(|_| SecretResolutionError)?;
734            file.sync_all().map_err(|_| SecretResolutionError)?;
735            let metadata = file.metadata().map_err(|_| SecretResolutionError)?;
736            cleanup.file_identity = Some(metadata_identity(&metadata));
737            drop(file);
738            cleanup.verify(bytes)?;
739            sync_directory(&cleanup.directory)
740        })();
741        if result.is_err() {
742            drop(cleanup);
743            return Err(SecretResolutionError);
744        }
745        Ok(cleanup)
746    }
747
748    fn verify(&self, expected: &[u8]) -> Result<(), SecretResolutionError> {
749        validate_safe_ancestry(&self.directory, true)?;
750        let directory_metadata =
751            std::fs::symlink_metadata(&self.directory).map_err(|_| SecretResolutionError)?;
752        if stable_node_identity(&directory_metadata) != self.directory_identity {
753            return Err(SecretResolutionError);
754        }
755        let mut options = OpenOptions::new();
756        options.read(true).custom_flags(
757            (rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits() as i32,
758        );
759        let mut file = options
760            .open(&self.path)
761            .map_err(|_| SecretResolutionError)?;
762        let metadata = file.metadata().map_err(|_| SecretResolutionError)?;
763        if Some(metadata_identity(&metadata)) != self.file_identity
764            || !metadata.is_file()
765            || metadata.uid() != rustix::process::geteuid().as_raw()
766            || metadata.mode() & 0o777 != 0o700
767            || metadata.nlink() != 1
768            || metadata.len() != expected.len() as u64
769        {
770            return Err(SecretResolutionError);
771        }
772        let mut observed = Vec::with_capacity(expected.len());
773        Read::by_ref(&mut file)
774            .take(expected.len() as u64 + 1)
775            .read_to_end(&mut observed)
776            .map_err(|_| SecretResolutionError)?;
777        let final_metadata = file.metadata().map_err(|_| SecretResolutionError)?;
778        if observed != expected || Some(metadata_identity(&final_metadata)) != self.file_identity {
779            return Err(SecretResolutionError);
780        }
781        Ok(())
782    }
783
784    fn cleanup(mut self, expected: &[u8]) -> Result<(), SecretResolutionError> {
785        let identity = self.file_identity.ok_or(SecretResolutionError)?;
786        let mut options = OpenOptions::new();
787        options.read(true).custom_flags(
788            (rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits() as i32,
789        );
790        let mut file = options
791            .open(&self.path)
792            .map_err(|_| SecretResolutionError)?;
793        let metadata = file.metadata().map_err(|_| SecretResolutionError)?;
794        if !metadata.is_file()
795            || metadata.dev() != identity.device
796            || metadata.ino() != identity.inode
797            || metadata.uid() != identity.uid
798            || metadata.mode() != identity.mode
799            || metadata.nlink() != 1
800            || metadata.len() != identity.length
801        {
802            return Err(SecretResolutionError);
803        }
804        let mut observed = Vec::with_capacity(expected.len());
805        Read::by_ref(&mut file)
806            .take(expected.len() as u64 + 1)
807            .read_to_end(&mut observed)
808            .map_err(|_| SecretResolutionError)?;
809        if observed != expected {
810            return Err(SecretResolutionError);
811        }
812        drop(file);
813        let directory_metadata =
814            std::fs::symlink_metadata(&self.directory).map_err(|_| SecretResolutionError)?;
815        if stable_node_identity(&directory_metadata) != self.directory_identity {
816            return Err(SecretResolutionError);
817        }
818        std::fs::remove_file(&self.path).map_err(|_| SecretResolutionError)?;
819        self.file_identity = None;
820        sync_directory(&self.directory)?;
821        std::fs::remove_dir(&self.directory).map_err(|_| SecretResolutionError)?;
822        sync_directory(&self.parent)
823    }
824}
825
826#[cfg(unix)]
827impl Drop for StagedSecretHelper {
828    fn drop(&mut self) {
829        if std::fs::symlink_metadata(&self.path).is_ok_and(|metadata| {
830            !metadata.file_type().is_symlink()
831                && metadata.is_file()
832                && Some(metadata_identity(&metadata)) == self.file_identity
833        }) {
834            let _ = std::fs::remove_file(&self.path);
835            let _ = sync_directory(&self.directory);
836        }
837        if std::fs::symlink_metadata(&self.directory).is_ok_and(|metadata| {
838            !metadata.file_type().is_symlink()
839                && metadata.is_dir()
840                && stable_node_identity(&metadata) == self.directory_identity
841        }) {
842            let _ = std::fs::remove_dir(&self.directory);
843            let _ = sync_directory(&self.parent);
844        }
845    }
846}
847
848#[cfg(unix)]
849fn sync_directory(path: &Path) -> Result<(), SecretResolutionError> {
850    File::open(path)
851        .and_then(|directory| directory.sync_all())
852        .map_err(|_| SecretResolutionError)
853}
854
855#[cfg(unix)]
856fn read_bounded_nonblocking(
857    stdout: &mut std::process::ChildStdout,
858    value: &mut Zeroizing<Vec<u8>>,
859) -> Result<(), SecretResolutionError> {
860    let mut buffer = [0_u8; 4096];
861    loop {
862        match stdout.read(&mut buffer) {
863            Ok(0) => return Ok(()),
864            Ok(read) => {
865                if value.len().saturating_add(read) > MAX_RESOLVED_SECRET_BYTES {
866                    return Err(SecretResolutionError);
867                }
868                value.extend_from_slice(&buffer[..read]);
869            }
870            Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => return Ok(()),
871            Err(_) => return Err(SecretResolutionError),
872        }
873    }
874}
875
876#[derive(Clone, Copy, Debug, PartialEq, Eq)]
877pub enum ProviderWireProtocol {
878    OllamaNdjson,
879    OpenRouterSse,
880    OpenAiCompatSse,
881    AnthropicSse,
882    GeminiSse,
883}
884
885/// Fully prepared only immediately before the injected executor is called.
886/// Debug deliberately excludes the body, secret reference, and resolved secret.
887pub struct PreparedRequest {
888    pub method: &'static str,
889    pub origin: String,
890    pub path: String,
891    pub content_type: &'static str,
892    pub accept: &'static str,
893    pub network: NetworkPolicy,
894    pub protocol: ProviderWireProtocol,
895    body: Vec<u8>,
896    authorization: Option<ResolvedSecret>,
897    /// When set, the executor writes the resolved secret in this header name
898    /// instead of the `Authorization: Bearer` header. The Anthropic
899    /// (`x-api-key`) and Gemini (`x-goog-api-key`) protocols require a custom
900    /// API-key header.
901    pub auth_header_name: Option<&'static str>,
902    /// A fixed protocol header the executor writes alongside the credential
903    /// header. The Anthropic Messages protocol requires the pinned
904    /// `anthropic-version: 2023-06-01` header on every request.
905    pub version_header: Option<(&'static str, &'static str)>,
906    /// True for the bounded non-streaming completion dispatch (model
907    /// intelligence categorization). Bounded requests carry `application/json`
908    /// accept and are validated against the bounded allowlist, which accepts
909    /// the OpenRouter origin with the OpenAiCompat wire protocol.
910    pub bounded: bool,
911}
912
913impl PreparedRequest {
914    pub fn body_for_dispatch(&self) -> &[u8] {
915        &self.body
916    }
917
918    pub fn authorization_for_dispatch(&self) -> Option<&[u8]> {
919        self.authorization
920            .as_ref()
921            .map(ResolvedSecret::expose_for_dispatch)
922    }
923}
924
925impl fmt::Debug for PreparedRequest {
926    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
927        formatter
928            .debug_struct("PreparedRequest")
929            .field("method", &self.method)
930            .field("origin", &self.origin)
931            .field("path", &self.path)
932            .field("content_type", &self.content_type)
933            .field("accept", &self.accept)
934            .field("network", &self.network)
935            .field("protocol", &self.protocol)
936            .field("body", &"[REDACTED]")
937            .field(
938                "authorization",
939                &self.authorization.as_ref().map(|_| "[REDACTED]"),
940            )
941            .field("auth_header_name", &self.auth_header_name)
942            .field("version_header", &self.version_header)
943            .finish()
944    }
945}
946
947#[derive(Clone, Debug, PartialEq, Eq)]
948pub struct ProviderHttpResponse {
949    pub status: u16,
950    pub content_type: Option<String>,
951    pub retry_after: Option<String>,
952    pub redirected: bool,
953    pub request_id: Option<String>,
954    pub chunks: Vec<Vec<u8>>,
955}
956
957#[derive(Clone, Debug, Default)]
958pub struct DispatchControl {
959    pub cancelled_before_dispatch: bool,
960    pub forwarded_tool_result: bool,
961    pub cancellation: Option<CancellationSignal>,
962}
963
964#[derive(Clone, Debug)]
965pub struct CancellationSignal {
966    inner: Arc<CancellationState>,
967}
968
969#[derive(Debug)]
970struct CancellationState {
971    cancelled: AtomicBool,
972    notify: Notify,
973}
974
975impl Default for CancellationSignal {
976    fn default() -> Self {
977        Self::new()
978    }
979}
980
981impl CancellationSignal {
982    pub fn new() -> Self {
983        Self {
984            inner: Arc::new(CancellationState {
985                cancelled: AtomicBool::new(false),
986                notify: Notify::new(),
987            }),
988        }
989    }
990
991    pub fn cancel(&self) {
992        self.inner.cancelled.store(true, Ordering::Release);
993        self.inner.notify.notify_waiters();
994    }
995
996    pub fn is_cancelled(&self) -> bool {
997        self.inner.cancelled.load(Ordering::Acquire)
998    }
999
1000    async fn cancelled(&self) {
1001        loop {
1002            let notified = self.inner.notify.notified();
1003            if self.is_cancelled() {
1004                return;
1005            }
1006            notified.await;
1007        }
1008    }
1009}
1010
1011#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1012pub struct ProviderHttpError {
1013    pub kind: ProviderHttpErrorKind,
1014    pub body_write: BodyWriteState,
1015}
1016
1017#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1018pub enum ProviderHttpErrorKind {
1019    Cancelled,
1020    Timeout,
1021    Transport,
1022    InvalidPreparedRequest,
1023    ResponseTooLarge,
1024}
1025
1026impl fmt::Display for ProviderHttpError {
1027    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1028        formatter.write_str("provider HTTP execution failed")
1029    }
1030}
1031
1032impl std::error::Error for ProviderHttpError {}
1033
1034pub type ProviderHttpFuture<'a> =
1035    Pin<Box<dyn Future<Output = Result<ProviderHttpResponse, ProviderHttpError>> + Send + 'a>>;
1036
1037pub trait ProviderHttpExecutor: Send + Sync {
1038    fn execute<'a>(
1039        &'a self,
1040        request: PreparedRequest,
1041        control: DispatchControl,
1042    ) -> ProviderHttpFuture<'a>;
1043}
1044
1045#[derive(Clone)]
1046pub struct ReqwestExecutor {
1047    client: reqwest::Client,
1048    absolute_timeout: Duration,
1049}
1050
1051impl fmt::Debug for ReqwestExecutor {
1052    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1053        formatter
1054            .debug_struct("ReqwestExecutor")
1055            .field("absolute_timeout", &self.absolute_timeout)
1056            .field("redirects", &"disabled")
1057            .field("proxy", &"disabled")
1058            .field("tls", &"rustls")
1059            .finish()
1060    }
1061}
1062
1063#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1064pub struct ReqwestExecutorBuildError;
1065
1066impl fmt::Display for ReqwestExecutorBuildError {
1067    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1068        formatter.write_str("provider HTTP client construction failed")
1069    }
1070}
1071
1072impl std::error::Error for ReqwestExecutorBuildError {}
1073
1074impl ReqwestExecutor {
1075    pub fn new(
1076        connect_timeout: Duration,
1077        read_timeout: Duration,
1078        absolute_timeout: Duration,
1079    ) -> Result<Self, ReqwestExecutorBuildError> {
1080        if connect_timeout.is_zero()
1081            || read_timeout.is_zero()
1082            || absolute_timeout.is_zero()
1083            || absolute_timeout < connect_timeout
1084        {
1085            return Err(ReqwestExecutorBuildError);
1086        }
1087        let client = reqwest::Client::builder()
1088            .use_rustls_tls()
1089            .redirect(reqwest::redirect::Policy::none())
1090            .no_proxy()
1091            .user_agent(FIXED_USER_AGENT)
1092            .connect_timeout(connect_timeout)
1093            .read_timeout(read_timeout)
1094            .build()
1095            .map_err(|_| ReqwestExecutorBuildError)?;
1096        Ok(Self {
1097            client,
1098            absolute_timeout,
1099        })
1100    }
1101
1102    pub const fn user_agent(&self) -> &'static str {
1103        FIXED_USER_AGENT
1104    }
1105
1106    async fn execute_bounded(
1107        &self,
1108        request: PreparedRequest,
1109        control: DispatchControl,
1110    ) -> Result<ProviderHttpResponse, ProviderHttpError> {
1111        validate_prepared_request(&request)?;
1112        if control.cancelled_before_dispatch
1113            || control
1114                .cancellation
1115                .as_ref()
1116                .is_some_and(CancellationSignal::is_cancelled)
1117        {
1118            return Err(http_error(
1119                ProviderHttpErrorKind::Cancelled,
1120                BodyWriteState::NotWritten,
1121            ));
1122        }
1123
1124        let exact_url = format!("{}{}", request.origin, request.path);
1125        let mut builder = self
1126            .client
1127            .post(&exact_url)
1128            .header(CONTENT_TYPE, request.content_type)
1129            .header(ACCEPT, request.accept)
1130            .body(request.body);
1131        if let Some(authorization) = request.authorization {
1132            let value =
1133                sensitive_header_value(authorization.expose_for_dispatch()).map_err(|_| {
1134                    http_error(
1135                        ProviderHttpErrorKind::InvalidPreparedRequest,
1136                        BodyWriteState::NotWritten,
1137                    )
1138                })?;
1139            match request.auth_header_name {
1140                // The Anthropic and Gemini protocols carry the API key in a
1141                // custom header rather than `Authorization`.
1142                Some(name) => builder = builder.header(name, value),
1143                None => builder = builder.header(AUTHORIZATION, value),
1144            }
1145        }
1146        if let Some((name, value)) = request.version_header {
1147            builder = builder.header(name, value);
1148        }
1149
1150        let cancellation = control.cancellation.as_ref();
1151        let response = tokio::select! {
1152            result = builder.send() => result.map_err(|error| {
1153                let kind = if error.is_timeout() {
1154                    ProviderHttpErrorKind::Timeout
1155                } else {
1156                    ProviderHttpErrorKind::Transport
1157                };
1158                http_error(kind, BodyWriteState::Unknown)
1159            })?,
1160            () = wait_for_cancellation(cancellation) => {
1161                return Err(http_error(
1162                    ProviderHttpErrorKind::Cancelled,
1163                    BodyWriteState::Unknown,
1164                ));
1165            }
1166        };
1167
1168        let status = response.status().as_u16();
1169        let content_type = bounded_header(response.headers(), CONTENT_TYPE.as_str());
1170        let retry_after = bounded_header(response.headers(), "retry-after");
1171        let request_id = bounded_request_id(response.headers());
1172        let redirected = response.url().as_str() != exact_url;
1173        let success = response.status().as_u16() == 200;
1174        let mut stream = response.bytes_stream();
1175        let mut chunks = Vec::new();
1176        let mut total = 0usize;
1177        loop {
1178            let next = tokio::select! {
1179                next = stream.next() => next,
1180                () = wait_for_cancellation(cancellation) => {
1181                    return Err(http_error(
1182                        ProviderHttpErrorKind::Cancelled,
1183                        BodyWriteState::Written,
1184                    ));
1185                }
1186            };
1187            let Some(chunk) = next else {
1188                break;
1189            };
1190            let chunk = chunk.map_err(|error| {
1191                let kind = if error.is_timeout() {
1192                    ProviderHttpErrorKind::Timeout
1193                } else {
1194                    ProviderHttpErrorKind::Transport
1195                };
1196                http_error(kind, BodyWriteState::Written)
1197            })?;
1198            total = total.checked_add(chunk.len()).ok_or_else(|| {
1199                http_error(
1200                    ProviderHttpErrorKind::ResponseTooLarge,
1201                    BodyWriteState::Written,
1202                )
1203            })?;
1204            let limit = if success {
1205                MAX_RESPONSE_BYTES
1206            } else {
1207                MAX_ERROR_BODY_BYTES
1208            };
1209            if total > limit || (success && chunks.len() >= MAX_RESPONSE_CHUNKS) {
1210                if success {
1211                    return Err(http_error(
1212                        ProviderHttpErrorKind::ResponseTooLarge,
1213                        BodyWriteState::Written,
1214                    ));
1215                }
1216                break;
1217            }
1218            if success {
1219                chunks.push(chunk.to_vec());
1220            }
1221        }
1222
1223        Ok(ProviderHttpResponse {
1224            status,
1225            content_type,
1226            retry_after,
1227            redirected,
1228            request_id,
1229            chunks,
1230        })
1231    }
1232}
1233
1234fn sensitive_header_value(bytes: &[u8]) -> Result<reqwest::header::HeaderValue, ()> {
1235    let mut value = reqwest::header::HeaderValue::from_bytes(bytes).map_err(|_| ())?;
1236    value.set_sensitive(true);
1237    Ok(value)
1238}
1239
1240impl ProviderHttpExecutor for ReqwestExecutor {
1241    fn execute<'a>(
1242        &'a self,
1243        request: PreparedRequest,
1244        control: DispatchControl,
1245    ) -> ProviderHttpFuture<'a> {
1246        Box::pin(async move {
1247            tokio::time::timeout(
1248                self.absolute_timeout,
1249                self.execute_bounded(request, control),
1250            )
1251            .await
1252            .map_err(|_| http_error(ProviderHttpErrorKind::Timeout, BodyWriteState::Unknown))?
1253        })
1254    }
1255}
1256
1257async fn wait_for_cancellation(signal: Option<&CancellationSignal>) {
1258    match signal {
1259        Some(signal) => signal.cancelled().await,
1260        None => std::future::pending().await,
1261    }
1262}
1263
1264fn validate_prepared_request(request: &PreparedRequest) -> Result<(), ProviderHttpError> {
1265    let base_policy = !request.network.allow_proxy && !request.network.follow_redirects;
1266    let localhost = request.origin == "http://127.0.0.1:11434"
1267        && request.path == "/api/chat"
1268        && request.protocol == ProviderWireProtocol::OllamaNdjson
1269        && !request.network.allow_dns
1270        && !request.network.require_tls
1271        && request.authorization.is_none();
1272    let direct_ollama = request.origin == "https://ollama.com"
1273        && request.path == "/api/chat"
1274        && request.protocol == ProviderWireProtocol::OllamaNdjson
1275        && request.network.allow_dns
1276        && request.network.require_tls
1277        && request.authorization.is_some();
1278    let openrouter = request.origin == "https://openrouter.ai"
1279        && request.path == "/api/v1/chat/completions"
1280        && request.protocol == ProviderWireProtocol::OpenRouterSse
1281        && request.network.allow_dns
1282        && request.network.require_tls
1283        && request.authorization.is_some();
1284    let openai_compat = matches!(
1285        (request.origin.as_str(), request.path.as_str()),
1286        (
1287            router_protocol::DASHSCOPE_ORIGIN,
1288            router_protocol::DASHSCOPE_CHAT_PATH
1289        ) | (
1290            router_protocol::DASHSCOPE_TOKEN_PLAN_ORIGIN,
1291            router_protocol::DASHSCOPE_CHAT_PATH
1292        ) | (router_protocol::XAI_ORIGIN, router_protocol::XAI_CHAT_PATH)
1293            | (router_protocol::ZAI_ORIGIN, router_protocol::ZAI_CHAT_PATH)
1294            | (
1295                router_protocol::MOONSHOT_ORIGIN,
1296                router_protocol::MOONSHOT_CHAT_PATH
1297            )
1298            | (
1299                router_protocol::DEEPSEEK_ORIGIN,
1300                router_protocol::DEEPSEEK_CHAT_PATH
1301            )
1302    ) && request.protocol == ProviderWireProtocol::OpenAiCompatSse
1303        && request.network.allow_dns
1304        && request.network.require_tls
1305        && request.authorization.is_some();
1306    // The Anthropic Messages protocol: static `/v1/messages` path, the API key
1307    // in the `x-api-key` header, and the pinned `anthropic-version` header.
1308    let anthropic = request.origin == router_protocol::ANTHROPIC_ORIGIN
1309        && request.path == router_protocol::ANTHROPIC_CHAT_PATH
1310        && request.protocol == ProviderWireProtocol::AnthropicSse
1311        && request.network.allow_dns
1312        && request.network.require_tls
1313        && request.authorization.is_some()
1314        && request.auth_header_name == Some(router_protocol::ANTHROPIC_API_KEY_HEADER)
1315        && request.version_header
1316            == Some((
1317                router_protocol::ANTHROPIC_VERSION_HEADER,
1318                router_protocol::ANTHROPIC_VERSION,
1319            ));
1320    // The Gemini generateContent protocol: the API key in the `x-goog-api-key`
1321    // header and the model id materialized in the path.
1322    let gemini = request.origin == router_protocol::GEMINI_ORIGIN
1323        && request.protocol == ProviderWireProtocol::GeminiSse
1324        && request.network.allow_dns
1325        && request.network.require_tls
1326        && request.authorization.is_some()
1327        && request.auth_header_name == Some(router_protocol::GEMINI_API_KEY_HEADER)
1328        && request.version_header.is_none();
1329    let accept_matches = match request.protocol {
1330        ProviderWireProtocol::OpenRouterSse
1331        | ProviderWireProtocol::OpenAiCompatSse
1332        | ProviderWireProtocol::AnthropicSse
1333        | ProviderWireProtocol::GeminiSse => request.accept == "text/event-stream",
1334        ProviderWireProtocol::OllamaNdjson => {
1335            matches!(request.accept, "application/x-ndjson" | "application/json")
1336        }
1337    };
1338    // Bounded non-streaming completion dispatch (model intelligence
1339    // categorization). The request is genuinely non-streaming, so it carries
1340    // `application/json` accept; the allowlist reuses the streaming endpoint
1341    // predicates but accepts the OpenRouter origin with the OpenAiCompat wire
1342    // protocol (the only shape that differs from the streaming allowlist).
1343    let bounded_openrouter = request.origin == "https://openrouter.ai"
1344        && request.path == "/api/v1/chat/completions"
1345        && request.protocol == ProviderWireProtocol::OpenAiCompatSse
1346        && request.network.allow_dns
1347        && request.network.require_tls
1348        && request.authorization.is_some();
1349    // The bounded Anthropic and Gemini calls are non-streaming and carry the
1350    // API key in the same custom headers as their streaming siblings (plus the
1351    // pinned `anthropic-version` header for Anthropic). The Gemini path is
1352    // model-materialized, so like the streaming predicate it is not fixed.
1353    let bounded_anthropic = request.origin == router_protocol::ANTHROPIC_ORIGIN
1354        && request.path == router_protocol::ANTHROPIC_CHAT_PATH
1355        && request.protocol == ProviderWireProtocol::AnthropicSse
1356        && request.network.allow_dns
1357        && request.network.require_tls
1358        && request.authorization.is_some()
1359        && request.auth_header_name == Some(router_protocol::ANTHROPIC_API_KEY_HEADER)
1360        && request.version_header
1361            == Some((
1362                router_protocol::ANTHROPIC_VERSION_HEADER,
1363                router_protocol::ANTHROPIC_VERSION,
1364            ));
1365    let bounded_gemini = request.origin == router_protocol::GEMINI_ORIGIN
1366        && request.protocol == ProviderWireProtocol::GeminiSse
1367        && request.network.allow_dns
1368        && request.network.require_tls
1369        && request.authorization.is_some()
1370        && request.auth_header_name == Some(router_protocol::GEMINI_API_KEY_HEADER)
1371        && request.version_header.is_none();
1372    let bounded = request.bounded
1373        && (localhost
1374            || direct_ollama
1375            || bounded_openrouter
1376            || openai_compat
1377            || bounded_anthropic
1378            || bounded_gemini);
1379    if request.method != "POST"
1380        || request.content_type != "application/json"
1381        || !(accept_matches || (request.bounded && request.accept == "application/json"))
1382        || request.body.is_empty()
1383        || request.body.len() > MAX_REQUEST_BODY_BYTES
1384        || !base_policy
1385        || !(localhost
1386            || direct_ollama
1387            || openrouter
1388            || openai_compat
1389            || anthropic
1390            || gemini
1391            || bounded)
1392    {
1393        return Err(http_error(
1394            ProviderHttpErrorKind::InvalidPreparedRequest,
1395            BodyWriteState::NotWritten,
1396        ));
1397    }
1398    Ok(())
1399}
1400
1401fn bounded_header(headers: &reqwest::header::HeaderMap, name: &str) -> Option<String> {
1402    headers
1403        .get(name)
1404        .and_then(|value| value.to_str().ok())
1405        .filter(|value| value.len() <= MAX_REQUEST_ID_BYTES)
1406        .map(str::to_owned)
1407}
1408
1409fn bounded_request_id(headers: &reqwest::header::HeaderMap) -> Option<String> {
1410    ["x-request-id", "x-openrouter-request-id"]
1411        .into_iter()
1412        .find_map(|name| bounded_header(headers, name))
1413        .filter(|value| {
1414            !value.is_empty()
1415                && value.bytes().all(|byte| {
1416                    byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')
1417                })
1418        })
1419}
1420
1421const fn http_error(kind: ProviderHttpErrorKind, body_write: BodyWriteState) -> ProviderHttpError {
1422    ProviderHttpError { kind, body_write }
1423}
1424
1425#[derive(Clone, Debug, PartialEq)]
1426pub enum NormalizedProviderEvent {
1427    Ollama(OllamaStreamEvent),
1428    OpenRouter(OpenRouterStreamEvent),
1429    OpenAiCompat(OpenAiCompatStreamEvent),
1430    Anthropic(AnthropicStreamEvent),
1431    Gemini(GeminiStreamEvent),
1432}
1433
1434#[derive(Clone, Debug, PartialEq)]
1435pub struct DispatchSuccess {
1436    pub events: Vec<NormalizedProviderEvent>,
1437    pub replay: ReplayDecision,
1438}
1439
1440#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1441pub enum DispatchFailureKind {
1442    Cancelled,
1443    SecretResolution,
1444    ResponseBounds,
1445    Executor(ProviderHttpErrorKind),
1446    OllamaHttp(OllamaFailure),
1447    OpenRouterHttp(OpenRouterFailure),
1448    OpenAiCompatHttp(OpenAiCompatFailure),
1449    AnthropicHttp(AnthropicFailure),
1450    GeminiHttp(GeminiFailure),
1451    OllamaStream(OllamaStreamError),
1452    OpenRouterStream(OpenRouterStreamError),
1453    OpenAiCompatStream(OpenAiCompatStreamError),
1454    AnthropicStream(AnthropicStreamError),
1455    GeminiStream(GeminiStreamError),
1456}
1457
1458#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1459pub struct DispatchFailure {
1460    pub kind: DispatchFailureKind,
1461    pub replay: ReplayDecision,
1462}
1463
1464impl fmt::Display for DispatchFailure {
1465    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1466        formatter.write_str("provider dispatch failed")
1467    }
1468}
1469
1470impl std::error::Error for DispatchFailure {}
1471
1472pub async fn dispatch_ollama(
1473    spec: OllamaRequestSpec,
1474    attempt_id: &AttemptId,
1475    resolver: &dyn SecretResolver,
1476    executor: &dyn ProviderHttpExecutor,
1477    control: DispatchControl,
1478) -> Result<DispatchSuccess, DispatchFailure> {
1479    let mut replay = OllamaReplayGuard::default();
1480    if control.forwarded_tool_result {
1481        replay.mark_forwarded_tool_result();
1482    }
1483    if control.cancelled_before_dispatch
1484        || control
1485            .cancellation
1486            .as_ref()
1487            .is_some_and(CancellationSignal::is_cancelled)
1488    {
1489        return Err(DispatchFailure {
1490            kind: DispatchFailureKind::Cancelled,
1491            replay: replay.decision(),
1492        });
1493    }
1494
1495    let request = prepare_ollama(spec, resolver).map_err(|_| DispatchFailure {
1496        kind: DispatchFailureKind::SecretResolution,
1497        replay: replay.decision(),
1498    })?;
1499    let response = match executor.execute(request, control).await {
1500        Ok(response) => response,
1501        Err(error) => {
1502            if error.body_write != BodyWriteState::NotWritten {
1503                replay.mark_body_written();
1504                replay.mark_transport_outcome_unknown();
1505            }
1506            let kind = match error.kind {
1507                ProviderHttpErrorKind::Cancelled => {
1508                    DispatchFailureKind::OllamaHttp(normalize_ollama_transport_failure(
1509                        TransportFailureSignal::Cancelled,
1510                        error.body_write,
1511                    ))
1512                }
1513                ProviderHttpErrorKind::Timeout | ProviderHttpErrorKind::Transport => {
1514                    DispatchFailureKind::OllamaHttp(normalize_ollama_transport_failure(
1515                        TransportFailureSignal::Timeout,
1516                        error.body_write,
1517                    ))
1518                }
1519                ProviderHttpErrorKind::ResponseTooLarge => DispatchFailureKind::ResponseBounds,
1520                ProviderHttpErrorKind::InvalidPreparedRequest => {
1521                    DispatchFailureKind::Executor(error.kind)
1522                }
1523            };
1524            return Err(DispatchFailure {
1525                kind,
1526                replay: replay.decision(),
1527            });
1528        }
1529    };
1530    replay.mark_body_written();
1531    normalize_ollama_response(
1532        response.status,
1533        response.content_type.as_deref(),
1534        response.retry_after.as_deref(),
1535        response.redirected,
1536    )
1537    .map_err(|failure| DispatchFailure {
1538        kind: DispatchFailureKind::OllamaHttp(failure),
1539        replay: replay.decision(),
1540    })?;
1541    validate_response_bounds(&response.chunks).map_err(|_| {
1542        replay.mark_transport_outcome_unknown();
1543        DispatchFailure {
1544            kind: DispatchFailureKind::ResponseBounds,
1545            replay: replay.decision(),
1546        }
1547    })?;
1548
1549    let mut parser = OllamaNdjsonParser::new(attempt_id);
1550    let mut events = Vec::new();
1551    for chunk in response.chunks {
1552        let parsed = parser.feed(&chunk).map_err(|error| {
1553            replay.mark_transport_outcome_unknown();
1554            DispatchFailure {
1555                kind: DispatchFailureKind::OllamaStream(error),
1556                replay: replay.decision(),
1557            }
1558        })?;
1559        for event in parsed {
1560            replay.observe(&event);
1561            events.push(NormalizedProviderEvent::Ollama(event));
1562        }
1563    }
1564    for event in parser.finish().map_err(|error| {
1565        replay.mark_transport_outcome_unknown();
1566        DispatchFailure {
1567            kind: DispatchFailureKind::OllamaStream(error),
1568            replay: replay.decision(),
1569        }
1570    })? {
1571        replay.observe(&event);
1572        events.push(NormalizedProviderEvent::Ollama(event));
1573    }
1574    Ok(DispatchSuccess {
1575        events,
1576        replay: replay.decision(),
1577    })
1578}
1579
1580pub async fn dispatch_openrouter(
1581    spec: OpenRouterRequestSpec,
1582    attempt_id: &AttemptId,
1583    resolver: &dyn SecretResolver,
1584    executor: &dyn ProviderHttpExecutor,
1585    control: DispatchControl,
1586) -> Result<DispatchSuccess, DispatchFailure> {
1587    let mut parser = OpenRouterSseParser::new(attempt_id);
1588    if control.forwarded_tool_result {
1589        parser.mark_forwarded_tool_result();
1590    }
1591    if control.cancelled_before_dispatch
1592        || control
1593            .cancellation
1594            .as_ref()
1595            .is_some_and(CancellationSignal::is_cancelled)
1596    {
1597        return Err(DispatchFailure {
1598            kind: DispatchFailureKind::Cancelled,
1599            replay: parser.replay_decision(),
1600        });
1601    }
1602    let request = prepare_openrouter(spec, resolver).map_err(|_| DispatchFailure {
1603        kind: DispatchFailureKind::SecretResolution,
1604        replay: parser.replay_decision(),
1605    })?;
1606    let response = match executor.execute(request, control).await {
1607        Ok(response) => response,
1608        Err(error) => {
1609            if error.body_write != BodyWriteState::NotWritten {
1610                parser.mark_body_written();
1611                parser.mark_transport_outcome_unknown();
1612            }
1613            let signal = match error.kind {
1614                ProviderHttpErrorKind::Cancelled => {
1615                    crate::openrouter_chat::TransportFailureSignal::Cancelled
1616                }
1617                ProviderHttpErrorKind::Timeout | ProviderHttpErrorKind::Transport => {
1618                    crate::openrouter_chat::TransportFailureSignal::Timeout
1619                }
1620                ProviderHttpErrorKind::ResponseTooLarge => {
1621                    return Err(DispatchFailure {
1622                        kind: DispatchFailureKind::ResponseBounds,
1623                        replay: parser.replay_decision(),
1624                    });
1625                }
1626                ProviderHttpErrorKind::InvalidPreparedRequest => {
1627                    return Err(DispatchFailure {
1628                        kind: DispatchFailureKind::Executor(error.kind),
1629                        replay: parser.replay_decision(),
1630                    });
1631                }
1632            };
1633            let body_write = match error.body_write {
1634                BodyWriteState::NotWritten => crate::openrouter_chat::BodyWriteState::NotWritten,
1635                BodyWriteState::Written => crate::openrouter_chat::BodyWriteState::Written,
1636                BodyWriteState::Unknown => crate::openrouter_chat::BodyWriteState::Unknown,
1637            };
1638            return Err(DispatchFailure {
1639                kind: DispatchFailureKind::OpenRouterHttp(normalize_openrouter_transport_failure(
1640                    signal, body_write,
1641                )),
1642                replay: parser.replay_decision(),
1643            });
1644        }
1645    };
1646    parser.mark_body_written();
1647    normalize_openrouter_response(
1648        response.status,
1649        response.content_type.as_deref(),
1650        response.retry_after.as_deref(),
1651        response.redirected,
1652    )
1653    .map_err(|failure| DispatchFailure {
1654        kind: DispatchFailureKind::OpenRouterHttp(failure),
1655        replay: parser.replay_decision(),
1656    })?;
1657    validate_response_bounds(&response.chunks).map_err(|_| {
1658        parser.mark_transport_outcome_unknown();
1659        DispatchFailure {
1660            kind: DispatchFailureKind::ResponseBounds,
1661            replay: parser.replay_decision(),
1662        }
1663    })?;
1664
1665    let mut events = Vec::new();
1666    for chunk in response.chunks {
1667        events.extend(
1668            parser
1669                .feed(&chunk)
1670                .map_err(|error| {
1671                    parser.mark_transport_outcome_unknown();
1672                    DispatchFailure {
1673                        kind: DispatchFailureKind::OpenRouterStream(error),
1674                        replay: parser.replay_decision(),
1675                    }
1676                })?
1677                .into_iter()
1678                .map(NormalizedProviderEvent::OpenRouter),
1679        );
1680    }
1681    events.extend(
1682        parser
1683            .finish()
1684            .map_err(|error| {
1685                parser.mark_transport_outcome_unknown();
1686                DispatchFailure {
1687                    kind: DispatchFailureKind::OpenRouterStream(error),
1688                    replay: parser.replay_decision(),
1689                }
1690            })?
1691            .into_iter()
1692            .map(NormalizedProviderEvent::OpenRouter),
1693    );
1694    Ok(DispatchSuccess {
1695        events,
1696        replay: parser.replay_decision(),
1697    })
1698}
1699
1700pub async fn dispatch_openai_compat(
1701    spec: OpenAiCompatRequestSpec,
1702    attempt_id: &AttemptId,
1703    resolver: &dyn SecretResolver,
1704    executor: &dyn ProviderHttpExecutor,
1705    control: DispatchControl,
1706) -> Result<DispatchSuccess, DispatchFailure> {
1707    let mut parser = OpenAiCompatSseParser::new(attempt_id);
1708    if control.forwarded_tool_result {
1709        parser.mark_forwarded_tool_result();
1710    }
1711    if control.cancelled_before_dispatch
1712        || control
1713            .cancellation
1714            .as_ref()
1715            .is_some_and(CancellationSignal::is_cancelled)
1716    {
1717        return Err(DispatchFailure {
1718            kind: DispatchFailureKind::Cancelled,
1719            replay: parser.replay_decision(),
1720        });
1721    }
1722    let request = prepare_openai_compat(spec, resolver).map_err(|_| DispatchFailure {
1723        kind: DispatchFailureKind::SecretResolution,
1724        replay: parser.replay_decision(),
1725    })?;
1726    let response = match executor.execute(request, control).await {
1727        Ok(response) => response,
1728        Err(error) => {
1729            if error.body_write != crate::ollama_chat::BodyWriteState::NotWritten {
1730                parser.mark_body_written();
1731                parser.mark_transport_outcome_unknown();
1732            }
1733            let signal = match error.kind {
1734                ProviderHttpErrorKind::Cancelled => OpenAiCompatTransportFailureSignal::Cancelled,
1735                ProviderHttpErrorKind::Timeout | ProviderHttpErrorKind::Transport => {
1736                    OpenAiCompatTransportFailureSignal::Timeout
1737                }
1738                ProviderHttpErrorKind::ResponseTooLarge => {
1739                    return Err(DispatchFailure {
1740                        kind: DispatchFailureKind::ResponseBounds,
1741                        replay: parser.replay_decision(),
1742                    });
1743                }
1744                ProviderHttpErrorKind::InvalidPreparedRequest => {
1745                    return Err(DispatchFailure {
1746                        kind: DispatchFailureKind::Executor(error.kind),
1747                        replay: parser.replay_decision(),
1748                    });
1749                }
1750            };
1751            let body_write = match error.body_write {
1752                crate::ollama_chat::BodyWriteState::NotWritten => {
1753                    OpenAiCompatBodyWriteState::NotWritten
1754                }
1755                crate::ollama_chat::BodyWriteState::Written => OpenAiCompatBodyWriteState::Written,
1756                crate::ollama_chat::BodyWriteState::Unknown => OpenAiCompatBodyWriteState::Unknown,
1757            };
1758            return Err(DispatchFailure {
1759                kind: DispatchFailureKind::OpenAiCompatHttp(
1760                    normalize_openai_compat_transport_failure(signal, body_write),
1761                ),
1762                replay: parser.replay_decision(),
1763            });
1764        }
1765    };
1766    parser.mark_body_written();
1767    normalize_openai_compat_response(
1768        response.status,
1769        response.content_type.as_deref(),
1770        response.retry_after.as_deref(),
1771        response.redirected,
1772    )
1773    .map_err(|failure| DispatchFailure {
1774        kind: DispatchFailureKind::OpenAiCompatHttp(failure),
1775        replay: parser.replay_decision(),
1776    })?;
1777    validate_response_bounds(&response.chunks).map_err(|_| {
1778        parser.mark_transport_outcome_unknown();
1779        DispatchFailure {
1780            kind: DispatchFailureKind::ResponseBounds,
1781            replay: parser.replay_decision(),
1782        }
1783    })?;
1784
1785    let mut events = Vec::new();
1786    for chunk in response.chunks {
1787        events.extend(
1788            parser
1789                .feed(&chunk)
1790                .map_err(|error| {
1791                    parser.mark_transport_outcome_unknown();
1792                    DispatchFailure {
1793                        kind: DispatchFailureKind::OpenAiCompatStream(error),
1794                        replay: parser.replay_decision(),
1795                    }
1796                })?
1797                .into_iter()
1798                .map(NormalizedProviderEvent::OpenAiCompat),
1799        );
1800    }
1801    events.extend(
1802        parser
1803            .finish()
1804            .map_err(|error| {
1805                parser.mark_transport_outcome_unknown();
1806                DispatchFailure {
1807                    kind: DispatchFailureKind::OpenAiCompatStream(error),
1808                    replay: parser.replay_decision(),
1809                }
1810            })?
1811            .into_iter()
1812            .map(NormalizedProviderEvent::OpenAiCompat),
1813    );
1814    Ok(DispatchSuccess {
1815        events,
1816        replay: parser.replay_decision(),
1817    })
1818}
1819
1820pub async fn dispatch_anthropic(
1821    spec: AnthropicRequestSpec,
1822    attempt_id: &AttemptId,
1823    resolver: &dyn SecretResolver,
1824    executor: &dyn ProviderHttpExecutor,
1825    control: DispatchControl,
1826) -> Result<DispatchSuccess, DispatchFailure> {
1827    let mut parser = AnthropicSseParser::new(attempt_id);
1828    if control.cancelled_before_dispatch
1829        || control
1830            .cancellation
1831            .as_ref()
1832            .is_some_and(CancellationSignal::is_cancelled)
1833    {
1834        return Err(DispatchFailure {
1835            kind: DispatchFailureKind::Cancelled,
1836            replay: parser.replay_decision(),
1837        });
1838    }
1839    let request = prepare_anthropic(spec, resolver).map_err(|_| DispatchFailure {
1840        kind: DispatchFailureKind::SecretResolution,
1841        replay: parser.replay_decision(),
1842    })?;
1843    let response = match executor.execute(request, control).await {
1844        Ok(response) => response,
1845        Err(error) => {
1846            if error.body_write != crate::ollama_chat::BodyWriteState::NotWritten {
1847                parser.mark_body_written();
1848                parser.mark_transport_outcome_unknown();
1849            }
1850            let signal = match error.kind {
1851                ProviderHttpErrorKind::Cancelled => AnthropicTransportFailureSignal::Cancelled,
1852                ProviderHttpErrorKind::Timeout | ProviderHttpErrorKind::Transport => {
1853                    AnthropicTransportFailureSignal::Timeout
1854                }
1855                ProviderHttpErrorKind::ResponseTooLarge => {
1856                    return Err(DispatchFailure {
1857                        kind: DispatchFailureKind::ResponseBounds,
1858                        replay: parser.replay_decision(),
1859                    });
1860                }
1861                ProviderHttpErrorKind::InvalidPreparedRequest => {
1862                    return Err(DispatchFailure {
1863                        kind: DispatchFailureKind::Executor(error.kind),
1864                        replay: parser.replay_decision(),
1865                    });
1866                }
1867            };
1868            let body_write = match error.body_write {
1869                crate::ollama_chat::BodyWriteState::NotWritten => {
1870                    AnthropicBodyWriteState::NotWritten
1871                }
1872                crate::ollama_chat::BodyWriteState::Written => AnthropicBodyWriteState::Written,
1873                crate::ollama_chat::BodyWriteState::Unknown => AnthropicBodyWriteState::Unknown,
1874            };
1875            return Err(DispatchFailure {
1876                kind: DispatchFailureKind::AnthropicHttp(normalize_anthropic_transport_failure(
1877                    signal, body_write,
1878                )),
1879                replay: parser.replay_decision(),
1880            });
1881        }
1882    };
1883    parser.mark_body_written();
1884    normalize_anthropic_response(
1885        response.status,
1886        response.content_type.as_deref(),
1887        response.retry_after.as_deref(),
1888        response.redirected,
1889    )
1890    .map_err(|failure| DispatchFailure {
1891        kind: DispatchFailureKind::AnthropicHttp(failure),
1892        replay: parser.replay_decision(),
1893    })?;
1894    validate_response_bounds(&response.chunks).map_err(|_| {
1895        parser.mark_transport_outcome_unknown();
1896        DispatchFailure {
1897            kind: DispatchFailureKind::ResponseBounds,
1898            replay: parser.replay_decision(),
1899        }
1900    })?;
1901
1902    let mut events = Vec::new();
1903    for chunk in response.chunks {
1904        events.extend(
1905            parser
1906                .feed(&chunk)
1907                .map_err(|error| {
1908                    parser.mark_transport_outcome_unknown();
1909                    DispatchFailure {
1910                        kind: DispatchFailureKind::AnthropicStream(error),
1911                        replay: parser.replay_decision(),
1912                    }
1913                })?
1914                .into_iter()
1915                .map(NormalizedProviderEvent::Anthropic),
1916        );
1917    }
1918    events.extend(
1919        parser
1920            .finish()
1921            .map_err(|error| {
1922                parser.mark_transport_outcome_unknown();
1923                DispatchFailure {
1924                    kind: DispatchFailureKind::AnthropicStream(error),
1925                    replay: parser.replay_decision(),
1926                }
1927            })?
1928            .into_iter()
1929            .map(NormalizedProviderEvent::Anthropic),
1930    );
1931    Ok(DispatchSuccess {
1932        events,
1933        replay: parser.replay_decision(),
1934    })
1935}
1936
1937pub async fn dispatch_gemini(
1938    spec: GeminiRequestSpec,
1939    attempt_id: &AttemptId,
1940    resolver: &dyn SecretResolver,
1941    executor: &dyn ProviderHttpExecutor,
1942    control: DispatchControl,
1943) -> Result<DispatchSuccess, DispatchFailure> {
1944    let mut parser = GeminiSseParser::new(attempt_id);
1945    if control.cancelled_before_dispatch
1946        || control
1947            .cancellation
1948            .as_ref()
1949            .is_some_and(CancellationSignal::is_cancelled)
1950    {
1951        return Err(DispatchFailure {
1952            kind: DispatchFailureKind::Cancelled,
1953            replay: parser.replay_decision(),
1954        });
1955    }
1956    let request = prepare_gemini(spec, resolver).map_err(|_| DispatchFailure {
1957        kind: DispatchFailureKind::SecretResolution,
1958        replay: parser.replay_decision(),
1959    })?;
1960    let response = match executor.execute(request, control).await {
1961        Ok(response) => response,
1962        Err(error) => {
1963            if error.body_write != crate::ollama_chat::BodyWriteState::NotWritten {
1964                parser.mark_body_written();
1965                parser.mark_transport_outcome_unknown();
1966            }
1967            let signal = match error.kind {
1968                ProviderHttpErrorKind::Cancelled => GeminiTransportFailureSignal::Cancelled,
1969                ProviderHttpErrorKind::Timeout | ProviderHttpErrorKind::Transport => {
1970                    GeminiTransportFailureSignal::Timeout
1971                }
1972                ProviderHttpErrorKind::ResponseTooLarge => {
1973                    return Err(DispatchFailure {
1974                        kind: DispatchFailureKind::ResponseBounds,
1975                        replay: parser.replay_decision(),
1976                    });
1977                }
1978                ProviderHttpErrorKind::InvalidPreparedRequest => {
1979                    return Err(DispatchFailure {
1980                        kind: DispatchFailureKind::Executor(error.kind),
1981                        replay: parser.replay_decision(),
1982                    });
1983                }
1984            };
1985            let body_write = match error.body_write {
1986                crate::ollama_chat::BodyWriteState::NotWritten => GeminiBodyWriteState::NotWritten,
1987                crate::ollama_chat::BodyWriteState::Written => GeminiBodyWriteState::Written,
1988                crate::ollama_chat::BodyWriteState::Unknown => GeminiBodyWriteState::Unknown,
1989            };
1990            return Err(DispatchFailure {
1991                kind: DispatchFailureKind::GeminiHttp(normalize_gemini_transport_failure(
1992                    signal, body_write,
1993                )),
1994                replay: parser.replay_decision(),
1995            });
1996        }
1997    };
1998    parser.mark_body_written();
1999    normalize_gemini_response(
2000        response.status,
2001        response.content_type.as_deref(),
2002        response.retry_after.as_deref(),
2003        response.redirected,
2004    )
2005    .map_err(|failure| DispatchFailure {
2006        kind: DispatchFailureKind::GeminiHttp(failure),
2007        replay: parser.replay_decision(),
2008    })?;
2009    validate_response_bounds(&response.chunks).map_err(|_| {
2010        parser.mark_transport_outcome_unknown();
2011        DispatchFailure {
2012            kind: DispatchFailureKind::ResponseBounds,
2013            replay: parser.replay_decision(),
2014        }
2015    })?;
2016
2017    let mut events = Vec::new();
2018    for chunk in response.chunks {
2019        events.extend(
2020            parser
2021                .feed(&chunk)
2022                .map_err(|error| {
2023                    parser.mark_transport_outcome_unknown();
2024                    DispatchFailure {
2025                        kind: DispatchFailureKind::GeminiStream(error),
2026                        replay: parser.replay_decision(),
2027                    }
2028                })?
2029                .into_iter()
2030                .map(NormalizedProviderEvent::Gemini),
2031        );
2032    }
2033    events.extend(
2034        parser
2035            .finish()
2036            .map_err(|error| {
2037                parser.mark_transport_outcome_unknown();
2038                DispatchFailure {
2039                    kind: DispatchFailureKind::GeminiStream(error),
2040                    replay: parser.replay_decision(),
2041                }
2042            })?
2043            .into_iter()
2044            .map(NormalizedProviderEvent::Gemini),
2045    );
2046    Ok(DispatchSuccess {
2047        events,
2048        replay: parser.replay_decision(),
2049    })
2050}
2051
2052fn prepare_anthropic(
2053    spec: AnthropicRequestSpec,
2054    resolver: &dyn SecretResolver,
2055) -> Result<PreparedRequest, SecretResolutionError> {
2056    let body = spec.body_bytes().map_err(|_| SecretResolutionError)?;
2057    let authorization = resolve_api_key_header(resolver, spec.api_key_secret.as_str())?;
2058    Ok(PreparedRequest {
2059        method: "POST",
2060        origin: spec.origin,
2061        path: spec.path,
2062        content_type: spec.content_type,
2063        accept: spec.accept,
2064        network: spec.network,
2065        protocol: ProviderWireProtocol::AnthropicSse,
2066        body,
2067        authorization: Some(authorization),
2068        auth_header_name: Some(router_protocol::ANTHROPIC_API_KEY_HEADER),
2069        version_header: Some((
2070            router_protocol::ANTHROPIC_VERSION_HEADER,
2071            spec.version_header,
2072        )),
2073        bounded: false,
2074    })
2075}
2076
2077fn prepare_gemini(
2078    spec: GeminiRequestSpec,
2079    resolver: &dyn SecretResolver,
2080) -> Result<PreparedRequest, SecretResolutionError> {
2081    let body = spec.body_bytes().map_err(|_| SecretResolutionError)?;
2082    let authorization = resolve_api_key_header(resolver, spec.api_key_secret.as_str())?;
2083    Ok(PreparedRequest {
2084        method: "POST",
2085        origin: spec.origin,
2086        path: spec.path,
2087        content_type: spec.content_type,
2088        accept: spec.accept,
2089        network: spec.network,
2090        protocol: ProviderWireProtocol::GeminiSse,
2091        body,
2092        authorization: Some(authorization),
2093        auth_header_name: Some(router_protocol::GEMINI_API_KEY_HEADER),
2094        version_header: None,
2095        bounded: false,
2096    })
2097}
2098
2099fn prepare_openai_compat(
2100    spec: OpenAiCompatRequestSpec,
2101    resolver: &dyn SecretResolver,
2102) -> Result<PreparedRequest, SecretResolutionError> {
2103    let body = spec.body_bytes().map_err(|_| SecretResolutionError)?;
2104    let authorization = resolve_bearer_header(resolver, spec.bearer_secret.as_str())?;
2105    Ok(PreparedRequest {
2106        method: "POST",
2107        origin: spec.origin,
2108        path: spec.path,
2109        content_type: spec.content_type,
2110        accept: spec.accept,
2111        network: spec.network,
2112        protocol: ProviderWireProtocol::OpenAiCompatSse,
2113        body,
2114        authorization: Some(authorization),
2115        auth_header_name: None,
2116        version_header: None,
2117        bounded: false,
2118    })
2119}
2120
2121fn prepare_ollama(
2122    spec: OllamaRequestSpec,
2123    resolver: &dyn SecretResolver,
2124) -> Result<PreparedRequest, SecretResolutionError> {
2125    let body = spec.body_bytes().map_err(|_| SecretResolutionError)?;
2126    let localhost = spec.origin == "http://127.0.0.1:11434";
2127    let authorization = match &spec.authentication {
2128        OllamaAuthentication::None => None,
2129        OllamaAuthentication::BearerSecretReference(reference) if !localhost => {
2130            Some(resolve_bearer_header(resolver, reference.as_str())?)
2131        }
2132        OllamaAuthentication::BearerSecretReference(_) => return Err(SecretResolutionError),
2133    };
2134    Ok(PreparedRequest {
2135        method: "POST",
2136        origin: spec.origin,
2137        path: spec.path,
2138        content_type: spec.content_type,
2139        accept: spec.accept,
2140        network: spec.network,
2141        protocol: ProviderWireProtocol::OllamaNdjson,
2142        body,
2143        authorization,
2144        auth_header_name: None,
2145        version_header: None,
2146        bounded: false,
2147    })
2148}
2149
2150fn prepare_openrouter(
2151    spec: OpenRouterRequestSpec,
2152    resolver: &dyn SecretResolver,
2153) -> Result<PreparedRequest, SecretResolutionError> {
2154    let body = spec.body_bytes().map_err(|_| SecretResolutionError)?;
2155    let authorization = resolve_bearer_header(resolver, spec.bearer_secret.as_str())?;
2156    Ok(PreparedRequest {
2157        method: "POST",
2158        origin: spec.origin,
2159        path: spec.path,
2160        content_type: spec.content_type,
2161        accept: spec.accept,
2162        network: spec.network,
2163        protocol: ProviderWireProtocol::OpenRouterSse,
2164        body,
2165        authorization: Some(authorization),
2166        auth_header_name: None,
2167        version_header: None,
2168        bounded: false,
2169    })
2170}
2171
2172/// A bounded, non-streaming chat completion used by the model intelligence
2173/// loop. The response is validated against `max_response_bytes` before parsing,
2174/// and only the completion text escapes the transport layer.
2175#[derive(Clone, PartialEq, Eq)]
2176pub struct BoundedChatCompletionSpec {
2177    pub origin: String,
2178    pub path: String,
2179    pub model: String,
2180    pub prompt: String,
2181    pub bearer_secret: Option<String>,
2182    pub protocol: BoundedChatCompletionProtocol,
2183    pub max_response_bytes: usize,
2184}
2185
2186impl fmt::Debug for BoundedChatCompletionSpec {
2187    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2188        formatter
2189            .debug_struct("BoundedChatCompletionSpec")
2190            .field("origin", &self.origin)
2191            .field("path", &self.path)
2192            .field("model", &self.model)
2193            .field("prompt", &"[redacted]")
2194            .field("protocol", &self.protocol)
2195            .field("max_response_bytes", &self.max_response_bytes)
2196            .finish_non_exhaustive()
2197    }
2198}
2199
2200/// The wire protocol for a bounded chat completion. The OpenAI-compatible and
2201/// Ollama protocols share the chat-completions request shape with
2202/// `stream: false`; they differ from the Anthropic (`/v1/messages`) and Gemini
2203/// (`generateContent`) protocols in request shape, response framing, and
2204/// authentication.
2205#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2206pub enum BoundedChatCompletionProtocol {
2207    Ollama,
2208    OpenAiCompat,
2209    Anthropic,
2210    Gemini,
2211}
2212
2213#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2214pub enum BoundedChatCompletionErrorKind {
2215    SecretResolution,
2216    InvalidPreparedRequest,
2217    Cancelled,
2218    Timeout,
2219    Transport,
2220    ResponseTooLarge,
2221    Http { status: u16 },
2222    InvalidResponse,
2223    EmptyCompletion,
2224}
2225
2226#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2227pub struct BoundedChatCompletionError {
2228    pub kind: BoundedChatCompletionErrorKind,
2229}
2230
2231impl fmt::Display for BoundedChatCompletionError {
2232    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2233        match self.kind {
2234            BoundedChatCompletionErrorKind::SecretResolution => {
2235                formatter.write_str("bounded chat completion credential resolution failed")
2236            }
2237            BoundedChatCompletionErrorKind::InvalidPreparedRequest => {
2238                formatter.write_str("bounded chat completion request was invalid")
2239            }
2240            BoundedChatCompletionErrorKind::Cancelled => {
2241                formatter.write_str("bounded chat completion was cancelled")
2242            }
2243            BoundedChatCompletionErrorKind::Timeout => {
2244                formatter.write_str("bounded chat completion timed out")
2245            }
2246            BoundedChatCompletionErrorKind::Transport => {
2247                formatter.write_str("bounded chat completion transport failed")
2248            }
2249            BoundedChatCompletionErrorKind::ResponseTooLarge => {
2250                formatter.write_str("bounded chat completion response exceeded its bound")
2251            }
2252            BoundedChatCompletionErrorKind::Http { status } => {
2253                write!(
2254                    formatter,
2255                    "bounded chat completion provider returned HTTP {status}"
2256                )
2257            }
2258            BoundedChatCompletionErrorKind::InvalidResponse => {
2259                formatter.write_str("bounded chat completion response was invalid")
2260            }
2261            BoundedChatCompletionErrorKind::EmptyCompletion => {
2262                formatter.write_str("bounded chat completion returned no text")
2263            }
2264        }
2265    }
2266}
2267
2268impl std::error::Error for BoundedChatCompletionError {}
2269
2270impl From<SecretResolutionError> for BoundedChatCompletionError {
2271    fn from(_: SecretResolutionError) -> Self {
2272        Self {
2273            kind: BoundedChatCompletionErrorKind::SecretResolution,
2274        }
2275    }
2276}
2277
2278/// Dispatches one bounded, non-streaming chat completion and returns the
2279/// completion text. The response is bounded before parsing and the returned
2280/// text is the only content that escapes the transport layer.
2281pub async fn dispatch_bounded_chat_completion(
2282    spec: BoundedChatCompletionSpec,
2283    resolver: &dyn SecretResolver,
2284    executor: &dyn ProviderHttpExecutor,
2285) -> Result<String, BoundedChatCompletionError> {
2286    let body = build_bounded_chat_body(spec.protocol, &spec.model, &spec.prompt)?;
2287    let authorization = match (&spec.protocol, spec.bearer_secret.as_deref()) {
2288        (BoundedChatCompletionProtocol::Ollama, None) => None,
2289        (BoundedChatCompletionProtocol::Ollama, Some(secret_ref))
2290        | (BoundedChatCompletionProtocol::OpenAiCompat, Some(secret_ref)) => {
2291            Some(resolve_bearer_header(resolver, secret_ref)?)
2292        }
2293        (BoundedChatCompletionProtocol::OpenAiCompat, None) => {
2294            return Err(BoundedChatCompletionError {
2295                kind: BoundedChatCompletionErrorKind::SecretResolution,
2296            });
2297        }
2298        // The Anthropic and Gemini protocols carry the raw key in a custom
2299        // header (`x-api-key`, `x-goog-api-key`), never a Bearer prefix.
2300        (BoundedChatCompletionProtocol::Anthropic, Some(secret_ref))
2301        | (BoundedChatCompletionProtocol::Gemini, Some(secret_ref)) => {
2302            Some(resolve_api_key_header(resolver, secret_ref)?)
2303        }
2304        (BoundedChatCompletionProtocol::Anthropic, None)
2305        | (BoundedChatCompletionProtocol::Gemini, None) => {
2306            return Err(BoundedChatCompletionError {
2307                kind: BoundedChatCompletionErrorKind::SecretResolution,
2308            });
2309        }
2310    };
2311    // The Gemini model id is a path parameter (`models/{model}:generateContent`),
2312    // so the bounded request materializes the route template with the model id.
2313    let path = match spec.protocol {
2314        BoundedChatCompletionProtocol::Gemini => {
2315            materialize_gemini_bounded_path(&spec.path, &spec.model)
2316        }
2317        _ => spec.path,
2318    };
2319    // Local Ollama is a loopback daemon: no DNS, no TLS, no authorization.
2320    // Every other bounded endpoint is a remote provider: DNS and TLS required,
2321    // authorization required (enforced by the caller for cloud providers).
2322    let local = spec.origin == "http://127.0.0.1:11434";
2323    let request = PreparedRequest {
2324        method: "POST",
2325        origin: spec.origin,
2326        path,
2327        content_type: "application/json",
2328        accept: "application/json",
2329        network: NetworkPolicy {
2330            allow_proxy: false,
2331            allow_dns: !local,
2332            follow_redirects: false,
2333            require_tls: !local,
2334        },
2335        protocol: match spec.protocol {
2336            BoundedChatCompletionProtocol::Ollama => ProviderWireProtocol::OllamaNdjson,
2337            BoundedChatCompletionProtocol::OpenAiCompat => ProviderWireProtocol::OpenAiCompatSse,
2338            BoundedChatCompletionProtocol::Anthropic => ProviderWireProtocol::AnthropicSse,
2339            BoundedChatCompletionProtocol::Gemini => ProviderWireProtocol::GeminiSse,
2340        },
2341        body,
2342        authorization,
2343        auth_header_name: match spec.protocol {
2344            BoundedChatCompletionProtocol::Anthropic => {
2345                Some(router_protocol::ANTHROPIC_API_KEY_HEADER)
2346            }
2347            BoundedChatCompletionProtocol::Gemini => Some(router_protocol::GEMINI_API_KEY_HEADER),
2348            _ => None,
2349        },
2350        version_header: match spec.protocol {
2351            BoundedChatCompletionProtocol::Anthropic => Some((
2352                router_protocol::ANTHROPIC_VERSION_HEADER,
2353                router_protocol::ANTHROPIC_VERSION,
2354            )),
2355            _ => None,
2356        },
2357        bounded: true,
2358    };
2359    let response = executor
2360        .execute(request, DispatchControl::default())
2361        .await
2362        .map_err(|error| BoundedChatCompletionError {
2363            kind: match error.kind {
2364                ProviderHttpErrorKind::Cancelled => BoundedChatCompletionErrorKind::Cancelled,
2365                ProviderHttpErrorKind::Timeout => BoundedChatCompletionErrorKind::Timeout,
2366                ProviderHttpErrorKind::Transport => BoundedChatCompletionErrorKind::Transport,
2367                ProviderHttpErrorKind::ResponseTooLarge => {
2368                    BoundedChatCompletionErrorKind::ResponseTooLarge
2369                }
2370                ProviderHttpErrorKind::InvalidPreparedRequest => {
2371                    BoundedChatCompletionErrorKind::InvalidPreparedRequest
2372                }
2373            },
2374        })?;
2375    if response.redirected || response.status != 200 {
2376        return Err(BoundedChatCompletionError {
2377            kind: BoundedChatCompletionErrorKind::Http {
2378                status: response.status,
2379            },
2380        });
2381    }
2382    let mut total = 0usize;
2383    for chunk in &response.chunks {
2384        total = total
2385            .checked_add(chunk.len())
2386            .ok_or(BoundedChatCompletionError {
2387                kind: BoundedChatCompletionErrorKind::ResponseTooLarge,
2388            })?;
2389        if total > spec.max_response_bytes {
2390            return Err(BoundedChatCompletionError {
2391                kind: BoundedChatCompletionErrorKind::ResponseTooLarge,
2392            });
2393        }
2394    }
2395    let text = match spec.protocol {
2396        BoundedChatCompletionProtocol::Ollama => parse_ollama_bounded_completion(&response.chunks)?,
2397        BoundedChatCompletionProtocol::OpenAiCompat => {
2398            parse_openai_compat_bounded_completion(&response.chunks)?
2399        }
2400        BoundedChatCompletionProtocol::Anthropic => {
2401            parse_anthropic_bounded_completion(&response.chunks)?
2402        }
2403        BoundedChatCompletionProtocol::Gemini => parse_gemini_bounded_completion(&response.chunks)?,
2404    };
2405    if text.is_empty() {
2406        return Err(BoundedChatCompletionError {
2407            kind: BoundedChatCompletionErrorKind::EmptyCompletion,
2408        });
2409    }
2410    Ok(text)
2411}
2412
2413fn build_bounded_chat_body(
2414    protocol: BoundedChatCompletionProtocol,
2415    model: &str,
2416    prompt: &str,
2417) -> Result<Vec<u8>, BoundedChatCompletionError> {
2418    let body = match protocol {
2419        // Ollama and the OpenAI-compatible direct APIs share the
2420        // chat-completions request shape.
2421        BoundedChatCompletionProtocol::Ollama | BoundedChatCompletionProtocol::OpenAiCompat => {
2422            serde_json::json!({
2423                "model": model,
2424                "messages": [{"role": "user", "content": prompt}],
2425                "stream": false,
2426            })
2427        }
2428        // The Anthropic Messages body requires `max_tokens` and carries the
2429        // model id in the body (the path is static).
2430        BoundedChatCompletionProtocol::Anthropic => serde_json::json!({
2431            "model": model,
2432            "max_tokens": crate::anthropic_chat::DEFAULT_MAX_TOKENS,
2433            "messages": [{"role": "user", "content": prompt}],
2434            "stream": false,
2435        }),
2436        // The Gemini generateContent body carries the model id in the path,
2437        // not the body; the generationConfig bounds the response.
2438        BoundedChatCompletionProtocol::Gemini => serde_json::json!({
2439            "contents": [{"role": "user", "parts": [{"text": prompt}]}],
2440            "generationConfig": {
2441                "maxOutputTokens": crate::gemini_chat::DEFAULT_MAX_OUTPUT_TOKENS,
2442            },
2443        }),
2444    };
2445    serde_json::to_vec(&body).map_err(|_| BoundedChatCompletionError {
2446        kind: BoundedChatCompletionErrorKind::InvalidPreparedRequest,
2447    })
2448}
2449
2450/// Percent-encode any byte outside the Gemini model-id safe set and substitute
2451/// the result into the `{model}` route template, producing the non-streaming
2452/// generateContent path (`/v1beta/models/{model}:generateContent`).
2453fn materialize_gemini_bounded_path(template: &str, model_id: &str) -> String {
2454    let mut encoded = String::with_capacity(model_id.len());
2455    for byte in model_id.bytes() {
2456        match byte {
2457            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b'-' => {
2458                encoded.push(byte as char);
2459            }
2460            other => {
2461                encoded.push('%');
2462                encoded.push_str(&format!("{other:02X}"));
2463            }
2464        }
2465    }
2466    template.replace("{model}", &encoded)
2467}
2468
2469fn parse_openai_compat_bounded_completion(
2470    chunks: &[Vec<u8>],
2471) -> Result<String, BoundedChatCompletionError> {
2472    let mut bytes = Vec::new();
2473    for chunk in chunks {
2474        bytes.extend_from_slice(chunk);
2475    }
2476    let value: serde_json::Value =
2477        serde_json::from_slice(&bytes).map_err(|_| BoundedChatCompletionError {
2478            kind: BoundedChatCompletionErrorKind::InvalidResponse,
2479        })?;
2480    value
2481        .get("choices")
2482        .and_then(serde_json::Value::as_array)
2483        .and_then(|choices| choices.first())
2484        .and_then(|choice| choice.get("message"))
2485        .and_then(|message| message.get("content"))
2486        .and_then(serde_json::Value::as_str)
2487        .map(str::to_owned)
2488        .ok_or(BoundedChatCompletionError {
2489            kind: BoundedChatCompletionErrorKind::InvalidResponse,
2490        })
2491}
2492
2493fn parse_ollama_bounded_completion(
2494    chunks: &[Vec<u8>],
2495) -> Result<String, BoundedChatCompletionError> {
2496    let mut bytes = Vec::new();
2497    for chunk in chunks {
2498        bytes.extend_from_slice(chunk);
2499    }
2500    let value: serde_json::Value =
2501        serde_json::from_slice(&bytes).map_err(|_| BoundedChatCompletionError {
2502            kind: BoundedChatCompletionErrorKind::InvalidResponse,
2503        })?;
2504    value
2505        .get("message")
2506        .and_then(serde_json::Value::as_object)
2507        .and_then(|message| message.get("content"))
2508        .and_then(serde_json::Value::as_str)
2509        .map(str::to_owned)
2510        .ok_or(BoundedChatCompletionError {
2511            kind: BoundedChatCompletionErrorKind::InvalidResponse,
2512        })
2513}
2514
2515fn parse_anthropic_bounded_completion(
2516    chunks: &[Vec<u8>],
2517) -> Result<String, BoundedChatCompletionError> {
2518    let mut bytes = Vec::new();
2519    for chunk in chunks {
2520        bytes.extend_from_slice(chunk);
2521    }
2522    let value: serde_json::Value =
2523        serde_json::from_slice(&bytes).map_err(|_| BoundedChatCompletionError {
2524            kind: BoundedChatCompletionErrorKind::InvalidResponse,
2525        })?;
2526    value
2527        .get("content")
2528        .and_then(serde_json::Value::as_array)
2529        .and_then(|blocks| {
2530            blocks
2531                .iter()
2532                .find(|block| block.get("type").and_then(serde_json::Value::as_str) == Some("text"))
2533        })
2534        .and_then(|block| block.get("text"))
2535        .and_then(serde_json::Value::as_str)
2536        .map(str::to_owned)
2537        .ok_or(BoundedChatCompletionError {
2538            kind: BoundedChatCompletionErrorKind::InvalidResponse,
2539        })
2540}
2541
2542fn parse_gemini_bounded_completion(
2543    chunks: &[Vec<u8>],
2544) -> Result<String, BoundedChatCompletionError> {
2545    let mut bytes = Vec::new();
2546    for chunk in chunks {
2547        bytes.extend_from_slice(chunk);
2548    }
2549    let value: serde_json::Value =
2550        serde_json::from_slice(&bytes).map_err(|_| BoundedChatCompletionError {
2551            kind: BoundedChatCompletionErrorKind::InvalidResponse,
2552        })?;
2553    value
2554        .get("candidates")
2555        .and_then(serde_json::Value::as_array)
2556        .and_then(|candidates| candidates.first())
2557        .and_then(|candidate| candidate.get("content"))
2558        .and_then(|content| content.get("parts"))
2559        .and_then(serde_json::Value::as_array)
2560        .and_then(|parts| parts.first())
2561        .and_then(|part| part.get("text"))
2562        .and_then(serde_json::Value::as_str)
2563        .map(str::to_owned)
2564        .ok_or(BoundedChatCompletionError {
2565            kind: BoundedChatCompletionErrorKind::InvalidResponse,
2566        })
2567}
2568
2569fn resolve_bearer_header(
2570    resolver: &dyn SecretResolver,
2571    secret_ref: &str,
2572) -> Result<ResolvedSecret, SecretResolutionError> {
2573    let secret = resolver.resolve(secret_ref)?;
2574    let mut header = Vec::with_capacity(7usize.saturating_add(secret.expose_for_dispatch().len()));
2575    header.extend_from_slice(b"Bearer ");
2576    header.extend_from_slice(secret.expose_for_dispatch());
2577    ResolvedSecret::new(header)
2578}
2579
2580/// Resolve a secret for a custom API-key header (`x-api-key`, `x-goog-api-key`).
2581/// Unlike the bearer helper, the raw secret is the header value, with no
2582/// scheme prefix.
2583fn resolve_api_key_header(
2584    resolver: &dyn SecretResolver,
2585    secret_ref: &str,
2586) -> Result<ResolvedSecret, SecretResolutionError> {
2587    resolver.resolve(secret_ref)
2588}
2589
2590fn validate_response_bounds(chunks: &[Vec<u8>]) -> Result<(), ()> {
2591    if chunks.len() > MAX_RESPONSE_CHUNKS {
2592        return Err(());
2593    }
2594    let mut total = 0usize;
2595    for chunk in chunks {
2596        total = total.checked_add(chunk.len()).ok_or(())?;
2597        if total > MAX_RESPONSE_BYTES {
2598            return Err(());
2599        }
2600    }
2601    Ok(())
2602}
2603
2604#[cfg(test)]
2605mod tests {
2606    use super::*;
2607    use crate::ollama_chat::{self, OllamaFailureClass, OllamaStreamEvent, ReplayBoundary};
2608    use crate::openrouter_chat::{self, OpenRouterFailureClass, OpenRouterStreamEvent};
2609    use router_protocol::{
2610        BillingAuthorityClass, CanonicalRequest, CapabilityRequirements, CatalogSourceKind,
2611        CredentialSourceKind, EndpointIdentity, ExecutionLocation, HarnessInstanceId,
2612        M1_WIRE_VERSION, ModelNamespace, PhaseKind, PhasePlacement, ProviderService, RouteIdentity,
2613        SessionId, ToolChoice, TransportKind, TurnId,
2614    };
2615    use std::sync::atomic::{AtomicUsize, Ordering};
2616    use std::sync::{Arc, Mutex};
2617
2618    #[cfg(unix)]
2619    fn secret_helper(contents: &str) -> (tempfile::TempDir, String) {
2620        use std::os::unix::fs::PermissionsExt;
2621
2622        let directory = tempfile::Builder::new()
2623            .prefix(".steadymux-secret-helper-test-")
2624            .tempdir_in(std::env::current_dir().expect("current directory"))
2625            .expect("temporary directory");
2626        let canonical = std::fs::canonicalize(directory.path()).expect("canonical temp directory");
2627        let helper = canonical.join("secret-helper");
2628        std::fs::write(&helper, contents).expect("write helper");
2629        std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o700))
2630            .expect("owner-only executable helper");
2631        (directory, format!("command:{}", helper.display()))
2632    }
2633
2634    type ObservedRequest = Arc<Mutex<Option<(String, String, bool, String)>>>;
2635
2636    struct Resolver {
2637        calls: AtomicUsize,
2638        value: &'static [u8],
2639    }
2640
2641    impl SecretResolver for Resolver {
2642        fn resolve(&self, _secret_ref: &str) -> Result<ResolvedSecret, SecretResolutionError> {
2643            self.calls.fetch_add(1, Ordering::SeqCst);
2644            ResolvedSecret::new(self.value)
2645        }
2646    }
2647
2648    struct FakeExecutor {
2649        response: Mutex<Option<Result<ProviderHttpResponse, ProviderHttpError>>>,
2650        observed: ObservedRequest,
2651    }
2652
2653    #[test]
2654    fn keychain_reference_parser_is_strict_bounded_and_content_free() {
2655        let parsed =
2656            parse_secret_reference("keychain:com.steadymux.openrouter/user+alpha@example.com")
2657                .expect("valid keychain reference");
2658        assert!(matches!(
2659            parsed,
2660            ParsedSecretReference::MacOsKeychain {
2661                service: "com.steadymux.openrouter",
2662                account: "user+alpha@example.com"
2663            }
2664        ));
2665
2666        for invalid in [
2667            "keychain:",
2668            "keychain:/account",
2669            "keychain:service/",
2670            "keychain:service/account/extra",
2671            "keychain:service name/account",
2672            "keychain:service/account name",
2673            "keychain:service/account?query",
2674            "keychain:service/accouñt",
2675            "keychain:service/account\ncanary-secret",
2676        ] {
2677            let error = parse_secret_reference(invalid).expect_err("invalid keychain reference");
2678            let diagnostic = format!("{error:?} {error}");
2679            assert_eq!(
2680                diagnostic,
2681                "SecretResolutionError provider credential resolution failed"
2682            );
2683            assert!(!diagnostic.contains(invalid));
2684            assert!(!diagnostic.contains("canary-secret"));
2685        }
2686
2687        let oversized_service = "s".repeat(MAX_KEYCHAIN_COMPONENT_BYTES + 1);
2688        let oversized_account = "a".repeat(MAX_KEYCHAIN_COMPONENT_BYTES + 1);
2689        assert!(parse_secret_reference(&format!("keychain:{oversized_service}/account")).is_err());
2690        assert!(parse_secret_reference(&format!("keychain:service/{oversized_account}")).is_err());
2691    }
2692
2693    #[test]
2694    fn production_secret_resolver_selects_only_supported_reference_forms() {
2695        let resolver = ProductionSecretResolver::default();
2696        assert!(matches!(
2697            parse_secret_reference("keychain:steadymux-openrouter/default"),
2698            Ok(ParsedSecretReference::MacOsKeychain { .. })
2699        ));
2700        #[cfg(unix)]
2701        assert!(matches!(
2702            parse_secret_reference("command:/owner/selected/helper"),
2703            Ok(ParsedSecretReference::Command)
2704        ));
2705        assert!(matches!(
2706            parse_secret_reference("local-file-v1:0123456789abcdef0123456789abcdef"),
2707            Ok(ParsedSecretReference::LocalFile)
2708        ));
2709        assert_eq!(
2710            ProductionSecretResolver::supports_reference(
2711                "local-file-v1:0123456789abcdef0123456789abcdef"
2712            ),
2713            cfg!(target_os = "macos")
2714        );
2715        assert_eq!(
2716            ProductionSecretResolver::validate_reference(
2717                "local-file-v1:0123456789abcdef0123456789abcdef"
2718            )
2719            .is_ok(),
2720            cfg!(target_os = "macos")
2721        );
2722        for invalid in [
2723            "local-file-v1:",
2724            "local-file-v1:0123456789abcdef0123456789abcde",
2725            "local-file-v1:0123456789abcdef0123456789abcdeg",
2726            "local-file-v1:0123456789abcdef0123456789abcde/",
2727        ] {
2728            assert!(!ProductionSecretResolver::supports_reference(invalid));
2729            assert!(ProductionSecretResolver::validate_reference(invalid).is_err());
2730        }
2731        assert!(matches!(
2732            parse_secret_reference("env:STEADYMUX_FOLLOW_ON_KEY"),
2733            Ok(ParsedSecretReference::Environment {
2734                name: "STEADYMUX_FOLLOW_ON_KEY"
2735            })
2736        ));
2737        assert!(ProductionSecretResolver::supports_reference(
2738            "env:STEADYMUX_FOLLOW_ON_KEY"
2739        ));
2740        ProductionSecretResolver::validate_reference("env:STEADYMUX_FOLLOW_ON_KEY")
2741            .expect("validated environment reference without reading the variable");
2742        for invalid_env in ["env:", "env:with space", "env:has-dash"] {
2743            assert!(!ProductionSecretResolver::supports_reference(invalid_env));
2744            assert!(ProductionSecretResolver::validate_reference(invalid_env).is_err());
2745        }
2746        assert!(!format!("{resolver:?}").contains("steadymux-openrouter"));
2747
2748        #[cfg(target_os = "macos")]
2749        {
2750            assert!(ProductionSecretResolver::supports_reference(
2751                "keychain:steadymux-openrouter/default"
2752            ));
2753            ProductionSecretResolver::validate_reference("keychain:steadymux-openrouter/default")
2754                .expect("validated native keychain reference without reading the keychain");
2755        }
2756    }
2757
2758    #[cfg(target_os = "macos")]
2759    #[test]
2760    fn native_keychain_mutation_entrypoints_reject_before_keychain_io() {
2761        let valid_reference = "keychain:steadymux-openrouter/default";
2762        let invalid_reference = "keychain:steadymux-openrouter/default/extra";
2763        assert!(upsert_macos_keychain_secret(valid_reference, b"").is_err());
2764        assert!(upsert_macos_keychain_secret(invalid_reference, b"canary-secret").is_err());
2765        assert!(verify_macos_keychain_secret(invalid_reference).is_err());
2766        assert!(delete_macos_keychain_secret(invalid_reference).is_err());
2767
2768        let diagnostic = format!(
2769            "{:?} {}",
2770            upsert_macos_keychain_secret(valid_reference, b""),
2771            SecretResolutionError
2772        );
2773        assert!(!diagnostic.contains("steadymux-openrouter"));
2774        assert!(!diagnostic.contains("canary-secret"));
2775    }
2776
2777    #[cfg(target_os = "macos")]
2778    #[test]
2779    fn native_keychain_upsert_query_selects_local_data_protection_backend() {
2780        let options = local_data_protection_password_options("fixture-service", "fixture-account");
2781        #[allow(deprecated)]
2782        let fields = options
2783            .query
2784            .iter()
2785            .map(|(key, value)| (key.to_string(), format!("{value:?}")))
2786            .collect::<Vec<_>>();
2787        assert_eq!(
2788            fields
2789                .iter()
2790                .map(|(key, _)| key.as_str())
2791                .collect::<Vec<_>>(),
2792            ["class", "svce", "acct", "nleg", "sync"]
2793        );
2794        assert!(fields[3].1.contains("value = true"));
2795        assert!(fields[4].1.contains("value = false"));
2796        assert!(fields.iter().all(|(key, _)| key != "agrp"));
2797    }
2798
2799    #[cfg(target_os = "macos")]
2800    #[test]
2801    #[cfg(not(target_os = "macos"))]
2802    #[test]
2803    fn production_secret_resolver_fails_closed_for_keychain_on_unsupported_platforms() {
2804        let resolver = ProductionSecretResolver::default();
2805        let reference = "keychain:steadymux-openrouter/default";
2806        assert!(!ProductionSecretResolver::supports_reference(reference));
2807        assert!(ProductionSecretResolver::validate_reference(reference).is_err());
2808        assert!(resolver.resolve(reference).is_err());
2809    }
2810
2811    #[cfg(unix)]
2812    #[test]
2813    fn command_secret_resolver_is_redacted_bounded_and_argument_free() {
2814        let (_directory, reference) = secret_helper(
2815            "#!/bin/sh\n[ \"$#\" -eq 0 ] || exit 7\n[ -z \"${HOME+x}\" ] || exit 8\nif IFS= read -r ignored; then exit 9; fi\nprintf 'credential-canary-value\\n'\n",
2816        );
2817        // A generous helper-execution window keeps the success path from
2818        // racing a load-delayed subprocess exec; the semantic under test is
2819        // the secret bytes and redaction, not the 5s production default.
2820        let resolver = CommandSecretResolver::with_timeout(Duration::from_secs(60));
2821        assert!(CommandSecretResolver::supports_reference(&reference));
2822        let secret = resolver
2823            .resolve(&reference)
2824            .expect("resolved helper secret");
2825        assert_eq!(secret.expose_for_dispatch(), b"credential-canary-value");
2826        assert!(!format!("{resolver:?} {secret:?}").contains("credential-canary-value"));
2827
2828        for unsupported in [
2829            "keychain:ollama",
2830            "secret-service:ollama",
2831            "credential-manager:ollama",
2832            "command:relative-helper",
2833        ] {
2834            assert!(!CommandSecretResolver::supports_reference(unsupported));
2835            assert!(resolver.resolve(unsupported).is_err());
2836        }
2837    }
2838
2839    #[cfg(unix)]
2840    #[test]
2841    fn command_secret_resolver_refuses_unsafe_helpers_and_multiple_newlines() {
2842        use std::os::unix::fs::{PermissionsExt, symlink};
2843
2844        let (directory, reference) = secret_helper("#!/bin/sh\nprintf 'secret\\n\\n'\n");
2845        let resolver = CommandSecretResolver::default();
2846        assert!(resolver.resolve(&reference).is_err());
2847
2848        let canonical = std::fs::canonicalize(directory.path()).expect("canonical temp directory");
2849        let helper = canonical.join("secret-helper");
2850        std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o722))
2851            .expect("unsafe permissions");
2852        assert!(resolver.resolve(&reference).is_err());
2853
2854        std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o711))
2855            .expect("non-owner executable permissions");
2856        assert!(resolver.resolve(&reference).is_err());
2857
2858        std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o700))
2859            .expect("restore permissions");
2860        let link = canonical.join("secret-helper-link");
2861        symlink(&helper, &link).expect("helper symlink");
2862        assert!(
2863            resolver
2864                .resolve(&format!("command:{}", link.display()))
2865                .is_err()
2866        );
2867
2868        let unsafe_parent = canonical.join("unsafe-parent");
2869        std::fs::create_dir(&unsafe_parent).expect("unsafe parent");
2870        std::fs::set_permissions(&unsafe_parent, std::fs::Permissions::from_mode(0o777))
2871            .expect("unsafe parent permissions");
2872        let nested = unsafe_parent.join("helper");
2873        std::fs::write(&nested, "#!/bin/sh\nprintf safe\n").expect("nested helper");
2874        std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o700))
2875            .expect("nested helper permissions");
2876        assert!(
2877            resolver
2878                .resolve(&format!("command:{}", nested.display()))
2879                .is_err()
2880        );
2881    }
2882
2883    #[cfg(unix)]
2884    #[test]
2885    fn command_secret_resolver_kills_timeout_and_rejects_oversized_stdout() {
2886        let (directory, timeout_reference) = secret_helper("#!/bin/sh\nexec /bin/sleep 2\n");
2887        let resolver = CommandSecretResolver::with_timeout(Duration::from_millis(50));
2888        let started = Instant::now();
2889        assert!(resolver.resolve(&timeout_reference).is_err());
2890        assert!(started.elapsed() < Duration::from_secs(1));
2891        let canonical = std::fs::canonicalize(directory.path()).expect("canonical temp directory");
2892        assert!(
2893            canonical
2894                .read_dir()
2895                .expect("stage cleanup inventory")
2896                .all(|entry| !entry
2897                    .expect("inventory entry")
2898                    .file_name()
2899                    .to_string_lossy()
2900                    .starts_with(".steadymux-secret-helper-"))
2901        );
2902
2903        let (_directory, oversized_reference) = secret_helper(
2904            "#!/bin/sh\ni=0\nwhile [ \"$i\" -lt 17000 ]; do printf x; i=$((i + 1)); done\n",
2905        );
2906        assert!(
2907            CommandSecretResolver::default()
2908                .resolve(&oversized_reference)
2909                .is_err()
2910        );
2911    }
2912
2913    #[cfg(unix)]
2914    #[test]
2915    fn command_secret_resolver_executes_fd_pinned_bytes_after_source_path_swap() {
2916        use std::os::unix::fs::PermissionsExt;
2917
2918        let (directory, reference) = secret_helper("#!/bin/sh\nprintf 'pinned-secret\\n'\n");
2919        let canonical = std::fs::canonicalize(directory.path()).expect("canonical temp directory");
2920        let displaced = canonical.join("displaced-helper");
2921        // Generous window: the fd-pinned secret bytes (not the 5s production
2922        // default) are the semantic under test, so a load-delayed child exec
2923        // must not turn this success path into a timeout.
2924        let secret =
2925            resolve_command_secret_with_hook(&reference, Duration::from_secs(60), |source_path| {
2926                std::fs::rename(source_path, &displaced).map_err(|_| SecretResolutionError)?;
2927                std::fs::write(source_path, "#!/bin/sh\nprintf 'swapped-secret\\n'\n")
2928                    .map_err(|_| SecretResolutionError)?;
2929                std::fs::set_permissions(source_path, std::fs::Permissions::from_mode(0o700))
2930                    .map_err(|_| SecretResolutionError)
2931            })
2932            .expect("FD-pinned helper execution");
2933        assert_eq!(secret.expose_for_dispatch(), b"pinned-secret");
2934        assert!(!format!("{secret:?}").contains("pinned-secret"));
2935        assert!(
2936            canonical
2937                .read_dir()
2938                .expect("stage cleanup inventory")
2939                .all(|entry| !entry
2940                    .expect("inventory entry")
2941                    .file_name()
2942                    .to_string_lossy()
2943                    .starts_with(".steadymux-secret-helper-"))
2944        );
2945    }
2946
2947    #[cfg(unix)]
2948    #[test]
2949    fn command_secret_resolver_revalidates_staging_ancestry_after_source_open() {
2950        use std::os::unix::fs::PermissionsExt;
2951
2952        let (directory, reference) = secret_helper("#!/bin/sh\nprintf safe\n");
2953        let canonical = std::fs::canonicalize(directory.path()).expect("canonical temp directory");
2954        let result = resolve_command_secret_with_hook(&reference, Duration::from_secs(2), |_| {
2955            std::fs::set_permissions(&canonical, std::fs::Permissions::from_mode(0o777))
2956                .map_err(|_| SecretResolutionError)
2957        });
2958        std::fs::set_permissions(&canonical, std::fs::Permissions::from_mode(0o700))
2959            .expect("restore staging parent permissions");
2960        assert!(result.is_err());
2961        assert_eq!(canonical.read_dir().expect("inventory").count(), 1);
2962    }
2963
2964    #[cfg(unix)]
2965    #[test]
2966    fn command_secret_resolver_kills_the_helper_process_group_on_timeout() {
2967        use std::os::unix::fs::PermissionsExt;
2968
2969        let directory = tempfile::tempdir().expect("temporary directory");
2970        let canonical = std::fs::canonicalize(directory.path()).expect("canonical temp directory");
2971        let marker = canonical.join("grandchild-survived");
2972        let script = format!(
2973            "#!/bin/sh\n(/bin/sleep 1; /usr/bin/touch '{}') &\nwait\n",
2974            marker.display()
2975        );
2976        let helper = canonical.join("secret-helper");
2977        std::fs::write(&helper, script).expect("write helper");
2978        std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o700))
2979            .expect("owner-only executable helper");
2980        let reference = format!("command:{}", helper.display());
2981        assert!(
2982            CommandSecretResolver::with_timeout(Duration::from_millis(100))
2983                .resolve(&reference)
2984                .is_err()
2985        );
2986        thread::sleep(Duration::from_millis(1_200));
2987        assert!(
2988            !marker.exists(),
2989            "grandchild survived process-group cleanup"
2990        );
2991    }
2992
2993    impl ProviderHttpExecutor for FakeExecutor {
2994        fn execute<'a>(
2995            &'a self,
2996            request: PreparedRequest,
2997            _control: DispatchControl,
2998        ) -> ProviderHttpFuture<'a> {
2999            let observed = Arc::clone(&self.observed);
3000            let response = self
3001                .response
3002                .lock()
3003                .expect("response lock")
3004                .take()
3005                .expect("one call");
3006            Box::pin(async move {
3007                *observed.lock().expect("observed lock") = Some((
3008                    request.origin.clone(),
3009                    request.path.clone(),
3010                    request.authorization_for_dispatch().is_some(),
3011                    format!("{request:?}"),
3012                ));
3013                response
3014            })
3015        }
3016    }
3017
3018    fn request() -> CanonicalRequest {
3019        CanonicalRequest {
3020            wire_version: M1_WIRE_VERSION.to_owned(),
3021            model: "router-auto".to_owned(),
3022            stream: true,
3023            harness_instance: HarnessInstanceId::parse("cli").expect("harness"),
3024            session_id: SessionId::parse("session-transport").expect("session"),
3025            turn_id: TurnId::parse("turn-transport").expect("turn"),
3026            input: Vec::new(),
3027            tools: Vec::new(),
3028            tool_choice: ToolChoice::None,
3029            structured_output: None,
3030            reasoning: Default::default(),
3031            max_output_tokens: None,
3032            max_spend_micro_usd: None,
3033            capabilities: CapabilityRequirements::default(),
3034        }
3035    }
3036
3037    fn ollama_route(transport: TransportKind) -> RouteIdentity {
3038        let direct = transport == TransportKind::DirectHttps;
3039        RouteIdentity {
3040            phase: PhaseKind::OllamaCloud,
3041            placement: PhasePlacement::Builtin,
3042            provider: ProviderService::OllamaCloud,
3043            transport,
3044            endpoint: EndpointIdentity {
3045                origin: if direct {
3046                    "https://ollama.com"
3047                } else {
3048                    "http://127.0.0.1:11434"
3049                }
3050                .to_owned(),
3051                path: "/api/chat".to_owned(),
3052            },
3053            credential_source: if direct {
3054                CredentialSourceKind::SecretRef
3055            } else {
3056                CredentialSourceKind::OllamaDaemonSession
3057            },
3058            credential_ref: direct.then(|| "keychain:ollama-secret-canary".to_owned()),
3059            credential_refs: Vec::new(),
3060            catalog_source: CatalogSourceKind::OllamaCloudCatalog,
3061            catalog_ref: "catalog:ollama-cloud".to_owned(),
3062            billing_authority: BillingAuthorityClass::OllamaCloud,
3063            model_namespace: ModelNamespace::OllamaCloud,
3064            model_id: if direct { "glm-5.2" } else { "glm-5.2:cloud" }.to_owned(),
3065            execution_location: ExecutionLocation::RemoteCloud,
3066            account_ref: "account:ollama-cloud".to_owned(),
3067            capacity_domain_ref: "capacity:ollama-cloud".to_owned(),
3068            local_execution_proof: false,
3069            approved_local_inventory: Vec::new(),
3070            reasoning_modes: vec!["reasoning".to_owned()],
3071        }
3072    }
3073
3074    fn openrouter_route() -> RouteIdentity {
3075        RouteIdentity {
3076            phase: PhaseKind::OpenrouterApi,
3077            placement: PhasePlacement::Builtin,
3078            provider: ProviderService::Openrouter,
3079            transport: TransportKind::DirectHttps,
3080            endpoint: EndpointIdentity {
3081                origin: "https://openrouter.ai".to_owned(),
3082                path: "/api/v1/chat/completions".to_owned(),
3083            },
3084            credential_source: CredentialSourceKind::SecretRef,
3085            credential_ref: Some("keychain:openrouter-secret-canary".to_owned()),
3086            credential_refs: Vec::new(),
3087            catalog_source: CatalogSourceKind::OpenrouterCatalog,
3088            catalog_ref: "catalog:openrouter".to_owned(),
3089            billing_authority: BillingAuthorityClass::OpenrouterMeteredApi,
3090            model_namespace: ModelNamespace::Openrouter,
3091            model_id: "anthropic/claude-sonnet-4.5".to_owned(),
3092            execution_location: ExecutionLocation::RemoteCloud,
3093            account_ref: "account:openrouter".to_owned(),
3094            capacity_domain_ref: "capacity:openrouter".to_owned(),
3095            local_execution_proof: false,
3096            approved_local_inventory: Vec::new(),
3097            reasoning_modes: Vec::new(),
3098        }
3099    }
3100
3101    fn executor(
3102        response: Result<ProviderHttpResponse, ProviderHttpError>,
3103    ) -> (FakeExecutor, ObservedRequest) {
3104        let observed = Arc::new(Mutex::new(None));
3105        (
3106            FakeExecutor {
3107                response: Mutex::new(Some(response)),
3108                observed: Arc::clone(&observed),
3109            },
3110            observed,
3111        )
3112    }
3113
3114    #[test]
3115    fn reqwest_executor_is_fixed_policy_and_rejects_drift_before_io() {
3116        let executor = ReqwestExecutor::new(
3117            Duration::from_secs(2),
3118            Duration::from_secs(3),
3119            Duration::from_secs(5),
3120        )
3121        .expect("client");
3122        assert_eq!(executor.user_agent(), FIXED_USER_AGENT);
3123        let debug = format!("{executor:?}");
3124        assert!(debug.contains("disabled"));
3125        assert!(debug.contains("rustls"));
3126
3127        let resolver = Resolver {
3128            calls: AtomicUsize::new(0),
3129            value: b"unused",
3130        };
3131        let spec = ollama_chat::build_request(
3132            &ollama_route(TransportKind::OllamaSignedInRelay),
3133            &request(),
3134        )
3135        .expect("spec");
3136        let mut prepared = prepare_ollama(spec, &resolver).expect("prepared");
3137        validate_prepared_request(&prepared).expect("exact local policy");
3138        assert_eq!(prepared.origin, "http://127.0.0.1:11434");
3139        assert!(!prepared.network.allow_dns);
3140        assert!(prepared.authorization_for_dispatch().is_none());
3141        prepared.network.allow_dns = true;
3142        assert_eq!(
3143            validate_prepared_request(&prepared),
3144            Err(ProviderHttpError {
3145                kind: ProviderHttpErrorKind::InvalidPreparedRequest,
3146                body_write: BodyWriteState::NotWritten,
3147            })
3148        );
3149        assert_eq!(resolver.calls.load(Ordering::SeqCst), 0);
3150    }
3151
3152    #[test]
3153    fn bearer_header_is_marked_sensitive_before_reqwest_receives_it() {
3154        let value = sensitive_header_value(b"Bearer credential-canary-value").expect("header");
3155        assert!(value.is_sensitive());
3156        assert!(!format!("{value:?}").contains("credential-canary-value"));
3157    }
3158
3159    #[tokio::test]
3160    async fn localhost_never_resolves_auth_and_parses_fragmented_ollama() {
3161        let resolver = Resolver {
3162            calls: AtomicUsize::new(0),
3163            value: b"unused",
3164        };
3165        let spec = ollama_chat::build_request(
3166            &ollama_route(TransportKind::OllamaSignedInRelay),
3167            &request(),
3168        )
3169        .expect("spec");
3170        let response = ProviderHttpResponse {
3171            status: 200,
3172            content_type: Some("application/x-ndjson".to_owned()),
3173            retry_after: None,
3174            redirected: false,
3175            request_id: None,
3176            chunks: vec![
3177                b"{\"message\":{\"content\":\"ok\"},\"do".to_vec(),
3178                b"ne\":false}\r\n{\"done\":true}\n".to_vec(),
3179            ],
3180        };
3181        let (executor, observed) = executor(Ok(response));
3182        let result = dispatch_ollama(
3183            spec,
3184            &AttemptId::parse("attempt-local").expect("attempt"),
3185            &resolver,
3186            &executor,
3187            DispatchControl::default(),
3188        )
3189        .await
3190        .expect("dispatch");
3191        assert_eq!(resolver.calls.load(Ordering::SeqCst), 0);
3192        assert!(matches!(
3193            result.events[0],
3194            NormalizedProviderEvent::Ollama(OllamaStreamEvent::Content { .. })
3195        ));
3196        let observed = observed.lock().expect("observed");
3197        assert_eq!(observed.as_ref().map(|value| value.2), Some(false));
3198    }
3199
3200    #[tokio::test]
3201    async fn remote_secret_is_resolved_jit_and_redacted_from_debug_and_errors() {
3202        let resolver = Resolver {
3203            calls: AtomicUsize::new(0),
3204            value: b"credential-canary-value",
3205        };
3206        let spec =
3207            ollama_chat::build_request(&ollama_route(TransportKind::DirectHttps), &request())
3208                .expect("spec");
3209        let (executor, observed) = executor(Ok(ProviderHttpResponse {
3210            status: 401,
3211            content_type: None,
3212            retry_after: None,
3213            redirected: false,
3214            request_id: None,
3215            chunks: Vec::new(),
3216        }));
3217        let error = dispatch_ollama(
3218            spec,
3219            &AttemptId::parse("attempt-remote").expect("attempt"),
3220            &resolver,
3221            &executor,
3222            DispatchControl::default(),
3223        )
3224        .await
3225        .expect_err("401");
3226        assert_eq!(resolver.calls.load(Ordering::SeqCst), 1);
3227        assert!(matches!(
3228            error.kind,
3229            DispatchFailureKind::OllamaHttp(OllamaFailure {
3230                class: OllamaFailureClass::Authentication,
3231                ..
3232            })
3233        ));
3234        let debug = observed
3235            .lock()
3236            .expect("observed")
3237            .as_ref()
3238            .expect("request")
3239            .3
3240            .clone();
3241        assert!(!debug.contains("credential-canary-value"));
3242        assert!(!format!("{error:?}").contains("credential-canary-value"));
3243    }
3244
3245    #[tokio::test]
3246    async fn openrouter_sse_is_normalized_with_usage_exactly_once() {
3247        let resolver = Resolver {
3248            calls: AtomicUsize::new(0),
3249            value: b"openrouter-canary",
3250        };
3251        let spec = openrouter_chat::build_request(&openrouter_route(), &request()).expect("spec");
3252        let transcript = b"data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\ndata: {\"choices\":[],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":1,\"total_tokens\":3}}\n\ndata: [DONE]\n\n";
3253        let response = ProviderHttpResponse {
3254            status: 200,
3255            content_type: Some("text/event-stream; charset=utf-8".to_owned()),
3256            retry_after: None,
3257            redirected: false,
3258            request_id: None,
3259            chunks: transcript.iter().map(|byte| vec![*byte]).collect(),
3260        };
3261        let (executor, _) = executor(Ok(response));
3262        let result = dispatch_openrouter(
3263            spec,
3264            &AttemptId::parse("attempt-openrouter").expect("attempt"),
3265            &resolver,
3266            &executor,
3267            DispatchControl::default(),
3268        )
3269        .await
3270        .expect("dispatch");
3271        assert_eq!(
3272            result
3273                .events
3274                .iter()
3275                .filter(|event| matches!(
3276                    event,
3277                    NormalizedProviderEvent::OpenRouter(OpenRouterStreamEvent::Usage(_))
3278                ))
3279                .count(),
3280            1
3281        );
3282        assert!(matches!(
3283            result.events.last(),
3284            Some(NormalizedProviderEvent::OpenRouter(
3285                OpenRouterStreamEvent::Done
3286            ))
3287        ));
3288        assert_eq!(
3289            result.replay,
3290            ReplayDecision::Denied(ReplayBoundary::PublicContent)
3291        );
3292    }
3293
3294    #[tokio::test]
3295    async fn cancellation_before_dispatch_is_safe_and_written_timeout_is_ambiguous() {
3296        let resolver = Resolver {
3297            calls: AtomicUsize::new(0),
3298            value: b"credential-canary",
3299        };
3300        let cancelled_spec =
3301            openrouter_chat::build_request(&openrouter_route(), &request()).expect("spec");
3302        let (unused_executor, _) = executor(Ok(ProviderHttpResponse {
3303            status: 200,
3304            content_type: Some("text/event-stream".to_owned()),
3305            retry_after: None,
3306            redirected: false,
3307            request_id: None,
3308            chunks: Vec::new(),
3309        }));
3310        let cancelled = dispatch_openrouter(
3311            cancelled_spec,
3312            &AttemptId::parse("attempt-cancel").expect("attempt"),
3313            &resolver,
3314            &unused_executor,
3315            DispatchControl {
3316                cancelled_before_dispatch: true,
3317                forwarded_tool_result: false,
3318                cancellation: None,
3319            },
3320        )
3321        .await
3322        .expect_err("cancelled");
3323        assert_eq!(resolver.calls.load(Ordering::SeqCst), 0);
3324        assert_eq!(cancelled.replay, ReplayDecision::Allowed);
3325
3326        let timeout_spec =
3327            openrouter_chat::build_request(&openrouter_route(), &request()).expect("spec");
3328        let (executor, _) = executor(Err(ProviderHttpError {
3329            kind: ProviderHttpErrorKind::Timeout,
3330            body_write: BodyWriteState::Written,
3331        }));
3332        let timeout = dispatch_openrouter(
3333            timeout_spec,
3334            &AttemptId::parse("attempt-timeout").expect("attempt"),
3335            &resolver,
3336            &executor,
3337            DispatchControl::default(),
3338        )
3339        .await
3340        .expect_err("timeout");
3341        assert!(matches!(
3342            timeout.kind,
3343            DispatchFailureKind::OpenRouterHttp(OpenRouterFailure {
3344                class: OpenRouterFailureClass::AmbiguousDispatch,
3345                ..
3346            })
3347        ));
3348        assert_eq!(
3349            timeout.replay,
3350            ReplayDecision::Denied(ReplayBoundary::AmbiguousDispatch)
3351        );
3352    }
3353
3354    fn bounded_spec(protocol: BoundedChatCompletionProtocol) -> BoundedChatCompletionSpec {
3355        BoundedChatCompletionSpec {
3356            origin: "https://provider.example".to_owned(),
3357            path: "/v1/chat/completions".to_owned(),
3358            model: "provider/categorizer".to_owned(),
3359            prompt: "categorize these models".to_owned(),
3360            bearer_secret: Some("env:STEADYMUX_CATEGORIZER_KEY".to_owned()),
3361            protocol,
3362            max_response_bytes: 256 * 1024,
3363        }
3364    }
3365
3366    fn openai_compat_response(text: &str) -> ProviderHttpResponse {
3367        ProviderHttpResponse {
3368            status: 200,
3369            content_type: Some("application/json".to_owned()),
3370            retry_after: None,
3371            redirected: false,
3372            request_id: None,
3373            chunks: vec![
3374                serde_json::json!({
3375                    "choices": [{"message": {"content": text}}],
3376                    "usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3},
3377                })
3378                .to_string()
3379                .into_bytes(),
3380            ],
3381        }
3382    }
3383
3384    fn ollama_response(text: &str) -> ProviderHttpResponse {
3385        ProviderHttpResponse {
3386            status: 200,
3387            content_type: Some("application/json".to_owned()),
3388            retry_after: None,
3389            redirected: false,
3390            request_id: None,
3391            chunks: vec![
3392                serde_json::json!({
3393                    "model": "provider/categorizer",
3394                    "message": {"role": "assistant", "content": text},
3395                    "done": true,
3396                })
3397                .to_string()
3398                .into_bytes(),
3399            ],
3400        }
3401    }
3402
3403    #[tokio::test]
3404    async fn bounded_chat_completion_openai_compat_returns_text() {
3405        let resolver = Resolver {
3406            calls: AtomicUsize::new(0),
3407            value: b"secret-value",
3408        };
3409        let (executor, observed) = executor(Ok(openai_compat_response("categorization-evidence")));
3410        let text = dispatch_bounded_chat_completion(
3411            bounded_spec(BoundedChatCompletionProtocol::OpenAiCompat),
3412            &resolver,
3413            &executor,
3414        )
3415        .await
3416        .expect("completion");
3417        assert_eq!(text, "categorization-evidence");
3418        let (origin, path, authorized, debug) =
3419            observed.lock().expect("observed").clone().expect("call");
3420        assert_eq!(origin, "https://provider.example");
3421        assert_eq!(path, "/v1/chat/completions");
3422        assert!(authorized);
3423        assert!(!debug.contains("secret-value"));
3424        assert!(!debug.contains("categorize these models"));
3425    }
3426
3427    #[tokio::test]
3428    async fn bounded_chat_completion_ollama_returns_text() {
3429        let resolver = Resolver {
3430            calls: AtomicUsize::new(0),
3431            value: b"secret-value",
3432        };
3433        let (executor, _) = executor(Ok(ollama_response("ollama-evidence")));
3434        let text = dispatch_bounded_chat_completion(
3435            bounded_spec(BoundedChatCompletionProtocol::Ollama),
3436            &resolver,
3437            &executor,
3438        )
3439        .await
3440        .expect("completion");
3441        assert_eq!(text, "ollama-evidence");
3442    }
3443
3444    #[tokio::test]
3445    async fn bounded_chat_completion_ollama_without_secret_is_allowed() {
3446        let resolver = Resolver {
3447            calls: AtomicUsize::new(0),
3448            value: b"unused",
3449        };
3450        let mut spec = bounded_spec(BoundedChatCompletionProtocol::Ollama);
3451        spec.bearer_secret = None;
3452        let (executor, observed) = executor(Ok(ollama_response("local-evidence")));
3453        let text = dispatch_bounded_chat_completion(spec, &resolver, &executor)
3454            .await
3455            .expect("completion");
3456        assert_eq!(text, "local-evidence");
3457        let (_, _, authorized, _) = observed.lock().expect("observed").clone().expect("call");
3458        assert!(!authorized);
3459        assert_eq!(resolver.calls.load(Ordering::SeqCst), 0);
3460    }
3461
3462    #[tokio::test]
3463    async fn bounded_chat_completion_openai_compat_requires_secret() {
3464        let resolver = Resolver {
3465            calls: AtomicUsize::new(0),
3466            value: b"unused",
3467        };
3468        let mut spec = bounded_spec(BoundedChatCompletionProtocol::OpenAiCompat);
3469        spec.bearer_secret = None;
3470        let (executor, _) = executor(Ok(openai_compat_response("x")));
3471        let error = dispatch_bounded_chat_completion(spec, &resolver, &executor)
3472            .await
3473            .expect_err("secret required");
3474        assert_eq!(error.kind, BoundedChatCompletionErrorKind::SecretResolution);
3475        assert_eq!(resolver.calls.load(Ordering::SeqCst), 0);
3476    }
3477
3478    #[tokio::test]
3479    async fn bounded_chat_completion_rejects_oversized_response() {
3480        let resolver = Resolver {
3481            calls: AtomicUsize::new(0),
3482            value: b"secret-value",
3483        };
3484        let mut spec = bounded_spec(BoundedChatCompletionProtocol::OpenAiCompat);
3485        spec.max_response_bytes = 16;
3486        let (executor, _) = executor(Ok(openai_compat_response("this text is far too long")));
3487        let error = dispatch_bounded_chat_completion(spec, &resolver, &executor)
3488            .await
3489            .expect_err("oversized");
3490        assert_eq!(error.kind, BoundedChatCompletionErrorKind::ResponseTooLarge);
3491    }
3492
3493    #[tokio::test]
3494    async fn bounded_chat_completion_rejects_non_200() {
3495        let resolver = Resolver {
3496            calls: AtomicUsize::new(0),
3497            value: b"secret-value",
3498        };
3499        let mut response = openai_compat_response("x");
3500        response.status = 429;
3501        let (executor, _) = executor(Ok(response));
3502        let error = dispatch_bounded_chat_completion(
3503            bounded_spec(BoundedChatCompletionProtocol::OpenAiCompat),
3504            &resolver,
3505            &executor,
3506        )
3507        .await
3508        .expect_err("rate limited");
3509        assert_eq!(
3510            error.kind,
3511            BoundedChatCompletionErrorKind::Http { status: 429 }
3512        );
3513    }
3514
3515    #[tokio::test]
3516    async fn bounded_chat_completion_rejects_invalid_response() {
3517        let resolver = Resolver {
3518            calls: AtomicUsize::new(0),
3519            value: b"secret-value",
3520        };
3521        let response = ProviderHttpResponse {
3522            status: 200,
3523            content_type: Some("application/json".to_owned()),
3524            retry_after: None,
3525            redirected: false,
3526            request_id: None,
3527            chunks: vec![b"not-json".to_vec()],
3528        };
3529        let (executor, _) = executor(Ok(response));
3530        let error = dispatch_bounded_chat_completion(
3531            bounded_spec(BoundedChatCompletionProtocol::OpenAiCompat),
3532            &resolver,
3533            &executor,
3534        )
3535        .await
3536        .expect_err("invalid response");
3537        assert_eq!(error.kind, BoundedChatCompletionErrorKind::InvalidResponse);
3538    }
3539
3540    #[tokio::test]
3541    async fn bounded_chat_completion_rejects_empty_completion() {
3542        let resolver = Resolver {
3543            calls: AtomicUsize::new(0),
3544            value: b"secret-value",
3545        };
3546        let (executor, _) = executor(Ok(openai_compat_response("")));
3547        let error = dispatch_bounded_chat_completion(
3548            bounded_spec(BoundedChatCompletionProtocol::OpenAiCompat),
3549            &resolver,
3550            &executor,
3551        )
3552        .await
3553        .expect_err("empty completion");
3554        assert_eq!(error.kind, BoundedChatCompletionErrorKind::EmptyCompletion);
3555    }
3556
3557    #[tokio::test]
3558    async fn bounded_chat_completion_anthropic_returns_text_with_native_headers() {
3559        let resolver = Resolver {
3560            calls: AtomicUsize::new(0),
3561            value: b"secret-value",
3562        };
3563        let response = ProviderHttpResponse {
3564            status: 200,
3565            content_type: Some("application/json".to_owned()),
3566            retry_after: None,
3567            redirected: false,
3568            request_id: None,
3569            chunks: vec![
3570                serde_json::json!({
3571                    "content": [{"type": "text", "text": "anthropic-evidence"}],
3572                    "model": "claude-sonnet-5",
3573                })
3574                .to_string()
3575                .into_bytes(),
3576            ],
3577        };
3578        let (executor, observed) = executor(Ok(response));
3579        let mut spec = bounded_spec(BoundedChatCompletionProtocol::Anthropic);
3580        spec.origin = router_protocol::ANTHROPIC_ORIGIN.to_owned();
3581        spec.path = router_protocol::ANTHROPIC_CHAT_PATH.to_owned();
3582        spec.model = "claude-sonnet-5".to_owned();
3583        let text = dispatch_bounded_chat_completion(spec, &resolver, &executor)
3584            .await
3585            .expect("completion");
3586        assert_eq!(text, "anthropic-evidence");
3587        let (origin, path, authorized, debug) =
3588            observed.lock().expect("observed").clone().expect("call");
3589        assert_eq!(origin, router_protocol::ANTHROPIC_ORIGIN);
3590        assert_eq!(path, router_protocol::ANTHROPIC_CHAT_PATH);
3591        assert!(authorized);
3592        assert!(
3593            debug.contains(router_protocol::ANTHROPIC_API_KEY_HEADER),
3594            "the bounded Anthropic request must carry the x-api-key header"
3595        );
3596        assert!(
3597            debug.contains(router_protocol::ANTHROPIC_VERSION_HEADER),
3598            "the bounded Anthropic request must carry the pinned anthropic-version header"
3599        );
3600        assert!(!debug.contains("secret-value"));
3601        assert!(!debug.contains("anthropic-evidence"));
3602    }
3603
3604    #[tokio::test]
3605    async fn bounded_chat_completion_gemini_returns_text_and_materializes_model_path() {
3606        let resolver = Resolver {
3607            calls: AtomicUsize::new(0),
3608            value: b"secret-value",
3609        };
3610        let response = ProviderHttpResponse {
3611            status: 200,
3612            content_type: Some("application/json".to_owned()),
3613            retry_after: None,
3614            redirected: false,
3615            request_id: None,
3616            chunks: vec![
3617                serde_json::json!({
3618                    "candidates": [{
3619                        "content": {"parts": [{"text": "gemini-evidence"}], "role": "model"},
3620                        "finishReason": "STOP",
3621                    }],
3622                })
3623                .to_string()
3624                .into_bytes(),
3625            ],
3626        };
3627        let (executor, observed) = executor(Ok(response));
3628        let mut spec = bounded_spec(BoundedChatCompletionProtocol::Gemini);
3629        spec.origin = router_protocol::GEMINI_ORIGIN.to_owned();
3630        spec.path = router_protocol::GEMINI_GENERATE_CONTENT_PATH.to_owned();
3631        spec.model = "gemini-3.6-flash".to_owned();
3632        let text = dispatch_bounded_chat_completion(spec, &resolver, &executor)
3633            .await
3634            .expect("completion");
3635        assert_eq!(text, "gemini-evidence");
3636        let (origin, path, authorized, debug) =
3637            observed.lock().expect("observed").clone().expect("call");
3638        assert_eq!(origin, router_protocol::GEMINI_ORIGIN);
3639        assert_eq!(path, "/v1beta/models/gemini-3.6-flash:generateContent");
3640        assert!(authorized);
3641        assert!(
3642            debug.contains(router_protocol::GEMINI_API_KEY_HEADER),
3643            "the bounded Gemini request must carry the x-goog-api-key header"
3644        );
3645        assert!(!debug.contains("secret-value"));
3646        assert!(!debug.contains("gemini-evidence"));
3647    }
3648
3649    #[tokio::test]
3650    async fn bounded_chat_completion_anthropic_and_gemini_require_secret() {
3651        for protocol in [
3652            BoundedChatCompletionProtocol::Anthropic,
3653            BoundedChatCompletionProtocol::Gemini,
3654        ] {
3655            let resolver = Resolver {
3656                calls: AtomicUsize::new(0),
3657                value: b"unused",
3658            };
3659            let mut spec = bounded_spec(protocol);
3660            spec.bearer_secret = None;
3661            let (executor, _) = executor(Ok(openai_compat_response("x")));
3662            let error = dispatch_bounded_chat_completion(spec, &resolver, &executor)
3663                .await
3664                .expect_err("secret required");
3665            assert_eq!(error.kind, BoundedChatCompletionErrorKind::SecretResolution);
3666            assert_eq!(resolver.calls.load(Ordering::SeqCst), 0);
3667        }
3668    }
3669
3670    #[tokio::test]
3671    async fn bounded_chat_completion_maps_executor_failures() {
3672        let resolver = Resolver {
3673            calls: AtomicUsize::new(0),
3674            value: b"secret-value",
3675        };
3676        let (executor, _) = executor(Err(ProviderHttpError {
3677            kind: ProviderHttpErrorKind::Timeout,
3678            body_write: BodyWriteState::NotWritten,
3679        }));
3680        let error = dispatch_bounded_chat_completion(
3681            bounded_spec(BoundedChatCompletionProtocol::OpenAiCompat),
3682            &resolver,
3683            &executor,
3684        )
3685        .await
3686        .expect_err("timeout");
3687        assert_eq!(error.kind, BoundedChatCompletionErrorKind::Timeout);
3688    }
3689
3690    fn bounded_request(
3691        origin: &str,
3692        path: &str,
3693        protocol: ProviderWireProtocol,
3694        local: bool,
3695        authorization: Option<ResolvedSecret>,
3696        auth_header_name: Option<&'static str>,
3697        version_header: Option<(&'static str, &'static str)>,
3698    ) -> PreparedRequest {
3699        PreparedRequest {
3700            method: "POST",
3701            origin: origin.to_owned(),
3702            path: path.to_owned(),
3703            content_type: "application/json",
3704            accept: "application/json",
3705            network: NetworkPolicy {
3706                allow_proxy: false,
3707                allow_dns: !local,
3708                follow_redirects: false,
3709                require_tls: !local,
3710            },
3711            protocol,
3712            body: b"{\"model\":\"categorizer\"}".to_vec(),
3713            authorization,
3714            auth_header_name,
3715            version_header,
3716            bounded: true,
3717        }
3718    }
3719
3720    #[test]
3721    fn bounded_requests_pass_validate_prepared_request() {
3722        // Local Ollama: loopback, no DNS, no TLS, no authorization.
3723        validate_prepared_request(&bounded_request(
3724            "http://127.0.0.1:11434",
3725            "/api/chat",
3726            ProviderWireProtocol::OllamaNdjson,
3727            true,
3728            None,
3729            None,
3730            None,
3731        ))
3732        .expect("bounded localhost passes");
3733        // Ollama cloud: DNS and TLS required, authorization required.
3734        validate_prepared_request(&bounded_request(
3735            "https://ollama.com",
3736            "/api/chat",
3737            ProviderWireProtocol::OllamaNdjson,
3738            false,
3739            Some(ResolvedSecret::new(b"secret-value").expect("valid secret")),
3740            None,
3741            None,
3742        ))
3743        .expect("bounded ollama cloud passes");
3744        // OpenRouter origin with the OpenAiCompat wire protocol (the bounded
3745        // categorization dispatch shape for the OpenAI and OpenRouter phases).
3746        validate_prepared_request(&bounded_request(
3747            "https://openrouter.ai",
3748            "/api/v1/chat/completions",
3749            ProviderWireProtocol::OpenAiCompatSse,
3750            false,
3751            Some(ResolvedSecret::new(b"secret-value").expect("valid secret")),
3752            None,
3753            None,
3754        ))
3755        .expect("bounded openrouter passes");
3756        // A bounded OpenAI-compatible origin.
3757        validate_prepared_request(&bounded_request(
3758            router_protocol::DASHSCOPE_ORIGIN,
3759            router_protocol::DASHSCOPE_CHAT_PATH,
3760            ProviderWireProtocol::OpenAiCompatSse,
3761            false,
3762            Some(ResolvedSecret::new(b"secret-value").expect("valid secret")),
3763            None,
3764            None,
3765        ))
3766        .expect("bounded openai compat passes");
3767        // The DeepSeek direct-API origin (PRD-001y S1), also OpenAI-compatible.
3768        validate_prepared_request(&bounded_request(
3769            router_protocol::DEEPSEEK_ORIGIN,
3770            router_protocol::DEEPSEEK_CHAT_PATH,
3771            ProviderWireProtocol::OpenAiCompatSse,
3772            false,
3773            Some(ResolvedSecret::new(b"secret-value").expect("valid secret")),
3774            None,
3775            None,
3776        ))
3777        .expect("bounded deepseek passes");
3778        // The Qwen token-plan origin (PRD-001y S3 requalification): the
3779        // operator's Qwen account authenticates only against the token-plan
3780        // host, and the follow-on relay must be allowed to reach it.
3781        validate_prepared_request(&bounded_request(
3782            router_protocol::DASHSCOPE_TOKEN_PLAN_ORIGIN,
3783            router_protocol::DASHSCOPE_CHAT_PATH,
3784            ProviderWireProtocol::OpenAiCompatSse,
3785            false,
3786            Some(ResolvedSecret::new(b"secret-value").expect("valid secret")),
3787            None,
3788            None,
3789        ))
3790        .expect("bounded qwen token-plan passes");
3791        // The Anthropic bounded shape (PRD-001y S2): `x-api-key` plus the
3792        // pinned `anthropic-version` header.
3793        validate_prepared_request(&bounded_request(
3794            router_protocol::ANTHROPIC_ORIGIN,
3795            router_protocol::ANTHROPIC_CHAT_PATH,
3796            ProviderWireProtocol::AnthropicSse,
3797            false,
3798            Some(ResolvedSecret::new(b"secret-value").expect("valid secret")),
3799            Some(router_protocol::ANTHROPIC_API_KEY_HEADER),
3800            Some((
3801                router_protocol::ANTHROPIC_VERSION_HEADER,
3802                router_protocol::ANTHROPIC_VERSION,
3803            )),
3804        ))
3805        .expect("bounded anthropic passes");
3806        // The Gemini bounded shape (PRD-001y S2): `x-goog-api-key`, no version
3807        // header, and the model-materialized generateContent path.
3808        validate_prepared_request(&bounded_request(
3809            router_protocol::GEMINI_ORIGIN,
3810            "/v1beta/models/gemini-3.6-flash:generateContent",
3811            ProviderWireProtocol::GeminiSse,
3812            false,
3813            Some(ResolvedSecret::new(b"secret-value").expect("valid secret")),
3814            Some(router_protocol::GEMINI_API_KEY_HEADER),
3815            None,
3816        ))
3817        .expect("bounded gemini passes");
3818    }
3819
3820    #[test]
3821    fn non_bounded_openrouter_request_requires_streaming_accept_and_protocol() {
3822        // The same OpenRouter shape with `bounded: false` must be rejected: the
3823        // streaming allowlist requires the OpenRouterSse wire protocol and a
3824        // text/event-stream accept header.
3825        let mut request = bounded_request(
3826            "https://openrouter.ai",
3827            "/api/v1/chat/completions",
3828            ProviderWireProtocol::OpenAiCompatSse,
3829            false,
3830            Some(ResolvedSecret::new(b"secret-value").expect("valid secret")),
3831            None,
3832            None,
3833        );
3834        request.bounded = false;
3835        assert!(validate_prepared_request(&request).is_err());
3836    }
3837
3838    #[test]
3839    fn streaming_deepseek_dispatch_passes_allowlist() {
3840        // PRD-001y S1 wire shape: the DeepSeek direct path is a streaming
3841        // OpenAI-compatible SSE dispatch against
3842        // `https://api.deepseek.com/chat/completions` with bearer auth. The
3843        // bounded helper builds the shared field shape; flip the two fields the
3844        // streaming allowlist predicate requires (unbounded, text/event-stream
3845        // accept) to prove the route is eligible for transport validation.
3846        let mut request = bounded_request(
3847            router_protocol::DEEPSEEK_ORIGIN,
3848            router_protocol::DEEPSEEK_CHAT_PATH,
3849            ProviderWireProtocol::OpenAiCompatSse,
3850            false,
3851            Some(ResolvedSecret::new(b"secret-value").expect("valid secret")),
3852            None,
3853            None,
3854        );
3855        request.bounded = false;
3856        request.accept = "text/event-stream";
3857        validate_prepared_request(&request).expect("deepseek streaming dispatch passes");
3858
3859        // The Qwen token-plan host (PRD-001y S3): the operator's live Qwen key
3860        // resolves only against `token-plan.ap-southeast-1.maas.aliyuncs.com`,
3861        // never the generic dashscope-intl origin, so the streaming OpenAI-
3862        // compatible dispatch must pass transport validation for that origin.
3863        let mut qwen = bounded_request(
3864            router_protocol::DASHSCOPE_TOKEN_PLAN_ORIGIN,
3865            router_protocol::DASHSCOPE_CHAT_PATH,
3866            ProviderWireProtocol::OpenAiCompatSse,
3867            false,
3868            Some(ResolvedSecret::new(b"secret-value").expect("valid secret")),
3869            None,
3870            None,
3871        );
3872        qwen.accept = "text/event-stream";
3873        validate_prepared_request(&qwen).expect("qwen token-plan streaming dispatch passes");
3874    }
3875
3876    #[test]
3877    fn openai_compat_request_to_unlisted_origin_fails_closed() {
3878        // An OpenAI-compatible streaming shape against an origin that is NOT in
3879        // the fail-closed allowlist must be rejected before any wire IO, even
3880        // though the protocol, authorization, DNS, and TLS predicates all hold.
3881        let mut request = bounded_request(
3882            "https://not-allowlisted.example.com",
3883            "/chat/completions",
3884            ProviderWireProtocol::OpenAiCompatSse,
3885            false,
3886            Some(ResolvedSecret::new(b"secret-value").expect("valid secret")),
3887            None,
3888            None,
3889        );
3890        request.bounded = false;
3891        request.accept = "text/event-stream";
3892        assert_eq!(
3893            validate_prepared_request(&request),
3894            Err(ProviderHttpError {
3895                kind: ProviderHttpErrorKind::InvalidPreparedRequest,
3896                body_write: BodyWriteState::NotWritten,
3897            })
3898        );
3899    }
3900
3901    #[test]
3902    fn qwen_token_plan_admit_rejects_non_chat_path() {
3903        // The PRD-001y S3 admit is scoped to exactly
3904        // `(DASHSCOPE_TOKEN_PLAN_ORIGIN, DASHSCOPE_CHAT_PATH)`. A request to
3905        // the token-plan host with any other path must fail closed even though
3906        // the protocol, DNS, TLS, and authorization predicates all hold.
3907        let mut request = bounded_request(
3908            router_protocol::DASHSCOPE_TOKEN_PLAN_ORIGIN,
3909            "/v1/chat/completions",
3910            ProviderWireProtocol::OpenAiCompatSse,
3911            false,
3912            Some(ResolvedSecret::new(b"secret-value").expect("valid secret")),
3913            None,
3914            None,
3915        );
3916        request.bounded = false;
3917        request.accept = "text/event-stream";
3918        assert_eq!(
3919            validate_prepared_request(&request),
3920            Err(ProviderHttpError {
3921                kind: ProviderHttpErrorKind::InvalidPreparedRequest,
3922                body_write: BodyWriteState::NotWritten,
3923            })
3924        );
3925    }
3926}