1mod 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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub thinking: Option<Value>,
483 #[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}