Skip to main content

agent_model_contract/
lib.rs

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