1use super::AzureOpenAiConfig;
4use serde::{Deserialize, Serialize};
5use std::{collections::HashSet, fmt};
6
7pub const ARM_API_VERSION: &str = "2024-10-01";
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct AzureError {
11 code: &'static str,
12 message: String,
13}
14impl AzureError {
15 fn new(code: &'static str, message: impl Into<String>) -> Self {
16 Self {
17 code,
18 message: message.into(),
19 }
20 }
21 pub fn code(&self) -> &'static str {
22 self.code
23 }
24}
25impl fmt::Display for AzureError {
26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27 write!(f, "{}: {}", self.code, self.message)
28 }
29}
30impl std::error::Error for AzureError {}
31type Result<T> = std::result::Result<T, AzureError>;
32
33#[derive(Clone, PartialEq, Eq)]
34pub struct AzureRegistration {
35 client_id: String,
36}
37impl fmt::Debug for AzureRegistration {
38 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39 f.debug_struct("AzureRegistration")
40 .field("client_id", &"[configured]")
41 .finish()
42 }
43}
44impl AzureRegistration {
45 pub fn production(client_id: Option<String>) -> Result<Self> {
46 match client_id.filter(|v| !v.trim().is_empty()) { Some(id) => Self::validated(id), None => Err(AzureError::new("registration_required", "configure a Synaps-owned Microsoft Entra public-client application ID with device-code flow enabled; no client secret is used")) }
47 }
48 #[doc(hidden)]
49 pub fn test(client_id: &str) -> Result<Self> {
50 Self::validated(client_id.to_owned())
51 }
52 fn validated(client_id: String) -> Result<Self> {
53 if !guid(&client_id) {
54 return Err(AzureError::new(
55 "invalid_client_id",
56 "public client ID must be a UUID",
57 ));
58 }
59 Ok(Self { client_id })
60 }
61 pub fn client_id(&self) -> &str {
62 &self.client_id
63 }
64}
65fn guid(v: &str) -> bool {
66 let p: Vec<_> = v.split('-').collect();
67 p.len() == 5
68 && [8, 4, 4, 4, 12]
69 .iter()
70 .zip(p)
71 .all(|(n, s)| s.len() == *n && s.bytes().all(|b| b.is_ascii_hexdigit()))
72}
73fn segment(v: &str) -> bool {
74 !v.is_empty()
75 && v.len() <= 128
76 && v.bytes()
77 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
81pub enum AzureAudience {
82 Arm,
83 Inference,
84}
85impl AzureAudience {
86 pub const fn scope(self) -> &'static str {
87 match self {
88 Self::Arm => "https://management.azure.com/.default",
89 Self::Inference => "https://cognitiveservices.azure.com/.default",
90 }
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct FormRequest {
96 pub method: &'static str,
97 pub url: String,
98 pub form: Vec<(String, String)>,
99}
100pub fn device_code_request(
101 config: &AzureOpenAiConfig,
102 registration: &AzureRegistration,
103) -> Result<FormRequest> {
104 if config.tenant == "common" || !segment(&config.tenant) {
105 return Err(AzureError::new(
106 "invalid_tenant",
107 "use a tenant UUID or organizations",
108 ));
109 }
110 Ok(FormRequest {
111 method: "POST",
112 url: format!(
113 "https://login.microsoftonline.com/{}/oauth2/v2.0/devicecode",
114 config.tenant
115 ),
116 form: vec![
117 ("client_id".into(), registration.client_id.clone()),
118 ("scope".into(), AzureAudience::Arm.scope().into()),
119 ],
120 })
121}
122pub fn refresh_request(
123 config: &AzureOpenAiConfig,
124 registration: &AzureRegistration,
125 audience: AzureAudience,
126 refresh_token: &str,
127) -> Result<FormRequest> {
128 let mut r = device_code_request(config, registration)?;
129 r.url = r.url.replace("/devicecode", "/token");
130 r.form = vec![
131 ("client_id".into(), registration.client_id.clone()),
132 ("grant_type".into(), "refresh_token".into()),
133 ("refresh_token".into(), refresh_token.into()),
134 ("scope".into(), audience.scope().into()),
135 ];
136 Ok(r)
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum PollReply {
141 AuthorizationPending,
142 SlowDown,
143 Denied,
144}
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum PollAction {
147 Sleep(u64),
148}
149pub struct DevicePoll {
150 interval: u64,
151 expires_at: u64,
152}
153impl DevicePoll {
154 pub fn new(interval: u64, issued_at: u64, expires_in: u64) -> Self {
155 Self {
156 interval: interval.max(1),
157 expires_at: issued_at.saturating_add(expires_in),
158 }
159 }
160 pub fn apply(&mut self, now: u64, cancelled: bool, reply: PollReply) -> Result<PollAction> {
161 if cancelled {
162 return Err(AzureError::new(
163 "cancelled",
164 "device authorization cancelled",
165 ));
166 }
167 if now >= self.expires_at {
168 return Err(AzureError::new(
169 "device_code_expired",
170 "device code expired",
171 ));
172 }
173 match reply {
174 PollReply::AuthorizationPending => Ok(PollAction::Sleep(self.interval)),
175 PollReply::SlowDown => {
176 self.interval = self.interval.saturating_add(5);
177 Ok(PollAction::Sleep(self.interval))
178 }
179 PollReply::Denied => Err(AzureError::new("access_denied", "authorization denied")),
180 }
181 }
182}
183
184#[derive(Clone)]
185pub struct TokenGrant {
186 access: String,
187 refresh: Option<String>,
188 expires_at: u64,
189}
190impl TokenGrant {
191 pub fn new(access: impl Into<String>, refresh: Option<String>, expires_at: u64) -> Self {
192 Self {
193 access: access.into(),
194 refresh,
195 expires_at,
196 }
197 }
198}
199#[derive(Clone, Serialize)]
200pub struct AzureTokenSet {
201 #[serde(skip)]
202 refresh: String,
203 #[serde(skip)]
204 arm: Option<TokenGrant>,
205 #[serde(skip)]
206 inference: Option<TokenGrant>,
207}
208impl fmt::Debug for AzureTokenSet {
209 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210 f.write_str("AzureTokenSet { credentials: [REDACTED] }")
211 }
212}
213impl AzureTokenSet {
214 pub fn new(refresh: impl Into<String>) -> Self {
215 Self {
216 refresh: refresh.into(),
217 arm: None,
218 inference: None,
219 }
220 }
221 pub fn commit(&mut self, a: AzureAudience, mut g: TokenGrant) {
222 if let Some(r) = g.refresh.take() {
223 self.refresh = r;
224 }
225 match a {
226 AzureAudience::Arm => self.arm = Some(g),
227 AzureAudience::Inference => self.inference = Some(g),
228 }
229 }
230 pub fn access(&self, a: AzureAudience, now: u64) -> Result<&str> {
231 let g = match a {
232 AzureAudience::Arm => &self.arm,
233 AzureAudience::Inference => &self.inference,
234 }
235 .as_ref()
236 .ok_or_else(|| AzureError::new("login_required", "audience token absent"))?;
237 if now >= g.expires_at {
238 return Err(AzureError::new("token_expired", "audience token expired"));
239 }
240 Ok(&g.access)
241 }
242 pub fn refresh_token(&self) -> &str {
243 &self.refresh
244 }
245}
246
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct AzureEndpoint(String);
249impl AzureEndpoint {
250 pub fn parse(value: &str) -> Result<Self> {
251 let u = url::Url::parse(value)
252 .map_err(|_| AzureError::new("invalid_endpoint", "invalid Azure endpoint"))?;
253 let host = u.host_str().unwrap_or_default();
254 if u.scheme() != "https"
255 || u.port().is_some()
256 || u.path() != "/"
257 || u.query().is_some()
258 || !(host.ends_with(".openai.azure.com")
259 || host.ends_with(".cognitiveservices.azure.com"))
260 {
261 return Err(AzureError::new(
262 "invalid_endpoint",
263 "endpoint must be an exact Azure OpenAI/Cognitive Services HTTPS origin",
264 ));
265 }
266 Ok(Self(format!("https://{host}")))
267 }
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
271pub struct CatalogEntry {
272 pub id: String,
273 pub display_name: String,
274 pub source: &'static str,
275 pub model: Option<String>,
276 pub version: Option<String>,
277}
278#[derive(Deserialize)]
279struct Page {
280 value: Vec<Deployment>,
281 #[serde(rename = "nextLink")]
282 next_link: Option<String>,
283}
284#[derive(Deserialize)]
285struct Deployment {
286 name: String,
287 properties: Properties,
288}
289#[derive(Deserialize)]
290struct Properties {
291 model: Option<Model>,
292 #[serde(rename = "provisioningState")]
293 state: Option<String>,
294}
295#[derive(Deserialize)]
296struct Model {
297 name: String,
298 version: Option<String>,
299}
300pub struct DeploymentDiscovery {
301 config: AzureOpenAiConfig,
302 max_pages: usize,
303 max_entries: usize,
304 pages: usize,
305 seen: HashSet<String>,
306 entries: Vec<CatalogEntry>,
307}
308impl DeploymentDiscovery {
309 pub fn new(config: AzureOpenAiConfig, max_pages: usize, max_entries: usize) -> Self {
310 Self {
311 config,
312 max_pages,
313 max_entries,
314 pages: 0,
315 seen: HashSet::new(),
316 entries: vec![],
317 }
318 }
319 pub fn initial_url(&self) -> String {
320 format!("https://management.azure.com/subscriptions/{}/resourceGroups/{}/providers/Microsoft.CognitiveServices/accounts/{}/deployments?api-version={ARM_API_VERSION}",self.config.subscription_id,self.config.resource_group,self.config.resource_name)
321 }
322 pub fn accept_page(&mut self, body: &str) -> Result<Option<String>> {
323 if body.len() > 1024 * 1024 || self.pages >= self.max_pages {
324 return Err(AzureError::new(
325 "catalog_limit",
326 "ARM catalog limit exceeded",
327 ));
328 }
329 self.pages += 1;
330 let p: Page = serde_json::from_str(body).map_err(|_| {
331 AzureError::new("malformed_catalog", "malformed ARM deployments response")
332 })?;
333 for d in p.value {
334 if d.properties.state.as_deref() != Some("Succeeded") || !segment(&d.name) {
335 continue;
336 }
337 if self.seen.insert(d.name.clone()) {
338 if self.entries.len() >= self.max_entries {
339 return Err(AzureError::new(
340 "catalog_limit",
341 "deployment limit exceeded",
342 ));
343 }
344 let (m, v) = d
345 .properties
346 .model
347 .map(|m| (Some(m.name), m.version))
348 .unwrap_or_default();
349 self.entries.push(CatalogEntry {
350 id: format!("azure-openai/{}", d.name),
351 display_name: d.name,
352 source: "dynamic",
353 model: m,
354 version: v,
355 });
356 }
357 }
358 match p.next_link {
359 Some(n) => {
360 let u = url::Url::parse(&n)
361 .map_err(|_| AzureError::new("invalid_pagination", "invalid ARM nextLink"))?;
362 if u.scheme() != "https"
363 || u.host_str() != Some("management.azure.com")
364 || !u.path().starts_with("/subscriptions/")
365 {
366 return Err(AzureError::new(
367 "invalid_pagination",
368 "ARM nextLink escaped fixed authority/path",
369 ));
370 }
371 Ok(Some(n))
372 }
373 None => Ok(None),
374 }
375 }
376 pub fn finish(mut self) -> Result<Vec<CatalogEntry>> {
377 if self.entries.is_empty() {
378 return Err(AzureError::new(
379 "empty_catalog",
380 "no callable Azure OpenAI deployments found",
381 ));
382 }
383 self.entries.sort_by(|a, b| a.id.cmp(&b.id));
384 Ok(self.entries)
385 }
386}
387
388#[derive(Debug, Clone, PartialEq, Eq)]
389pub struct RuntimeRequest {
390 pub method: &'static str,
391 pub url: String,
392 pub body: Vec<u8>,
393}
394pub fn responses_request(
395 endpoint: &AzureEndpoint,
396 deployment: &str,
397 body: &[u8],
398) -> Result<RuntimeRequest> {
399 if !segment(deployment) || body.len() > 4 * 1024 * 1024 {
400 return Err(AzureError::new(
401 "invalid_request",
402 "invalid deployment or oversized body",
403 ));
404 }
405 let mut value: serde_json::Value = serde_json::from_slice(body)
406 .map_err(|_| AzureError::new("invalid_request", "Responses body must be JSON"))?;
407 let obj = value
408 .as_object_mut()
409 .ok_or_else(|| AzureError::new("invalid_request", "Responses body must be an object"))?;
410 obj.insert("model".into(), serde_json::Value::String(deployment.into()));
411 let body = serde_json::to_vec(&value).unwrap();
412 Ok(RuntimeRequest {
413 method: "POST",
414 url: format!("{}/openai/v1/responses", endpoint.0),
415 body,
416 })
417}