1use 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
81pub 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#[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 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#[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#[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#[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 .skip_authenticated_items(true);
388 options
389}
390
391#[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 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
885pub 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 pub auth_header_name: Option<&'static str>,
902 pub version_header: Option<(&'static str, &'static str)>,
906 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 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 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 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 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 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#[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#[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
2278pub 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 (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 let path = match spec.protocol {
2314 BoundedChatCompletionProtocol::Gemini => {
2315 materialize_gemini_bounded_path(&spec.path, &spec.model)
2316 }
2317 _ => spec.path,
2318 };
2319 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 BoundedChatCompletionProtocol::Ollama | BoundedChatCompletionProtocol::OpenAiCompat => {
2422 serde_json::json!({
2423 "model": model,
2424 "messages": [{"role": "user", "content": prompt}],
2425 "stream": false,
2426 })
2427 }
2428 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 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
2450fn 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
2580fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}