1use std::collections::BTreeMap;
23use std::pin::Pin;
24use std::sync::Arc;
25use std::time::Duration;
26
27use async_trait::async_trait;
28use futures::Stream;
29use serde::{Deserialize, Serialize};
30
31use super::cloud::{CloudProviderId, InvokeRequest};
32use super::provider::OAuthProviderId;
33use super::static_providers::{
34 allowed_proxy_paths, static_provider, StaticProviderSpec, LOCAL_DEFAULT_BASE_URL,
35 LOCAL_PROVIDER_KEY, STATIC_PROVIDERS,
36};
37use super::{load_provider_auth, storage};
38
39pub const MAX_PROXY_REQUEST_BYTES: usize = 2 * 1024 * 1024;
48pub const MAX_PROXY_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
50pub const MAX_UPSTREAM_ERROR_BYTES: usize = 2 * 1024;
54pub const PROXY_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
56
57const ANTHROPIC_USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
60const ANTHROPIC_OAUTH_BETA: &str = "oauth-2025-04-20";
61
62pub const GOOGLE_GEMINI_CODE_ASSIST_BASE_URL: &str = "https://cloudcode-pa.googleapis.com";
66
67pub(crate) fn is_allowed_google_gemini_path(path: &str) -> bool {
71 matches!(
72 path,
73 "/v1internal:loadCodeAssist"
74 | "/v1internal:onboardUser"
75 | "/v1internal:streamGenerateContent"
76 | "/v1internal:countTokens"
77 ) || path.starts_with("/v1internal/operations/")
78}
79
80pub const OPENAI_CODEX_BACKEND_BASE_URL: &str = "https://chatgpt.com/backend-api";
82
83pub(crate) fn is_allowed_openai_codex_path(path: &str) -> bool {
90 let Some((base, query)) = path.split_once('?') else {
91 return false;
92 };
93 if base != "/models" {
94 return false;
95 }
96 let mut client_version: Option<&str> = None;
97 for pair in query.split('&') {
98 let Some((k, v)) = pair.split_once('=') else {
99 return false;
100 };
101 if k != "client_version" || client_version.is_some() {
102 return false;
103 }
104 if v.trim().is_empty() {
105 return false;
106 }
107 client_version = Some(v);
108 }
109 client_version.is_some()
110}
111
112pub(crate) fn is_allowed_anthropic_path(path: &str) -> bool {
117 if path == "/v1/models" {
118 return true;
119 }
120 let Some((base, query)) = path.split_once('?') else {
121 return false;
122 };
123 if base != "/v1/models" {
124 return false;
125 }
126 if query.is_empty() {
127 return false;
128 }
129 let mut saw_limit = false;
130 let mut saw_after_id = false;
131 for pair in query.split('&') {
132 let Some((k, v)) = pair.split_once('=') else {
133 return false;
134 };
135 if v.is_empty() {
136 return false;
137 }
138 match k {
139 "limit" if !saw_limit => saw_limit = true,
140 "after_id" if !saw_after_id => saw_after_id = true,
141 _ => return false,
142 }
143 }
144 true
147}
148
149pub(crate) fn anthropic_oauth_catalog_request_headers() -> &'static [(&'static str, &'static str)] {
153 &[
154 ("anthropic-version", "2023-06-01"),
155 ("anthropic-beta", ANTHROPIC_OAUTH_BETA),
156 ("accept", "application/json"),
157 ]
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
164pub enum BrokerError {
165 UnknownProvider(String),
167 RegistrationRequired {
169 provider: String,
170 remediation: String,
171 },
172 NotConfigured(String),
174 Unauthorized,
176 Denied(String),
178 UnsupportedCapability {
181 provider: String,
182 capability: String,
183 },
184 Transport(String),
186 Credential(String),
188}
189
190impl std::fmt::Display for BrokerError {
191 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192 match self {
193 Self::UnknownProvider(p) => write!(f, "unknown provider: {p}"),
194 Self::RegistrationRequired {
195 provider,
196 remediation,
197 } => {
198 write!(f, "registration required for '{provider}': {remediation}")
199 }
200 Self::NotConfigured(p) => write!(
201 f,
202 "no credential configured for '{p}'. Run `synaps login` to add one."
203 ),
204 Self::Unauthorized => write!(f, "broker rejected machine auth"),
205 Self::Denied(msg) => write!(f, "broker denied request: {msg}"),
206 Self::UnsupportedCapability {
207 provider,
208 capability,
209 } => write!(
210 f,
211 "cloud provider '{provider}' is text-only: {capability} are not supported yet \
212 (no credential was used and no network request was made)"
213 ),
214 Self::Transport(msg) => write!(f, "broker transport error: {msg}"),
215 Self::Credential(msg) => write!(f, "credential error: {msg}"),
216 }
217 }
218}
219
220impl std::error::Error for BrokerError {}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct AccessToken {
230 pub token: String,
231 pub expires: u64,
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(rename_all = "lowercase")]
238pub enum ProxyMethod {
239 Get,
240 Post,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct ProxyRequest {
252 pub provider: String,
254 pub method: ProxyMethod,
255 pub path: String,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub body: Option<serde_json::Value>,
260 #[serde(default)]
262 pub stream: bool,
263 #[serde(skip)]
272 pub body_bytes: Option<bytes::Bytes>,
273}
274
275impl ProxyRequest {
276 pub fn validate(&self) -> Result<(), BrokerError> {
279 if self.provider != LOCAL_PROVIDER_KEY
280 && self.provider != "xai-auth"
281 && self.provider != "github-copilot"
282 && self.provider != "google-gemini"
283 && self.provider != "openai-codex"
284 && self.provider != "anthropic"
285 && static_provider(&self.provider).is_none()
286 {
287 return Err(BrokerError::UnknownProvider(self.provider.clone()));
288 }
289 if !self.path.starts_with('/') {
290 return Err(BrokerError::Denied("proxy path must be relative".into()));
291 }
292 if self.path.contains("://") || self.path.contains("..") {
293 return Err(BrokerError::Denied(
294 "proxy path is not a plain relative path".into(),
295 ));
296 }
297 let oauth_path_allowed = self.provider == "xai-auth" && self.path == "/responses"
301 || self.provider == "github-copilot"
302 && matches!(
303 self.path.as_str(),
304 "/models" | "/chat/completions" | "/responses"
305 )
306 || self.provider == "google-gemini" && is_allowed_google_gemini_path(&self.path)
307 || self.provider == "openai-codex"
308 && self.method == ProxyMethod::Get
309 && is_allowed_openai_codex_path(&self.path)
310 || self.provider == "anthropic"
311 && self.method == ProxyMethod::Get
312 && is_allowed_anthropic_path(&self.path);
313 if !oauth_path_allowed && !allowed_proxy_paths(&self.provider).contains(&self.path.as_str())
314 {
315 return Err(BrokerError::Denied(format!(
316 "proxy path '{}' is not in the provider's endpoint allowlist",
317 self.path
318 )));
319 }
320 let size = match (&self.body_bytes, &self.body) {
325 (Some(bytes), _) => Some(bytes.len()),
327 (None, Some(body)) => Some(
328 serde_json::to_vec(body)
329 .map(|v| v.len())
330 .unwrap_or(usize::MAX),
331 ),
332 (None, None) => None,
333 };
334 if let Some(size) = size {
335 if size > MAX_PROXY_REQUEST_BYTES {
336 return Err(BrokerError::Denied(format!(
337 "request body exceeds the {MAX_PROXY_REQUEST_BYTES}-byte broker limit"
338 )));
339 }
340 }
341 #[cfg(any(test, debug_assertions))]
347 if let (Some(bytes), Some(body)) = (&self.body_bytes, &self.body) {
348 let parsed = serde_json::from_slice::<serde_json::Value>(bytes).ok();
349 if parsed.as_ref() != Some(body) {
350 return Err(BrokerError::Denied(
351 "body_bytes does not match the JSON body (exact-byte handoff incoherent)"
352 .into(),
353 ));
354 }
355 }
356 Ok(())
357 }
358
359 pub fn post_json_exact(
368 provider: impl Into<String>,
369 path: impl Into<String>,
370 body: serde_json::Value,
371 stream: bool,
372 ) -> Result<(Self, bytes::Bytes), BrokerError> {
373 let bytes =
374 bytes::Bytes::from(serde_json::to_vec(&body).map_err(|e| {
375 BrokerError::Denied(format!("request body failed to serialize: {e}"))
376 })?);
377 let request = Self {
378 provider: provider.into(),
379 method: ProxyMethod::Post,
380 path: path.into(),
381 body: Some(body),
382 stream,
383 body_bytes: Some(bytes.clone()),
384 };
385 Ok((request, bytes))
386 }
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct ProxyResponse {
392 pub status: u16,
393 pub body: String,
394}
395
396pub type ProxyByteStream = Pin<Box<dyn Stream<Item = Result<bytes::Bytes, BrokerError>> + Send>>;
398
399#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
401#[serde(rename_all = "snake_case")]
402pub enum CredentialKind {
403 OAuth,
404 StaticKey,
405 LocalEndpoint,
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct ProviderStatus {
411 pub key: String,
412 pub name: String,
413 pub kind: CredentialKind,
414 pub configured: bool,
416}
417
418#[derive(Debug, Clone, PartialEq, Eq)]
420pub enum StaticKeyStatus {
421 NotSet,
422 Configured {
425 masked: String,
426 },
427 FromEnv,
429}
430
431impl StaticKeyStatus {
432 pub fn is_configured(&self) -> bool {
433 !matches!(self, StaticKeyStatus::NotSet)
434 }
435}
436
437#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441pub struct CloudCatalogEntry {
442 pub provider: CloudProviderId,
443 pub id: String,
444 pub display_name: String,
445 pub context_ref: String,
447 pub context_label: String,
448 pub stale: bool,
449 pub fetched_at: u64,
451}
452
453#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
455#[serde(tag = "type", rename_all = "snake_case")]
456pub enum CloudEvent {
457 TextDelta {
458 delta: String,
459 },
460 ToolArguments {
461 id: String,
462 name: Option<String>,
463 delta: String,
464 },
465 Usage {
466 input_tokens: u64,
467 output_tokens: u64,
468 },
469 Done,
470}
471pub type CloudEventStream = Pin<Box<dyn Stream<Item = Result<CloudEvent, BrokerError>> + Send>>;
472
473#[async_trait]
477pub trait CredentialBroker: Send + Sync {
478 async fn access_token(&self, provider: OAuthProviderId) -> Result<AccessToken, BrokerError>;
480
481 async fn proxy(&self, request: ProxyRequest) -> Result<ProxyResponse, BrokerError>;
483
484 async fn proxy_stream(&self, request: ProxyRequest) -> Result<ProxyByteStream, BrokerError>;
488
489 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError>;
496
497 async fn cloud_catalog(
499 &self,
500 provider: CloudProviderId,
501 context_ref: &str,
502 allow_stale: bool,
503 ) -> Result<Vec<CloudCatalogEntry>, BrokerError> {
504 let _ = (context_ref, allow_stale);
505 Err(BrokerError::NotConfigured(provider.to_string()))
506 }
507
508 async fn cloud_invoke(
511 &self,
512 provider: CloudProviderId,
513 context_ref: &str,
514 model_id: &str,
515 request: InvokeRequest,
516 ) -> Result<CloudEventStream, BrokerError> {
517 let _ = (context_ref, model_id, request);
518 Err(BrokerError::NotConfigured(provider.to_string()))
519 }
520
521 async fn capabilities(&self) -> Result<Vec<ProviderStatus>, BrokerError>;
523}
524
525#[async_trait]
528pub trait CloudBackend: Send + Sync {
529 async fn catalog(
530 &self,
531 provider: CloudProviderId,
532 context_ref: &str,
533 allow_stale: bool,
534 ) -> Result<Vec<CloudCatalogEntry>, BrokerError>;
535 async fn invoke(
536 &self,
537 provider: CloudProviderId,
538 context_ref: &str,
539 model_id: &str,
540 request: InvokeRequest,
541 ) -> Result<CloudEventStream, BrokerError>;
542}
543
544#[derive(Clone)]
545struct ProductionCloudBackend {
546 http: reqwest::Client,
549 refresh_lock: Arc<tokio::sync::Mutex<()>>,
550}
551impl ProductionCloudBackend {
552 fn new(_http: reqwest::Client) -> Self {
553 let http = reqwest::Client::builder()
554 .redirect(reqwest::redirect::Policy::none())
555 .connect_timeout(Duration::from_secs(10))
556 .timeout(Duration::from_secs(90))
557 .build()
558 .expect("cloud HTTPS client configuration is valid");
559 Self {
560 http,
561 refresh_lock: Arc::new(tokio::sync::Mutex::new(())),
562 }
563 }
564
565 fn state(&self, provider: CloudProviderId) -> Result<serde_json::Value, BrokerError> {
566 storage::load_cloud_state(provider.as_str())
567 .map_err(BrokerError::Credential)?
568 .ok_or_else(|| BrokerError::NotConfigured(provider.to_string()))
569 }
570
571 fn context_ref(&self, provider: CloudProviderId) -> Result<String, BrokerError> {
572 use sha2::{Digest, Sha256};
573 let state = self.state(provider)?;
574 let public = serde_json::to_vec(&state["config"])
575 .map_err(|_| BrokerError::Credential("invalid cloud context".into()))?;
576 let digest = Sha256::digest([provider.as_str().as_bytes(), &public].concat());
577 Ok(format!(
578 "ctx-{}",
579 digest[..16]
580 .iter()
581 .map(|b| format!("{b:02x}"))
582 .collect::<String>()
583 ))
584 }
585
586 fn validate_context(
587 &self,
588 provider: CloudProviderId,
589 supplied: &str,
590 ) -> Result<String, BrokerError> {
591 let opaque = self.context_ref(provider)?;
592 if supplied == provider.as_str() || supplied == opaque {
595 Ok(opaque)
596 } else {
597 Err(BrokerError::Denied(
598 "cloud context does not match stored provider".into(),
599 ))
600 }
601 }
602
603 async fn aws(
604 &self,
605 ) -> Result<super::aws_bedrock::AwsBedrockBroker<super::aws_bedrock::AwsHttpApi>, BrokerError>
606 {
607 #[derive(Deserialize)]
608 struct State {
609 config: super::cloud::AwsBedrockConfig,
610 access_key: String,
611 secret_key: String,
612 session_token: String,
613 expires_at: u64,
614 registered_client: serde_json::Value,
615 sso_access_token: String,
616 sso_refresh_token: Option<String>,
617 sso_expires_at: u64,
618 }
619 let decode = |value| {
620 serde_json::from_value::<State>(value)
621 .map_err(|_| BrokerError::Credential("invalid AWS broker state".into()))
622 };
623 let mut raw = self.state(CloudProviderId::AwsBedrock)?;
624 let now = std::time::SystemTime::now()
625 .duration_since(std::time::UNIX_EPOCH)
626 .unwrap_or_default()
627 .as_millis() as u64;
628 let mut s = decode(raw.clone())?;
629 if s.expires_at <= now + 60_000 {
630 let _guard = self.refresh_lock.lock().await;
633 raw = self.state(CloudProviderId::AwsBedrock)?;
634 s = decode(raw.clone())?;
635 if s.expires_at <= now + 60_000 {
636 let api =
637 super::aws_bedrock::AwsHttpApi::new(self.http.clone(), &s.config.sso_region);
638 if s.sso_expires_at <= now + 60_000 {
639 let refresh = s
640 .sso_refresh_token
641 .as_deref()
642 .filter(|v| !v.is_empty())
643 .ok_or_else(|| {
644 BrokerError::Credential("AWS SSO session expired; login again".into())
645 })?;
646 let client = super::aws_bedrock::RegisteredClient::new(
647 s.registered_client["id"].as_str().ok_or_else(|| {
648 BrokerError::Credential("invalid AWS client registration".into())
649 })?,
650 s.registered_client["secret"].as_str().ok_or_else(|| {
651 BrokerError::Credential("invalid AWS client registration".into())
652 })?,
653 s.registered_client["expires_at"].as_u64().ok_or_else(|| {
654 BrokerError::Credential("invalid AWS client registration".into())
655 })?,
656 );
657 if client.expires_at <= now / 1000 {
658 return Err(BrokerError::Credential(
659 "AWS client registration expired; login again".into(),
660 ));
661 }
662 use super::aws_bedrock::AwsApi;
663 let token = api
664 .create_token(
665 &client,
666 &s.config.sso_region,
667 super::aws_bedrock::TokenGrant::RefreshToken(refresh),
668 )
669 .await
670 .map_err(|_| {
671 BrokerError::Credential("AWS SSO refresh rejected; login again".into())
672 })?;
673 raw["sso_access_token"] = token.access().into();
674 if let Some(rotated) = token.refresh() {
675 raw["sso_refresh_token"] = rotated.into();
676 }
677 raw["sso_expires_at"] = (now + token.expires_in * 1000).into();
678 storage::save_cloud_state("aws-bedrock", &raw)
683 .map_err(BrokerError::Credential)?;
684 s = decode(raw.clone())?;
685 }
686 use super::aws_bedrock::AwsApi;
687 let credentials = api
688 .get_role_credentials(
689 &s.config.sso_region,
690 &s.sso_access_token,
691 &s.config.account_id,
692 &s.config.role_name,
693 )
694 .await
695 .map_err(|_| {
696 BrokerError::Credential("AWS role refresh rejected; login again".into())
697 })?;
698 raw["access_key"] = credentials.access_key().into();
699 raw["secret_key"] = credentials.secret_key().into();
700 raw["session_token"] = credentials.session_token().into();
701 raw["expires_at"] = credentials.expires_at.into();
702 storage::save_cloud_state("aws-bedrock", &raw).map_err(BrokerError::Credential)?;
703 s = decode(raw)?;
704 }
705 }
706 let api = super::aws_bedrock::AwsHttpApi::new(self.http.clone(), &s.config.sso_region);
707 Ok(super::aws_bedrock::AwsBedrockBroker::from_credentials(
708 api,
709 s.config,
710 super::aws_bedrock::RoleCredentials::new(
711 s.access_key,
712 s.secret_key,
713 s.session_token,
714 s.expires_at,
715 ),
716 ))
717 }
718 async fn azure_request(
719 &self,
720 state: &mut serde_json::Value,
721 audience: super::azure_openai::AzureAudience,
722 ) -> Result<String, BrokerError> {
723 let now = std::time::SystemTime::now()
724 .duration_since(std::time::UNIX_EPOCH)
725 .unwrap_or_default()
726 .as_millis() as u64;
727 let key = match audience {
728 super::azure_openai::AzureAudience::Arm => "arm",
729 super::azure_openai::AzureAudience::Inference => "inference",
730 };
731 if state[key]["expires_at"].as_u64().unwrap_or(0) > now + 60_000 {
732 return state[key]["access_token"]
733 .as_str()
734 .map(str::to_owned)
735 .ok_or_else(|| BrokerError::Credential("invalid Azure token state".into()));
736 }
737 let _refresh = self.refresh_lock.lock().await;
740 *state = self.state(CloudProviderId::AzureOpenAi)?;
741 if state[key]["expires_at"].as_u64().unwrap_or(0) > now + 60_000 {
742 return state[key]["access_token"]
743 .as_str()
744 .map(str::to_owned)
745 .ok_or_else(|| BrokerError::Credential("invalid Azure token state".into()));
746 }
747 let config: super::cloud::AzureOpenAiConfig =
748 serde_json::from_value(state["config"].clone())
749 .map_err(|_| BrokerError::Credential("invalid Azure broker state".into()))?;
750 let client_id = state["client_id"]
751 .as_str()
752 .ok_or_else(|| BrokerError::Credential("invalid Azure client registration".into()))?;
753 let refresh = state["refresh_token"]
754 .as_str()
755 .ok_or_else(|| BrokerError::Credential("invalid Azure refresh state".into()))?;
756 let reg = super::azure_openai::AzureRegistration::production(Some(client_id.into()))
757 .map_err(|e| BrokerError::Credential(e.to_string()))?;
758 let r = super::azure_openai::refresh_request(&config, ®, audience, refresh)
759 .map_err(|e| BrokerError::Credential(e.to_string()))?;
760 let response = self
761 .http
762 .post(r.url)
763 .form(&r.form)
764 .send()
765 .await
766 .map_err(|_| BrokerError::Transport("Azure refresh failed".into()))?;
767 if !response.status().is_success() {
768 return Err(BrokerError::Credential(
769 "Azure refresh rejected; login again".into(),
770 ));
771 }
772 let wire: serde_json::Value = response
773 .json()
774 .await
775 .map_err(|_| BrokerError::Transport("invalid Azure token response".into()))?;
776 let access = wire["access_token"]
777 .as_str()
778 .filter(|v| !v.is_empty())
779 .ok_or_else(|| BrokerError::Transport("invalid Azure token response".into()))?
780 .to_owned();
781 if let Some(r) = wire["refresh_token"].as_str().filter(|v| !v.is_empty()) {
782 state["refresh_token"] = r.into();
783 }
784 state[key] = serde_json::json!({"access_token": access, "expires_at": now + wire["expires_in"].as_u64().unwrap_or(3600) * 1000});
785 storage::save_cloud_state("azure-openai", state).map_err(BrokerError::Credential)?;
786 Ok(access)
787 }
788
789 async fn vertex_request(&self, state: &mut serde_json::Value) -> Result<String, BrokerError> {
790 let now = std::time::SystemTime::now()
791 .duration_since(std::time::UNIX_EPOCH)
792 .unwrap_or_default()
793 .as_millis() as u64;
794 if state["expires_at"].as_u64().unwrap_or(0) > now + 60_000 {
795 return state["access_token"]
796 .as_str()
797 .map(str::to_owned)
798 .ok_or_else(|| BrokerError::Credential("invalid Vertex token state".into()));
799 }
800 let _refresh = self.refresh_lock.lock().await;
801 *state = self.state(CloudProviderId::GoogleVertex)?;
802 if state["expires_at"].as_u64().unwrap_or(0) > now + 60_000 {
803 return state["access_token"]
804 .as_str()
805 .map(str::to_owned)
806 .ok_or_else(|| BrokerError::Credential("invalid Vertex token state".into()));
807 }
808 let client_id = state["client_id"]
809 .as_str()
810 .ok_or_else(|| BrokerError::Credential("invalid Vertex registration".into()))?;
811 let refresh = state["refresh_token"]
812 .as_str()
813 .ok_or_else(|| BrokerError::Credential("invalid Vertex refresh state".into()))?;
814 let response = self
815 .http
816 .post(super::google_vertex::TOKEN_URL)
817 .form(&[
818 ("client_id", client_id),
819 ("grant_type", "refresh_token"),
820 ("refresh_token", refresh),
821 ])
822 .send()
823 .await
824 .map_err(|_| BrokerError::Transport("Vertex refresh failed".into()))?;
825 if !response.status().is_success() {
826 return Err(BrokerError::Credential(
827 "Vertex refresh rejected; login again".into(),
828 ));
829 }
830 let wire: serde_json::Value = response
831 .json()
832 .await
833 .map_err(|_| BrokerError::Transport("invalid Vertex token response".into()))?;
834 let access = wire["access_token"]
835 .as_str()
836 .filter(|v| !v.is_empty())
837 .ok_or_else(|| BrokerError::Transport("invalid Vertex token response".into()))?
838 .to_owned();
839 if let Some(r) = wire["refresh_token"].as_str().filter(|v| !v.is_empty()) {
840 state["refresh_token"] = r.into();
841 }
842 state["access_token"] = access.clone().into();
843 state["expires_at"] = (now + wire["expires_in"].as_u64().unwrap_or(3600) * 1000).into();
844 storage::save_cloud_state("google-vertex", state).map_err(BrokerError::Credential)?;
845 Ok(access)
846 }
847}
848
849#[async_trait]
850impl CloudBackend for ProductionCloudBackend {
851 async fn catalog(
852 &self,
853 provider: CloudProviderId,
854 context_ref: &str,
855 _allow_stale: bool,
856 ) -> Result<Vec<CloudCatalogEntry>, BrokerError> {
857 let opaque_context = self.validate_context(provider, context_ref)?;
858 let fetched_at = std::time::SystemTime::now()
859 .duration_since(std::time::UNIX_EPOCH)
860 .unwrap_or_default()
861 .as_millis() as u64;
862 match provider {
863 CloudProviderId::AwsBedrock => {
864 let broker = self.aws().await?;
865 let label = broker.public_context().bedrock_region;
866 broker
867 .catalog()
868 .await
869 .map_err(|_| {
870 BrokerError::Transport("AWS catalog failed (details redacted)".into())
871 })
872 .map(|xs| {
873 xs.into_iter()
874 .map(|x| CloudCatalogEntry {
875 provider,
876 id: x.id,
877 display_name: x.display_name,
878 context_ref: opaque_context.clone(),
879 context_label: label.clone(),
880 stale: false,
881 fetched_at,
882 })
883 .collect()
884 })
885 }
886 CloudProviderId::AzureOpenAi => {
887 let mut state = self.state(provider)?;
888 let config: super::cloud::AzureOpenAiConfig =
889 serde_json::from_value(state["config"].clone()).map_err(|_| {
890 BrokerError::Credential("invalid Azure broker state".into())
891 })?;
892 let token = self
893 .azure_request(&mut state, super::azure_openai::AzureAudience::Arm)
894 .await?;
895 let mut discovery =
896 super::azure_openai::DeploymentDiscovery::new(config.clone(), 20, 1000);
897 let mut url = Some(discovery.initial_url());
898 while let Some(next) = url {
899 let r = self
900 .http
901 .get(next)
902 .bearer_auth(&token)
903 .send()
904 .await
905 .map_err(|_| BrokerError::Transport("Azure catalog failed".into()))?;
906 if !r.status().is_success() {
907 return Err(BrokerError::Transport("Azure catalog rejected".into()));
908 }
909 let body = read_body_capped(r, MAX_CLOUD_CATALOG_BODY_BYTES)
910 .await
911 .map_err(|_| {
912 BrokerError::Transport("Azure catalog exceeded body limit".into())
913 })?;
914 url = discovery
915 .accept_page(&body)
916 .map_err(|_| BrokerError::Transport("invalid Azure catalog".into()))?;
917 }
918 discovery
919 .finish()
920 .map_err(|_| BrokerError::Transport("Azure catalog unavailable".into()))
921 .map(|xs| {
922 xs.into_iter()
923 .map(|x| CloudCatalogEntry {
924 provider,
925 id: x.id,
926 display_name: x.display_name,
927 context_ref: opaque_context.clone(),
928 context_label: format!(
929 "{}/{}",
930 config.resource_name, config.resource_group
931 ),
932 stale: false,
933 fetched_at,
934 })
935 .collect()
936 })
937 }
938 CloudProviderId::GoogleVertex => {
939 let mut state = self.state(provider)?;
940 let token = self.vertex_request(&mut state).await?;
941 let config: super::cloud::GoogleVertexConfig =
942 serde_json::from_value(state["config"].clone()).map_err(|_| {
943 BrokerError::Credential("invalid Vertex broker state".into())
944 })?;
945 let mut page: Option<String> = None;
946 let mut out = Vec::new();
947 let mut seen = std::collections::HashSet::new();
948 for _ in 0..20 {
949 let mut url=format!("https://{}-aiplatform.googleapis.com/v1/projects/{}/locations/{}/publishers/google/models",config.location,config.project_id,config.location);
950 if let Some(p) = &page {
951 let mut parsed = url::Url::parse(&url).map_err(|_| {
952 BrokerError::Transport("invalid Vertex catalog URL".into())
953 })?;
954 parsed.query_pairs_mut().append_pair("pageToken", p);
955 url = parsed.into();
956 }
957 let r = self
958 .http
959 .get(url)
960 .bearer_auth(&token)
961 .send()
962 .await
963 .map_err(|_| BrokerError::Transport("Vertex catalog failed".into()))?;
964 if !r.status().is_success() {
965 return Err(BrokerError::Transport("Vertex catalog rejected".into()));
966 }
967 let v = cloud_catalog_json(r).await?;
968 for m in v["publisherModels"].as_array().into_iter().flatten() {
969 if let Some(name) = m["name"]
970 .as_str()
971 .filter(|n| n.starts_with("publishers/google/models/"))
972 {
973 if out.len() >= MAX_CLOUD_CATALOG_ENTRIES {
974 return Err(BrokerError::Transport(
975 "Vertex catalog exceeded entry limit".into(),
976 ));
977 }
978 if !out.iter().any(|entry: &CloudCatalogEntry| {
979 entry.id == format!("google-vertex/{name}")
980 }) {
981 out.push(CloudCatalogEntry {
982 provider,
983 id: format!("google-vertex/{name}"),
984 display_name: m["displayName"].as_str().unwrap_or(name).into(),
985 context_ref: opaque_context.clone(),
986 context_label: format!(
987 "{}/{}",
988 config.project_id, config.location
989 ),
990 stale: false,
991 fetched_at,
992 });
993 }
994 }
995 }
996 page = v["nextPageToken"].as_str().map(str::to_owned);
997 match &page {
998 Some(p) if seen.insert(p.clone()) => {}
999 Some(_) => {
1000 return Err(BrokerError::Transport("Vertex pagination loop".into()))
1001 }
1002 None => break,
1003 }
1004 }
1005 if out.is_empty() {
1006 Err(BrokerError::Transport("Vertex catalog is empty".into()))
1007 } else {
1008 Ok(out)
1009 }
1010 }
1011 }
1012 }
1013 async fn invoke(
1014 &self,
1015 provider: CloudProviderId,
1016 context_ref: &str,
1017 model_id: &str,
1018 request: InvokeRequest,
1019 ) -> Result<CloudEventStream, BrokerError> {
1020 self.validate_context(provider, context_ref)?;
1021 if !request.tools.is_empty() {
1022 return Err(BrokerError::Denied(
1023 "tools are not yet supported by cloud providers".into(),
1024 ));
1025 }
1026 let authorized = self.catalog(provider, context_ref, false).await?;
1027 if !authorized.iter().any(|entry| entry.id == model_id) {
1028 return Err(BrokerError::Denied(
1029 "model is not present in the current provider catalog".into(),
1030 ));
1031 }
1032 match provider {
1033 CloudProviderId::AwsBedrock => {
1034 let broker = self.aws().await?;
1035 if request.stream {
1036 use futures::StreamExt;
1037 let stream = broker
1038 .converse_stream(model_id, request)
1039 .await
1040 .map_err(|_| {
1041 BrokerError::Transport(
1042 "AWS invocation failed (details redacted)".into(),
1043 )
1044 })?
1045 .map(|event| {
1046 event
1047 .map_err(|_| {
1048 BrokerError::Transport(
1049 "AWS stream failed (details redacted)".into(),
1050 )
1051 })
1052 .map(|e| match e {
1053 super::aws_bedrock::ConverseEvent::TextDelta(delta) => {
1054 CloudEvent::TextDelta { delta }
1055 }
1056 super::aws_bedrock::ConverseEvent::ToolArguments {
1057 id,
1058 delta,
1059 } => CloudEvent::ToolArguments {
1060 id,
1061 name: None,
1062 delta,
1063 },
1064 super::aws_bedrock::ConverseEvent::Usage(u) => {
1065 CloudEvent::Usage {
1066 input_tokens: u.input_tokens,
1067 output_tokens: u.output_tokens,
1068 }
1069 }
1070 super::aws_bedrock::ConverseEvent::Done => CloudEvent::Done,
1071 })
1072 });
1073 Ok(Box::pin(stream))
1074 } else {
1075 let o = broker.converse(model_id, request).await.map_err(|_| {
1076 BrokerError::Transport("AWS invocation failed (details redacted)".into())
1077 })?;
1078 Ok(Box::pin(futures::stream::iter(vec![
1079 Ok(CloudEvent::TextDelta { delta: o.text }),
1080 Ok(CloudEvent::Usage {
1081 input_tokens: o.usage.input_tokens,
1082 output_tokens: o.usage.output_tokens,
1083 }),
1084 Ok(CloudEvent::Done),
1085 ])))
1086 }
1087 }
1088 CloudProviderId::AzureOpenAi => {
1089 let mut state = self.state(provider)?;
1090 let token = self
1091 .azure_request(&mut state, super::azure_openai::AzureAudience::Inference)
1092 .await?;
1093 let endpoint = super::azure_openai::AzureEndpoint::parse(
1094 state["endpoint"]
1095 .as_str()
1096 .ok_or_else(|| BrokerError::Credential("invalid Azure endpoint".into()))?,
1097 )
1098 .map_err(|_| BrokerError::Credential("invalid Azure endpoint".into()))?;
1099 let deployment = model_id
1100 .strip_prefix("azure-openai/")
1101 .ok_or_else(|| BrokerError::Denied("invalid Azure model".into()))?;
1102 let body = serde_json::json!({"input":request.messages.into_iter().map(|m|serde_json::json!({"role":match m.role{super::cloud::MessageRole::Assistant=>"assistant",super::cloud::MessageRole::System=>"system",_=>"user"},"content":m.content})).collect::<Vec<_>>(),"stream":request.stream});
1103 let rr = super::azure_openai::responses_request(
1104 &endpoint,
1105 deployment,
1106 &serde_json::to_vec(&body).unwrap(),
1107 )
1108 .map_err(|_| BrokerError::Denied("invalid Azure request".into()))?;
1109 let r = self
1110 .http
1111 .post(rr.url)
1112 .bearer_auth(token)
1113 .header("content-type", "application/json")
1114 .body(rr.body)
1115 .send()
1116 .await
1117 .map_err(|_| BrokerError::Transport("Azure invocation failed".into()))?;
1118 if !r.status().is_success() {
1119 return Err(BrokerError::Transport("Azure invocation rejected".into()));
1120 }
1121 if request.stream {
1122 use futures::StreamExt;
1123 use std::sync::{
1124 atomic::{AtomicBool, Ordering},
1125 Arc,
1126 };
1127 let terminal = Arc::new(AtomicBool::new(false));
1128 let seen = terminal.clone();
1129 let events = sse_json_stream(r).filter_map(move |item| {
1130 let seen = seen.clone();
1131 async move {
1132 match item {
1133 Ok(Some(v)) => v["delta"]
1134 .as_str()
1135 .or_else(|| v["text"].as_str())
1136 .map(|delta| {
1137 Ok(CloudEvent::TextDelta {
1138 delta: delta.into(),
1139 })
1140 }),
1141 Ok(None) => {
1142 seen.store(true, Ordering::SeqCst);
1143 Some(Ok(CloudEvent::Done))
1144 }
1145 Err(error) => Some(Err(error)),
1146 }
1147 }
1148 });
1149 let stream = events.chain(futures::stream::unfold(
1150 (terminal, false),
1151 |(terminal, emitted)| async move {
1152 if terminal.load(Ordering::SeqCst) || emitted {
1153 None
1154 } else {
1155 Some((
1156 Err(BrokerError::Transport(
1157 "Azure stream ended without [DONE]".into(),
1158 )),
1159 (terminal, true),
1160 ))
1161 }
1162 },
1163 ));
1164 Ok(Box::pin(stream))
1165 } else {
1166 let text = read_body_capped(r, MAX_CLOUD_STREAM_EVENT_BYTES).await?;
1167 let v: serde_json::Value = serde_json::from_str(&text)
1168 .map_err(|_| BrokerError::Transport("invalid Azure response".into()))?;
1169 let delta = v["output_text"]
1170 .as_str()
1171 .ok_or_else(|| {
1172 BrokerError::Transport("Azure response omitted output".into())
1173 })?
1174 .to_owned();
1175 Ok(Box::pin(futures::stream::iter(vec![
1176 Ok(CloudEvent::TextDelta { delta }),
1177 Ok(CloudEvent::Done),
1178 ])))
1179 }
1180 }
1181 CloudProviderId::GoogleVertex => {
1182 let mut state = self.state(provider)?;
1183 let token = self.vertex_request(&mut state).await?;
1184 let config: super::cloud::GoogleVertexConfig =
1185 serde_json::from_value(state["config"].clone())
1186 .map_err(|_| BrokerError::Credential("invalid Vertex state".into()))?;
1187 let model = model_id
1188 .strip_prefix("google-vertex/publishers/google/models/")
1189 .filter(|m| {
1190 m.bytes()
1191 .all(|b| b.is_ascii_alphanumeric() || b"-._".contains(&b))
1192 })
1193 .ok_or_else(|| BrokerError::Denied("invalid Vertex model".into()))?;
1194 let action = if request.stream {
1195 "streamGenerateContent?alt=sse"
1196 } else {
1197 "generateContent"
1198 };
1199 let url=format!("https://{}-aiplatform.googleapis.com/v1/projects/{}/locations/{}/publishers/google/models/{}:{}",config.location,config.project_id,config.location,model,action);
1200 let body = serde_json::json!({"contents":request.messages.into_iter().map(|m|serde_json::json!({"role":if matches!(m.role,super::cloud::MessageRole::Assistant){"model"}else{"user"},"parts":[{"text":m.content}]})).collect::<Vec<_>>()});
1201 let r = self
1202 .http
1203 .post(url)
1204 .bearer_auth(token)
1205 .json(&body)
1206 .send()
1207 .await
1208 .map_err(|_| BrokerError::Transport("Vertex invocation failed".into()))?;
1209 if !r.status().is_success() {
1210 return Err(BrokerError::Transport("Vertex invocation rejected".into()));
1211 }
1212 if request.stream {
1213 use futures::StreamExt;
1214 let events = sse_json_stream(r).map(|item| {
1215 item.and_then(|value| {
1216 value.ok_or_else(|| {
1217 BrokerError::Transport(
1218 "Vertex stream terminated without a provider event".into(),
1219 )
1220 })
1221 })
1222 });
1223 use std::sync::{
1224 atomic::{AtomicBool, Ordering},
1225 Arc,
1226 };
1227 let terminal = Arc::new(AtomicBool::new(false));
1228 let terminal_in_events = terminal.clone();
1229 let stream = events
1230 .flat_map(move |item| {
1231 let mut out = Vec::new();
1232 match item {
1233 Ok(v) => {
1234 if terminal_in_events.load(Ordering::SeqCst) {
1235 out.push(Err(BrokerError::Transport(
1236 "Vertex emitted data after terminal metadata".into(),
1237 )));
1238 return futures::stream::iter(out);
1239 }
1240 for c in v["candidates"].as_array().into_iter().flatten() {
1241 for p in
1242 c["content"]["parts"].as_array().into_iter().flatten()
1243 {
1244 if let Some(delta) = p["text"].as_str() {
1245 out.push(Ok(CloudEvent::TextDelta {
1246 delta: delta.into(),
1247 }));
1248 }
1249 }
1250 if let Some(reason) = c["finishReason"].as_str() {
1251 const VALID: &[&str] = &[
1252 "STOP",
1253 "MAX_TOKENS",
1254 "SAFETY",
1255 "RECITATION",
1256 "OTHER",
1257 "BLOCKLIST",
1258 "PROHIBITED_CONTENT",
1259 "SPII",
1260 "MALFORMED_FUNCTION_CALL",
1261 ];
1262 if !VALID.contains(&reason) {
1263 out.push(Err(BrokerError::Transport(
1264 "Vertex returned an invalid finish reason"
1265 .into(),
1266 )));
1267 } else if terminal_in_events
1268 .swap(true, Ordering::SeqCst)
1269 {
1270 out.push(Err(BrokerError::Transport(
1271 "Vertex returned duplicate terminal metadata"
1272 .into(),
1273 )));
1274 } else {
1275 out.push(Ok(CloudEvent::Done));
1276 }
1277 }
1278 }
1279 if let Some(u) = v.get("usageMetadata") {
1280 out.push(Ok(CloudEvent::Usage {
1281 input_tokens: u["promptTokenCount"]
1282 .as_u64()
1283 .unwrap_or(0),
1284 output_tokens: u["candidatesTokenCount"]
1285 .as_u64()
1286 .unwrap_or(0),
1287 }));
1288 }
1289 }
1290 Err(error) => out.push(Err(error)),
1291 }
1292 futures::stream::iter(out)
1293 })
1294 .chain(futures::stream::unfold(
1295 (terminal, false),
1296 |(terminal, emitted)| async move {
1297 if terminal.load(Ordering::SeqCst) || emitted {
1298 None
1299 } else {
1300 Some((
1301 Err(BrokerError::Transport(
1302 "Vertex stream ended without finish metadata".into(),
1303 )),
1304 (terminal, true),
1305 ))
1306 }
1307 },
1308 ));
1309 Ok(Box::pin(stream))
1310 } else {
1311 let text = read_body_capped(r, MAX_CLOUD_STREAM_EVENT_BYTES).await?;
1312 let v: serde_json::Value = serde_json::from_str(&text)
1313 .map_err(|_| BrokerError::Transport("invalid Vertex response".into()))?;
1314 let mut out = Vec::new();
1315 for c in v["candidates"].as_array().into_iter().flatten() {
1316 for p in c["content"]["parts"].as_array().into_iter().flatten() {
1317 if let Some(delta) = p["text"].as_str() {
1318 out.push(Ok(CloudEvent::TextDelta {
1319 delta: delta.into(),
1320 }));
1321 }
1322 }
1323 }
1324 out.push(Ok(CloudEvent::Done));
1325 Ok(Box::pin(futures::stream::iter(out)))
1326 }
1327 }
1328 }
1329 }
1330}
1331
1332const MAX_CLOUD_CATALOG_BODY_BYTES: usize = 2 * 1024 * 1024;
1333const MAX_CLOUD_CATALOG_ENTRIES: usize = 10_000;
1334const MAX_CLOUD_STREAM_EVENT_BYTES: usize = 1024 * 1024;
1335
1336fn sse_json_stream(
1340 resp: reqwest::Response,
1341) -> Pin<Box<dyn Stream<Item = Result<Option<serde_json::Value>, BrokerError>> + Send>> {
1342 use futures::StreamExt;
1343 let chunks = Box::pin(resp.bytes_stream());
1344 Box::pin(futures::stream::unfold(
1345 (chunks, Vec::<u8>::new(), false),
1346 |(mut chunks, mut buf, done)| async move {
1347 if done {
1348 return None;
1349 }
1350 loop {
1351 if let Some(end) = buf.windows(2).position(|w| w == b"\n\n") {
1352 let frame: Vec<u8> = buf.drain(..end + 2).collect();
1353 let text = match std::str::from_utf8(&frame[..end]) {
1354 Ok(v) => v,
1355 Err(_) => {
1356 return Some((
1357 Err(BrokerError::Transport("invalid SSE encoding".into())),
1358 (chunks, buf, true),
1359 ))
1360 }
1361 };
1362 let data = text
1363 .lines()
1364 .filter_map(|line| line.strip_prefix("data:"))
1365 .map(str::trim_start)
1366 .collect::<Vec<_>>()
1367 .join("\n");
1368 if data.is_empty() {
1369 continue;
1370 }
1371 if data == "[DONE]" {
1372 return Some((Ok(None), (chunks, buf, true)));
1373 }
1374 let value = serde_json::from_str(&data)
1375 .map_err(|_| BrokerError::Transport("invalid SSE event".into()));
1376 let terminal = value.is_err();
1377 return Some((value.map(Some), (chunks, buf, terminal)));
1378 }
1379 match chunks.next().await {
1380 Some(Ok(chunk)) if buf.len() + chunk.len() <= MAX_CLOUD_STREAM_EVENT_BYTES => {
1381 buf.extend_from_slice(&chunk)
1382 }
1383 Some(_) => {
1384 return Some((
1385 Err(BrokerError::Transport(
1386 "cloud stream event exceeded limit".into(),
1387 )),
1388 (chunks, buf, true),
1389 ))
1390 }
1391 None if buf.is_empty() => return None,
1392 None => {
1393 return Some((
1394 Err(BrokerError::Transport("truncated SSE event".into())),
1395 (chunks, buf, true),
1396 ))
1397 }
1398 }
1399 }
1400 },
1401 ))
1402}
1403
1404async fn cloud_catalog_json(resp: reqwest::Response) -> Result<serde_json::Value, BrokerError> {
1406 let body = read_body_capped(resp, MAX_CLOUD_CATALOG_BODY_BYTES).await?;
1407 serde_json::from_str(&body)
1408 .map_err(|_| BrokerError::Transport("invalid cloud catalog response".into()))
1409}
1410
1411pub struct LocalBroker {
1415 http: reqwest::Client,
1416 local_base_url: Option<String>,
1418 anthropic_usage_url: Option<String>,
1420 google_gemini_base_url: Option<String>,
1422 request_timeout: Duration,
1424 max_response_bytes: usize,
1426 cloud_backend: Option<Arc<dyn CloudBackend>>,
1427}
1428
1429impl LocalBroker {
1430 pub fn new(http: reqwest::Client) -> Self {
1431 let cloud_backend = Arc::new(ProductionCloudBackend::new(http.clone()));
1432 Self {
1433 http,
1434 local_base_url: None,
1435 anthropic_usage_url: None,
1436 google_gemini_base_url: None,
1437 request_timeout: PROXY_REQUEST_TIMEOUT,
1438 max_response_bytes: MAX_PROXY_RESPONSE_BYTES,
1439 cloud_backend: Some(cloud_backend),
1440 }
1441 }
1442
1443 pub fn with_cloud_backend(mut self, backend: Arc<dyn CloudBackend>) -> Self {
1444 self.cloud_backend = Some(backend);
1445 self
1446 }
1447
1448 pub fn with_local_base_url(http: reqwest::Client, base_url: impl Into<String>) -> Self {
1450 Self {
1451 local_base_url: Some(base_url.into()),
1452 ..Self::new(http)
1453 }
1454 }
1455
1456 #[doc(hidden)]
1460 pub fn with_google_gemini_base_url_for_tests(
1461 http: reqwest::Client,
1462 base_url: impl Into<String>,
1463 ) -> Self {
1464 Self {
1465 google_gemini_base_url: Some(base_url.into().trim_end_matches('/').to_string()),
1466 ..Self::new(http)
1467 }
1468 }
1469
1470 #[doc(hidden)]
1472 pub fn with_anthropic_usage_url(mut self, url: impl Into<String>) -> Self {
1473 self.anthropic_usage_url = Some(url.into());
1474 self
1475 }
1476
1477 #[doc(hidden)]
1479 pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
1480 self.request_timeout = timeout;
1481 self
1482 }
1483
1484 #[doc(hidden)]
1486 pub fn with_max_response_bytes(mut self, cap: usize) -> Self {
1487 self.max_response_bytes = cap;
1488 self
1489 }
1490
1491 fn resolve_static_key(&self, provider: &str) -> Result<String, BrokerError> {
1495 if provider == LOCAL_PROVIDER_KEY {
1496 return Ok(self.resolve_local_key());
1497 }
1498 let spec = static_provider(provider)
1499 .ok_or_else(|| BrokerError::UnknownProvider(provider.to_string()))?;
1500 if let Ok(Some(key)) = storage::load_static_key(provider) {
1501 return Ok(key);
1502 }
1503 if let Some(key) = discover_legacy_static_key(spec) {
1504 if let Err(e) = storage::save_static_key(provider, &key) {
1507 tracing::warn!(provider, "static key migration failed: {e}");
1508 }
1509 return Ok(key);
1510 }
1511 Err(BrokerError::NotConfigured(provider.to_string()))
1512 }
1513
1514 fn resolve_local_key(&self) -> String {
1515 storage::load_static_key(LOCAL_PROVIDER_KEY)
1516 .ok()
1517 .flatten()
1518 .or_else(|| {
1519 crate::config::get_provider_keys()
1520 .get(LOCAL_PROVIDER_KEY)
1521 .filter(|s| !s.is_empty())
1522 .cloned()
1523 })
1524 .or_else(|| {
1525 std::env::var("LOCAL_API_KEY")
1526 .ok()
1527 .filter(|s| !s.is_empty())
1528 })
1529 .unwrap_or_else(|| "local".to_string())
1530 }
1531
1532 fn base_url_for(&self, provider: &str) -> Result<String, BrokerError> {
1533 if provider == LOCAL_PROVIDER_KEY {
1534 if let Some(url) = &self.local_base_url {
1535 return Ok(url.trim_end_matches('/').to_string());
1536 }
1537 return Ok(local_endpoint_url());
1538 }
1539 static_provider(provider)
1540 .map(|s| s.base_url.trim_end_matches('/').to_string())
1541 .ok_or_else(|| BrokerError::UnknownProvider(provider.to_string()))
1542 }
1543
1544 async fn send(&self, request: &ProxyRequest) -> Result<reqwest::Response, BrokerError> {
1545 request.validate()?;
1546 let (key, base) = if request.provider == "xai-auth" {
1547 let token = self.access_token(OAuthProviderId::Xai).await?;
1548 (token.token, "https://api.x.ai/v1".to_string())
1549 } else if request.provider == "github-copilot" {
1550 let token = self.access_token(OAuthProviderId::GitHubCopilot).await?;
1553 (
1554 token.token,
1555 super::github_copilot_models_base_url().to_string(),
1556 )
1557 } else if request.provider == "google-gemini" {
1558 let token = self.access_token(OAuthProviderId::GoogleGemini).await?;
1561 let base = self
1562 .google_gemini_base_url
1563 .clone()
1564 .unwrap_or_else(|| GOOGLE_GEMINI_CODE_ASSIST_BASE_URL.to_string());
1565 (token.token, base)
1566 } else if request.provider == "openai-codex" {
1567 let token = self.access_token(OAuthProviderId::OpenAiCodex).await?;
1570 (token.token, OPENAI_CODEX_BACKEND_BASE_URL.to_string())
1571 } else if request.provider == "anthropic" {
1572 let token = self.access_token(OAuthProviderId::Anthropic).await?;
1575 (token.token, "https://api.anthropic.com".to_string())
1576 } else {
1577 (
1578 self.resolve_static_key(&request.provider)?,
1579 self.base_url_for(&request.provider)?,
1580 )
1581 };
1582 let url = format!("{base}{}", request.path);
1583 let mut builder = if request.provider == "google-gemini"
1586 || request.provider == "openai-codex"
1587 || request.provider == "anthropic"
1588 {
1589 let no_redirect = reqwest::Client::builder()
1590 .redirect(reqwest::redirect::Policy::none())
1591 .connect_timeout(Duration::from_secs(10))
1592 .build()
1593 .map_err(|e| BrokerError::Transport(format!("client build failed: {e}")))?;
1594 match request.method {
1595 ProxyMethod::Get => no_redirect.get(&url),
1596 ProxyMethod::Post => no_redirect.post(&url),
1597 }
1598 } else {
1599 match request.method {
1600 ProxyMethod::Get => self.http.get(&url),
1601 ProxyMethod::Post => self.http.post(&url),
1602 }
1603 };
1604 builder = builder.bearer_auth(&key);
1605 if request.provider == "openai-codex" {
1606 let account_id = super::load_provider_auth("openai-codex")
1609 .ok()
1610 .flatten()
1611 .and_then(|c| c.account_id)
1612 .filter(|s| !s.trim().is_empty())
1613 .or_else(|| super::extract_codex_account_id(&key))
1614 .ok_or_else(|| {
1615 BrokerError::Credential(
1616 "openai-codex credential is missing chatgpt account id".into(),
1617 )
1618 })?;
1619 builder = builder
1620 .header("chatgpt-account-id", account_id)
1621 .header("originator", "synaps")
1622 .header("OpenAI-Beta", "responses=experimental")
1623 .header("accept", "application/json");
1624 }
1625 if request.provider == "anthropic" {
1626 for (name, value) in anthropic_oauth_catalog_request_headers() {
1629 builder = builder.header(*name, *value);
1630 }
1631 }
1632 if request.provider == "github-copilot" {
1633 for (name, value) in super::github_copilot_models_request_headers() {
1634 builder = builder.header(*name, *value);
1635 }
1636 if request.path != "/models" {
1637 builder = builder
1638 .header("Openai-Intent", "conversation-edits")
1639 .header("X-Initiator", "agent");
1640 }
1641 }
1642 if request.provider == "google-gemini" {
1643 if request.stream && request.path == "/v1internal:streamGenerateContent" {
1647 builder = builder.query(&[("alt", "sse")]);
1648 }
1649 builder = builder.header("user-agent", "SynapsCLI/0.6.0 (google-gemini)");
1650 }
1651 if let Some(bytes) = &request.body_bytes {
1652 builder = builder
1656 .header("content-type", "application/json")
1657 .body(bytes.clone());
1658 } else if let Some(body) = &request.body {
1659 builder = builder.json(body);
1660 }
1661 if request.stream {
1662 builder = builder.header("accept", "text/event-stream");
1663 } else {
1664 builder = builder.timeout(self.request_timeout);
1668 }
1669 builder.send().await.map_err(|e| {
1670 if e.is_connect() && request.provider == LOCAL_PROVIDER_KEY {
1671 BrokerError::Transport(format!(
1672 "can't reach local endpoint at {url} — is Ollama/LM Studio running?"
1673 ))
1674 } else {
1675 BrokerError::Transport(format!("request to {} failed: {e}", request.provider))
1677 }
1678 })
1679 }
1680}
1681
1682async fn read_body_capped(resp: reqwest::Response, cap: usize) -> Result<String, BrokerError> {
1687 use futures::StreamExt;
1688 let mut buf: Vec<u8> = Vec::new();
1689 let mut stream = resp.bytes_stream();
1690 while let Some(chunk) = stream.next().await {
1691 let chunk =
1692 chunk.map_err(|e| BrokerError::Transport(format!("failed to read response: {e}")))?;
1693 if buf.len() + chunk.len() > cap {
1694 return Err(BrokerError::Transport(format!(
1695 "response body exceeded the {cap}-byte broker buffering limit"
1696 )));
1697 }
1698 buf.extend_from_slice(&chunk);
1699 }
1700 String::from_utf8(buf)
1701 .map_err(|_| BrokerError::Transport("response body was not valid UTF-8".into()))
1702}
1703
1704fn discover_legacy_static_key(spec: &StaticProviderSpec) -> Option<String> {
1707 if let Some(v) = crate::config::get_provider_keys().get(spec.key) {
1708 if !v.is_empty() {
1709 return Some(v.clone());
1710 }
1711 }
1712 spec.env_vars
1713 .iter()
1714 .find_map(|var| std::env::var(var).ok().filter(|v| !v.is_empty()))
1715}
1716
1717pub fn static_key_configured(provider: &str) -> bool {
1720 if provider == LOCAL_PROVIDER_KEY {
1721 return true; }
1723 let Some(spec) = static_provider(provider) else {
1724 return false;
1725 };
1726 storage::load_static_key(provider).ok().flatten().is_some()
1727 || discover_legacy_static_key(spec).is_some()
1728}
1729
1730pub fn static_key_status(provider: &str) -> StaticKeyStatus {
1734 let stored = storage::load_static_key(provider)
1735 .ok()
1736 .flatten()
1737 .or_else(|| {
1738 crate::config::get_provider_keys()
1739 .get(provider)
1740 .filter(|s| !s.is_empty())
1741 .cloned()
1742 });
1743 if let Some(key) = stored {
1744 return StaticKeyStatus::Configured {
1745 masked: mask_key(&key),
1746 };
1747 }
1748 if let Some(spec) = static_provider(provider) {
1749 if spec
1750 .env_vars
1751 .iter()
1752 .any(|var| std::env::var(var).is_ok_and(|v| !v.is_empty()))
1753 {
1754 return StaticKeyStatus::FromEnv;
1755 }
1756 }
1757 StaticKeyStatus::NotSet
1758}
1759
1760fn mask_key(key: &str) -> String {
1762 let chars: Vec<char> = key.chars().collect();
1763 if chars.len() <= 8 {
1764 return "…".to_string();
1765 }
1766 let head: String = chars[..4].iter().collect();
1767 let tail: String = chars[chars.len() - 4..].iter().collect();
1768 format!("{head}…{tail}")
1769}
1770
1771pub fn configured_static_provider_keys() -> std::collections::BTreeSet<String> {
1773 STATIC_PROVIDERS
1774 .iter()
1775 .filter(|s| static_key_configured(s.key))
1776 .map(|s| s.key.to_string())
1777 .collect()
1778}
1779
1780pub fn local_endpoint_url() -> String {
1783 local_endpoint_config()
1784 .unwrap_or_else(|| LOCAL_DEFAULT_BASE_URL.to_string())
1785 .trim_end_matches('/')
1786 .to_string()
1787}
1788
1789pub fn local_endpoint_config() -> Option<String> {
1791 crate::config::get_provider_keys()
1792 .get("local.url")
1793 .filter(|s| !s.is_empty())
1794 .cloned()
1795 .or_else(|| {
1796 std::env::var("LOCAL_ENDPOINT")
1797 .ok()
1798 .filter(|s| !s.is_empty())
1799 })
1800}
1801
1802pub fn local_model_ids() -> Vec<String> {
1804 crate::config::get_provider_keys()
1805 .get("local.models")
1806 .map(|value| {
1807 value
1808 .split(',')
1809 .map(|s| s.trim().to_string())
1810 .filter(|s| !s.is_empty())
1811 .collect()
1812 })
1813 .unwrap_or_default()
1814}
1815
1816pub fn anthropic_credential_available() -> bool {
1819 let oauth = load_provider_auth(OAuthProviderId::Anthropic.as_str())
1820 .ok()
1821 .flatten()
1822 .map(|c| c.auth_type == "oauth" && !c.access.is_empty())
1823 .unwrap_or(false);
1824 oauth || std::env::var("ANTHROPIC_API_KEY").is_ok_and(|v| !v.is_empty())
1825}
1826
1827pub fn oauth_provider_logged_in(provider: OAuthProviderId) -> bool {
1831 load_provider_auth(provider.as_str())
1832 .ok()
1833 .flatten()
1834 .is_some_and(|creds| {
1835 creds.auth_type == "oauth" && (!creds.refresh.is_empty() || !creds.access.is_empty())
1836 })
1837}
1838
1839#[async_trait]
1840impl CredentialBroker for LocalBroker {
1841 async fn access_token(&self, provider: OAuthProviderId) -> Result<AccessToken, BrokerError> {
1842 let creds = super::ensure_fresh_provider_token(&self.http, provider)
1843 .await
1844 .map_err(BrokerError::Credential)?;
1845 Ok(AccessToken {
1847 token: creds.access,
1848 expires: creds.expires,
1849 })
1850 }
1851
1852 async fn proxy(&self, request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
1853 let resp = self.send(&request).await?;
1854 let status = resp.status().as_u16();
1855 let body = read_body_capped(resp, self.max_response_bytes).await?;
1856 Ok(ProxyResponse { status, body })
1857 }
1858
1859 async fn proxy_stream(&self, request: ProxyRequest) -> Result<ProxyByteStream, BrokerError> {
1860 let mut request = request;
1861 request.stream = true;
1862 let resp = self.send(&request).await?;
1863 let status = resp.status();
1864 if !status.is_success() {
1865 drop(resp);
1872 return Err(BrokerError::Transport(format!(
1873 "provider request failed: {status}"
1874 )));
1875 }
1876 use futures::StreamExt;
1877 let stream = resp
1878 .bytes_stream()
1879 .map(|chunk| chunk.map_err(|e| BrokerError::Transport(format!("stream error: {e}"))));
1880 Ok(Box::pin(stream))
1881 }
1882
1883 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
1884 let token = self.access_token(OAuthProviderId::Anthropic).await?;
1887 let url = self
1888 .anthropic_usage_url
1889 .as_deref()
1890 .unwrap_or(ANTHROPIC_USAGE_URL);
1891 let resp = self
1892 .http
1893 .get(url)
1894 .timeout(self.request_timeout)
1895 .bearer_auth(&token.token)
1896 .header("anthropic-beta", ANTHROPIC_OAUTH_BETA)
1897 .send()
1898 .await
1899 .map_err(|e| BrokerError::Transport(format!("usage request failed: {e}")))?;
1900 let status = resp.status();
1901 if !status.is_success() {
1902 drop(resp);
1905 return Err(BrokerError::Transport(format!(
1906 "usage request failed: {status}"
1907 )));
1908 }
1909 let body = read_body_capped(resp, self.max_response_bytes).await?;
1910 serde_json::from_str(&body)
1911 .map_err(|e| BrokerError::Transport(format!("invalid usage response: {e}")))
1912 }
1913
1914 async fn cloud_catalog(
1915 &self,
1916 provider: CloudProviderId,
1917 context_ref: &str,
1918 allow_stale: bool,
1919 ) -> Result<Vec<CloudCatalogEntry>, BrokerError> {
1920 self.cloud_backend
1921 .as_ref()
1922 .ok_or_else(|| BrokerError::NotConfigured(provider.to_string()))?
1923 .catalog(provider, context_ref, allow_stale)
1924 .await
1925 }
1926
1927 async fn cloud_invoke(
1928 &self,
1929 provider: CloudProviderId,
1930 context_ref: &str,
1931 model_id: &str,
1932 request: InvokeRequest,
1933 ) -> Result<CloudEventStream, BrokerError> {
1934 self.cloud_backend
1935 .as_ref()
1936 .ok_or_else(|| BrokerError::NotConfigured(provider.to_string()))?
1937 .invoke(provider, context_ref, model_id, request)
1938 .await
1939 }
1940
1941 async fn capabilities(&self) -> Result<Vec<ProviderStatus>, BrokerError> {
1942 let mut out = Vec::new();
1943 for descriptor in super::provider::registry().iter() {
1944 out.push(ProviderStatus {
1945 key: descriptor.id.as_str().to_string(),
1946 name: descriptor.display_name.to_string(),
1947 kind: CredentialKind::OAuth,
1948 configured: load_provider_auth(descriptor.id.as_str())
1949 .ok()
1950 .flatten()
1951 .is_some(),
1952 });
1953 }
1954 for spec in STATIC_PROVIDERS {
1955 out.push(ProviderStatus {
1956 key: spec.key.to_string(),
1957 name: spec.name.to_string(),
1958 kind: CredentialKind::StaticKey,
1959 configured: static_key_configured(spec.key),
1960 });
1961 }
1962 out.push(ProviderStatus {
1963 key: LOCAL_PROVIDER_KEY.to_string(),
1964 name: "Local endpoint".to_string(),
1965 kind: CredentialKind::LocalEndpoint,
1966 configured: true,
1967 });
1968 Ok(out)
1969 }
1970}
1971
1972pub struct RemoteBroker {
1978 http: reqwest::Client,
1979 endpoint: String,
1980 machine_token: String,
1981 cache: super::TokenCache,
1982}
1983
1984impl RemoteBroker {
1985 pub fn new(
1986 endpoint: impl Into<String>,
1987 machine_token: impl Into<String>,
1988 http: reqwest::Client,
1989 cache: super::TokenCache,
1990 ) -> Self {
1991 let endpoint = endpoint.into().trim_end_matches('/').to_string();
1992 let http = reqwest::Client::builder()
1995 .redirect(reqwest::redirect::Policy::none())
1996 .connect_timeout(Duration::from_secs(10))
1997 .timeout(Duration::from_secs(90))
1998 .build()
1999 .unwrap_or(http);
2000 Self {
2001 http,
2002 endpoint,
2003 machine_token: machine_token.into(),
2004 cache,
2005 }
2006 }
2007
2008 async fn post_proxy(&self, request: &ProxyRequest) -> Result<reqwest::Response, BrokerError> {
2009 request.validate()?;
2010 let resp = self
2011 .http
2012 .post(format!("{}/proxy", self.endpoint))
2013 .bearer_auth(&self.machine_token)
2014 .json(request)
2015 .send()
2016 .await
2017 .map_err(|e| BrokerError::Transport(format!("broker request failed: {e}")))?;
2018 match resp.status().as_u16() {
2019 401 => Err(BrokerError::Unauthorized),
2020 s @ (400 | 403) => {
2021 drop(resp);
2025 Err(BrokerError::Denied(format!(
2026 "broker rejected the request (HTTP {s})"
2027 )))
2028 }
2029 _ => Ok(resp),
2030 }
2031 }
2032}
2033
2034#[async_trait]
2035impl CredentialBroker for RemoteBroker {
2036 async fn access_token(&self, provider: OAuthProviderId) -> Result<AccessToken, BrokerError> {
2037 let fetcher = super::BrokerClient::with_client(
2038 self.endpoint.clone(),
2039 self.machine_token.clone(),
2040 self.http.clone(),
2041 );
2042 let tok = super::resolve_remote(
2043 &fetcher,
2044 &self.cache,
2045 provider.as_str(),
2046 super::DEFAULT_MARGIN_MS,
2047 )
2048 .await
2049 .map_err(BrokerError::Transport)?;
2050 Ok(AccessToken {
2051 token: tok.access_token,
2052 expires: tok.expires,
2053 })
2054 }
2055
2056 async fn proxy(&self, request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
2057 let mut request = request;
2058 request.stream = false;
2059 let resp = self.post_proxy(&request).await?;
2060 if !resp.status().is_success() {
2061 return Err(BrokerError::Transport(format!(
2062 "broker proxy returned HTTP {}",
2063 resp.status()
2064 )));
2065 }
2066 let body = read_body_capped(resp, MAX_PROXY_RESPONSE_BYTES).await?;
2067 serde_json::from_str::<ProxyResponse>(&body)
2068 .map_err(|e| BrokerError::Transport(format!("invalid broker proxy response: {e}")))
2069 }
2070
2071 async fn proxy_stream(&self, request: ProxyRequest) -> Result<ProxyByteStream, BrokerError> {
2072 let mut request = request;
2073 request.stream = true;
2074 let resp = self.post_proxy(&request).await?;
2075 let status = resp.status();
2076 if !status.is_success() {
2077 drop(resp);
2079 return Err(BrokerError::Transport(format!(
2080 "broker proxy stream failed: {status}"
2081 )));
2082 }
2083 use futures::StreamExt;
2084 let stream = resp
2085 .bytes_stream()
2086 .map(|chunk| chunk.map_err(|e| BrokerError::Transport(format!("stream error: {e}"))));
2087 Ok(Box::pin(stream))
2088 }
2089
2090 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
2091 let resp = self
2094 .http
2095 .get(format!("{}/usage", self.endpoint))
2096 .bearer_auth(&self.machine_token)
2097 .send()
2098 .await
2099 .map_err(|e| BrokerError::Transport(format!("broker request failed: {e}")))?;
2100 match resp.status().as_u16() {
2101 401 => Err(BrokerError::Unauthorized),
2102 s if !(200..300).contains(&s) => {
2103 drop(resp);
2105 Err(BrokerError::Transport(format!(
2106 "broker usage returned HTTP {s}"
2107 )))
2108 }
2109 _ => {
2110 let body = read_body_capped(resp, MAX_PROXY_RESPONSE_BYTES).await?;
2111 serde_json::from_str(&body)
2112 .map_err(|e| BrokerError::Transport(format!("invalid usage response: {e}")))
2113 }
2114 }
2115 }
2116
2117 async fn cloud_catalog(
2118 &self,
2119 provider: CloudProviderId,
2120 context_ref: &str,
2121 allow_stale: bool,
2122 ) -> Result<Vec<CloudCatalogEntry>, BrokerError> {
2123 let resp = self.http.post(format!("{}/cloud/catalog", self.endpoint)).bearer_auth(&self.machine_token).json(&serde_json::json!({"provider":provider,"context_ref":context_ref,"allow_stale":allow_stale})).send().await.map_err(|e| BrokerError::Transport(e.to_string()))?;
2124 if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
2125 return Err(BrokerError::Unauthorized);
2126 }
2127 if !resp.status().is_success() {
2128 return Err(BrokerError::Transport(format!(
2129 "cloud catalog returned HTTP {}",
2130 resp.status()
2131 )));
2132 }
2133 let body = read_body_capped(resp, MAX_CLOUD_CATALOG_BODY_BYTES).await?;
2134 let entries: Vec<CloudCatalogEntry> = serde_json::from_str(&body)
2135 .map_err(|e| BrokerError::Transport(format!("invalid cloud catalog: {e}")))?;
2136 if entries.len() > MAX_CLOUD_CATALOG_ENTRIES
2137 || entries.iter().any(|e| {
2138 e.provider != provider || !e.context_ref.starts_with("ctx-") || e.id.len() > 512
2139 })
2140 {
2141 return Err(BrokerError::Transport(
2142 "broker returned an invalid cloud catalog".into(),
2143 ));
2144 }
2145 Ok(entries)
2146 }
2147
2148 async fn cloud_invoke(
2149 &self,
2150 provider: CloudProviderId,
2151 context_ref: &str,
2152 model_id: &str,
2153 request: InvokeRequest,
2154 ) -> Result<CloudEventStream, BrokerError> {
2155 let resp = self.http.post(format!("{}/cloud/invoke", self.endpoint)).bearer_auth(&self.machine_token).json(&serde_json::json!({"provider":provider,"context_ref":context_ref,"model_id":model_id,"request":request})).send().await.map_err(|e| BrokerError::Transport(e.to_string()))?;
2156 if !resp.status().is_success() {
2157 return Err(if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
2158 BrokerError::Unauthorized
2159 } else {
2160 BrokerError::Transport(format!("cloud invoke returned HTTP {}", resp.status()))
2161 });
2162 }
2163 use futures::StreamExt;
2164 let chunks = Box::pin(resp.bytes_stream());
2165 let stream = futures::stream::unfold(
2166 (chunks, Vec::<u8>::new(), false, false),
2167 |(mut chunks, mut buffer, done, failed)| async move {
2168 if failed {
2169 return None;
2170 }
2171 loop {
2172 if let Some(end) = buffer.iter().position(|b| *b == b'\n') {
2173 let line: Vec<u8> = buffer.drain(..=end).collect();
2174 let parsed = serde_json::from_slice::<CloudEvent>(&line[..end])
2175 .map_err(|_| BrokerError::Transport("invalid cloud event".into()));
2176 let event = match parsed {
2177 Ok(CloudEvent::Done) if done => Err(BrokerError::Transport(
2178 "duplicate cloud terminal event".into(),
2179 )),
2180 Ok(CloudEvent::Done) => Ok(CloudEvent::Done),
2181 Ok(_) if done => Err(BrokerError::Transport(
2182 "cloud data followed terminal event".into(),
2183 )),
2184 other => other,
2185 };
2186 let next_done = done || matches!(event, Ok(CloudEvent::Done));
2187 let next_failed = event.is_err();
2188 return Some((event, (chunks, buffer, next_done, next_failed)));
2189 }
2190 match chunks.next().await {
2191 Some(Ok(chunk))
2192 if buffer.len() + chunk.len() <= MAX_CLOUD_STREAM_EVENT_BYTES =>
2193 {
2194 buffer.extend_from_slice(&chunk)
2195 }
2196 Some(Ok(_)) => {
2197 return Some((
2198 Err(BrokerError::Transport("cloud event exceeded limit".into())),
2199 (chunks, Vec::new(), done, true),
2200 ))
2201 }
2202 Some(Err(_)) => {
2203 return Some((
2204 Err(BrokerError::Transport("broker stream failed".into())),
2205 (chunks, Vec::new(), done, true),
2206 ))
2207 }
2208 None if buffer.is_empty() && done => return None,
2209 None if buffer.is_empty() => {
2210 return Some((
2211 Err(BrokerError::Transport(
2212 "cloud stream ended without terminal event".into(),
2213 )),
2214 (chunks, Vec::new(), done, true),
2215 ))
2216 }
2217 None => {
2218 return Some((
2219 Err(BrokerError::Transport("truncated cloud event".into())),
2220 (chunks, Vec::new(), done, true),
2221 ))
2222 }
2223 }
2224 }
2225 },
2226 );
2227 Ok(Box::pin(stream))
2228 }
2229
2230 async fn capabilities(&self) -> Result<Vec<ProviderStatus>, BrokerError> {
2231 let resp = self
2232 .http
2233 .get(format!("{}/capabilities", self.endpoint))
2234 .bearer_auth(&self.machine_token)
2235 .send()
2236 .await
2237 .map_err(|e| BrokerError::Transport(format!("broker request failed: {e}")))?;
2238 match resp.status().as_u16() {
2239 401 => Err(BrokerError::Unauthorized),
2240 s if !(200..300).contains(&s) => {
2241 Err(BrokerError::Transport(format!("broker returned HTTP {s}")))
2242 }
2243 _ => {
2244 let body = read_body_capped(resp, MAX_PROXY_RESPONSE_BYTES).await?;
2245 serde_json::from_str::<Vec<ProviderStatus>>(&body).map_err(|e| {
2246 BrokerError::Transport(format!("invalid capabilities response: {e}"))
2247 })
2248 }
2249 }
2250 }
2251}
2252
2253pub fn preflight_cloud_capability(
2262 provider: CloudProviderId,
2263 needs_tools: bool,
2264) -> Result<(), BrokerError> {
2265 if needs_tools && !provider.supports_tools() {
2266 return Err(BrokerError::UnsupportedCapability {
2267 provider: provider.to_string(),
2268 capability: "tools".into(),
2269 });
2270 }
2271 Ok(())
2272}
2273
2274pub fn broker_from_source(
2276 source: &super::CredentialSource,
2277 cache: &super::TokenCache,
2278 http: reqwest::Client,
2279) -> Arc<dyn CredentialBroker> {
2280 match source {
2281 super::CredentialSource::Local => Arc::new(LocalBroker::new(http)),
2282 super::CredentialSource::Remote {
2283 endpoint,
2284 machine_token,
2285 } => Arc::new(RemoteBroker::new(
2286 endpoint.clone(),
2287 machine_token.clone(),
2288 http,
2289 cache.clone(),
2290 )),
2291 }
2292}
2293
2294static GLOBAL_BROKER: std::sync::RwLock<Option<Arc<dyn CredentialBroker>>> =
2295 std::sync::RwLock::new(None);
2296
2297pub fn set_global_broker(broker: Arc<dyn CredentialBroker>) {
2299 *GLOBAL_BROKER.write().expect("broker registry poisoned") = Some(broker);
2300}
2301
2302pub fn global_broker() -> Arc<dyn CredentialBroker> {
2305 if let Some(b) = GLOBAL_BROKER
2306 .read()
2307 .expect("broker registry poisoned")
2308 .clone()
2309 {
2310 return b;
2311 }
2312 let default: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::new(
2313 reqwest::Client::builder()
2314 .connect_timeout(std::time::Duration::from_secs(10))
2315 .timeout(std::time::Duration::from_secs(300))
2316 .build()
2317 .unwrap_or_default(),
2318 ));
2319 let mut guard = GLOBAL_BROKER.write().expect("broker registry poisoned");
2320 if let Some(b) = guard.clone() {
2321 return b;
2322 }
2323 *guard = Some(default.clone());
2324 default
2325}
2326
2327pub async fn broker_access_token(
2330 provider: OAuthProviderId,
2331 source: &super::CredentialSource,
2332 cache: &super::TokenCache,
2333 http: &reqwest::Client,
2334) -> Result<AccessToken, BrokerError> {
2335 broker_from_source(source, cache, http.clone())
2336 .access_token(provider)
2337 .await
2338}
2339
2340pub fn static_key_status_map() -> BTreeMap<String, StaticKeyStatus> {
2343 STATIC_PROVIDERS
2344 .iter()
2345 .map(|s| (s.key.to_string(), static_key_status(s.key)))
2346 .collect()
2347}
2348
2349#[cfg(test)]
2350mod tests {
2351 use super::*;
2352
2353 #[test]
2358 fn preflight_rejects_tool_requiring_cloud_routes_with_typed_error() {
2359 for provider in [
2360 CloudProviderId::AzureOpenAi,
2361 CloudProviderId::AwsBedrock,
2362 CloudProviderId::GoogleVertex,
2363 ] {
2364 let err = preflight_cloud_capability(provider, true)
2365 .expect_err("tool-requiring cloud route must fail pre-flight");
2366 assert_eq!(
2367 err,
2368 BrokerError::UnsupportedCapability {
2369 provider: provider.to_string(),
2370 capability: "tools".into(),
2371 }
2372 );
2373 assert!(err.to_string().contains("text-only"));
2374 assert_eq!(preflight_cloud_capability(provider, false), Ok(()));
2375 }
2376 }
2377
2378 async fn spawn_ndjson(body: &'static str) -> String {
2379 use axum::{body::Body, routing::post, Router};
2380 use bytes::Bytes;
2381 let app = Router::new().route(
2382 "/cloud/invoke",
2383 post(move || async move {
2384 let chunks = body
2385 .as_bytes()
2386 .chunks(3)
2387 .map(|chunk| Ok::<_, std::convert::Infallible>(Bytes::copy_from_slice(chunk)));
2388 axum::response::Response::builder()
2389 .header("content-type", "application/x-ndjson")
2390 .body(Body::from_stream(futures::stream::iter(chunks)))
2391 .unwrap()
2392 }),
2393 );
2394 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2395 let addr = listener.local_addr().unwrap();
2396 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2397 format!("http://{addr}")
2398 }
2399
2400 async fn remote_events(body: &'static str) -> Vec<Result<CloudEvent, BrokerError>> {
2401 use futures::StreamExt;
2402 let broker = RemoteBroker::new(
2403 spawn_ndjson(body).await,
2404 "opaque-machine-token",
2405 reqwest::Client::new(),
2406 super::super::TokenCache::new(),
2407 );
2408 broker
2409 .cloud_invoke(
2410 CloudProviderId::GoogleVertex,
2411 "ctx-opaque",
2412 "google-vertex/publishers/google/models/test",
2413 InvokeRequest {
2414 messages: vec![],
2415 tools: vec![],
2416 stream: true,
2417 options: Default::default(),
2418 },
2419 )
2420 .await
2421 .unwrap()
2422 .collect()
2423 .await
2424 }
2425
2426 #[tokio::test]
2427 async fn remote_ndjson_requires_exactly_one_terminal_and_stops_after_error() {
2428 let valid =
2429 remote_events("{\"type\":\"text_delta\",\"delta\":\"hi\"}\n{\"type\":\"done\"}\n")
2430 .await;
2431 assert_eq!(valid.len(), 2);
2432 assert!(matches!(valid[1], Ok(CloudEvent::Done)));
2433
2434 for body in [
2435 "{\"type\":\"text_delta\",\"delta\":\"hi\"}\n",
2436 "{\"type\":\"done\"}\n{\"type\":\"done\"}\n",
2437 "{\"type\":\"done\"}\n{\"type\":\"text_delta\",\"delta\":\"late\"}\n",
2438 ] {
2439 let events = remote_events(body).await;
2440 assert_eq!(events.iter().filter(|event| event.is_err()).count(), 1);
2441 assert!(events.last().unwrap().is_err());
2442 }
2443 }
2444
2445 #[test]
2448 fn proxy_rejects_unlisted_same_host_paths() {
2449 for (provider, path) in [
2450 ("groq", "/v1/keys"),
2451 ("groq", "/admin"),
2452 ("openrouter", "/api/v1/auth/keys"),
2453 ("local", "/audio/speech"),
2454 ] {
2455 let req = ProxyRequest {
2456 provider: provider.into(),
2457 method: ProxyMethod::Get,
2458 path: path.into(),
2459 body: None,
2460 stream: false,
2461 body_bytes: None,
2462 };
2463 match req.validate() {
2464 Err(BrokerError::Denied(msg)) => {
2465 assert!(msg.contains("allowlist"), "got: {msg}")
2466 }
2467 other => panic!("{provider} {path} must be denied, got {other:?}"),
2468 }
2469 }
2470 for path in ["/models", "/chat/completions"] {
2472 let req = ProxyRequest {
2473 provider: "groq".into(),
2474 method: ProxyMethod::Get,
2475 path: path.into(),
2476 body: None,
2477 stream: false,
2478 body_bytes: None,
2479 };
2480 assert!(req.validate().is_ok(), "{path} must be allowed");
2481 }
2482 }
2483
2484 #[test]
2487 fn proxy_rejects_oversize_request_body() {
2488 let req = ProxyRequest {
2489 provider: "groq".into(),
2490 method: ProxyMethod::Post,
2491 path: "/chat/completions".into(),
2492 body: Some(serde_json::json!({
2493 "blob": "x".repeat(MAX_PROXY_REQUEST_BYTES + 1)
2494 })),
2495 stream: false,
2496 body_bytes: None,
2497 };
2498 match req.validate() {
2499 Err(BrokerError::Denied(msg)) => assert!(msg.contains("byte"), "got: {msg}"),
2500 other => panic!("oversize body must be denied, got {other:?}"),
2501 }
2502 }
2503
2504 #[test]
2508 fn proxy_rejects_oversize_body_bytes_without_json_body() {
2509 let req = ProxyRequest {
2510 provider: "groq".into(),
2511 method: ProxyMethod::Post,
2512 path: "/chat/completions".into(),
2513 body: None,
2514 stream: false,
2515 body_bytes: Some(bytes::Bytes::from(vec![b'x'; MAX_PROXY_REQUEST_BYTES + 1])),
2516 };
2517 match req.validate() {
2518 Err(BrokerError::Denied(msg)) => assert!(msg.contains("byte"), "got: {msg}"),
2519 other => panic!("oversize body_bytes must be denied, got {other:?}"),
2520 }
2521 }
2522
2523 #[test]
2527 fn post_json_exact_sets_coherent_body_and_bytes() {
2528 let value = serde_json::json!({"model": "m", "stream": true});
2529 let (req, digest_bytes) =
2530 ProxyRequest::post_json_exact("groq", "/chat/completions", value.clone(), true)
2531 .expect("serializable body must construct");
2532 assert_eq!(req.method, ProxyMethod::Post);
2533 assert!(req.stream);
2534 assert_eq!(req.body.as_ref(), Some(&value));
2535 let stored = req.body_bytes.as_ref().expect("bytes must be set");
2536 assert_eq!(stored, &digest_bytes, "caller digest bytes == wire bytes");
2537 assert_eq!(
2538 serde_json::from_slice::<serde_json::Value>(stored).unwrap(),
2539 value
2540 );
2541 req.validate().expect("coherent request must validate");
2542 }
2543
2544 #[test]
2548 fn mismatched_body_bytes_rejected_by_debug_validation() {
2549 let req = ProxyRequest {
2550 provider: "groq".into(),
2551 method: ProxyMethod::Post,
2552 path: "/chat/completions".into(),
2553 body: Some(serde_json::json!({"model": "claimed"})),
2554 stream: true,
2555 body_bytes: Some(bytes::Bytes::from_static(b"{\"model\":\"actually-sent\"}")),
2556 };
2557 match req.validate() {
2558 Err(BrokerError::Denied(msg)) => {
2559 assert!(msg.contains("body_bytes"), "got: {msg}");
2560 }
2561 other => panic!("incoherent handoff must be denied, got {other:?}"),
2562 }
2563 }
2564
2565 #[test]
2566 fn proxy_request_validation_fails_closed() {
2567 let ok = ProxyRequest {
2568 provider: "groq".into(),
2569 method: ProxyMethod::Post,
2570 path: "/chat/completions".into(),
2571 body: None,
2572 stream: false,
2573 body_bytes: None,
2574 };
2575 assert!(ok.validate().is_ok());
2576
2577 let unknown = ProxyRequest {
2578 provider: "evil".into(),
2579 ..ok.clone()
2580 };
2581 assert!(matches!(
2582 unknown.validate(),
2583 Err(BrokerError::UnknownProvider(_))
2584 ));
2585
2586 let absolute = ProxyRequest {
2587 path: "https://evil.example/x".into(),
2588 ..ok.clone()
2589 };
2590 assert!(matches!(absolute.validate(), Err(BrokerError::Denied(_))));
2591
2592 let traversal = ProxyRequest {
2593 path: "/../secrets".into(),
2594 ..ok.clone()
2595 };
2596 assert!(matches!(traversal.validate(), Err(BrokerError::Denied(_))));
2597
2598 let relative = ProxyRequest {
2599 path: "chat/completions".into(),
2600 ..ok
2601 };
2602 assert!(matches!(relative.validate(), Err(BrokerError::Denied(_))));
2603 }
2604
2605 #[test]
2609 fn proxy_rejects_oauth_providers_except_codex_catalog() {
2610 let claude = ProxyRequest {
2612 provider: "claude".into(),
2613 method: ProxyMethod::Get,
2614 path: "/models".into(),
2615 body: None,
2616 stream: false,
2617 body_bytes: None,
2618 };
2619 assert!(
2620 matches!(claude.validate(), Err(BrokerError::UnknownProvider(_))),
2621 "claude alias must not be proxyable"
2622 );
2623 let anth = ProxyRequest {
2625 provider: "anthropic".into(),
2626 method: ProxyMethod::Get,
2627 path: "/models".into(),
2628 body: None,
2629 stream: false,
2630 body_bytes: None,
2631 };
2632 assert!(
2633 matches!(anth.validate(), Err(BrokerError::Denied(_))),
2634 "anthropic /models must be denied"
2635 );
2636 let bare = ProxyRequest {
2638 provider: "openai-codex".into(),
2639 method: ProxyMethod::Get,
2640 path: "/models".into(),
2641 body: None,
2642 stream: false,
2643 body_bytes: None,
2644 };
2645 assert!(
2646 matches!(bare.validate(), Err(BrokerError::Denied(_))),
2647 "openai-codex /models without client_version must be denied"
2648 );
2649 }
2650
2651 #[test]
2654 fn proxy_allows_only_pinned_openai_codex_models_path() {
2655 let allowed = ProxyRequest {
2656 provider: "openai-codex".into(),
2657 method: ProxyMethod::Get,
2658 path: "/models?client_version=0.6.0".into(),
2659 body: None,
2660 stream: false,
2661 body_bytes: None,
2662 };
2663 assert!(
2664 allowed.validate().is_ok(),
2665 "exact codex models path must be allowed"
2666 );
2667
2668 for path in [
2669 "/models",
2670 "/models?client_version=",
2671 "/models?foo=1",
2672 "/models?client_version=0.6.0&extra=1",
2673 "/codex/responses",
2674 "/backend-api/models?client_version=0.6.0",
2675 "/v1/models?client_version=0.6.0",
2676 "/chat/completions",
2677 ] {
2678 let req = ProxyRequest {
2679 provider: "openai-codex".into(),
2680 method: ProxyMethod::Get,
2681 path: path.into(),
2682 body: None,
2683 stream: false,
2684 body_bytes: None,
2685 };
2686 assert!(
2687 matches!(req.validate(), Err(BrokerError::Denied(_))),
2688 "openai-codex path {path} must be denied"
2689 );
2690 }
2691
2692 let post = ProxyRequest {
2694 provider: "openai-codex".into(),
2695 method: ProxyMethod::Post,
2696 path: "/models?client_version=0.6.0".into(),
2697 body: None,
2698 stream: false,
2699 body_bytes: None,
2700 };
2701 assert!(
2702 matches!(post.validate(), Err(BrokerError::Denied(_))),
2703 "POST openai-codex models must be denied"
2704 );
2705 }
2706
2707 #[test]
2709 fn proxy_allows_only_pinned_github_copilot_paths() {
2710 let models = ProxyRequest {
2711 provider: "github-copilot".into(),
2712 method: ProxyMethod::Get,
2713 path: "/models".into(),
2714 body: None,
2715 stream: false,
2716 body_bytes: None,
2717 };
2718 assert!(models.validate().is_ok());
2719
2720 let chat = ProxyRequest {
2721 provider: "github-copilot".into(),
2722 method: ProxyMethod::Post,
2723 path: "/chat/completions".into(),
2724 body: None,
2725 stream: false,
2726 body_bytes: None,
2727 };
2728 assert!(chat.validate().is_ok());
2729 let responses = ProxyRequest {
2730 provider: "github-copilot".into(),
2731 method: ProxyMethod::Post,
2732 path: "/responses".into(),
2733 body: None,
2734 stream: false,
2735 body_bytes: None,
2736 };
2737 assert!(responses.validate().is_ok());
2738 for path in ["/v1/messages", "/models?x=1", "/embeddings"] {
2739 let request = ProxyRequest {
2740 provider: "github-copilot".into(),
2741 method: ProxyMethod::Post,
2742 path: path.into(),
2743 body: None,
2744 stream: false,
2745 body_bytes: None,
2746 };
2747 assert!(matches!(request.validate(), Err(BrokerError::Denied(_))));
2748 }
2749 }
2750
2751 #[test]
2753 fn proxy_allows_only_pinned_anthropic_models_path() {
2754 for path in [
2755 "/v1/models",
2756 "/v1/models?limit=100",
2757 "/v1/models?limit=100&after_id=claude-opus-4-7",
2758 ] {
2759 let req = ProxyRequest {
2760 provider: "anthropic".into(),
2761 method: ProxyMethod::Get,
2762 path: path.into(),
2763 body: None,
2764 stream: false,
2765 body_bytes: None,
2766 };
2767 assert!(req.validate().is_ok(), "{path} must be allowed");
2768 }
2769 for path in [
2770 "/v1/messages",
2771 "/models",
2772 "/v1/models?foo=1",
2773 "/v1/models?limit=",
2774 "/api/oauth/usage",
2775 "/v1/models?limit=100&evil=1",
2776 "/v1/models?limit=100&limit=50",
2777 "/v1/models?after_id=a&after_id=b",
2778 "/v1/models?limit=100&after_id=a&limit=50",
2779 ] {
2780 let req = ProxyRequest {
2781 provider: "anthropic".into(),
2782 method: ProxyMethod::Get,
2783 path: path.into(),
2784 body: None,
2785 stream: false,
2786 body_bytes: None,
2787 };
2788 assert!(
2789 matches!(req.validate(), Err(BrokerError::Denied(_))),
2790 "anthropic path {path} must be denied"
2791 );
2792 }
2793 }
2794
2795 #[test]
2796 fn anthropic_oauth_catalog_headers_are_bearer_version_beta_not_x_api_key() {
2797 let headers = anthropic_oauth_catalog_request_headers();
2798 let names: Vec<_> = headers.iter().map(|(n, _)| *n).collect();
2799 assert!(names.contains(&"anthropic-version"));
2800 assert!(names.contains(&"anthropic-beta"));
2801 assert!(names.contains(&"accept"));
2802 assert!(!names.iter().any(|n| n.eq_ignore_ascii_case("x-api-key")));
2803 assert!(!names
2804 .iter()
2805 .any(|n| n.eq_ignore_ascii_case("authorization")));
2806 let beta = headers
2807 .iter()
2808 .find(|(n, _)| *n == "anthropic-beta")
2809 .map(|(_, v)| *v)
2810 .expect("beta");
2811 assert_eq!(beta, "oauth-2025-04-20");
2812 }
2813
2814 #[test]
2815 fn anthropic_models_path_round_trip_and_rejects_duplicates() {
2816 assert!(is_allowed_anthropic_path("/v1/models"));
2817 assert!(is_allowed_anthropic_path("/v1/models?limit=100"));
2818 assert!(is_allowed_anthropic_path(
2819 "/v1/models?limit=100&after_id=claude-opus-4-7"
2820 ));
2821 assert!(!is_allowed_anthropic_path("/v1/models?limit=100&limit=50"));
2822 assert!(!is_allowed_anthropic_path(
2823 "/v1/models?after_id=a&after_id=b"
2824 ));
2825 let page0 = "/v1/models?limit=100";
2827 let page1 = "/v1/models?limit=100&after_id=model-1";
2828 assert!(is_allowed_anthropic_path(page0));
2829 assert!(is_allowed_anthropic_path(page1));
2830 }
2831
2832 #[test]
2834 fn proxy_allows_only_pinned_google_gemini_paths() {
2835 for path in [
2836 "/v1internal:loadCodeAssist",
2837 "/v1internal:onboardUser",
2838 "/v1internal:streamGenerateContent",
2839 "/v1internal:countTokens",
2840 "/v1internal/operations/op-12345",
2841 ] {
2842 let req = ProxyRequest {
2843 provider: "google-gemini".into(),
2844 method: ProxyMethod::Post,
2845 path: path.into(),
2846 body: None,
2847 stream: false,
2848 body_bytes: None,
2849 };
2850 assert!(req.validate().is_ok(), "{path} must be allowed");
2851 }
2852 for path in [
2853 "/v1internal:listExperiments",
2855 "/v1internal:fetchAdminControls",
2856 "/v1internal:setCodeAssistGlobalUserSetting",
2857 "/v1internal:generateContent",
2858 "/v2/models",
2860 "/v1beta/models",
2861 "/",
2863 "/v1internal:",
2864 ] {
2865 let req = ProxyRequest {
2866 provider: "google-gemini".into(),
2867 method: ProxyMethod::Post,
2868 path: path.into(),
2869 body: None,
2870 stream: false,
2871 body_bytes: None,
2872 };
2873 assert!(
2874 matches!(req.validate(), Err(BrokerError::Denied(_))),
2875 "{path} must be denied"
2876 );
2877 }
2878 }
2879
2880 #[test]
2881 fn access_token_type_has_no_refresh_field() {
2882 let t: AccessToken =
2885 serde_json::from_str(r#"{"token":"sk-x","expires":123,"refresh":"MUST-NOT-EXIST"}"#)
2886 .unwrap();
2887 let round = serde_json::to_value(&t).unwrap();
2888 assert_eq!(round.get("refresh"), None);
2889 assert_eq!(round["token"], "sk-x");
2890 }
2891
2892 #[test]
2893 fn broker_error_display_never_echoes_values() {
2894 let e = BrokerError::NotConfigured("groq".into());
2896 assert!(!format!("{e}").contains("sk-"));
2897 let e = BrokerError::Unauthorized;
2898 assert_eq!(format!("{e}"), "broker rejected machine auth");
2899 }
2900
2901 #[test]
2902 fn mask_key_is_short_and_lossy() {
2903 assert_eq!(mask_key("gsk-live-1234567890abcdef"), "gsk-…cdef");
2904 assert_eq!(mask_key("short"), "…");
2905 }
2906
2907 #[test]
2908 fn proxy_request_serde_roundtrip() {
2909 let req = ProxyRequest {
2910 provider: "openrouter".into(),
2911 method: ProxyMethod::Get,
2912 path: "/models".into(),
2913 body: Some(serde_json::json!({"a": 1})),
2914 stream: true,
2915 body_bytes: None,
2916 };
2917 let json = serde_json::to_string(&req).unwrap();
2918 let back: ProxyRequest = serde_json::from_str(&json).unwrap();
2919 assert_eq!(back.provider, "openrouter");
2920 assert!(back.stream);
2921 assert!(matches!(back.method, ProxyMethod::Get));
2922 }
2923
2924 async fn spawn_upstream(app: axum::Router) -> String {
2927 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2928 let addr = listener.local_addr().unwrap();
2929 tokio::spawn(async move {
2930 axum::serve(listener, app).await.unwrap();
2931 });
2932 format!("http://{addr}")
2933 }
2934
2935 #[tokio::test]
2939 async fn local_broker_streams_local_endpoint_and_applies_key() {
2940 use axum::routing::post;
2941 let seen_auth = Arc::new(std::sync::Mutex::new(String::new()));
2942 let seen = seen_auth.clone();
2943 let app = axum::Router::new().route(
2944 "/chat/completions",
2945 post(move |headers: axum::http::HeaderMap| {
2946 let seen = seen.clone();
2947 async move {
2948 *seen.lock().unwrap() = headers
2949 .get("authorization")
2950 .and_then(|v| v.to_str().ok())
2951 .unwrap_or("")
2952 .to_string();
2953 (
2954 [("content-type", "text/event-stream")],
2955 "data: {\"x\":1}\n\ndata: [DONE]\n\n",
2956 )
2957 }
2958 }),
2959 );
2960 let url = spawn_upstream(app).await;
2961 let broker = LocalBroker::with_local_base_url(reqwest::Client::new(), url);
2962 let mut stream = broker
2963 .proxy_stream(ProxyRequest {
2964 provider: LOCAL_PROVIDER_KEY.into(),
2965 method: ProxyMethod::Post,
2966 path: "/chat/completions".into(),
2967 body: Some(serde_json::json!({"model": "m"})),
2968 stream: true,
2969 body_bytes: None,
2970 })
2971 .await
2972 .expect("stream must open");
2973 use futures::StreamExt;
2974 let mut collected = Vec::new();
2975 while let Some(chunk) = stream.next().await {
2976 collected.extend_from_slice(&chunk.unwrap());
2977 }
2978 let text = String::from_utf8(collected).unwrap();
2979 assert!(text.contains("data: {\"x\":1}"));
2980 assert!(text.contains("[DONE]"));
2981 assert_eq!(&*seen_auth.lock().unwrap(), "Bearer local");
2983 }
2984
2985 #[tokio::test]
2988 async fn local_broker_stream_error_is_typed_and_keyless() {
2989 use axum::routing::post;
2990 let app = axum::Router::new().route(
2991 "/chat/completions",
2992 post(|| async {
2993 (
2994 axum::http::StatusCode::UNAUTHORIZED,
2995 "{\"error\":\"bad key\"}",
2996 )
2997 }),
2998 );
2999 let url = spawn_upstream(app).await;
3000 let broker = LocalBroker::with_local_base_url(reqwest::Client::new(), url);
3001 let err = match broker
3002 .proxy_stream(ProxyRequest {
3003 provider: LOCAL_PROVIDER_KEY.into(),
3004 method: ProxyMethod::Post,
3005 path: "/chat/completions".into(),
3006 body: None,
3007 stream: true,
3008 body_bytes: None,
3009 })
3010 .await
3011 {
3012 Err(e) => e,
3013 Ok(_) => panic!("non-2xx upstream must not yield a stream"),
3014 };
3015 let msg = format!("{err}");
3016 assert!(msg.contains("401"), "got: {msg}");
3017 assert!(
3018 !msg.to_lowercase().contains("bearer"),
3019 "no auth material in errors"
3020 );
3021 }
3022
3023 #[tokio::test]
3025 async fn local_broker_proxy_returns_status_and_body() {
3026 use axum::routing::get;
3027 let app =
3028 axum::Router::new().route("/models", get(|| async { "{\"data\":[{\"id\":\"m1\"}]}" }));
3029 let url = spawn_upstream(app).await;
3030 let broker = LocalBroker::with_local_base_url(reqwest::Client::new(), url);
3031 let resp = broker
3032 .proxy(ProxyRequest {
3033 provider: LOCAL_PROVIDER_KEY.into(),
3034 method: ProxyMethod::Get,
3035 path: "/models".into(),
3036 body: None,
3037 stream: false,
3038 body_bytes: None,
3039 })
3040 .await
3041 .unwrap();
3042 assert_eq!(resp.status, 200);
3043 assert!(resp.body.contains("m1"));
3044 }
3045
3046 #[tokio::test]
3049 async fn local_broker_proxy_rejects_oversize_response() {
3050 use axum::routing::get;
3051 let app = axum::Router::new().route("/models", get(|| async { "x".repeat(4096) }));
3052 let url = spawn_upstream(app).await;
3053 let broker = LocalBroker::with_local_base_url(reqwest::Client::new(), url)
3054 .with_max_response_bytes(64);
3055 let err = broker
3056 .proxy(ProxyRequest {
3057 provider: LOCAL_PROVIDER_KEY.into(),
3058 method: ProxyMethod::Get,
3059 path: "/models".into(),
3060 body: None,
3061 stream: false,
3062 body_bytes: None,
3063 })
3064 .await
3065 .expect_err("oversize body must be rejected");
3066 let msg = format!("{err}");
3067 assert!(msg.contains("limit"), "got: {msg}");
3068 }
3069
3070 #[tokio::test]
3073 async fn local_broker_stream_error_is_status_only() {
3074 use axum::routing::post;
3075 let app = axum::Router::new().route(
3076 "/chat/completions",
3077 post(|| async {
3078 (
3079 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
3080 format!("\x1b[2J{}", "E".repeat(64 * 1024)),
3081 )
3082 }),
3083 );
3084 let url = spawn_upstream(app).await;
3085 let broker = LocalBroker::with_local_base_url(reqwest::Client::new(), url);
3086 let err = broker
3087 .proxy_stream(ProxyRequest {
3088 provider: LOCAL_PROVIDER_KEY.into(),
3089 method: ProxyMethod::Post,
3090 path: "/chat/completions".into(),
3091 body: None,
3092 stream: true,
3093 body_bytes: None,
3094 })
3095 .await
3096 .err()
3097 .expect("non-2xx upstream must not yield a stream");
3098 let msg = format!("{err}");
3099 assert!(
3100 msg.len() < 700,
3101 "error must be bounded, got {} bytes",
3102 msg.len()
3103 );
3104 assert!(!msg.contains('\x1b'), "control chars must be stripped");
3105 assert!(msg.contains("500"));
3106 }
3107
3108 #[tokio::test]
3111 async fn local_broker_buffered_request_times_out() {
3112 use axum::routing::get;
3113 let app = axum::Router::new().route(
3114 "/models",
3115 get(|| async {
3116 tokio::time::sleep(Duration::from_secs(30)).await;
3117 "too late"
3118 }),
3119 );
3120 let url = spawn_upstream(app).await;
3121 let broker = LocalBroker::with_local_base_url(reqwest::Client::new(), url)
3122 .with_request_timeout(Duration::from_millis(200));
3123 let started = std::time::Instant::now();
3124 let err = broker
3125 .proxy(ProxyRequest {
3126 provider: LOCAL_PROVIDER_KEY.into(),
3127 method: ProxyMethod::Get,
3128 path: "/models".into(),
3129 body: None,
3130 stream: false,
3131 body_bytes: None,
3132 })
3133 .await
3134 .expect_err("hung upstream must time out");
3135 assert!(matches!(err, BrokerError::Transport(_)));
3136 assert!(
3137 started.elapsed() < Duration::from_secs(5),
3138 "timeout must be enforced promptly"
3139 );
3140 }
3141
3142 #[tokio::test]
3145 async fn global_broker_defaults_to_in_process_local() {
3146 let broker = global_broker();
3147 let caps = broker
3148 .capabilities()
3149 .await
3150 .expect("in-process broker needs no daemon");
3151 assert!(caps.iter().any(|c| c.key == LOCAL_PROVIDER_KEY));
3152 let again = global_broker();
3154 assert!(again.capabilities().await.is_ok());
3155 }
3156
3157 const HOSTILE_SENTINEL: &str = "ZX9-HOSTILE-SENTINEL-7Q";
3167
3168 fn hostile_body() -> String {
3171 format!(
3172 "{{\"error\":{{\"message\":\"ECHOED {HOSTILE_SENTINEL} \
3173 {{\\\"messages\\\":[{{\\\"content\\\":\\\"my secret prompt\\\"}}],\
3174 \\\"tools\\\":[{{\\\"input_schema\\\":{{}}}}]}}\"}}}}"
3175 )
3176 }
3177
3178 fn hostile_broker_json() -> String {
3181 format!(
3182 "{{\"error\":\"ECHOED {HOSTILE_SENTINEL} input_schema \
3183 my secret prompt\"}}"
3184 )
3185 }
3186
3187 fn assert_no_hostile_bytes(msg: &str) {
3188 assert!(
3189 !msg.contains(HOSTILE_SENTINEL),
3190 "sentinel leaked into error: {msg}"
3191 );
3192 assert!(!msg.contains("ECHOED"), "echoed body leaked: {msg}");
3193 assert!(
3194 !msg.contains("input_schema"),
3195 "request-shaped data leaked: {msg}"
3196 );
3197 assert!(
3198 !msg.contains("secret prompt"),
3199 "prompt content leaked: {msg}"
3200 );
3201 }
3202
3203 #[tokio::test]
3208 async fn local_broker_stream_error_omits_upstream_body() {
3209 use axum::routing::post;
3210 let app = axum::Router::new().route(
3211 "/chat/completions",
3212 post(|| async {
3213 (
3214 axum::http::StatusCode::TOO_MANY_REQUESTS,
3215 format!("\x1b[2J{}", hostile_body()),
3216 )
3217 }),
3218 );
3219 let url = spawn_upstream(app).await;
3220 let broker = LocalBroker::with_local_base_url(reqwest::Client::new(), url);
3221 let err = broker
3222 .proxy_stream(ProxyRequest {
3223 provider: LOCAL_PROVIDER_KEY.into(),
3224 method: ProxyMethod::Post,
3225 path: "/chat/completions".into(),
3226 body: None,
3227 stream: true,
3228 body_bytes: None,
3229 })
3230 .await
3231 .err()
3232 .expect("non-2xx upstream must not yield a stream");
3233 assert!(matches!(err, BrokerError::Transport(_)));
3234 let msg = format!("{err}");
3235 assert!(
3236 msg.contains("provider request failed: 429 Too Many Requests"),
3237 "status prefix must survive for engine classification: {msg}"
3238 );
3239 assert!(
3240 !msg.contains('\x1b'),
3241 "escapes must not reach errors: {msg}"
3242 );
3243 assert_no_hostile_bytes(&msg);
3244 }
3245
3246 #[tokio::test]
3250 #[serial_test::serial]
3251 async fn local_broker_usage_error_omits_upstream_body() {
3252 use axum::routing::get;
3253 let app = axum::Router::new().route(
3254 "/usage",
3255 get(|| async {
3256 (
3257 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
3258 hostile_body(),
3259 )
3260 }),
3261 );
3262 let url = spawn_upstream(app).await;
3263
3264 let dir = tempfile::tempdir().unwrap();
3270 let base = dir.path().join(".synaps-cli");
3271 std::fs::create_dir_all(&base).unwrap();
3272 let creds = crate::auth::OAuthCredentials {
3273 auth_type: "oauth".into(),
3274 refresh: String::new(),
3275 access: "test-access-token".into(),
3276 expires: crate::epoch_millis() + 3_600_000,
3277 account_id: None,
3278 };
3279 super::super::storage::save_provider_auth_at_test_hook(
3280 &base.join("auth.json"),
3281 "anthropic",
3282 &creds,
3283 )
3284 .unwrap();
3285 let old_base = std::env::var("SYNAPS_BASE_DIR").ok();
3286 let old_home = std::env::var("HOME").ok();
3287 std::env::remove_var("SYNAPS_BASE_DIR");
3288 std::env::set_var("HOME", dir.path());
3289
3290 let broker = LocalBroker::new(reqwest::Client::new())
3291 .with_anthropic_usage_url(format!("{url}/usage"));
3292 let result = broker.anthropic_usage().await;
3293
3294 match old_home {
3295 Some(v) => std::env::set_var("HOME", v),
3296 None => std::env::remove_var("HOME"),
3297 }
3298 if let Some(v) = old_base {
3299 std::env::set_var("SYNAPS_BASE_DIR", v);
3300 }
3301
3302 let err = result.expect_err("non-2xx usage response must fail");
3303 assert!(matches!(err, BrokerError::Transport(_)));
3304 let msg = format!("{err}");
3305 assert!(
3306 msg.contains("usage request failed") && msg.contains("500"),
3307 "operation + status must survive: {msg}"
3308 );
3309 assert_no_hostile_bytes(&msg);
3310 }
3311
3312 async fn spawn_remote_broker_error(status: u16) -> RemoteBroker {
3313 use axum::routing::{get, post};
3314 let code = axum::http::StatusCode::from_u16(status).unwrap();
3315 let app = axum::Router::new()
3316 .route(
3317 "/proxy",
3318 post(move || async move { (code, hostile_broker_json()) }),
3319 )
3320 .route(
3321 "/usage",
3322 get(move || async move { (code, hostile_broker_json()) }),
3323 );
3324 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3325 let addr = listener.local_addr().unwrap();
3326 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3327 RemoteBroker::new(
3328 format!("http://{addr}"),
3329 "opaque-machine-token",
3330 reqwest::Client::new(),
3331 super::super::TokenCache::new(),
3332 )
3333 }
3334
3335 fn groq_request(stream: bool) -> ProxyRequest {
3336 ProxyRequest {
3337 provider: "groq".into(),
3338 method: ProxyMethod::Post,
3339 path: "/chat/completions".into(),
3340 body: None,
3341 stream,
3342 body_bytes: None,
3343 }
3344 }
3345
3346 #[tokio::test]
3349 async fn remote_broker_denied_omits_broker_error_body() {
3350 for status in [400u16, 403] {
3351 let broker = spawn_remote_broker_error(status).await;
3352 let err = broker
3353 .proxy(groq_request(false))
3354 .await
3355 .expect_err("4xx broker response must fail");
3356 assert!(
3357 matches!(err, BrokerError::Denied(_)),
3358 "typed category must survive, got {err:?}"
3359 );
3360 let msg = format!("{err}");
3361 assert!(msg.contains(&status.to_string()), "status lost: {msg}");
3362 assert_no_hostile_bytes(&msg);
3363 }
3364 }
3365
3366 #[tokio::test]
3369 async fn remote_broker_stream_error_omits_broker_body() {
3370 let broker = spawn_remote_broker_error(500).await;
3371 let err = broker
3372 .proxy_stream(groq_request(true))
3373 .await
3374 .err()
3375 .expect("non-2xx broker response must not yield a stream");
3376 assert!(matches!(err, BrokerError::Transport(_)));
3377 let msg = format!("{err}");
3378 assert!(
3379 msg.contains("broker proxy stream failed") && msg.contains("500"),
3380 "operation + status must survive: {msg}"
3381 );
3382 assert_no_hostile_bytes(&msg);
3383 }
3384
3385 #[tokio::test]
3388 async fn remote_broker_usage_error_omits_broker_body() {
3389 let broker = spawn_remote_broker_error(502).await;
3390 let err = broker
3391 .anthropic_usage()
3392 .await
3393 .expect_err("non-2xx broker response must fail");
3394 assert!(matches!(err, BrokerError::Transport(_)));
3395 let msg = format!("{err}");
3396 assert!(
3397 msg.contains("broker usage returned HTTP 502"),
3398 "operation + status must survive: {msg}"
3399 );
3400 assert_no_hostile_bytes(&msg);
3401 }
3402
3403 #[tokio::test]
3406 async fn capabilities_expose_no_secret_fields() {
3407 let broker = LocalBroker::new(reqwest::Client::new());
3408 let caps = broker.capabilities().await.unwrap();
3409 assert!(caps
3410 .iter()
3411 .any(|c| c.key == "anthropic" && c.kind == CredentialKind::OAuth));
3412 assert!(caps
3413 .iter()
3414 .any(|c| c.key == "groq" && c.kind == CredentialKind::StaticKey));
3415 let json = serde_json::to_string(&caps).unwrap();
3416 for field in ["key\":", "name\":", "kind\":", "configured\":"] {
3417 assert!(json.contains(field));
3418 }
3419 assert!(!json.contains("refresh"));
3420 assert!(!json.contains("access"));
3421 }
3422}