dwara_core/config/ai.rs
1//! The `ai:` configuration block (DW-075): AI provider adapters.
2//!
3//! This is the source shape only — the runtime translation lives in the
4//! AI domain one level up (see the `ai` module's `AiRuntime`), which
5//! consumes these types at compile time. The block is ADDITIVE to the
6//! gateway schema: absent (the default), the gateway has no AI surface
7//! at all; present, it declares the provider pool (`providers:`) and
8//! the model alias table (`models:`) that map an OpenAI-shaped client
9//! request onto a concrete provider call.
10//!
11//! Design notes (DW-075):
12//!
13//! - A provider names an UPSTREAM (`upstreams:` entry), not a raw base
14//! URL: the provider's transport — endpoint set, TLS trust, connection
15//! pooling, timeouts, breaker, passive health — is the SAME machinery
16//! every other upstream gets, and the adapter layer stays pure
17//! request/response translation with no HTTP client of its own. A
18//! multi-region provider pool is then just an upstream with several
19//! endpoints.
20//! - Authentication is a verbatim header (`auth.header` + `auth.value`)
21//! rather than a per-provider auth enum: providers disagree on the
22//! convention (OpenAI wants `Authorization: Bearer ...`, Anthropic
23//! `x-api-key: ...`, Gemini `x-goog-api-key: ...`), and a verbatim
24//! pair expresses all of them without the schema growing a variant
25//! per provider. `value` is secret-bearing: inline values are
26//! redacted in every config echo (DW-045) and `${...}` references
27//! resolve at config-compile time, failing the generation closed when
28//! unresolvable.
29//! - `models:` is a map from the MODEL ALIAS the client names in the
30//! request body to the provider that serves it plus the provider's own
31//! model identifier. The alias is what the gateway echoes back in the
32//! response; the provider model never leaks to the client.
33
34use schemars::JsonSchema;
35use serde::{Deserialize, Serialize};
36use std::collections::BTreeMap;
37
38/// Serde skip helper: elide `audit` when false (the default).
39fn is_false(b: &bool) -> bool {
40 !*b
41}
42
43/// The `ai:` top-level block (DW-075). Absent: no AI surface. Present:
44/// validation requires at least one provider and at least one model —
45/// an `ai:` block that can never serve a model is an authoring error,
46/// not a silently inert config.
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
48#[serde(deny_unknown_fields)]
49pub struct AiConfig {
50 /// The provider pool. Each entry names an upstream that carries the
51 /// transport and the adapter `kind` that carries the wire translation.
52 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub providers: Vec<AiProvider>,
54 /// Model alias table: the `model` value a client puts in its request
55 /// body maps to the provider that serves it and the provider's own
56 /// model identifier. The alias is echoed back to the client; the
57 /// `provider_model` never leaves the gateway.
58 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
59 pub models: BTreeMap<String, AiModel>,
60 /// Per-model pricing table (DW-079): the key is the PROVIDER MODEL
61 /// identifier (the actual model the provider charges for, e.g.
62 /// `gpt-4o-mini`), NOT the client-facing alias. Costs are integer
63 /// micro-USD per 1 000 tokens (1 000 000 micro-USD = $1.00 — no
64 /// floating-point money). Spend = usage x price, aggregated per
65 /// key/team/model (DW-079). Absent (the default): no pricing, so
66 /// cost attribution records zero cost (the budget cost window and
67 /// the spend store stay live but inert until prices are declared).
68 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
69 pub pricing: BTreeMap<String, AiPricing>,
70 /// Model governance (DW-084): per-team (policy) model allowlists
71 /// and the shadow-audit switch. Absent (the default): no
72 /// governance — every consumer may call every configured alias,
73 /// and no governance audit events are recorded.
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub governance: Option<AiGovernance>,
76 /// Prompt/response logging (DW-081): opt-in capture of the
77 /// canonical prompt and response with PII redaction, sampling,
78 /// and retention. Absent (the default): no capture — privacy-first
79 /// (no prompt or response text is ever stored). When present and
80 /// `enabled: true`, a redaction pass scrubs PII/secrets before
81 /// storage, `sample_rate` controls volume, and `retention_secs`
82 /// ages records out. Per-consumer `ai_logging` overrides respect
83 /// tenant preference (see [`crate::config::Consumer::ai_logging`]).
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub logging: Option<AiLogging>,
86 /// Guardrails (DW-082): prompt-injection heuristics, PII
87 /// detection, banned-content filters, and output schema
88 /// enforcement as a middleware chain on the AI proxy action.
89 /// Absent (the default): no guardrails — every prompt and
90 /// response passes through uninspected. When present, each rule
91 /// inspects the prompt (before the provider call) and/or the
92 /// response (after), and blocks, redacts, or logs per its
93 /// `action`. Policy-scoped rules apply only to consumers
94 /// attaching a listed policy; an empty `policies` list applies
95 /// to all. Schema enforcement uses the unconditional `jsonschema`
96 /// dependency and works in every build.
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub guardrails: Option<AiGuardrails>,
99 /// Semantic cache (DW-083 `ai.semantic_cache`): an
100 /// embedding-similarity cache for AI prompts. A paraphrased
101 /// prompt within the cosine-similarity threshold returns the
102 /// cached response with no provider call and no token spend.
103 /// Uses an external embedding service (OpenAI-compatible
104 /// /v1/embeddings API) and a pure-Rust HNSW ANN index
105 /// (`hnsw_rs`). Feature-gated behind the `semantic_cache` cargo
106 /// feature; without it the config is accepted but the cache is a
107 /// no-op. Absent (the default): no semantic cache.
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub semantic_cache: Option<SemanticCacheConfig>,
110 /// Routing policies (DW-085 `ai.routing_policies`): named
111 /// within-request escalation or latency-vs-cost selection plans,
112 /// keyed by name. A model alias declares `routing_policy: <name>`
113 /// to be served by one; the policy is evaluated per request (a
114 /// FallbackChain policy may call an external classifier service;
115 /// a LatencyCost policy picks deterministically at compile time).
116 /// Absent (the default): no routing policies.
117 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
118 pub routing_policies: BTreeMap<String, AiRoutingPolicy>,
119 /// Prompt experimentation (DW-086 `ai.experiments`): prompt
120 /// versioning, A/B model comparison, regression evals, and
121 /// feedback ingestion. Absent (the default): no experiments.
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub experiments: Option<AiExperiments>,
124 /// MCP gateway (DW-087 `ai.mcp`): turns dwara into an MCP
125 /// (Model Context Protocol) server/router. Configured tools are
126 /// exposed over JSON-RPC 2.0 on a reserved HTTP path (default
127 /// `/mcp`); the gateway proxies tool calls to upstream HTTP
128 /// endpoints with authN/authZ, manages agent sessions in the
129 /// state store, and correlates tool calls in analytics. Absent
130 /// (the default): no MCP surface (the `/mcp` path returns 404).
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub mcp: Option<AiMcpConfig>,
133 /// A2A (agent-to-agent) protocol support (DW-114 `ai.a2a`):
134 /// configures remote A2A agents that the gateway can route chat
135 /// requests to. Each agent names an upstream (the transport) and
136 /// an Agent Card (discovery doc, file path or inline JSON). The
137 /// adapter translates a canonical chat request into an A2A
138 /// task-submit body; the task lifecycle is STUBBED pending spec
139 /// freeze. Compiled into every build (no cargo feature). Absent
140 /// (the default): no A2A surface.
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub a2a: Option<A2aConfig>,
143 /// Unknown-model pricing policy (DW-AI-03
144 /// `ai.unknown_model_policy`): what to do when a provider model
145 /// has no entry in `ai.pricing`. Absent (the default): `allow`
146 /// (cost 0, fail-open — the original behavior). `fail_closed`
147 /// rejects the response with 403 `ai_unknown_model_pricing`;
148 /// `alert` proceeds with cost 0 but logs a WARN and increments
149 /// the `dwara_ai_unknown_pricing_total{model}` metric.
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub unknown_model_policy: Option<UnknownModelPolicy>,
152}
153
154/// Model governance (DW-084 `ai.governance`): per-team model
155/// allowlists plus a shadow-audit switch. A team is a POLICY name
156/// (the same vocabulary the token budgets use for `scope: policy`):
157/// every consumer attaching a policy with an allowlist entry is
158/// restricted to that allowlist's aliases. The check runs BEFORE
159/// routing, against the client-facing alias (the `model` value in
160/// the request body), so a typo or a renamed alias is blocked at the
161/// edge rather than surfacing as a provider 404. Multiple policies
162/// with allowlists that bind one request intersect (deny-wins): the
163/// model must be in EVERY binding allowlist, matching the authz
164/// deny-anywhere-wins principle. Consumers with no binding allowlist
165/// policy are unrestricted (fail-open, the DW-017 default posture).
166/// `audit: true` records BOTH allowed and denied attempts into the
167/// `ai_governance_events` analytics table for shadow review.
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
169#[serde(deny_unknown_fields)]
170pub struct AiGovernance {
171 /// Per-policy (team) model allowlists. The key is the POLICY name
172 /// (the team), the value is the list of allowed client-facing
173 /// model aliases. A consumer attaching a policy listed here may
174 /// only call the aliases in its allowlist; a consumer attaching
175 /// no listed policy is unrestricted. Validation rejects an
176 /// allowlist entry that names an alias absent from `ai.models`
177 /// (an authoring error, not a runtime 404).
178 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
179 pub team_allowlists: BTreeMap<String, Vec<String>>,
180 /// When true, record model usage for shadow audit: BOTH allowed
181 /// calls and denied attempts land in the `ai_governance_events`
182 /// analytics table (the admin `/analytics/governance-audit`
183 /// endpoint reads it). When false (the default), only the
184 /// denial metric fires — no per-event audit rows are written.
185 #[serde(default, skip_serializing_if = "is_false")]
186 pub audit: bool,
187 /// SEC-14 / CFG-14: dry-run mode. When true, the governance engine
188 /// evaluates the team allowlist and records would-be denials
189 /// (`dwara_policy_dry_run_total{phase="ai_governance"}` + a
190 /// structured log) but does NOT reject the request — the call
191 /// proceeds to the model. Useful for rolling out a new allowlist
192 /// without breaking existing callers. Default false (reject on
193 /// mismatch).
194 #[serde(default, skip_serializing_if = "is_false")]
195 pub dry_run: bool,
196}
197
198/// One AI provider (DW-075 `ai.providers[]`).
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
200#[serde(deny_unknown_fields)]
201pub struct AiProvider {
202 /// Provider name; unique within the `ai:` block. Referenced by
203 /// `ai.models[].provider`.
204 pub name: String,
205 /// Wire-protocol adapter kind. Selects the request/response
206 /// translation applied to every call routed to this provider.
207 pub kind: AiProviderKind,
208 /// Name of the `upstreams:` entry that carries this provider's
209 /// transport (endpoints, TLS trust, pooling, timeouts, breaker).
210 /// The adapter builds provider-specific paths; the endpoint's
211 /// authority becomes the dialed host, exactly like a proxy route.
212 pub upstream: String,
213 /// Authentication attached to every call to this provider: a
214 /// verbatim header. Optional — a provider behind an internal network
215 /// (a self-hosted gateway fronting an Ollama-style endpoint, for
216 /// example) may need none.
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub auth: Option<AiProviderAuth>,
219 /// Full request path override for this provider. When set, the
220 /// transport uses this path verbatim instead of the adapter's
221 /// built-in path. This bridges providers whose API path differs
222 /// from the adapter's default (e.g. z.ai's coding-plan endpoint
223 /// `/api/coding/paas/v4/chat/completions` vs the OpenAI adapter's
224 /// `/v1/chat/completions`) without an external reverse proxy.
225 /// When unset, the adapter's per-kind default path is used.
226 /// Applies to both adapter-translated and passthrough requests.
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub path: Option<String>,
229 /// DW-080 (Ent): a credential pool for this provider. When set,
230 /// the gateway rotates across multiple API keys to aggregate
231 /// rate-limit headroom (the LiteLLM pattern). Each entry is an
232 /// `AiProviderAuth` (same shape as `auth`); the pool is used
233 /// INSTEAD of `auth` — validation rejects a provider that sets
234 /// both. At request time the pool picks one credential per
235 /// request (round-robin by default); when a key returns 429, it
236 /// is temporarily quarantined and subsequent requests rotate to
237 /// the next. Pool exhaustion (all keys quarantined) degrades
238 /// gracefully with a 429 + Retry-After, not a panic. Ent-only:
239 /// rejected at validation when the `ent` cargo feature is off.
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub credential_pool: Option<AiCredentialPool>,
242}
243
244/// The wire protocol an adapter speaks (DW-075).
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
246#[serde(rename_all = "snake_case")]
247pub enum AiProviderKind {
248 /// OpenAI chat-completions API (`POST /v1/chat/completions`). Also
249 /// the dialect spoken by OpenAI-compatible servers (vLLM, Ollama's
250 /// compatibility endpoint, Groq, DeepSeek, Mistral, Cohere, and
251 /// others) — point the upstream at one of those and this adapter
252 /// speaks to it unchanged (AI-01).
253 Openai,
254 /// Anthropic messages API (`POST /v1/messages`).
255 Anthropic,
256 /// Google Gemini `generateContent` API
257 /// (`POST /v1beta/models/{model}:generateContent`).
258 Gemini,
259 /// DW-114: A2A (agent-to-agent) protocol. An A2A provider is a
260 /// configured `ai.a2a.agents[]` entry: the adapter translates a
261 /// canonical chat request into an A2A task-submit body and parses
262 /// the task response back. The task lifecycle state machine is
263 /// implemented against the A2A Protocol Specification draft (legal
264 /// and illegal transitions are enforced). Compiled into every
265 /// build (no cargo feature).
266 A2a,
267 /// AI-01: Azure OpenAI. Uses the Azure deployment URL format
268 /// (`/openai/deployments/{deployment}/chat/completions?api-version=...`)
269 /// and `api-key` header authentication. The request/response body
270 /// is OpenAI-compatible (the adapter reuses the OpenAI body
271 /// translation and overrides the path + headers).
272 AzureOpenai,
273 /// AI-01: AWS Bedrock. Uses the Bedrock invoke API
274 /// (`/model/{model_id}/invoke`) with AWS SigV4 request signing.
275 /// The request/response body follows the Anthropic messages format
276 /// for Claude models on Bedrock (the adapter reuses the Anthropic
277 /// body translation and overrides the path + headers). SigV4
278 /// signing is applied by the transport layer using the provider's
279 /// configured AWS credentials.
280 Bedrock,
281}
282
283impl AiProviderKind {
284 /// The OTel GenAI semantic-convention `gen_ai.system` value
285 /// (AI-07): the lowercase provider kind token. Used as the
286 /// `gen_ai.system` span attribute and never a metric label by
287 /// itself (the config-bounded provider NAME is the label).
288 pub fn gen_ai_system(&self) -> &'static str {
289 match self {
290 AiProviderKind::Openai => "openai",
291 AiProviderKind::Anthropic => "anthropic",
292 AiProviderKind::Gemini => "gemini",
293 AiProviderKind::A2a => "a2a",
294 AiProviderKind::AzureOpenai => "azure_openai",
295 AiProviderKind::Bedrock => "aws_bedrock",
296 }
297 }
298}
299
300/// Verbatim authentication header for a provider (DW-075
301/// `ai.providers[].auth`). `value` is SECRET-BEARING: an inline value is
302/// replaced by the redaction placeholder in every config echo, and a
303/// `${...}` reference is resolved at config-compile time (DW-045).
304#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
305#[serde(deny_unknown_fields)]
306pub struct AiProviderAuth {
307 /// Header name (e.g. `Authorization`, `x-api-key`,
308 /// `x-goog-api-key`). Must be a valid HTTP header name.
309 pub header: String,
310 /// Header value, verbatim (e.g. `Bearer ${OPENAI_API_KEY}` —
311 /// reference resolution covers the whole value, so the `Bearer `
312 /// prefix belongs INSIDE the referenced variable or the literal).
313 /// Inline values are redacted in config echoes; never logged.
314 pub value: String,
315}
316
317/// DW-080 (Ent): a credential pool for a provider — multiple API keys
318/// rotated across to aggregate rate-limit headroom. Each entry is an
319/// `AiProviderAuth` (same header+value shape as the singular `auth`
320/// field). The pool is used INSTEAD of `auth`; validation rejects a
321/// provider that sets both.
322#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
323#[serde(deny_unknown_fields)]
324pub struct AiCredentialPool {
325 /// One or more credential entries. Each entry has the same shape
326 /// as `ai.providers[].auth` (a header name + secret-bearing value).
327 /// At least two entries are required (a single-entry pool is
328 /// equivalent to `auth` — validation rejects it).
329 pub credentials: Vec<AiProviderAuth>,
330 /// Rotation strategy (DW-080). `round_robin` (the default) cycles
331 /// through credentials in config order; `weighted` picks
332 /// proportionally to each entry's `weight` (deterministic per
333 /// request id, same as canary splits). When a credential is
334 /// quarantined (429 from the provider), the pool skips it and
335 /// picks the next available one.
336 #[serde(default, skip_serializing_if = "is_default_pool_strategy")]
337 pub strategy: AiPoolStrategy,
338 /// Quarantine duration in seconds for a credential that received a
339 /// 429 from the provider. During quarantine, the pool skips the
340 /// credential; after the window expires, it re-enters rotation.
341 /// Default: 60 seconds. A provider's `Retry-After` header, when
342 /// present, overrides this per-429 (capped at 600 seconds to
343 /// prevent a hostile provider from pinning a key out indefinitely).
344 #[serde(
345 default = "default_quarantine_secs",
346 skip_serializing_if = "is_default_quarantine_secs"
347 )]
348 pub quarantine_secs: u64,
349}
350
351/// Credential pool rotation strategy (DW-080).
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
353#[serde(rename_all = "snake_case")]
354pub enum AiPoolStrategy {
355 /// Cycle through credentials in config order (default).
356 #[default]
357 RoundRobin,
358 /// Pick proportionally to each entry's weight (deterministic per
359 /// request id, same hash as canary splits). Weights are implicit
360 /// in the entry order (equal weight); a future extension may add
361 /// an explicit `weight` field per entry.
362 Weighted,
363}
364
365fn is_default_pool_strategy(s: &AiPoolStrategy) -> bool {
366 *s == AiPoolStrategy::RoundRobin
367}
368
369fn default_quarantine_secs() -> u64 {
370 60
371}
372
373fn is_default_quarantine_secs(v: &u64) -> bool {
374 *v == 60
375}
376
377/// One model alias mapping (DW-075 `ai.models{alias}`).
378#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
379#[serde(deny_unknown_fields)]
380pub struct AiModel {
381 /// Name of the `ai.providers[]` entry that serves this alias.
382 pub provider: String,
383 /// The provider's own model identifier (e.g. `gpt-4o-mini`,
384 /// `claude-sonnet-4-5`, `gemini-2.5-flash`), sent to the provider
385 /// in place of the alias.
386 pub provider_model: String,
387 /// Ordered failover chain (DW-076): when the primary target
388 /// answers 429 or a 5xx, or its transport errors, the gateway
389 /// retries the next entry — a DIFFERENT provider/model pair, never
390 /// a re-send to the provider that just failed (its upstream's
391 /// breaker owns same-provider retries). The client sees one
392 /// response. Absent (the default): no failover, the primary's
393 /// answer is final. Cannot be combined with `canary` on the same
394 /// alias (validation rejects the pairing): failover is an
395 /// availability chain for a single logical model; a canary split
396 /// is deliberately serving multiple versions at once — mixing the
397 /// two would retry a canary request onto the stable version and
398 /// silently undo the experiment.
399 #[serde(default, skip_serializing_if = "Vec::is_empty")]
400 pub failover: Vec<AiModelTarget>,
401 /// Weighted canary split (DW-076): traffic for this alias
402 /// distributes across the listed versions by a deterministic
403 /// weighted hash of the request id (the same slot semantics as
404 /// traffic splitting: ratios hold per request, and re-sending a
405 /// request with the same id lands on the same version). Each
406 /// version names exactly one provider/model pair and carries no
407 /// failover of its own. Absent (the default): no split. Cannot be
408 /// combined with `failover` on the same alias.
409 #[serde(default, skip_serializing_if = "Vec::is_empty")]
410 pub canary: Vec<AiCanaryVersion>,
411 /// A routing policy name (DW-085): when set, this alias is served
412 /// by the named entry in `ai.routing_policies` instead of a plain
413 /// failover chain or canary split. The policy is evaluated per
414 /// request (a FallbackChain policy may call an external classifier
415 /// service to decide cheap-vs-expensive; a LatencyCost policy
416 /// picks deterministically). Cannot be combined with `failover`
417 /// or `canary` on the same alias (validation enforces mutual
418 /// exclusivity): a policy alias composes over OTHER aliases'
419 /// routing plans, so it has no primary provider/model pair of its
420 /// own.
421 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub routing_policy: Option<String>,
423 /// An A/B test name (DW-086): when set, this alias is served by
424 /// the named entry in `ai.experiments.ab_tests` instead of a
425 /// plain failover chain, canary split, or routing policy. The
426 /// experiment is evaluated per request (a deterministic weighted
427 /// pick by request id selects a variant). Cannot be combined
428 /// with `failover`, `canary`, or `routing_policy` on the same
429 /// alias (validation enforces mutual exclusivity): an experiment
430 /// alias composes over OTHER aliases' routing plans, so it has
431 /// no primary provider/model pair of its own.
432 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub ab_test: Option<String>,
434 /// Auto-canary analysis (DW-091): when set, a background
435 /// controller compares the canary (second version) vs the
436 /// baseline (first version) on error rate or latency and adjusts
437 /// the canary split weights automatically. Only valid when the
438 /// canary has exactly 2 versions (baseline + canary). Weight
439 /// changes are transient (Generation swap) and revert on config
440 /// reload.
441 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub canary_analysis: Option<crate::config::CanaryAnalysis>,
443}
444
445/// One provider/model pair an alias can route to (DW-076): the primary
446/// shape reused by failover entries and canary versions.
447#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
448#[serde(deny_unknown_fields)]
449pub struct AiModelTarget {
450 /// Name of the `ai.providers[]` entry.
451 pub provider: String,
452 /// The provider's own model identifier.
453 pub provider_model: String,
454}
455
456/// One weighted version of a canary split (DW-076
457/// `ai.models.<alias>.canary[]`).
458#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
459#[serde(deny_unknown_fields)]
460pub struct AiCanaryVersion {
461 /// Version name — the analytics/metrics attribution label for
462 /// requests this version served. Unique within the alias; bounded
463 /// label (config entity).
464 pub version: String,
465 /// Relative weight (>= 1). A weight of 0 is not allowed here (the
466 /// split machinery parks traffic by RE-BALANCING weights, and a
467 /// zero-weight canary entry that still exists would read as
468 /// coverage it does not have) — remove the entry to park it.
469 pub weight: u32,
470 /// Name of the `ai.providers[]` entry serving this version.
471 pub provider: String,
472 /// The provider's own model identifier for this version.
473 pub provider_model: String,
474}
475
476/// Per-model token pricing (DW-079 `ai.pricing.<provider_model>`).
477/// Costs are integer MICRO-USD per 1 000 tokens (1 000 000 micro-USD =
478/// $1.00 — no floating-point money). The key in the parent map is the
479/// PROVIDER MODEL identifier (the actual model the provider charges
480/// for), not the client-facing alias. Spend is computed as
481/// `input_tokens * input_per_1k_micros / 1000 + output_tokens *
482/// output_per_1k_micros / 1000` (integer micro-USD, saturating).
483///
484/// The optional cached-token and batch-rate fields (DW-AI-03) extend
485/// the table for prompt-caching and batch-API discounts. When absent,
486/// the standard input/output rates apply (the fallback). Cached tokens
487/// are priced at `cached_input_per_1k_micros` when present (the
488/// discounted rate providers charge for prompt-cache hits); the
489/// non-cached portion of the prompt is priced at the standard
490/// `input_per_1k_micros`. Batch rates apply when the request is a
491/// batch-API call (`batch: true` on the cost computation).
492#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
493#[serde(deny_unknown_fields)]
494pub struct AiPricing {
495 /// Micro-USD per 1 000 input (prompt) tokens.
496 pub input_per_1k_micros: u64,
497 /// Micro-USD per 1 000 output (completion) tokens.
498 pub output_per_1k_micros: u64,
499 /// Micro-USD per 1 000 CACHED input (prompt-cached) tokens. When
500 /// present, the `cached_tokens` portion of the prompt is priced at
501 /// this discounted rate instead of `input_per_1k_micros`. Absent
502 /// (the default): cached tokens are priced at the standard input
503 /// rate (no separate cached tier).
504 #[serde(default, skip_serializing_if = "Option::is_none")]
505 pub cached_input_per_1k_micros: Option<u64>,
506 /// Micro-USD per 1 000 input tokens under the batch API. When
507 /// present and the request is a batch call, this rate replaces
508 /// `input_per_1k_micros`. Absent (the default): batch calls fall
509 /// back to the standard input rate.
510 #[serde(default, skip_serializing_if = "Option::is_none")]
511 pub batch_input_per_1k_micros: Option<u64>,
512 /// Micro-USD per 1 000 output tokens under the batch API. When
513 /// present and the request is a batch call, this rate replaces
514 /// `output_per_1k_micros`. Absent (the default): batch calls fall
515 /// back to the standard output rate.
516 #[serde(default, skip_serializing_if = "Option::is_none")]
517 pub batch_output_per_1k_micros: Option<u64>,
518}
519
520/// The unknown-model pricing policy (DW-AI-03
521/// `ai.unknown_model_policy`): what the gateway does when a provider
522/// model has no entry in the `ai.pricing` table. The default (`allow`)
523/// preserves the original fail-open behavior (cost 0, never a crash);
524/// `fail_closed` rejects the response with 403
525/// `ai_unknown_model_pricing` so an unpriced model cannot serve
526/// traffic silently; `alert` proceeds with cost 0 but logs a WARN and
527/// increments `dwara_ai_unknown_pricing_total{model}` for observability
528/// without blocking the call.
529#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
530#[serde(rename_all = "snake_case")]
531pub enum UnknownModelPolicy {
532 /// Proceed with cost 0 (the original fail-open behavior). A
533 /// misconfigured price table must not 500 a request.
534 #[default]
535 Allow,
536 /// Reject the response with 403 `ai_unknown_model_pricing` when the
537 /// model has no pricing entry. The provider call has already been
538 /// made (cost is computed from the response usage), but the
539 /// response is not returned to the client — the unpriced model
540 /// cannot serve traffic silently.
541 FailClosed,
542 /// Proceed with cost 0 but log a WARN and increment the
543 /// `dwara_ai_unknown_pricing_total{model}` metric. The call
544 /// succeeds; the metric and log surface the gap for operators.
545 Alert,
546}
547
548/// Default sampling rate: capture all when enabled (1.0).
549fn default_sample_rate() -> f64 {
550 1.0
551}
552
553/// Default retention: 7 days in seconds (604800).
554fn default_retention_secs() -> u64 {
555 604_800
556}
557
558/// Default redaction replacement string.
559fn default_replacement() -> String {
560 "[REDACTED]".to_string()
561}
562
563/// Prompt/response logging (DW-081 `ai.logging`): opt-in capture of
564/// the canonical prompt and response with PII redaction, sampling,
565/// and retention. Capture is OFF by default (privacy-first): the
566/// `enabled` field defaults to false, so an `ai.logging` block
567/// without `enabled: true` captures nothing. When on, a redaction
568/// pass scrubs PII/secrets before storage, `sample_rate` controls
569/// volume, and `retention_secs` ages records out. Per-consumer
570/// `ai_logging` overrides respect tenant preference.
571#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
572#[serde(deny_unknown_fields)]
573pub struct AiLogging {
574 /// Master switch. Default false (privacy-first): no prompt or
575 /// response text is stored unless this is explicitly true.
576 #[serde(default)]
577 pub enabled: bool,
578 /// Sampling rate 0.0..=1.0. Default 1.0 (capture all when
579 /// enabled). The sample decision is deterministic per request
580 /// (a hash of the request id), so a re-send with the same id
581 /// lands on the same decision.
582 #[serde(default = "default_sample_rate")]
583 pub sample_rate: f64,
584 /// Retention in seconds. Records older than this are deleted by
585 /// the analytics maintenance tick. Default 7 days (604800).
586 #[serde(default = "default_retention_secs")]
587 pub retention_secs: u64,
588 /// PII redaction patterns. Built-in defaults (emails, phone
589 /// numbers, API keys, credit card numbers) are always active
590 /// when redaction is on; custom patterns are added to the set.
591 #[serde(default)]
592 pub redaction: RedactionConfig,
593 /// SCALE-12 (#190): external sink for prompt/response logs. When
594 /// configured, the analytics maintenance tick invokes the
595 /// specified command to sync exported prompt log files to an
596 /// external storage system (S3, GCS, etc.). The command receives
597 /// the export directory as its first argument. This is a
598 /// lean-dependency alternative to embedding S3/GCS SDKs — the
599 /// operator provides the sync tool (e.g., `aws s3 sync`, `gsutil
600 /// rsync`). The sink has its own retention (`retention_secs`
601 /// above); the local store prunes on its schedule, the external
602 /// sink prunes on the operator's policy.
603 #[serde(default, skip_serializing_if = "Option::is_none")]
604 pub external_sink: Option<AiLoggingExternalSink>,
605}
606
607/// SCALE-12 (#190): external sink configuration for prompt/response
608/// logs. The sync command is invoked by the analytics maintenance
609/// tick with the export directory as its first argument. Failures are
610/// logged and silently ignored (best-effort sync).
611#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
612#[serde(deny_unknown_fields)]
613pub struct AiLoggingExternalSink {
614 /// The command to invoke for syncing prompt log files to external
615 /// storage. The export directory path is passed as the first
616 /// argument. Example: `aws s3 sync` or `gsutil rsync -r`.
617 pub sync_command: String,
618 /// Optional additional arguments to pass to the sync command
619 /// (after the export directory path).
620 #[serde(default, skip_serializing_if = "Vec::is_empty")]
621 pub extra_args: Vec<String>,
622 /// How often to invoke the sync command, in seconds. Default
623 /// 3600 (1 hour). Minimum 60.
624 #[serde(default = "default_sync_interval_secs")]
625 pub sync_interval_secs: u64,
626}
627
628fn default_sync_interval_secs() -> u64 {
629 3600
630}
631
632/// PII redaction configuration (DW-081 `ai.logging.redaction`).
633/// Built-in patterns scrub common PII/secrets; custom patterns add
634/// deployment-specific scrubbing. All patterns are compiled into a
635/// single `regex::RegexSet` for one-pass efficiency.
636#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
637#[serde(deny_unknown_fields)]
638pub struct RedactionConfig {
639 /// Additional regex patterns to scrub (beyond the built-in
640 /// defaults). Each is a Rust `regex` crate pattern; invalid
641 /// patterns fail validation at publish time.
642 #[serde(default, skip_serializing_if = "Vec::is_empty")]
643 pub patterns: Vec<String>,
644 /// Replacement string for redacted content. Default
645 /// `"[REDACTED]"`.
646 #[serde(default = "default_replacement")]
647 pub replacement: String,
648}
649
650/// Guardrails (DW-082 `ai.guardrails`): a list of rules that inspect
651/// prompts and responses for prompt-injection, PII, banned content,
652/// and output schema conformance. Each rule is one check applied at
653/// the prompt phase (before the provider call) and/or the response
654/// phase (after). Policy-scoped rules apply only to consumers
655/// attaching a listed policy; an empty `policies` list applies to
656/// all. See [`AiGuardrailRule`] for the per-rule shape.
657#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
658#[serde(deny_unknown_fields)]
659pub struct AiGuardrails {
660 /// Guardrail rules. Each rule is one check applied to prompts
661 /// and/or responses, in declaration order. Rule names must be
662 /// unique (validation rejects duplicates).
663 #[serde(default, skip_serializing_if = "Vec::is_empty")]
664 pub rules: Vec<AiGuardrailRule>,
665 /// SEC-14 / CFG-14: dry-run mode. When true, guardrail rules are
666 /// evaluated and would-be blocks are recorded
667 /// (`dwara_policy_dry_run_total{phase="ai_guardrails"}` + a
668 /// structured log) but the request/response is NOT blocked — the
669 /// call proceeds. Useful for tuning guardrail patterns before
670 /// enforcing. Default false (block on match).
671 #[serde(default, skip_serializing_if = "is_false")]
672 pub dry_run: bool,
673}
674
675/// One guardrail rule (DW-082 `ai.guardrails.rules[]`). A rule
676/// declares a `kind` (what to check), an `action` (what to do on a
677/// match), a `phase` (prompt, response, or both), the patterns or
678/// schema to check against, and the policies it attaches to.
679#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
680#[serde(deny_unknown_fields)]
681pub struct AiGuardrailRule {
682 /// Rule name (for logging/metrics attribution). Must be unique
683 /// within the guardrails block (validation rejects duplicates).
684 pub name: String,
685 /// Guardrail kind: what the rule checks.
686 pub kind: AiGuardrailKind,
687 /// Action on match: `block` (return an error to the client),
688 /// `redact` (scrub the match and continue — prompt phase only),
689 /// or `log` (observe only, dry-run — the request proceeds).
690 pub action: AiGuardrailAction,
691 /// When to apply: `prompt` (before the provider call), `response`
692 /// (after), or `both`. Default `both`.
693 #[serde(default = "default_guardrail_phase")]
694 pub phase: AiGuardrailPhase,
695 /// Patterns for injection/pii/banned kinds (regex strings). Each
696 /// is a Rust `regex` crate pattern; invalid patterns fail
697 /// validation at publish time. For `pii` kind, the built-in PII
698 /// patterns (email, phone, API key, credit card) are always
699 /// active alongside any custom patterns declared here.
700 #[serde(default, skip_serializing_if = "Vec::is_empty")]
701 pub patterns: Vec<String>,
702 /// JSON schema for output schema enforcement (`schema` kind
703 /// only). The response content (parsed as JSON) is validated
704 /// against this schema; a violation blocks the response. Uses the
705 /// unconditional `jsonschema` dependency; works in every build.
706 #[serde(default, skip_serializing_if = "Option::is_none")]
707 pub schema: Option<serde_json::Value>,
708 /// Policy names this rule attaches to. Empty (the default) means
709 /// the rule applies to ALL consumers. When non-empty, the rule
710 /// applies only to consumers whose attached policies (the
711 /// consumer > route > service > listener > global chain) include
712 /// at least one listed name.
713 #[serde(default, skip_serializing_if = "Vec::is_empty")]
714 pub policies: Vec<String>,
715}
716
717/// The default guardrail phase: `both`.
718fn default_guardrail_phase() -> AiGuardrailPhase {
719 AiGuardrailPhase::Both
720}
721
722/// Guardrail kind (DW-082 `ai.guardrails.rules[].kind`).
723#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
724#[serde(rename_all = "snake_case")]
725pub enum AiGuardrailKind {
726 /// Prompt-injection heuristics: pattern matching for common
727 /// injection attempts ("ignore previous instructions", role
728 /// injection, instruction override). Applied at the prompt
729 /// phase.
730 Injection,
731 /// PII detection: the built-in PII patterns (email, phone, API
732 /// key, credit card) plus any custom patterns. Applied at the
733 /// prompt phase; `redact` action scrubs the PII before the
734 /// provider call.
735 Pii,
736 /// Banned-content filter: pattern matching against a banned
737 /// content list. Applied at the prompt and/or response phase per
738 /// the rule's `phase`.
739 Banned,
740 /// Output schema enforcement: the response content is validated
741 /// against the declared JSON schema. Applied at the response
742 /// phase (non-streaming only — streaming cannot validate a
743 /// schema on partial content). Works in every build.
744 Schema,
745}
746
747/// Guardrail action (DW-082 `ai.guardrails.rules[].action`).
748#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
749#[serde(rename_all = "snake_case")]
750pub enum AiGuardrailAction {
751 /// Block the request/response: return a 400 error to the client
752 /// with code `guardrail_blocked` (prompt) or
753 /// `response_schema_violation` / `guardrail_blocked` (response).
754 Block,
755 /// Redact the match and continue: scrub the PII/banned content
756 /// from the prompt before the provider call. Prompt phase only.
757 Redact,
758 /// Log only (dry-run): record the match but allow the request
759 /// through. Use to tune thresholds before switching to `block`.
760 Log,
761}
762
763/// Guardrail phase (DW-082 `ai.guardrails.rules[].phase`).
764#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
765#[serde(rename_all = "snake_case")]
766pub enum AiGuardrailPhase {
767 /// Apply at the prompt phase (before the provider call).
768 Prompt,
769 /// Apply at the response phase (after the provider call, before
770 /// the response is returned to the client).
771 Response,
772 /// Apply at both phases.
773 Both,
774}
775
776/// Default semantic-cache cosine-similarity threshold: 0.85.
777fn default_semantic_cache_threshold() -> f64 {
778 0.85
779}
780
781/// Default semantic-cache TTL: 1 hour (3600 s).
782fn default_semantic_cache_ttl() -> u64 {
783 3600
784}
785
786/// Default semantic-cache max entries: 10 000.
787fn default_semantic_cache_max_entries() -> usize {
788 10_000
789}
790
791/// Default semantic-cache embedding-service timeout: 5 s (5000 ms).
792fn default_semantic_cache_embedding_timeout_ms() -> u64 {
793 5000
794}
795
796/// Semantic cache config (DW-083 `ai.semantic_cache`): an
797/// embedding-similarity cache for AI prompts. A paraphrased prompt
798/// within the cosine-similarity threshold returns the cached response
799/// with no provider call and no token spend. Uses an external
800/// embedding service (OpenAI-compatible /v1/embeddings API) to
801/// vectorize prompts and `hnsw_rs` (pure Rust HNSW) for approximate
802/// nearest neighbor search. Compiled into every build (`hnsw_rs` is
803/// an unconditional dependency).
804#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
805#[serde(deny_unknown_fields)]
806pub struct SemanticCacheConfig {
807 /// Whether the semantic cache is enabled. Default false: an
808 /// `ai.semantic_cache` block without `enabled: true` caches
809 /// nothing (the engine compiles but stays inert).
810 #[serde(default, skip_serializing_if = "is_false")]
811 pub enabled: bool,
812 /// The URL of the external embedding service (OpenAI-compatible
813 /// /v1/embeddings API). Required when enabled. Validation
814 /// rejects an empty or non-http(s) URL.
815 pub embedding_url: String,
816 /// The model name passed to the embedding service in the
817 /// `model` field of the POST body.
818 pub embedding_model: String,
819 /// The dimension of the embedding vectors. Must match what the
820 /// embedding service returns for the configured model;
821 /// validation rejects 0.
822 pub embedding_dim: usize,
823 /// Cosine similarity threshold (0.0 to 1.0, inclusive). A cached
824 /// entry is returned only if its cosine similarity to the query
825 /// embedding is >= this threshold. Higher = stricter matching
826 /// (fewer hits, more accurate). Default 0.85.
827 #[serde(default = "default_semantic_cache_threshold")]
828 pub threshold: f64,
829 /// TTL for cached entries in seconds. Entries older than this are
830 /// considered stale and not returned. Default 3600 (1 hour).
831 #[serde(default = "default_semantic_cache_ttl")]
832 pub ttl_secs: u64,
833 /// Maximum number of entries to cache. When the cache is full,
834 /// it is reset (all entries evicted, the HNSW index rebuilt).
835 /// Default 10 000.
836 #[serde(default = "default_semantic_cache_max_entries")]
837 pub max_entries: usize,
838 /// Timeout for the embedding service HTTP call in milliseconds.
839 /// Default 5000 (5 seconds).
840 #[serde(default = "default_semantic_cache_embedding_timeout_ms")]
841 pub embedding_timeout_ms: u64,
842 /// Optional API key for the embedding service (sent as
843 /// `Authorization: Bearer <key>`). Can be a `${...}` secret
844 /// reference (resolved at config-compile time). Inline values
845 /// are redacted in config echoes; never logged.
846 #[serde(default, skip_serializing_if = "Option::is_none")]
847 pub embedding_api_key: Option<String>,
848}
849
850// -------------------------------------------------------------------------
851// DW-085: routing policies (fallback chains + latency-cost selection).
852// -------------------------------------------------------------------------
853
854/// Default classifier complexity threshold: 0.5.
855fn default_classifier_threshold() -> f64 {
856 0.5
857}
858
859/// Default classifier HTTP timeout: 1000 ms.
860fn default_classifier_timeout_ms() -> u64 {
861 1000
862}
863
864/// A routing policy (DW-085 `ai.routing_policies.<name>`):
865/// within-request escalation or latency-vs-cost selection. Keyed by
866/// name in `ai.routing_policies`. An alias declares
867/// `routing_policy: <name>` to be served by one; the policy is
868/// evaluated per request and composes over DW-076 routing (the
869/// candidate aliases it names are themselves plain chain/canary
870/// aliases). Internally tagged by `kind` so the YAML shape is:
871///
872/// ```yaml
873/// routing_policies:
874/// my-policy:
875/// kind: fallback_chain
876/// cheap: cheap-model
877/// escalate_to: expensive-model
878/// classifier_url: http://...
879/// classifier_model: complexity
880/// ```
881#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
882#[serde(deny_unknown_fields, tag = "kind", rename_all = "snake_case")]
883pub enum AiRoutingPolicy {
884 /// Cheap-model-first with complexity-signal escalation (DW-085).
885 /// Calls an external classifier service to estimate prompt
886 /// complexity; simple prompts (score < threshold) use the cheap
887 /// model, complex prompts (score >= threshold) escalate to the
888 /// costlier model. On classifier error, fails open to the cheap
889 /// model (the safe default).
890 FallbackChain(AiFallbackChainPolicy),
891 /// Latency-vs-cost routing (DW-085): static config-based
892 /// selection. The operator declares cost/latency scores per
893 /// candidate and a preference; the policy picks deterministically
894 /// at compile time (no runtime metrics needed).
895 LatencyCost(AiLatencyCostPolicy),
896}
897
898/// A fallback-chain routing policy (DW-085 `kind: fallback_chain`):
899/// cheap-model-first with complexity-signal escalation. The
900/// `cheap` and `escalate_to` fields name OTHER model aliases (plain
901/// chain/canary aliases — a policy composes over DW-076 routing, so
902/// it has no provider/model pair of its own).
903#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
904#[serde(deny_unknown_fields)]
905pub struct AiFallbackChainPolicy {
906 /// The model alias to use for simple prompts (the cheap model).
907 /// Must reference an existing alias in `ai.models` (validation
908 /// rejects a typo at publish time).
909 pub cheap: String,
910 /// The model alias to escalate to for complex prompts. Must
911 /// reference an existing alias in `ai.models`.
912 pub escalate_to: String,
913 /// The URL of the external classifier service. The service must
914 /// accept a POST with `{"model": "...", "input": "..."}` and
915 /// return `{"data": [{"score": 0.0-1.0}]}` (the OpenAI-compatible
916 /// embeddings response shape, with `score` in place of
917 /// `embedding`). Validation rejects an empty or non-http(s) URL.
918 pub classifier_url: String,
919 /// The model name to pass to the classifier service in the
920 /// `model` field of the POST body.
921 pub classifier_model: String,
922 /// Complexity score threshold (0.0 to 1.0, inclusive). A score
923 /// at or above the threshold triggers escalation to the costlier
924 /// model; a score below the threshold uses the cheap model.
925 /// Default 0.5.
926 #[serde(default = "default_classifier_threshold")]
927 pub threshold: f64,
928 /// Timeout for the classifier HTTP call in milliseconds. Default
929 /// 1000 (1 second). Validation rejects 0.
930 #[serde(default = "default_classifier_timeout_ms")]
931 pub timeout_ms: u64,
932 /// Optional API key for the classifier service (sent as
933 /// `Authorization: Bearer <key>`). Can be a `${...}` secret
934 /// reference (resolved at config-compile time). Inline values
935 /// are redacted in config echoes; never logged.
936 #[serde(default, skip_serializing_if = "Option::is_none")]
937 pub api_key: Option<String>,
938}
939
940/// A latency-vs-cost routing policy (DW-085 `kind: latency_cost`):
941/// static config-based selection. The operator declares cost/latency
942/// scores per candidate and a preference; the policy sorts candidates
943/// at compile time and picks the best one deterministically (no
944/// runtime metrics needed).
945#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
946#[serde(deny_unknown_fields)]
947pub struct AiLatencyCostPolicy {
948 /// The candidate models with their cost/latency scores. Each
949 /// `model` must reference an existing alias in `ai.models`.
950 /// Validation rejects an empty list.
951 pub candidates: Vec<AiLatencyCostCandidate>,
952 /// The selection preference: `cost` (cheapest), `latency`
953 /// (fastest), or `balanced` (best cost/latency sum).
954 pub preference: AiLatencyPreference,
955 /// AI-15 (#203): when true, the policy uses live latency
956 /// observations to dynamically adjust candidate ordering. The
957 /// static `latency` scores are used as priors; observed latencies
958 /// from recent requests refine the selection. Default false
959 /// (static selection, the DW-085 behavior).
960 #[serde(default, skip_serializing_if = "is_false")]
961 pub live: bool,
962 /// AI-15 (#203): the window size for live latency tracking
963 /// (number of recent observations to average). Default 10. Only
964 /// used when `live` is true.
965 #[serde(
966 default = "default_live_window",
967 skip_serializing_if = "Option::is_none"
968 )]
969 pub live_window: Option<usize>,
970}
971
972/// AI-15 (#203): default live latency window size.
973fn default_live_window() -> Option<usize> {
974 Some(10)
975}
976
977/// One candidate in a latency-vs-cost policy (DW-085
978/// `ai.routing_policies.<name>.candidates[]`).
979#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
980#[serde(deny_unknown_fields)]
981pub struct AiLatencyCostCandidate {
982 /// The model alias to route to. Must reference an existing alias
983 /// in `ai.models`.
984 pub model: String,
985 /// Relative cost score (1-10, where 1 = cheapest). Validation
986 /// rejects values outside 1-10.
987 pub cost: u32,
988 /// Relative latency score (1-10, where 1 = fastest). Validation
989 /// rejects values outside 1-10.
990 pub latency: u32,
991}
992
993/// The latency-vs-cost selection preference (DW-085
994/// `ai.routing_policies.<name>.preference`).
995#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
996#[serde(rename_all = "snake_case")]
997pub enum AiLatencyPreference {
998 /// Pick the cheapest candidate (lowest `cost` score).
999 Cost,
1000 /// Pick the fastest candidate (lowest `latency` score).
1001 Latency,
1002 /// Pick the best cost/latency ratio (lowest `cost + latency`
1003 /// sum).
1004 Balanced,
1005}
1006
1007// -------------------------------------------------------------------------
1008// DW-086: prompt experimentation (versioning, A/B tests, evals,
1009// feedback).
1010// -------------------------------------------------------------------------
1011
1012/// Default feedback enabled flag: true.
1013fn default_feedback_enabled() -> bool {
1014 true
1015}
1016
1017/// Prompt experimentation (DW-086 `ai.experiments`): prompt
1018/// versioning, A/B model comparison, regression evals, and feedback
1019/// ingestion. Absent (the default): no experiments surface.
1020#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
1021#[serde(deny_unknown_fields)]
1022pub struct AiExperiments {
1023 /// Named prompt version sets (DW-086). Each entry declares one
1024 /// or more versions (each with a system message) and an active
1025 /// version. The active version can be overridden at runtime via
1026 /// the admin API (stored in the state store). Absent (the
1027 /// default): no prompt versioning.
1028 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1029 pub prompts: BTreeMap<String, AiPromptVersions>,
1030 /// Named A/B tests (DW-086). Each test declares two or more
1031 /// variants (each naming a model alias, an optional prompt
1032 /// version, and a weight). A model alias declares `ab_test:
1033 /// <name>` to be served by one; the test is evaluated per
1034 /// request (a deterministic weighted pick by request id selects
1035 /// a variant). Absent (the default): no A/B tests.
1036 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1037 pub ab_tests: BTreeMap<String, AiAbTest>,
1038 /// Named regression evals (DW-086). Each eval declares a golden
1039 /// set of input/expected cases and an optional prompt version;
1040 /// the admin API runs it against a model alias (or an A/B test's
1041 /// variants) and stores the results. Absent (the default): no
1042 /// evals.
1043 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1044 pub evals: BTreeMap<String, AiEval>,
1045 /// Feedback ingestion config (DW-086). When enabled, the admin
1046 /// API accepts feedback records (request id, label, comment)
1047 /// and stores them in analytics. Absent (the default): feedback
1048 /// is enabled (the default is `enabled: true`).
1049 #[serde(default, skip_serializing_if = "Option::is_none")]
1050 pub feedback: Option<AiFeedbackConfig>,
1051}
1052
1053/// One prompt's version set (DW-086 `ai.experiments.prompts.<name>`):
1054/// a map of version names to system messages plus the active version.
1055/// The active version can be overridden at runtime via the admin API.
1056#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1057#[serde(deny_unknown_fields)]
1058pub struct AiPromptVersions {
1059 /// The named versions, each carrying a system message. Version
1060 /// names must be non-empty (validation rejects empty strings).
1061 pub versions: BTreeMap<String, AiPromptVersion>,
1062 /// The active version name. Must exist in `versions` (validation
1063 /// rejects a name not in the map). Can be overridden at runtime
1064 /// via the admin API (the override is stored in the state store).
1065 pub active: String,
1066}
1067
1068/// One prompt version (DW-086
1069/// `ai.experiments.prompts.<name>.versions.<version>`): a system
1070/// message prepended to the request's messages when this version is
1071/// active.
1072#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1073#[serde(deny_unknown_fields)]
1074pub struct AiPromptVersion {
1075 /// The system message prepended to the request's messages. If
1076 /// the request already has a system message, the template's
1077 /// system message is prepended BEFORE it.
1078 pub system: String,
1079}
1080
1081/// One A/B test (DW-086 `ai.experiments.ab_tests.<name>`): two or
1082/// more variants, each naming a model alias, an optional prompt
1083/// version, and a weight. A model alias declares `ab_test: <name>`
1084/// to be served by one; the test is evaluated per request (a
1085/// deterministic weighted pick by request id selects a variant).
1086#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1087#[serde(deny_unknown_fields)]
1088pub struct AiAbTest {
1089 /// The variants. Validation requires at least 2 (one variant is
1090 /// not a test). Each variant's `model` must reference an existing
1091 /// alias in `ai.models` that is NOT itself an experiment alias
1092 /// (no nested experiments).
1093 pub variants: Vec<AiAbVariant>,
1094}
1095
1096/// One variant of an A/B test (DW-086
1097/// `ai.experiments.ab_tests.<name>.variants[]`).
1098#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1099#[serde(deny_unknown_fields)]
1100pub struct AiAbVariant {
1101 /// The variant name (for analytics/metrics attribution). Must be
1102 /// non-empty.
1103 pub name: String,
1104 /// The model alias this variant routes to. Must reference an
1105 /// existing alias in `ai.models` that is NOT itself an experiment
1106 /// alias (no nested experiments — validation rejects it).
1107 pub model: String,
1108 /// An optional prompt version reference in
1109 /// `"prompt_name/version_name"` format. When set, the named
1110 /// version's system message is prepended to the request's
1111 /// messages for this variant. Validation rejects a reference
1112 /// whose prompt or version does not exist.
1113 #[serde(default, skip_serializing_if = "Option::is_none")]
1114 pub prompt: Option<String>,
1115 /// Relative weight (>= 1). The deterministic weighted pick uses
1116 /// the same slot semantics as canary splits (ratios hold per
1117 /// request, and re-sending a request with the same id lands on
1118 /// the same variant). Validation rejects 0.
1119 pub weight: u32,
1120}
1121
1122/// One regression eval (DW-086 `ai.experiments.evals.<name>`): a
1123/// golden set of input/expected cases plus an optional prompt
1124/// version. The admin API runs it against a model alias (or an A/B
1125/// test's variants) and stores the results in analytics.
1126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1127#[serde(deny_unknown_fields)]
1128pub struct AiEval {
1129 /// An optional prompt version reference in
1130 /// `"prompt_name/version_name"` format. When set, the named
1131 /// version's system message is prepended to each eval case's
1132 /// input. Validation rejects a reference whose prompt or version
1133 /// does not exist.
1134 #[serde(default, skip_serializing_if = "Option::is_none")]
1135 pub prompt: Option<String>,
1136 /// The golden set: input/expected pairs with a scorer. Validation
1137 /// requires at least one case and non-empty `input`/`expected`.
1138 pub golden_set: Vec<AiEvalCase>,
1139}
1140
1141/// One eval case (DW-086
1142/// `ai.experiments.evals.<name>.golden_set[]`).
1143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1144#[serde(deny_unknown_fields)]
1145pub struct AiEvalCase {
1146 /// The input prompt (sent as the user message to the provider).
1147 /// Must be non-empty (validation rejects empty strings).
1148 pub input: String,
1149 /// The expected output. The scorer compares the provider's output
1150 /// against this value. For `exact_match`, an exact string match;
1151 /// for `contains`, the output must contain this string; for
1152 /// `regex`, this is the regex pattern the output must match.
1153 pub expected: String,
1154 /// The scorer name. One of: `exact_match` (default), `contains`,
1155 /// `regex`. When `regex`, `expected` must be a valid regex
1156 /// pattern (validation compiles it at publish time).
1157 #[serde(default, skip_serializing_if = "Option::is_none")]
1158 pub scorer: Option<String>,
1159}
1160
1161/// Feedback ingestion config (DW-086
1162/// `ai.experiments.feedback`).
1163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1164#[serde(deny_unknown_fields)]
1165pub struct AiFeedbackConfig {
1166 /// Whether the feedback admin endpoint accepts records. Default
1167 /// true (an `ai.experiments.feedback` block without `enabled:
1168 /// false` accepts feedback). When false, the endpoint returns
1169 /// 403.
1170 #[serde(default = "default_feedback_enabled", skip_serializing_if = "is_false")]
1171 pub enabled: bool,
1172}
1173
1174/// MCP gateway config (DW-087 `ai.mcp`): the tool table, session
1175/// policy, and reserved path. Tools are configured with upstream
1176/// references; the gateway proxies tool calls to upstream HTTP
1177/// endpoints (POST JSON body, get JSON response). The gateway is a
1178/// router, not a tool executor.
1179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1180#[serde(deny_unknown_fields)]
1181pub struct AiMcpConfig {
1182 /// The tool table, keyed by tool name. Each tool names an
1183 /// upstream that carries the transport and a path on that
1184 /// upstream. Tool names must be non-empty and unique (validation
1185 /// rejects duplicates and empty names).
1186 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1187 pub tools: BTreeMap<String, AiMcpTool>,
1188 /// AI-11 (#200): Static resources exposed via `resources/list`
1189 /// and `resources/read`. Each resource has a URI, a name, a
1190 /// description, and an optional MIME type. The resource content
1191 /// is served inline (no upstream proxy).
1192 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1193 pub resources: BTreeMap<String, AiMcpResource>,
1194 /// AI-11 (#200): Static prompt templates exposed via
1195 /// `prompts/list` and `prompts/get`. Each prompt has a name, a
1196 /// description, and a template body with optional arguments.
1197 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1198 pub prompts: BTreeMap<String, AiMcpPrompt>,
1199 /// Session policy. Absent (the default): the built-in defaults
1200 /// (TTL 3600s, max 1000 concurrent sessions).
1201 #[serde(default, skip_serializing_if = "Option::is_none")]
1202 pub sessions: Option<AiMcpSessions>,
1203 /// The reserved HTTP path for the MCP JSON-RPC endpoint. Default
1204 /// `/mcp`. Must start with `/` if specified. The path is reserved
1205 /// like `/healthz`, `/readyz`, `/metrics` — it shadows any
1206 /// configured route and is handled before route resolution.
1207 #[serde(default, skip_serializing_if = "Option::is_none")]
1208 pub path: Option<String>,
1209}
1210
1211/// One MCP tool (DW-087 `ai.mcp.tools.<name>`). The gateway proxies
1212/// tool calls to the named upstream's HTTP endpoint: the tool's
1213/// arguments are sent as the JSON request body, and the upstream's
1214/// response body becomes the tool's output.
1215#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1216#[serde(deny_unknown_fields)]
1217pub struct AiMcpTool {
1218 /// Human-readable description of the tool (returned to the client
1219 /// in `tools/list`).
1220 pub description: String,
1221 /// Name of the `upstreams[]` entry that carries this tool's
1222 /// transport. The gateway resolves the upstream's first endpoint
1223 /// to build the tool's URL. Validation rejects a reference to an
1224 /// unknown upstream.
1225 pub upstream: String,
1226 /// The path on the upstream (appended to the endpoint's
1227 /// `address:port`). Default `/`.
1228 #[serde(default, skip_serializing_if = "Option::is_none")]
1229 pub path: Option<String>,
1230 /// The HTTP method for the upstream call. Default `POST`. Must be
1231 /// a valid HTTP method (GET, POST, PUT, PATCH, DELETE) if
1232 /// specified.
1233 #[serde(default, skip_serializing_if = "Option::is_none")]
1234 pub method: Option<String>,
1235 /// The JSON Schema for the tool's arguments (returned to the
1236 /// client in `tools/list` as `inputSchema`). Must be a valid JSON
1237 /// object.
1238 pub input_schema: serde_json::Value,
1239 /// Optional per-tool authorization. When present, the tool is
1240 /// only callable by consumers satisfying the authz rules. When
1241 /// absent, any authenticated consumer may call the tool.
1242 #[serde(default, skip_serializing_if = "Option::is_none")]
1243 pub authz: Option<crate::config::Authz>,
1244 /// Upstream call timeout in milliseconds. Default 30000.
1245 #[serde(default, skip_serializing_if = "Option::is_none")]
1246 pub timeout_ms: Option<u64>,
1247}
1248
1249/// AI-11 (#200): One MCP static resource (`ai.mcp.resources.<name>`).
1250/// Resources are served inline (no upstream proxy). The content is
1251/// returned verbatim in `resources/read` responses.
1252#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1253#[serde(deny_unknown_fields)]
1254pub struct AiMcpResource {
1255 /// The resource URI (e.g., `config://routes`, `data://users`). Must
1256 /// be non-empty (validation rejects empty strings).
1257 pub uri: String,
1258 /// Human-readable name (returned in `resources/list`).
1259 pub name: String,
1260 /// Human-readable description (returned in `resources/list`).
1261 pub description: String,
1262 /// The MIME type of the content (e.g., `text/plain`,
1263 /// `application/json`). Default `text/plain`.
1264 #[serde(default, skip_serializing_if = "Option::is_none")]
1265 pub mime_type: Option<String>,
1266 /// The resource content (returned verbatim in `resources/read`).
1267 pub content: String,
1268}
1269
1270/// AI-11 (#200): One MCP static prompt template
1271/// (`ai.mcp.prompts.<name>`). Prompts are served inline. The
1272/// template body is returned in `prompts/get` responses, with
1273/// optional argument substitution.
1274#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1275#[serde(deny_unknown_fields)]
1276pub struct AiMcpPrompt {
1277 /// Human-readable description (returned in `prompts/list`).
1278 pub description: String,
1279 /// The prompt template body. May contain `{{argument_name}}`
1280 /// placeholders that are substituted from the `arguments` field
1281 /// of a `prompts/get` request.
1282 pub template: String,
1283 /// The argument definitions for this prompt. Each argument has a
1284 /// name, a description, and a required flag.
1285 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1286 pub arguments: Vec<AiMcpPromptArgument>,
1287}
1288
1289/// AI-11 (#200): One prompt argument definition.
1290#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1291#[serde(deny_unknown_fields)]
1292pub struct AiMcpPromptArgument {
1293 /// The argument name (used as the `{{name}}` placeholder in the
1294 /// template).
1295 pub name: String,
1296 /// Human-readable description of the argument.
1297 pub description: String,
1298 /// Whether the argument is required (default false).
1299 #[serde(default, skip_serializing_if = "is_false")]
1300 pub required: bool,
1301}
1302
1303/// MCP session policy (DW-087 `ai.mcp.sessions`).
1304#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1305#[serde(deny_unknown_fields)]
1306pub struct AiMcpSessions {
1307 /// Session TTL in seconds. Default 3600. Expired sessions are
1308 /// cleaned up periodically and rejected on use.
1309 #[serde(default, skip_serializing_if = "Option::is_none")]
1310 pub ttl_secs: Option<u64>,
1311 /// Maximum number of concurrent active sessions. Default 1000.
1312 /// New `initialize` requests beyond this limit are rejected.
1313 #[serde(default, skip_serializing_if = "Option::is_none")]
1314 pub max_concurrent: Option<usize>,
1315}
1316
1317/// A2A (agent-to-agent) protocol config (DW-114 `ai.a2a`). Declares
1318/// the remote A2A agents the gateway can route chat requests to and
1319/// the session policy for agent-to-agent task sessions. The block is
1320/// ADDITIVE: absent (the default), the gateway has no A2A surface.
1321/// Compiled into every build (no cargo feature).
1322#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1323#[serde(deny_unknown_fields)]
1324pub struct A2aConfig {
1325 /// Master switch. Off by default: even when the `a2a` feature is
1326 /// enabled, the block is inert until `enabled: true`. This lets
1327 /// operators stage the config ahead of activating the surface.
1328 #[serde(default, skip_serializing_if = "is_false")]
1329 pub enabled: bool,
1330 /// The A2A agent pool. Each entry names an upstream (the
1331 /// transport) and an Agent Card (discovery doc). Agent names
1332 /// must be non-empty and unique (validation rejects duplicates
1333 /// and empty names). Each agent appears as a provider in the
1334 /// model alias table (its `kind` is `a2a`), so a model alias
1335 /// can route to it by name.
1336 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1337 pub agents: Vec<A2aAgent>,
1338 /// Session policy. Absent (the default): the built-in defaults
1339 /// (TTL 3600s, max 1000 concurrent sessions), mirroring MCP.
1340 #[serde(default, skip_serializing_if = "Option::is_none")]
1341 pub sessions: Option<A2aSessions>,
1342}
1343
1344/// One A2A agent (DW-114 `ai.a2a.agents[]`). The gateway routes a
1345/// chat request to the agent by translating it into an A2A task-submit
1346/// body and placing the call through the named upstream. The Agent
1347/// Card (discovery doc) declares the agent's capabilities and
1348/// authentication; it is parsed at compile time.
1349#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1350#[serde(deny_unknown_fields)]
1351pub struct A2aAgent {
1352 /// Agent name; unique within the `ai.a2a.agents` list. Referenced
1353 /// by `ai.models[].provider` (the agent appears as a provider of
1354 /// kind `a2a`). Validation rejects empty and duplicate names.
1355 pub name: String,
1356 /// The agent's base URL (the A2A endpoint). Must be an `http` or
1357 /// `https` URL. The adapter builds the task-submit path under
1358 /// this URL.
1359 pub url: String,
1360 /// The Agent Card: a discovery doc declaring the agent's name,
1361 /// description, version, capabilities, and authentication.
1362 /// Either a filesystem path to a JSON document (`/path/to/card.json`)
1363 /// or an inline JSON object. Validation rejects an inline card
1364 /// that is not a JSON object. The card is parsed at compile time
1365 /// (see `ai::a2a::AgentCardParser`).
1366 #[serde(default, skip_serializing_if = "Option::is_none")]
1367 pub card: Option<A2aAgentCard>,
1368 /// Name of the `upstreams[]` entry that carries this agent's
1369 /// transport (endpoints, TLS trust, pooling, timeouts, breaker).
1370 /// Validation rejects a reference to an unknown upstream.
1371 pub upstream: String,
1372}
1373
1374/// The Agent Card source (DW-114): a file path or an inline JSON
1375/// object. One of the two is required when `card` is present.
1376#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1377#[serde(deny_unknown_fields)]
1378pub struct A2aAgentCard {
1379 /// Filesystem path to an Agent Card JSON document. Read at
1380 /// compile time; an unreadable or malformed file fails the
1381 /// generation closed (the same DW-045 contract as secret refs).
1382 #[serde(default, skip_serializing_if = "Option::is_none")]
1383 pub path: Option<String>,
1384 /// Inline Agent Card JSON object. When present, takes precedence
1385 /// over `path`. Must be a JSON object.
1386 #[serde(default, skip_serializing_if = "Option::is_none")]
1387 pub inline: Option<serde_json::Value>,
1388}
1389
1390/// A2A session policy (DW-114 `ai.a2a.sessions`), mirroring MCP.
1391#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1392#[serde(deny_unknown_fields)]
1393pub struct A2aSessions {
1394 /// Session TTL in seconds. Default 3600. Expired sessions are
1395 /// cleaned up periodically and rejected on use.
1396 #[serde(default, skip_serializing_if = "Option::is_none")]
1397 pub ttl_secs: Option<u64>,
1398 /// Maximum number of concurrent active sessions. Default 1000.
1399 /// New task sessions beyond this limit are rejected.
1400 #[serde(default, skip_serializing_if = "Option::is_none")]
1401 pub max_concurrent: Option<usize>,
1402}