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
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}