1use 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#[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
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325#[serde(rename_all = "camelCase", deny_unknown_fields)]
326pub struct ProfileCommandRequest {
327 pub expected_revision: u64,
328 pub idempotency_key: String,
329}
330
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332#[serde(rename_all = "camelCase", deny_unknown_fields)]
333pub struct ListModelProfilesRequest {
334 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub status: Option<ModelProfileStatus>,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub after: Option<ModelProfileId>,
338 #[serde(default = "default_list_limit")]
339 pub limit: usize,
340}
341
342impl Default for ListModelProfilesRequest {
343 fn default() -> Self {
344 Self {
345 status: None,
346 after: None,
347 limit: default_list_limit(),
348 }
349 }
350}
351
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "camelCase", deny_unknown_fields)]
354pub struct ListModelProfilesResponse {
355 pub items: Vec<ModelProfile>,
356 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub next: Option<ModelProfileId>,
358}
359
360#[derive(Debug, Clone, PartialEq, Eq)]
361pub struct ModelActor {
362 pub scope: TenantProjectScope,
363 pub runtime_identity_id: RuntimeIdentityId,
364 pub capabilities: Vec<CredentialCapability>,
365 pub credential_expires_at_seconds: Option<u64>,
366 pub relations: agent_registry_contract::ResourceRelations,
367}
368
369#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
370#[serde(rename_all = "snake_case")]
371pub enum CanonicalRole {
372 Developer,
373 System,
374 User,
375 Assistant,
376 Tool,
377}
378
379#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
380#[serde(tag = "type", rename_all = "snake_case")]
381pub enum CanonicalContent {
382 Text {
383 text: String,
384 },
385 Image {
386 source: Value,
387 },
388 ToolUse {
389 id: String,
390 name: String,
391 input: Value,
392 },
393 ToolResult {
394 tool_use_id: String,
395 content: Value,
396 },
397 Audio {
398 data: String,
399 format: String,
400 },
401 Thinking {
402 text: String,
403 },
404}
405
406#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
407#[serde(rename_all = "camelCase")]
408pub struct CanonicalMessage {
409 pub role: CanonicalRole,
410 pub content: Vec<CanonicalContent>,
411}
412
413#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
414#[serde(rename_all = "camelCase")]
415pub struct ToolDefinition {
416 pub name: String,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub description: Option<String>,
419 pub input_schema: Value,
420}
421
422#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
423#[serde(rename_all = "camelCase")]
424pub struct CanonicalModelRequest {
425 pub invocation_id: InvocationId,
426 pub public_model_id: String,
427 pub source_api: ModelApiFamily,
428 pub messages: Vec<CanonicalMessage>,
429 #[serde(default)]
430 pub tools: Vec<ToolDefinition>,
431 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub tool_choice: Option<Value>,
433 #[serde(default, skip_serializing_if = "Option::is_none")]
434 pub response_format: Option<Value>,
435 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub temperature: Option<f32>,
437 #[serde(default, skip_serializing_if = "Option::is_none")]
438 pub max_output_tokens: Option<u32>,
439 #[serde(default)]
440 pub stop_sequences: Vec<String>,
441 #[serde(default)]
442 pub metadata: BTreeMap<String, String>,
443}
444
445#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
446#[serde(rename_all = "camelCase")]
447pub struct TokenUsage {
448 pub input_tokens: u64,
449 pub output_tokens: u64,
450}
451
452#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
453#[serde(rename_all = "camelCase")]
454pub struct CanonicalModelResponse {
455 pub invocation_id: InvocationId,
456 pub provider_request_id: Option<String>,
457 pub output: Vec<CanonicalContent>,
458 pub finish_reason: String,
459 pub usage: TokenUsage,
460}
461
462#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
463#[serde(tag = "type", rename_all = "snake_case")]
464pub enum CanonicalModelEvent {
465 Started {
466 provider_request_id: Option<String>,
467 },
468 TextDelta {
469 delta: String,
470 },
471 ToolUseStart {
472 index: usize,
473 id: String,
474 name: String,
475 },
476 ToolArgumentsDelta {
477 index: usize,
478 delta: String,
479 },
480 Usage {
481 usage: TokenUsage,
482 },
483 Completed {
484 finish_reason: String,
485 },
486 Failed {
487 code: String,
488 message: String,
489 },
490}
491
492#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
493#[serde(rename_all = "snake_case")]
494pub enum InvocationChannel {
495 Http,
496 Sse,
497 Websocket,
498}
499
500#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
501#[serde(rename_all = "snake_case")]
502pub enum InvocationState {
503 Accepted,
504 Connecting,
505 Streaming,
506 Completed,
507 Failed,
508 Cancelled,
509}
510
511#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
512#[serde(rename_all = "snake_case")]
513pub enum ModelCredentialSource {
514 Agent,
515 Builtin,
516 #[default]
517 LegacyProfile,
518}
519
520#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
521#[serde(rename_all = "camelCase")]
522pub struct ModelInvocation {
523 pub id: InvocationId,
524 pub scope: TenantProjectScope,
525 pub runtime_identity_id: RuntimeIdentityId,
526 #[serde(default)]
527 pub relations: agent_registry_contract::ResourceRelations,
528 #[serde(default, skip_serializing_if = "Option::is_none")]
529 pub agent_id: Option<agent_registry_contract::AgentId>,
530 #[serde(default)]
531 pub requested_model_id: String,
532 #[serde(default)]
533 pub resolved_model_id: String,
534 #[serde(default)]
535 pub credential_source: ModelCredentialSource,
536 pub profile_id: ModelProfileId,
537 pub profile_revision: u64,
538 pub api_family: ModelApiFamily,
539 pub channel: InvocationChannel,
540 pub state: InvocationState,
541 pub request_digest: String,
542 pub provider_request_id: Option<String>,
543 pub usage: Option<TokenUsage>,
544 pub finish_reason: Option<String>,
545 pub error_code: Option<String>,
546 pub created_at: DateTime<Utc>,
547 pub completed_at: Option<DateTime<Utc>>,
548}
549
550#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
551#[serde(rename_all = "camelCase")]
552pub struct ModelUsageSummary {
553 pub invocation_count: u64,
554 pub completed_count: u64,
555 pub failed_count: u64,
556 pub input_tokens: u64,
557 pub output_tokens: u64,
558 pub builtin_invocation_count: u64,
559 pub agent_invocation_count: u64,
560}
561
562#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
563#[serde(rename_all = "camelCase")]
564pub struct ModelUsageLedgerEntry {
565 pub invocation_id: InvocationId,
566 #[serde(default, skip_serializing_if = "Option::is_none")]
567 pub agent_id: Option<agent_registry_contract::AgentId>,
568 pub requested_model_id: String,
569 pub resolved_model_id: String,
570 pub credential_source: ModelCredentialSource,
571 pub state: InvocationState,
572 #[serde(default, skip_serializing_if = "Option::is_none")]
573 pub usage: Option<TokenUsage>,
574 pub created_at: DateTime<Utc>,
575 #[serde(default, skip_serializing_if = "Option::is_none")]
576 pub completed_at: Option<DateTime<Utc>>,
577}
578
579#[derive(Debug, Clone, Copy, PartialEq, Eq)]
580pub enum ModelErrorKind {
581 Invalid,
582 Unauthenticated,
583 Forbidden,
584 NotFound,
585 Conflict,
586 RateLimited,
587 Timeout,
588 Overloaded,
589 Upstream,
590 Internal,
591}
592
593#[derive(Debug, Clone, PartialEq, Eq)]
594pub struct ModelError {
595 pub kind: ModelErrorKind,
596 pub code: String,
597 pub message: String,
598 pub param: Option<String>,
599 pub retryable: bool,
600 pub outcome_unknown: bool,
601}
602
603impl ModelError {
604 pub fn new(kind: ModelErrorKind, code: &str, message: impl Into<String>) -> Self {
605 Self {
606 kind,
607 code: code.into(),
608 message: message.into(),
609 param: None,
610 retryable: false,
611 outcome_unknown: false,
612 }
613 }
614
615 pub fn invalid(code: &str, message: impl Into<String>, param: Option<&str>) -> Self {
616 let mut error = Self::new(ModelErrorKind::Invalid, code, message);
617 error.param = param.map(str::to_owned);
618 error
619 }
620}
621
622impl fmt::Display for ModelError {
623 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
624 self.message.fmt(formatter)
625 }
626}
627
628impl std::error::Error for ModelError {}
629
630pub type ModelResult<T> = Result<T, ModelError>;
631
632pub fn capability(profile_id: &ModelProfileId, action: &str) -> String {
633 format!("model-profile:{profile_id}:{action}")
634}
635
636pub fn validate_identifier(label: &str, value: &str, max: usize) -> Result<(), String> {
637 if value.is_empty() || value.len() > max {
638 return Err(format!("{label} must contain 1..={max} bytes"));
639 }
640 if !value.bytes().all(|byte| {
641 byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'.' | b'-' | b'_' | b'@')
642 }) {
643 return Err(format!("{label} contains unsupported characters"));
644 }
645 Ok(())
646}
647
648fn default_list_limit() -> usize {
649 50
650}
651
652#[cfg(test)]
653mod tests {
654 use super::*;
655
656 #[test]
657 fn secret_debug_never_reveals_reference() {
658 let secret = SecretRef::new("secret://model-infra/prod").unwrap();
659 assert_eq!(format!("{secret:?}"), "SecretRef([REDACTED])");
660 }
661
662 #[test]
663 fn profile_body_cannot_override_scope() {
664 let result = serde_json::from_value::<CreateModelProfileRequest>(serde_json::json!({
665 "id":"echo", "tenantId":"attacker"
666 }));
667 assert!(result.is_err());
668 }
669}