Skip to main content

agent_model_contract/
lib.rs

1//! Transport-neutral domain contract for Model Infra.
2
3mod usage;
4pub use usage::{TokenUsage, TokenUsageCoverage};
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::fmt;
8
9use agent_runtime_identity_contract::{
10    CredentialCapability, RuntimeIdentityId, TenantProjectScope,
11};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16pub const SERVICE_NAME: &str = "agent-model-infra";
17pub const AUDIENCE: &str = "agent-model-infra";
18pub const DEFAULT_PORT: u16 = 5130;
19
20macro_rules! identifier {
21    ($name:ident, $label:literal, $max:expr) => {
22        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
23        #[serde(transparent)]
24        pub struct $name(String);
25
26        impl $name {
27            pub fn new(value: impl Into<String>) -> Result<Self, String> {
28                let value = value.into();
29                validate_identifier($label, &value, $max)?;
30                Ok(Self(value))
31            }
32
33            pub fn as_str(&self) -> &str {
34                &self.0
35            }
36        }
37
38        impl fmt::Display for $name {
39            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40                self.0.fmt(formatter)
41            }
42        }
43    };
44}
45
46identifier!(ModelProfileId, "model_profile_id", 128);
47identifier!(ModelConfigId, "model_config_id", 128);
48identifier!(InvocationId, "invocation_id", 255);
49
50#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(transparent)]
52pub struct SecretRef(String);
53
54impl SecretRef {
55    pub fn new(value: impl Into<String>) -> Result<Self, String> {
56        let value = value.into();
57        if !value.starts_with("secret://") || value.len() > 512 || value.len() <= 9 {
58            return Err("credential_ref must be a secret:// reference of at most 512 bytes".into());
59        }
60        Ok(Self(value))
61    }
62
63    pub fn as_str(&self) -> &str {
64        &self.0
65    }
66}
67
68impl fmt::Debug for SecretRef {
69    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
70        formatter.write_str("SecretRef([REDACTED])")
71    }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum ModelApiFamily {
77    OpenAi,
78    Claude,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum ModelProfileStatus {
84    Draft,
85    Active,
86    Disabled,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum ProviderKind {
92    OpenAiCompatible,
93    Anthropic,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum Modality {
99    Text,
100    Image,
101    Audio,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "camelCase", deny_unknown_fields)]
106pub struct ModelCapabilities {
107    pub input_modalities: BTreeSet<Modality>,
108    pub output_modalities: BTreeSet<Modality>,
109    pub tools: bool,
110    pub structured_output: bool,
111    pub reasoning: bool,
112    pub logprobs: bool,
113    pub token_count: bool,
114    pub realtime: bool,
115}
116
117impl Default for ModelCapabilities {
118    fn default() -> Self {
119        Self {
120            input_modalities: BTreeSet::from([Modality::Text]),
121            output_modalities: BTreeSet::from([Modality::Text]),
122            tools: true,
123            structured_output: false,
124            reasoning: false,
125            logprobs: false,
126            token_count: true,
127            realtime: false,
128        }
129    }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase", deny_unknown_fields)]
134pub struct ProviderEndpoint {
135    pub id: String,
136    pub base_url: String,
137    pub priority: u16,
138    pub weight: u16,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub region: Option<String>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "camelCase", deny_unknown_fields)]
145pub struct ModelPolicy {
146    pub connect_timeout_ms: u64,
147    pub first_event_timeout_ms: u64,
148    pub total_timeout_ms: u64,
149    pub stream_idle_timeout_ms: u64,
150    pub max_output_tokens: u32,
151    pub max_request_bytes: usize,
152    pub max_stream_event_bytes: usize,
153    pub max_concurrency: usize,
154}
155
156impl Default for ModelPolicy {
157    fn default() -> Self {
158        Self {
159            connect_timeout_ms: 3_000,
160            first_event_timeout_ms: 30_000,
161            total_timeout_ms: 300_000,
162            stream_idle_timeout_ms: 60_000,
163            max_output_tokens: 8_192,
164            max_request_bytes: 2 * 1024 * 1024,
165            max_stream_event_bytes: 256 * 1024,
166            max_concurrency: 64,
167        }
168    }
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(rename_all = "camelCase", deny_unknown_fields)]
173pub struct ModelProfile {
174    pub id: ModelProfileId,
175    pub scope: TenantProjectScope,
176    pub display_name: String,
177    pub public_model_id: String,
178    pub api_families: BTreeSet<ModelApiFamily>,
179    pub capabilities: ModelCapabilities,
180    pub provider: ProviderKind,
181    pub provider_model: String,
182    pub endpoints: Vec<ProviderEndpoint>,
183    pub credential_ref: SecretRef,
184    pub credential_configured: bool,
185    pub policy: ModelPolicy,
186    pub status: ModelProfileStatus,
187    pub revision: u64,
188    pub created_at: DateTime<Utc>,
189    pub updated_at: DateTime<Utc>,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "camelCase", deny_unknown_fields)]
194pub struct CreateModelProfileRequest {
195    pub id: ModelProfileId,
196    pub display_name: String,
197    pub public_model_id: String,
198    pub api_families: BTreeSet<ModelApiFamily>,
199    #[serde(default)]
200    pub capabilities: ModelCapabilities,
201    pub provider: ProviderKind,
202    pub provider_model: String,
203    pub endpoints: Vec<ProviderEndpoint>,
204    pub credential_ref: SecretRef,
205    #[serde(default)]
206    pub policy: ModelPolicy,
207    pub idempotency_key: String,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "camelCase", deny_unknown_fields)]
212pub struct UpdateModelProfileRequest {
213    pub expected_revision: u64,
214    pub display_name: String,
215    pub public_model_id: String,
216    pub api_families: BTreeSet<ModelApiFamily>,
217    pub capabilities: ModelCapabilities,
218    pub provider: ProviderKind,
219    pub provider_model: String,
220    pub endpoints: Vec<ProviderEndpoint>,
221    pub credential_ref: SecretRef,
222    pub policy: ModelPolicy,
223    pub idempotency_key: String,
224}
225
226/// Provider connection shared by platform and Agent-owned model configuration.
227/// Credentials are deliberately excluded and are accepted only on write requests.
228#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
229#[serde(rename_all = "camelCase", deny_unknown_fields)]
230pub struct ModelConnectionConfig {
231    pub provider: ProviderKind,
232    pub base_url: String,
233    pub api_models: Vec<String>,
234    pub default_model: String,
235    pub api_families: BTreeSet<ModelApiFamily>,
236    #[serde(default)]
237    pub capabilities: ModelCapabilities,
238    #[serde(default)]
239    pub policy: ModelPolicy,
240}
241
242#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
243#[serde(rename_all = "camelCase", deny_unknown_fields)]
244pub struct AgentModelConfig {
245    pub id: ModelConfigId,
246    pub scope: TenantProjectScope,
247    pub agent_id: agent_registry_contract::AgentId,
248    pub display_name: String,
249    pub connection: ModelConnectionConfig,
250    pub api_key_configured: bool,
251    pub enabled: bool,
252    pub revision: u64,
253    pub created_at: DateTime<Utc>,
254    pub updated_at: DateTime<Utc>,
255}
256
257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
258#[serde(rename_all = "camelCase", deny_unknown_fields)]
259pub struct BuiltinModelConfig {
260    pub id: ModelConfigId,
261    pub display_name: String,
262    pub connection: ModelConnectionConfig,
263    pub api_key_configured: bool,
264    pub enabled: bool,
265    pub is_default: bool,
266    pub revision: u64,
267    pub created_at: DateTime<Utc>,
268    pub updated_at: DateTime<Utc>,
269}
270
271#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(transparent)]
273pub struct WriteOnlyApiKey(String);
274
275impl WriteOnlyApiKey {
276    pub fn new(value: impl Into<String>) -> Result<Self, String> {
277        let value = value.into();
278        if value.trim().is_empty() || value.len() > 16 * 1024 {
279            return Err("api_key must be 1..=16384 bytes".into());
280        }
281        Ok(Self(value))
282    }
283
284    pub fn expose(&self) -> &str {
285        &self.0
286    }
287}
288
289impl fmt::Debug for WriteOnlyApiKey {
290    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
291        formatter.write_str("WriteOnlyApiKey([REDACTED])")
292    }
293}
294
295#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
296#[serde(rename_all = "camelCase", deny_unknown_fields)]
297pub struct UpsertAgentModelConfigRequest {
298    pub display_name: String,
299    pub connection: ModelConnectionConfig,
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub api_key: Option<WriteOnlyApiKey>,
302    #[serde(default = "default_true")]
303    pub enabled: bool,
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub expected_revision: Option<u64>,
306}
307
308#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
309#[serde(rename_all = "camelCase", deny_unknown_fields)]
310pub struct UpsertBuiltinModelConfigRequest {
311    pub display_name: String,
312    pub connection: ModelConnectionConfig,
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub api_key: Option<WriteOnlyApiKey>,
315    #[serde(default = "default_true")]
316    pub enabled: bool,
317    #[serde(default)]
318    pub is_default: bool,
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub expected_revision: Option<u64>,
321}
322
323fn default_true() -> bool {
324    true
325}
326
327pub const PLATFORM_ADMIN_ROLE: &str = "platform_admin";
328
329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
330#[serde(rename_all = "camelCase", deny_unknown_fields)]
331pub struct PlatformAdminUser {
332    pub username: String,
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub user_id: Option<String>,
335    pub role: String,
336    pub active: bool,
337    pub created_at: DateTime<Utc>,
338    pub updated_at: DateTime<Utc>,
339}
340
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342#[serde(rename_all = "camelCase", deny_unknown_fields)]
343pub struct CreatePlatformAdminUserRequest {
344    pub username: String,
345}
346
347#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
348#[serde(rename_all = "camelCase", deny_unknown_fields)]
349pub struct UpdatePlatformAdminUserRequest {
350    pub active: bool,
351}
352
353#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
354#[serde(rename_all = "camelCase")]
355pub struct PlatformAdminUserPage {
356    pub items: Vec<PlatformAdminUser>,
357}
358
359pub fn parse_admin_username(value: &str) -> Result<String, String> {
360    let value = value.trim();
361    validate_identifier("username", value, 255)?;
362    Ok(value.to_owned())
363}
364
365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
366#[serde(rename_all = "camelCase", deny_unknown_fields)]
367pub struct ProfileCommandRequest {
368    pub expected_revision: u64,
369    pub idempotency_key: String,
370}
371
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(rename_all = "camelCase", deny_unknown_fields)]
374pub struct ListModelProfilesRequest {
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub status: Option<ModelProfileStatus>,
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub after: Option<ModelProfileId>,
379    #[serde(default = "default_list_limit")]
380    pub limit: usize,
381}
382
383impl Default for ListModelProfilesRequest {
384    fn default() -> Self {
385        Self {
386            status: None,
387            after: None,
388            limit: default_list_limit(),
389        }
390    }
391}
392
393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
394#[serde(rename_all = "camelCase", deny_unknown_fields)]
395pub struct ListModelProfilesResponse {
396    pub items: Vec<ModelProfile>,
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub next: Option<ModelProfileId>,
399}
400
401#[derive(Debug, Clone, PartialEq, Eq)]
402pub struct ModelActor {
403    pub scope: TenantProjectScope,
404    pub runtime_identity_id: RuntimeIdentityId,
405    pub capabilities: Vec<CredentialCapability>,
406    pub credential_expires_at_seconds: Option<u64>,
407    pub relations: agent_registry_contract::ResourceRelations,
408}
409
410#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
411#[serde(rename_all = "snake_case")]
412pub enum CanonicalRole {
413    Developer,
414    System,
415    User,
416    Assistant,
417    Tool,
418}
419
420#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
421#[serde(tag = "type", rename_all = "snake_case")]
422pub enum CanonicalContent {
423    Text {
424        text: String,
425    },
426    Image {
427        source: Value,
428    },
429    ToolUse {
430        id: String,
431        name: String,
432        input: Value,
433    },
434    ToolResult {
435        tool_use_id: String,
436        content: Value,
437    },
438    Audio {
439        data: String,
440        format: String,
441    },
442    Thinking {
443        text: String,
444    },
445}
446
447#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
448#[serde(rename_all = "camelCase")]
449pub struct CanonicalMessage {
450    pub role: CanonicalRole,
451    pub content: Vec<CanonicalContent>,
452}
453
454#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
455#[serde(rename_all = "camelCase")]
456pub struct ToolDefinition {
457    pub name: String,
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub description: Option<String>,
460    pub input_schema: Value,
461}
462
463#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
464#[serde(rename_all = "camelCase")]
465pub struct CanonicalModelRequest {
466    pub invocation_id: InvocationId,
467    pub public_model_id: String,
468    pub source_api: ModelApiFamily,
469    pub messages: Vec<CanonicalMessage>,
470    #[serde(default)]
471    pub tools: Vec<ToolDefinition>,
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub tool_choice: Option<Value>,
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub response_format: Option<Value>,
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub temperature: Option<f32>,
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub max_output_tokens: Option<u32>,
480    /// OpenAI-compatible reasoning switch forwarded only by providers that support it.
481    #[serde(default, skip_serializing_if = "Option::is_none")]
482    pub thinking: Option<Value>,
483    /// Provider reasoning budget hint such as `low`, `high`, or `max`.
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub reasoning_effort: Option<String>,
486    #[serde(default)]
487    pub stop_sequences: Vec<String>,
488    #[serde(default)]
489    pub metadata: BTreeMap<String, String>,
490}
491
492#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
493#[serde(rename_all = "camelCase")]
494pub struct CanonicalModelResponse {
495    pub invocation_id: InvocationId,
496    pub provider_request_id: Option<String>,
497    pub output: Vec<CanonicalContent>,
498    pub finish_reason: String,
499    pub usage: TokenUsage,
500}
501
502#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
503#[serde(tag = "type", rename_all = "snake_case")]
504pub enum CanonicalModelEvent {
505    Started {
506        provider_request_id: Option<String>,
507    },
508    TextDelta {
509        delta: String,
510    },
511    ToolUseStart {
512        index: usize,
513        id: String,
514        name: String,
515    },
516    ToolArgumentsDelta {
517        index: usize,
518        delta: String,
519    },
520    Usage {
521        usage: TokenUsage,
522    },
523    Completed {
524        finish_reason: String,
525    },
526    Failed {
527        code: String,
528        message: String,
529    },
530}
531
532#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
533#[serde(rename_all = "snake_case")]
534pub enum InvocationChannel {
535    Http,
536    Sse,
537    Websocket,
538}
539
540#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
541#[serde(rename_all = "snake_case")]
542pub enum InvocationState {
543    Accepted,
544    Connecting,
545    Streaming,
546    Completed,
547    Failed,
548    Cancelled,
549}
550
551#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
552#[serde(rename_all = "snake_case")]
553pub enum ModelCredentialSource {
554    Agent,
555    Builtin,
556    #[default]
557    LegacyProfile,
558}
559
560#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
561#[serde(rename_all = "camelCase")]
562pub struct ModelInvocation {
563    pub id: InvocationId,
564    pub scope: TenantProjectScope,
565    pub runtime_identity_id: RuntimeIdentityId,
566    #[serde(default)]
567    pub relations: agent_registry_contract::ResourceRelations,
568    #[serde(default, skip_serializing_if = "Option::is_none")]
569    pub agent_id: Option<agent_registry_contract::AgentId>,
570    #[serde(default)]
571    pub requested_model_id: String,
572    #[serde(default)]
573    pub resolved_model_id: String,
574    #[serde(default)]
575    pub credential_source: ModelCredentialSource,
576    pub profile_id: ModelProfileId,
577    pub profile_revision: u64,
578    pub api_family: ModelApiFamily,
579    pub channel: InvocationChannel,
580    pub state: InvocationState,
581    pub request_digest: String,
582    pub provider_request_id: Option<String>,
583    pub usage: Option<TokenUsage>,
584    pub finish_reason: Option<String>,
585    pub error_code: Option<String>,
586    pub created_at: DateTime<Utc>,
587    pub completed_at: Option<DateTime<Utc>>,
588}
589
590#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
591#[serde(rename_all = "camelCase")]
592pub struct ModelUsageSummary {
593    pub invocation_count: u64,
594    pub completed_count: u64,
595    pub failed_count: u64,
596    pub input_tokens: u64,
597    pub output_tokens: u64,
598    pub builtin_invocation_count: u64,
599    pub agent_invocation_count: u64,
600    #[serde(default)]
601    pub usage_coverage: TokenUsageCoverage,
602}
603
604#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
605#[serde(rename_all = "camelCase")]
606pub struct ModelUsageLedgerEntry {
607    pub invocation_id: InvocationId,
608    #[serde(default, skip_serializing_if = "Option::is_none")]
609    pub agent_id: Option<agent_registry_contract::AgentId>,
610    pub requested_model_id: String,
611    pub resolved_model_id: String,
612    pub credential_source: ModelCredentialSource,
613    pub state: InvocationState,
614    #[serde(default, skip_serializing_if = "Option::is_none")]
615    pub usage: Option<TokenUsage>,
616    pub created_at: DateTime<Utc>,
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub completed_at: Option<DateTime<Utc>>,
619}
620
621#[derive(Debug, Clone, Copy, PartialEq, Eq)]
622pub enum ModelErrorKind {
623    Invalid,
624    Unauthenticated,
625    Forbidden,
626    NotFound,
627    Conflict,
628    RateLimited,
629    Timeout,
630    Overloaded,
631    Upstream,
632    Internal,
633}
634
635#[derive(Debug, Clone, PartialEq, Eq)]
636pub struct ModelError {
637    pub kind: ModelErrorKind,
638    pub code: String,
639    pub message: String,
640    pub param: Option<String>,
641    pub retryable: bool,
642    pub outcome_unknown: bool,
643}
644
645impl ModelError {
646    pub fn new(kind: ModelErrorKind, code: &str, message: impl Into<String>) -> Self {
647        Self {
648            kind,
649            code: code.into(),
650            message: message.into(),
651            param: None,
652            retryable: false,
653            outcome_unknown: false,
654        }
655    }
656
657    pub fn invalid(code: &str, message: impl Into<String>, param: Option<&str>) -> Self {
658        let mut error = Self::new(ModelErrorKind::Invalid, code, message);
659        error.param = param.map(str::to_owned);
660        error
661    }
662}
663
664impl fmt::Display for ModelError {
665    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
666        self.message.fmt(formatter)
667    }
668}
669
670impl std::error::Error for ModelError {}
671
672pub type ModelResult<T> = Result<T, ModelError>;
673
674pub fn capability(profile_id: &ModelProfileId, action: &str) -> String {
675    format!("model-profile:{profile_id}:{action}")
676}
677
678pub fn validate_identifier(label: &str, value: &str, max: usize) -> Result<(), String> {
679    if value.is_empty() || value.len() > max {
680        return Err(format!("{label} must contain 1..={max} bytes"));
681    }
682    if !value.bytes().all(|byte| {
683        byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'.' | b'-' | b'_' | b'@')
684    }) {
685        return Err(format!("{label} contains unsupported characters"));
686    }
687    Ok(())
688}
689
690fn default_list_limit() -> usize {
691    50
692}
693
694#[cfg(test)]
695mod tests {
696    use super::*;
697
698    #[test]
699    fn secret_debug_never_reveals_reference() {
700        let secret = SecretRef::new("secret://model-infra/prod").unwrap();
701        assert_eq!(format!("{secret:?}"), "SecretRef([REDACTED])");
702    }
703
704    #[test]
705    fn profile_body_cannot_override_scope() {
706        let result = serde_json::from_value::<CreateModelProfileRequest>(serde_json::json!({
707            "id":"echo", "tenantId":"attacker"
708        }));
709        assert!(result.is_err());
710    }
711
712    #[test]
713    fn admin_username_accepts_sky_gateway_usernames() {
714        assert_eq!(parse_admin_username(" jinc.cen ").unwrap(), "jinc.cen");
715        assert!(parse_admin_username("").is_err());
716        assert!(parse_admin_username("bad name").is_err());
717    }
718}