1use serde::{Deserialize, Serialize};
6use std::{fmt, str::FromStr};
7
8use super::provider::OAuthProviderId;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "kebab-case")]
12pub enum CloudProviderId {
13 AzureOpenAi,
14 AwsBedrock,
15 GoogleVertex,
16}
17
18impl CloudProviderId {
19 pub const fn as_str(self) -> &'static str {
20 match self {
21 Self::AzureOpenAi => "azure-openai",
22 Self::AwsBedrock => "aws-bedrock",
23 Self::GoogleVertex => "google-vertex",
24 }
25 }
26 pub fn supports_tools(self) -> bool {
30 CLOUD_PROVIDER_DESCRIPTORS
31 .iter()
32 .find(|d| d.id == self)
33 .is_some_and(|d| d.supports_tools)
34 }
35}
36impl fmt::Display for CloudProviderId {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 f.write_str(self.as_str())
39 }
40}
41impl FromStr for CloudProviderId {
42 type Err = String;
43 fn from_str(s: &str) -> Result<Self, Self::Err> {
44 match s {
45 "azure-openai" => Ok(Self::AzureOpenAi),
46 "aws-bedrock" => Ok(Self::AwsBedrock),
47 "google-vertex" => Ok(Self::GoogleVertex),
48 _ => Err(format!("unknown canonical cloud provider id: {s}")),
49 }
50 }
51}
52
53pub fn qualify_model_route(model_id: &str, context_ref: &str) -> Result<String, String> {
56 if !context_ref.starts_with("ctx-")
57 || context_ref.len() > 128
58 || !context_ref
59 .bytes()
60 .all(|b| b.is_ascii_alphanumeric() || b == b'-')
61 || model_id.contains("#synaps-context=")
62 {
63 return Err("invalid opaque cloud model context".into());
64 }
65 Ok(format!("{model_id}#synaps-context={context_ref}"))
66}
67
68pub fn split_model_route(route: &str) -> (&str, Option<&str>) {
69 route
70 .rsplit_once("#synaps-context=")
71 .map_or((route, None), |(model, context)| (model, Some(context)))
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
76pub enum ProviderId {
77 OAuth(OAuthProviderId),
78 Cloud(CloudProviderId),
79}
80impl TryFrom<&str> for ProviderId {
81 type Error = String;
82 fn try_from(s: &str) -> Result<Self, Self::Error> {
83 s.parse::<OAuthProviderId>()
84 .map(Self::OAuth)
85 .or_else(|_| s.parse::<CloudProviderId>().map(Self::Cloud))
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum AuthIdentity {
92 OAuthBearer,
93 AwsTemporaryCredentials,
94}
95impl AuthIdentity {
96 pub const fn for_provider(provider: ProviderId) -> Self {
97 match provider {
98 ProviderId::Cloud(CloudProviderId::AwsBedrock) => Self::AwsTemporaryCredentials,
99 _ => Self::OAuthBearer,
100 }
101 }
102}
103
104fn simple(value: &str, name: &str) -> Result<(), String> {
105 if value.is_empty()
106 || value.len() > 128
107 || !value
108 .bytes()
109 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
110 {
111 Err(format!("invalid {name}"))
112 } else {
113 Ok(())
114 }
115}
116fn region(value: &str) -> Result<(), String> {
117 if value.len() >= 3
118 && value
119 .bytes()
120 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
121 && value.contains('-')
122 {
123 Ok(())
124 } else {
125 Err("invalid region/location".into())
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct AzureOpenAiConfig {
131 pub tenant: String,
132 pub subscription_id: String,
133 pub resource_group: String,
134 pub resource_name: String,
135 pub deployment: String,
136}
137impl AzureOpenAiConfig {
138 pub fn new(
139 tenant: impl Into<String>,
140 subscription_id: impl Into<String>,
141 resource_group: impl Into<String>,
142 resource_name: impl Into<String>,
143 deployment: impl Into<String>,
144 ) -> Result<Self, String> {
145 let v = Self {
146 tenant: tenant.into(),
147 subscription_id: subscription_id.into(),
148 resource_group: resource_group.into(),
149 resource_name: resource_name.into(),
150 deployment: deployment.into(),
151 };
152 if v.tenant == "common" {
153 return Err("tenant 'common' is not allowed".into());
154 }
155 for (x, n) in [
156 (&v.tenant, "tenant"),
157 (&v.subscription_id, "subscription id"),
158 (&v.resource_group, "resource group"),
159 (&v.resource_name, "resource name"),
160 (&v.deployment, "deployment"),
161 ] {
162 simple(x, n)?;
163 }
164 Ok(v)
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct AwsBedrockConfig {
170 pub sso_start_url: String,
171 pub sso_region: String,
172 pub account_id: String,
173 pub role_name: String,
174 pub bedrock_region: String,
175}
176impl AwsBedrockConfig {
177 pub fn new(
178 sso_start_url: impl Into<String>,
179 sso_region: impl Into<String>,
180 account_id: impl Into<String>,
181 role_name: impl Into<String>,
182 bedrock_region: impl Into<String>,
183 ) -> Result<Self, String> {
184 let v = Self {
185 sso_start_url: sso_start_url.into(),
186 sso_region: sso_region.into(),
187 account_id: account_id.into(),
188 role_name: role_name.into(),
189 bedrock_region: bedrock_region.into(),
190 };
191 let url = url::Url::parse(&v.sso_start_url).map_err(|_| "invalid SSO start URL")?;
192 if url.scheme() != "https" || url.host_str().is_none() {
193 return Err("SSO start URL must be HTTPS".into());
194 }
195 region(&v.sso_region)?;
196 region(&v.bedrock_region)?;
197 if v.account_id.len() != 12 || !v.account_id.bytes().all(|b| b.is_ascii_digit()) {
198 return Err("invalid AWS account id".into());
199 }
200 simple(&v.role_name, "role name")?;
201 Ok(v)
202 }
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206pub struct GoogleVertexConfig {
207 pub project_id: String,
208 pub location: String,
209}
210impl GoogleVertexConfig {
211 pub fn new(project_id: impl Into<String>, location: impl Into<String>) -> Result<Self, String> {
212 let v = Self {
213 project_id: project_id.into(),
214 location: location.into(),
215 };
216 region(&v.location)?;
217 if v.project_id.len() < 6
218 || v.project_id.len() > 30
219 || !v
220 .project_id
221 .bytes()
222 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
223 {
224 return Err("invalid Google project id".into());
225 }
226 Ok(v)
227 }
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(tag = "operation", rename_all = "snake_case")]
232pub enum BrokerOperation {
233 Catalog {
234 provider: CloudProviderId,
235 context_ref: String,
236 #[serde(default)]
237 allow_stale: bool,
238 },
239 Invoke {
240 provider: CloudProviderId,
241 context_ref: String,
242 model_id: String,
243 request: InvokeRequest,
244 },
245}
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247pub struct InvokeRequest {
248 pub messages: Vec<BrokerMessage>,
249 #[serde(default)]
250 pub tools: Vec<BrokerTool>,
251 pub stream: bool,
252 #[serde(default)]
253 pub options: InvokeOptions,
254}
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256pub struct BrokerMessage {
257 pub role: MessageRole,
258 pub content: String,
259}
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(rename_all = "snake_case")]
262pub enum MessageRole {
263 System,
264 User,
265 Assistant,
266 Tool,
267}
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
269pub struct BrokerTool {
270 pub name: String,
271 pub description: String,
272 pub input_schema: serde_json::Value,
273}
274#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
275pub struct InvokeOptions {
276 pub max_output_tokens: Option<u32>,
277 pub temperature_milli: Option<u16>,
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub struct CloudProviderDescriptor {
282 pub id: CloudProviderId,
283 pub display_name: &'static str,
284 pub typed_broker_only: bool,
286 pub registration_required: bool,
288 pub supports_tools: bool,
291}
292
293pub const CLOUD_PROVIDER_DESCRIPTORS: [CloudProviderDescriptor; 3] = [
294 CloudProviderDescriptor {
295 id: CloudProviderId::AzureOpenAi,
296 display_name: "Azure OpenAI",
297 typed_broker_only: true,
298 registration_required: true,
299 supports_tools: false,
300 },
301 CloudProviderDescriptor {
302 id: CloudProviderId::AwsBedrock,
303 display_name: "AWS Bedrock",
304 typed_broker_only: true,
305 registration_required: false,
306 supports_tools: false,
307 },
308 CloudProviderDescriptor {
309 id: CloudProviderId::GoogleVertex,
310 display_name: "Google Vertex",
311 typed_broker_only: true,
312 registration_required: true,
313 supports_tools: false,
314 },
315];
316
317pub fn cloud_provider_descriptors() -> &'static [CloudProviderDescriptor] {
318 &CLOUD_PROVIDER_DESCRIPTORS
319}
320
321#[cfg(test)]
322mod descriptor_tests {
323 use super::*;
324
325 #[test]
326 fn cloud_descriptors_are_complete_and_never_vend_credentials() {
327 let descriptors = cloud_provider_descriptors();
328 assert_eq!(descriptors.len(), 3);
329 assert_eq!(descriptors[0].id.as_str(), "azure-openai");
330 assert_eq!(descriptors[1].id.as_str(), "aws-bedrock");
331 assert_eq!(descriptors[2].id.as_str(), "google-vertex");
332 assert!(descriptors.iter().all(|d| d.typed_broker_only));
333 assert_eq!(
334 descriptors
335 .iter()
336 .filter(|d| d.registration_required)
337 .map(|d| d.id)
338 .collect::<Vec<_>>(),
339 vec![CloudProviderId::AzureOpenAi, CloudProviderId::GoogleVertex]
340 );
341 }
342
343 #[test]
346 fn cloud_routes_advertise_text_only() {
347 for descriptor in cloud_provider_descriptors() {
348 assert!(
349 !descriptor.supports_tools,
350 "{} must advertise text-only until tool translation lands",
351 descriptor.id
352 );
353 assert!(!descriptor.id.supports_tools());
354 }
355 }
356}