Skip to main content

agent_trace_contract/
lib.rs

1//! HTTP API contract for the Trace Infra service.
2//!
3//! # Why trace is its own service and not part of History Infra
4//!
5//! Conversation messages and trace events look similar on the wire and behave
6//! nothing alike:
7//!
8//! | | Conversation | Trace |
9//! | --- | --- | --- |
10//! | Replayed into a model prompt | yes | **never** |
11//! | Losing one record | loses user work | acceptable |
12//! | Volume | one per turn | many per turn |
13//! | Retention | durable | short |
14//!
15//! Sharing one store would force the stricter guarantee onto high-volume
16//! diagnostic data, and — worse — put events one filtering mistake away from
17//! being replayed as conversation. Separate services keep that impossible by
18//! construction.
19//!
20//! # Layering
21//!
22//! ```text
23//! agent-trace-contract   paths + DTOs + redaction   (this crate)
24//! agent-trace-runtime    store implementation
25//! agent-trace-server     router + auth + bin
26//! ```
27//!
28//! See [`api.md`](../api.md) for the full HTTP reference.
29
30use serde::{Deserialize, Serialize};
31use serde_json::Value;
32
33use agent_registry_contract::{AgentId, ResourceRelations};
34
35pub const SERVICE_NAME: &str = "agent-trace-infra";
36pub const DEFAULT_PORT: u16 = 5140;
37pub const AUDIENCE: &str = "agent-trace-infra";
38
39/// Optional static service token. When unset, the server runs in open mode.
40pub const SERVICE_TOKEN_ENV: &str = "AGENT_TRACE_INFRA_SERVICE_TOKEN";
41
42pub const HEALTH_PATH: &str = "/health";
43pub const EVENTS_APPEND_PATH: &str = "/internal/v1/trace-infra/events/batch";
44pub const EVENTS_SEARCH_PATH: &str = "/internal/v1/trace-infra/events/search";
45
46// ── Langfuse project administration ───────────────────────────────────
47pub const LANGFUSE_PROJECTS_PATH: &str = "/internal/v1/trace-infra/langfuse-projects";
48pub const LANGFUSE_PROJECT_PATH: &str = "/internal/v1/trace-infra/langfuse-projects/{projectId}";
49pub const ADMIN_LANGFUSE_PROJECTS_PATH: &str = "/admin/api/langfuse-projects";
50pub const MAX_LANGFUSE_PROJECT_PAGE_SIZE: u32 = 200;
51pub const RUNTIME_BINDING_PATH: &str = "/internal/v1/trace-infra/runtime-bindings/{runtimeId}";
52pub const RUNTIME_BINDING_INITIALIZE_PATH: &str =
53    "/internal/v1/trace-infra/runtime-bindings/{runtimeId}/initialize";
54pub const ADMIN_LANGFUSE_CONFIG_PATH: &str = "/internal/v1/trace-infra/langfuse-config";
55pub const ADMIN_CLICKHOUSE_CONFIG_PATH: &str = "/internal/v1/trace-infra/clickhouse-config";
56pub const ADMIN_GATEWAY_LANGFUSE_CONFIG_PATH: &str = "/admin/api/langfuse-config";
57pub const ADMIN_GATEWAY_CLICKHOUSE_CONFIG_PATH: &str = "/admin/api/clickhouse-config";
58
59/// Allocate a Langfuse project and project-level API key to Infra resources.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(deny_unknown_fields, rename_all = "camelCase")]
62pub struct CreateLangfuseProjectRequest {
63    pub name: String,
64    pub owner_email: String,
65    #[serde(default)]
66    pub relations: ResourceRelations,
67}
68
69/// Query the project-to-resource relationships maintained by Trace Infra.
70#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(deny_unknown_fields, rename_all = "camelCase")]
72pub struct ListLangfuseProjectsRequest {
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub related_agent_id: Option<AgentId>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub related_runtime_id: Option<String>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub related_user_id: Option<String>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub cursor: Option<String>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub limit: Option<u32>,
83}
84
85/// Persisted Langfuse project assignment. Project secrets are intentionally
86/// absent from lookup/list responses.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub struct LangfuseProject {
90    pub id: String,
91    pub name: String,
92    pub owner_email: String,
93    pub public_key: String,
94    pub base_url: String,
95    #[serde(default)]
96    pub relations: ResourceRelations,
97    pub created_at_ms: u64,
98}
99
100#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(deny_unknown_fields, rename_all = "camelCase")]
102pub struct InitializeRuntimeBindingRequest {
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub project_name: Option<String>,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub agent_id: Option<AgentId>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub owner_email: Option<String>,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase")]
113pub struct RuntimeLangfuseBinding {
114    pub runtime_id: String,
115    pub project_name: String,
116    pub langfuse_project_id: String,
117    pub public_key: String,
118    pub status: String,
119}
120
121/// Redacted platform Langfuse organization credentials. Secrets are never serialized.
122#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "camelCase")]
124pub struct LangfuseAdminConfig {
125    #[serde(default)]
126    pub base_url: String,
127    pub public_key_configured: bool,
128    pub secret_key_configured: bool,
129    pub managed: bool,
130}
131
132/// Redacted platform ClickHouse connection. Secrets are never serialized.
133#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase")]
135pub struct ClickHouseAdminConfig {
136    #[serde(default)]
137    pub host: String,
138    #[serde(default)]
139    pub port: String,
140    #[serde(default)]
141    pub database: String,
142    #[serde(default)]
143    pub user: String,
144    pub password_configured: bool,
145    pub managed: bool,
146}
147
148#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "camelCase", deny_unknown_fields)]
150pub struct UpdateLangfuseAdminConfigRequest {
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub base_url: Option<String>,
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub public_key: Option<String>,
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub secret_key: Option<String>,
157    #[serde(default)]
158    pub clear_public_key: bool,
159    #[serde(default)]
160    pub clear_secret_key: bool,
161}
162
163#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(rename_all = "camelCase", deny_unknown_fields)]
165pub struct UpdateClickHouseAdminConfigRequest {
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub host: Option<String>,
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub port: Option<String>,
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub database: Option<String>,
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub user: Option<String>,
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub password: Option<String>,
176    #[serde(default)]
177    pub clear_password: bool,
178}
179
180/// Creation response. The secret can be recovered only through an idempotent
181/// replay of the same create request; ordinary project queries redact it.
182#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(rename_all = "camelCase")]
184pub struct ProvisionedLangfuseProject {
185    pub project: LangfuseProject,
186    pub secret_key: String,
187}
188
189impl std::fmt::Debug for ProvisionedLangfuseProject {
190    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191        formatter
192            .debug_struct("ProvisionedLangfuseProject")
193            .field("project", &self.project)
194            .field("secret_key", &"[REDACTED]")
195            .finish()
196    }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "camelCase")]
201pub struct LangfuseProjectPage {
202    pub items: Vec<LangfuseProject>,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub next_cursor: Option<String>,
205}
206
207// ── Langfuse token-usage metrics ───────────────────────────────────────
208pub const METRICS_TOKEN_USAGE_PATH: &str = "/internal/v1/trace-infra/metrics/token-usage";
209pub const METRICS_MODEL_BREAKDOWN_PATH: &str = "/internal/v1/trace-infra/metrics/model-breakdown";
210pub const METRICS_PROJECT_SUMMARY_PATH: &str = "/internal/v1/trace-infra/metrics/project-summary";
211pub const METRICS_LATENCY_PATH: &str = "/internal/v1/trace-infra/metrics/latency";
212pub const METRICS_ERROR_RATE_PATH: &str = "/internal/v1/trace-infra/metrics/error-rate";
213pub const METRICS_CACHE_HIT_RATE_PATH: &str = "/internal/v1/trace-infra/metrics/cache-hit-rate";
214pub const METRICS_COST_BREAKDOWN_PATH: &str = "/internal/v1/trace-infra/metrics/cost-breakdown";
215pub const METRICS_MODEL_TREND_PATH: &str = "/internal/v1/trace-infra/metrics/model-trend";
216pub const METRICS_TOKEN_DISTRIBUTION_PATH: &str =
217    "/internal/v1/trace-infra/metrics/token-distribution";
218pub const METRICS_ACTIVITY_PATH: &str = "/internal/v1/trace-infra/metrics/activity";
219pub const METRICS_PRICING_TIER_PATH: &str = "/internal/v1/trace-infra/metrics/pricing-tier";
220pub const MAX_METRICS_LOOKBACK_DAYS: u32 = 3_650;
221
222/// Identifies what a trace event belongs to.
223///
224/// `run_id` is what makes a trace useful: it groups every event emitted by one
225/// turn, so a single execution can be reconstructed without guessing from
226/// timestamps.
227#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(rename_all = "camelCase")]
229pub struct TraceScope {
230    pub conversation_id: String,
231    /// Stable Runtime Identity used by control-plane consumers to browse traces.
232    /// Older producers may omit it; conversation/run lookup remains compatible.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub runtime_id: Option<String>,
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub run_id: Option<String>,
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub user_id: Option<String>,
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub agent_id: Option<String>,
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub agent_space_id: Option<String>,
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub runtime_type: Option<String>,
245}
246
247impl TraceScope {
248    pub fn run(conversation_id: impl Into<String>, run_id: impl Into<String>) -> Self {
249        Self {
250            conversation_id: conversation_id.into(),
251            run_id: Some(run_id.into()),
252            ..Default::default()
253        }
254    }
255}
256
257/// One runtime trace event.
258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
259#[serde(rename_all = "camelCase")]
260pub struct RuntimeEventRecord {
261    /// Caller supplied id. Enables idempotent append, so a retry after a lost
262    /// response cannot duplicate a turn's trace.
263    pub id: String,
264    pub scope: TraceScope,
265    /// Coarse kind, e.g. `turn_started`, `tool_call`, `guard_rail`, `turn_finished`.
266    pub event_type: String,
267    #[serde(default)]
268    pub payload: Value,
269    /// Monotonic order within a run. Ties break by `created_at_ms`.
270    #[serde(default)]
271    pub sequence: i64,
272    #[serde(default)]
273    pub created_at_ms: i64,
274    #[serde(default, skip_serializing_if = "Value::is_null")]
275    pub metadata: Value,
276}
277
278#[derive(Debug, Clone, Default, Serialize, Deserialize)]
279#[serde(rename_all = "camelCase")]
280pub struct AppendEventsRequest {
281    pub events: Vec<RuntimeEventRecord>,
282}
283
284/// Result of an append. `skipped` counts ids that already existed, so a caller
285/// can tell a fresh write from a deduplicated retry.
286#[derive(Debug, Clone, Default, Serialize, Deserialize)]
287#[serde(rename_all = "camelCase")]
288pub struct AppendResponse {
289    pub appended: usize,
290    pub skipped: usize,
291}
292
293#[derive(Debug, Clone, Default, Serialize, Deserialize)]
294#[serde(rename_all = "camelCase")]
295pub struct SearchEventsRequest {
296    pub scope: TraceScope,
297    /// When set, only events of this type are returned.
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub event_type: Option<String>,
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub limit: Option<usize>,
302}
303
304/// Events in chronological order, oldest first.
305///
306/// The order is part of the contract: a client that has to re-sort is a client
307/// that will eventually get it wrong.
308#[derive(Debug, Clone, Default, Serialize, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct EventsResponse {
311    pub items: Vec<RuntimeEventRecord>,
312}
313
314/// Upper bound the server enforces on any page size.
315pub const MAX_PAGE_SIZE: usize = 1_000;
316
317/// Default page size when a request omits `limit`.
318pub const DEFAULT_PAGE_SIZE: usize = 100;
319
320/// Error body shared by every non-2xx response.
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct ApiError {
323    pub status: u16,
324    pub message: String,
325}
326
327// ── Langfuse token-usage metrics DTOs ──────────────────────────────────
328
329/// Which ClickHouse table to query.
330#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(rename_all = "camelCase")]
332pub enum MetricsDataSource {
333    /// Legacy `observations` + `traces` table (v3 path).
334    #[serde(rename = "v3")]
335    V3,
336    /// New `events_core` / `events_full` table (v4 path).
337    #[serde(rename = "v4")]
338    V4,
339    /// Auto-detect: use v4 for dates >= cutover, v3 for dates < cutover.
340    #[serde(rename = "auto")]
341    #[default]
342    Auto,
343}
344
345/// Request body for all metrics queries.
346#[derive(Debug, Clone, Serialize, Deserialize)]
347#[serde(rename_all = "camelCase")]
348pub struct MetricsQuery {
349    /// Project IDs to filter on (defaults to all configured projects).
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub project_ids: Option<Vec<String>>,
352    /// Number of days to look back (defaults to 30).
353    #[serde(default = "default_days")]
354    pub days: u32,
355    /// Which data source to query.
356    #[serde(default)]
357    pub data_source: MetricsDataSource,
358    /// Cutover date for v4 migration (ISO date string, e.g. "2026-09-01").
359    /// Only used when `data_source` is `auto`.
360    #[serde(default = "default_v4_cutover")]
361    pub v4_cutover_date: String,
362}
363
364impl Default for MetricsQuery {
365    fn default() -> Self {
366        Self {
367            project_ids: None,
368            days: default_days(),
369            data_source: MetricsDataSource::default(),
370            v4_cutover_date: default_v4_cutover(),
371        }
372    }
373}
374
375fn default_v4_cutover() -> String {
376    "2026-09-01 00:00:00".to_string()
377}
378
379const fn default_days() -> u32 {
380    30
381}
382
383/// A single data point in a token-usage timeseries.
384#[derive(Debug, Clone, Serialize, Deserialize)]
385#[serde(rename_all = "camelCase")]
386pub struct TokenUsagePoint {
387    pub day: String,
388    pub total_input_tokens: u64,
389    pub total_output_tokens: u64,
390    pub total_tokens: u64,
391    pub observation_count: u64,
392    pub generation_count: u64,
393    pub total_cost: Option<f64>,
394}
395
396/// Top-level response for the token-usage timeseries endpoint.
397#[derive(Debug, Clone, Serialize, Deserialize)]
398#[serde(rename_all = "camelCase")]
399pub struct TokenUsageResponse {
400    pub project_id: String,
401    pub points: Vec<TokenUsagePoint>,
402}
403
404/// A single model breakdown row.
405#[derive(Debug, Clone, Serialize, Deserialize)]
406#[serde(rename_all = "camelCase")]
407pub struct ModelBreakdownEntry {
408    pub project_id: String,
409    pub model_name: String,
410    pub observation_count: u64,
411    pub total_input_tokens: u64,
412    pub total_output_tokens: u64,
413    pub total_tokens: u64,
414    pub total_cost: Option<f64>,
415    pub pricing_tier: Option<String>,
416}
417
418/// Project-level summary stats.
419#[derive(Debug, Clone, Serialize, Deserialize)]
420#[serde(rename_all = "camelCase")]
421pub struct ProjectSummary {
422    pub project_id: String,
423    pub total_tokens: u64,
424    pub total_input_tokens: u64,
425    pub total_output_tokens: u64,
426    pub observation_count: u64,
427    pub generation_count: u64,
428    pub total_cost: Option<f64>,
429    pub unique_models: u64,
430    pub unique_traces: u64,
431}
432
433/// A single latency data point (daily bucket).
434#[derive(Debug, Clone, Serialize, Deserialize)]
435#[serde(rename_all = "camelCase")]
436pub struct LatencyPoint {
437    pub day: String,
438    pub project_id: String,
439    pub model_name: Option<String>,
440    pub observation_count: u64,
441    /// Average duration (end_time - start_time) in seconds.
442    pub avg_duration_seconds: Option<f64>,
443    /// P50 duration in seconds.
444    pub p50_duration_seconds: Option<f64>,
445    /// P95 duration in seconds.
446    pub p95_duration_seconds: Option<f64>,
447    /// P99 duration in seconds.
448    pub p99_duration_seconds: Option<f64>,
449    /// Average time-to-first-token (completion_start_time - start_time) in seconds.
450    pub avg_ttft_seconds: Option<f64>,
451}
452
453/// Latency response grouped by project.
454#[derive(Debug, Clone, Serialize, Deserialize)]
455#[serde(rename_all = "camelCase")]
456pub struct LatencyResponse {
457    pub project_id: String,
458    pub points: Vec<LatencyPoint>,
459}
460
461/// A single error-rate data point (daily bucket).
462#[derive(Debug, Clone, Serialize, Deserialize)]
463#[serde(rename_all = "camelCase")]
464pub struct ErrorRatePoint {
465    pub day: String,
466    pub project_id: String,
467    pub total_observations: u64,
468    pub error_count: u64,
469    /// error_count / total_observations.
470    pub error_rate: f64,
471    pub model_name: Option<String>,
472}
473
474/// Error rate response grouped by project.
475#[derive(Debug, Clone, Serialize, Deserialize)]
476#[serde(rename_all = "camelCase")]
477pub struct ErrorRateResponse {
478    pub project_id: String,
479    pub points: Vec<ErrorRatePoint>,
480}
481
482// ── Cache hit rate (P0) ────────────────────────────────────────────────
483
484/// Cache hit rate data point.
485#[derive(Debug, Clone, Serialize, Deserialize)]
486#[serde(rename_all = "camelCase")]
487pub struct CacheHitRatePoint {
488    pub project_id: String,
489    pub model_name: Option<String>,
490    pub total_input_tokens: u64,
491    pub cached_tokens: u64,
492    /// cached_tokens / total_input_tokens * 100.
493    pub cache_hit_pct: f64,
494}
495
496/// Cache hit rate response (flat list).
497#[derive(Debug, Clone, Serialize, Deserialize)]
498#[serde(rename_all = "camelCase")]
499pub struct CacheHitRateResponse {
500    pub items: Vec<CacheHitRatePoint>,
501}
502
503// ── Cost breakdown (P1) ────────────────────────────────────────────────
504
505/// Cost breakdown data point (input/output/total).
506#[derive(Debug, Clone, Serialize, Deserialize)]
507#[serde(rename_all = "camelCase")]
508pub struct CostBreakdownPoint {
509    pub project_id: String,
510    pub model_name: Option<String>,
511    pub total_input_cost: Option<f64>,
512    pub total_output_cost: Option<f64>,
513    pub total_cost: Option<f64>,
514    pub observation_count: u64,
515}
516
517/// Cost breakdown response (flat list).
518#[derive(Debug, Clone, Serialize, Deserialize)]
519#[serde(rename_all = "camelCase")]
520pub struct CostBreakdownResponse {
521    pub items: Vec<CostBreakdownPoint>,
522}
523
524// ── Model trend (P1) ───────────────────────────────────────────────────
525
526/// Model usage trend point (daily).
527#[derive(Debug, Clone, Serialize, Deserialize)]
528#[serde(rename_all = "camelCase")]
529pub struct ModelTrendPoint {
530    pub day: String,
531    pub project_id: String,
532    pub model_name: Option<String>,
533    pub call_count: u64,
534    pub total_tokens: u64,
535    pub total_cost: Option<f64>,
536}
537
538/// Model trend response grouped by project.
539#[derive(Debug, Clone, Serialize, Deserialize)]
540#[serde(rename_all = "camelCase")]
541pub struct ModelTrendResponse {
542    pub project_id: String,
543    pub points: Vec<ModelTrendPoint>,
544}
545
546// ── Token distribution (P2) ────────────────────────────────────────────
547
548/// Token distribution statistics (by model).
549#[derive(Debug, Clone, Serialize, Deserialize)]
550#[serde(rename_all = "camelCase")]
551pub struct TokenDistributionPoint {
552    pub project_id: String,
553    pub model_name: Option<String>,
554    pub observation_count: u64,
555    pub avg_tokens: Option<f64>,
556    pub p50_tokens: Option<f64>,
557    pub p95_tokens: Option<f64>,
558    pub p99_tokens: Option<f64>,
559    pub max_tokens: Option<u64>,
560}
561
562/// Token distribution response (flat list).
563#[derive(Debug, Clone, Serialize, Deserialize)]
564#[serde(rename_all = "camelCase")]
565pub struct TokenDistributionResponse {
566    pub items: Vec<TokenDistributionPoint>,
567}
568
569// ── Activity (P2) ──────────────────────────────────────────────────────
570
571/// Activity data point (daily).
572#[derive(Debug, Clone, Serialize, Deserialize)]
573#[serde(rename_all = "camelCase")]
574pub struct ActivityPoint {
575    pub day: String,
576    pub project_id: String,
577    pub active_traces: u64,
578    pub observation_count: u64,
579}
580
581/// Activity response grouped by project.
582#[derive(Debug, Clone, Serialize, Deserialize)]
583#[serde(rename_all = "camelCase")]
584pub struct ActivityResponse {
585    pub project_id: String,
586    pub points: Vec<ActivityPoint>,
587}
588
589// ── Pricing tier analysis (P2) ─────────────────────────────────────────
590
591/// Pricing tier analysis data point.
592#[derive(Debug, Clone, Serialize, Deserialize)]
593#[serde(rename_all = "camelCase")]
594pub struct PricingTierPoint {
595    pub project_id: String,
596    pub pricing_tier: Option<String>,
597    pub model_name: Option<String>,
598    pub observation_count: u64,
599    pub total_tokens: u64,
600    pub total_cost: Option<f64>,
601}
602
603/// Pricing tier response (flat list).
604#[derive(Debug, Clone, Serialize, Deserialize)]
605#[serde(rename_all = "camelCase")]
606pub struct PricingTierResponse {
607    pub items: Vec<PricingTierPoint>,
608}
609
610/// Placeholder written over any value that looks like a credential.
611pub const REDACTED: &str = "<redacted>";
612
613/// Mask credential-shaped fields anywhere in a trace payload.
614///
615/// # Why this lives in the contract
616///
617/// Trace payloads carry tool arguments and model requests, which are exactly
618/// where an API key or bearer token ends up. Traces are also the data most
619/// likely to be shipped to a log aggregator and kept, so an unredacted secret
620/// there outlives the request that leaked it.
621///
622/// Redaction is therefore part of the wire contract rather than a courtesy each
623/// caller might forget: both the client and the server apply it, so a payload
624/// cannot be stored unmasked even if a caller skips the step.
625pub fn redact_payload(value: &mut Value) {
626    match value {
627        Value::Object(fields) => {
628            for (key, field) in fields.iter_mut() {
629                if is_sensitive_key(key) {
630                    *field = Value::String(REDACTED.to_string());
631                } else {
632                    redact_payload(field);
633                }
634            }
635        }
636        Value::Array(items) => items.iter_mut().for_each(redact_payload),
637        _ => {}
638    }
639}
640
641/// True when a field name looks like it holds a credential.
642///
643/// Matching is done on a normalised key (lowercased, separators removed) so
644/// `api_key`, `apiKey`, and `API-KEY` are all caught by one rule.
645fn is_sensitive_key(key: &str) -> bool {
646    let normalised = key
647        .chars()
648        .filter(|character| character.is_ascii_alphanumeric())
649        .flat_map(|character| character.to_lowercase())
650        .collect::<String>();
651
652    const EXACT: &[&str] = &[
653        "apikey",
654        "authorization",
655        "gatewaytoken",
656        "password",
657        "secret",
658        "token",
659    ];
660    if EXACT.contains(&normalised.as_str()) {
661        return true;
662    }
663
664    // Compound names such as `accessToken`, `clientSecret`, `serviceApiKey`.
665    ["apikey", "secret", "token", "password"]
666        .iter()
667        .any(|needle| normalised.ends_with(needle))
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673    use serde_json::json;
674
675    #[test]
676    fn scope_serialises_camel_case_and_omits_absent_provenance() {
677        let value = serde_json::to_value(TraceScope::run("conv-1", "run-1")).expect("serialize");
678        assert_eq!(value, json!({"conversationId": "conv-1", "runId": "run-1"}));
679    }
680
681    #[test]
682    fn redaction_masks_credential_fields_at_any_depth() {
683        let mut payload = json!({
684            "model": "test-model",
685            "api_key": "sensitive-api-value",
686            "headers": {"Authorization": "sensitive-authorization-value"},
687            "calls": [
688                {"accessToken": "sensitive-access-value"},
689                {"clientSecret": "sensitive-client-value"}
690            ],
691            "nested": {"deep": {"apiKey": "sensitive-nested-value"}}
692        });
693
694        redact_payload(&mut payload);
695
696        assert_eq!(payload["model"], "test-model", "non-secrets must survive");
697        assert_eq!(payload["api_key"], REDACTED);
698        assert_eq!(payload["headers"]["Authorization"], REDACTED);
699        assert_eq!(payload["calls"][0]["accessToken"], REDACTED);
700        assert_eq!(payload["calls"][1]["clientSecret"], REDACTED);
701        assert_eq!(payload["nested"]["deep"]["apiKey"], REDACTED);
702    }
703
704    #[test]
705    fn redaction_does_not_mask_lookalike_field_names() {
706        // These read like secrets but carry no credential; masking them would
707        // quietly destroy useful trace data.
708        let mut payload = json!({
709            "tokenCount": 42,
710            "secretsManagerArn": "arn:aws:secretsmanager:...",
711            "passwordPolicy": {"minLength": 12}
712        });
713
714        redact_payload(&mut payload);
715
716        assert_eq!(payload["tokenCount"], 42);
717        assert_eq!(
718            payload["secretsManagerArn"], "arn:aws:secretsmanager:...",
719            "a name that merely contains 'secret' is not a secret"
720        );
721        assert_eq!(payload["passwordPolicy"]["minLength"], 12);
722    }
723
724    #[test]
725    fn an_event_round_trips_through_json() {
726        let record = RuntimeEventRecord {
727            id: "e-1".into(),
728            scope: TraceScope::run("conv-1", "run-1"),
729            event_type: "tool_call".into(),
730            payload: json!({"name": "execute_cmd"}),
731            sequence: 3,
732            created_at_ms: 1_700_000_000_000,
733            metadata: Value::Null,
734        };
735        let encoded = serde_json::to_value(&record).expect("serialize");
736        assert_eq!(encoded["eventType"], "tool_call");
737        assert_eq!(encoded["createdAtMs"], 1_700_000_000_000_i64);
738        let decoded: RuntimeEventRecord = serde_json::from_value(encoded).expect("deserialize");
739        assert_eq!(decoded, record);
740    }
741}