aid-runtime-python 0.1.39__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. aid_runtime/__init__.py +500 -0
  2. aid_runtime/agents/__init__.py +94 -0
  3. aid_runtime/agents/chat_model.py +351 -0
  4. aid_runtime/agents/deep_agents.py +600 -0
  5. aid_runtime/agents/engine.py +102 -0
  6. aid_runtime/agents/guardrails.py +519 -0
  7. aid_runtime/agents/ir.py +622 -0
  8. aid_runtime/agents/loop.py +1308 -0
  9. aid_runtime/agents/memory.py +241 -0
  10. aid_runtime/agents/policy.py +163 -0
  11. aid_runtime/agents/state.py +182 -0
  12. aid_runtime/cache.py +185 -0
  13. aid_runtime/concurrency.py +70 -0
  14. aid_runtime/control.py +329 -0
  15. aid_runtime/cost.py +168 -0
  16. aid_runtime/errors.py +337 -0
  17. aid_runtime/evals/__init__.py +72 -0
  18. aid_runtime/evals/dataset.py +77 -0
  19. aid_runtime/evals/gate.py +430 -0
  20. aid_runtime/evals/metrics.py +335 -0
  21. aid_runtime/evals/predicate.py +211 -0
  22. aid_runtime/evals/runner.py +224 -0
  23. aid_runtime/gateway.py +473 -0
  24. aid_runtime/pipeline.py +168 -0
  25. aid_runtime/prompts.py +185 -0
  26. aid_runtime/providers/__init__.py +20 -0
  27. aid_runtime/providers/base.py +57 -0
  28. aid_runtime/providers/fake.py +256 -0
  29. aid_runtime/providers/http.py +344 -0
  30. aid_runtime/py.typed +0 -0
  31. aid_runtime/retrieval/__init__.py +178 -0
  32. aid_runtime/retrieval/chunking.py +222 -0
  33. aid_runtime/retrieval/citations.py +131 -0
  34. aid_runtime/retrieval/connectors.py +131 -0
  35. aid_runtime/retrieval/embedding.py +293 -0
  36. aid_runtime/retrieval/gate.py +120 -0
  37. aid_runtime/retrieval/hybrid.py +164 -0
  38. aid_runtime/retrieval/metrics.py +213 -0
  39. aid_runtime/retrieval/pgvector.py +258 -0
  40. aid_runtime/retrieval/rerank.py +133 -0
  41. aid_runtime/retrieval/retriever.py +335 -0
  42. aid_runtime/retrieval/stores.py +161 -0
  43. aid_runtime/retrieval/types.py +433 -0
  44. aid_runtime/steps.py +284 -0
  45. aid_runtime/structured.py +150 -0
  46. aid_runtime/tools.py +271 -0
  47. aid_runtime/tracing.py +241 -0
  48. aid_runtime/types.py +332 -0
  49. aid_runtime_python-0.1.39.dist-info/METADATA +268 -0
  50. aid_runtime_python-0.1.39.dist-info/RECORD +52 -0
  51. aid_runtime_python-0.1.39.dist-info/WHEEL +5 -0
  52. aid_runtime_python-0.1.39.dist-info/top_level.txt +1 -0
@@ -0,0 +1,622 @@
1
+ """The IR seam: an emitted document in, resolved agent definitions out.
2
+
3
+ ``pipeline.py`` consumes the IR's *pipeline* shapes and is deliberately unaware of
4
+ agents; this module is the equivalent seam for them. It reads only the collections
5
+ an agent can reach -- ``agents``, ``models``, ``prompts``, ``tools``, ``memory``,
6
+ ``guardrails`` -- and resolves every reference at load time, so a loop never runs
7
+ against a dangling prompt id or an unpriced model.
8
+
9
+ Nothing here invents vocabulary. Field names are the IR's, ``instructions`` stays
10
+ the prompt *reference* the IR carries and is resolved to ``prompt.template`` here,
11
+ and the scopes are ``thread``/``user``/``global``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Mapping, Sequence
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+ from aid_runtime.errors import ConfigurationError
21
+ from aid_runtime.types import (
22
+ Capability,
23
+ CostProfile,
24
+ ModelCacheConfig,
25
+ ModelSpec,
26
+ SemanticCacheConfig,
27
+ )
28
+
29
+ __all__ = [
30
+ "AgentDefinition",
31
+ "GuardrailDefinition",
32
+ "HumanApproval",
33
+ "MemoryDefinition",
34
+ "RuntimeSpec",
35
+ "ToolSpec",
36
+ "from_ir",
37
+ ]
38
+
39
+ #: IR model capability -> runtime capability. The IR's ``json-schema`` is what the
40
+ #: runtime calls structured output; capabilities with no runtime counterpart
41
+ #: (``vision``, ``reasoning``, ``prompt-cache``, ``audio``) are dropped rather than
42
+ #: invented, because nothing in the loop negotiates them.
43
+ _RUNTIME_CAPABILITY: dict[str, Capability] = {
44
+ "tool-calling": Capability.TOOL_CALLING,
45
+ "json-schema": Capability.STRUCTURED_OUTPUT,
46
+ "streaming": Capability.STREAMING,
47
+ "long-context": Capability.LONG_CONTEXT,
48
+ "embeddings": Capability.EMBEDDINGS,
49
+ "rerank": Capability.RERANK,
50
+ }
51
+
52
+ _ON_BUDGET_EXHAUSTED = frozenset({"fail", "degrade"})
53
+
54
+ #: The closed vocabularies from ``packages/ir/src/shapes.ts``. They are repeated
55
+ #: here on purpose: the build-time gates reject an unknown name, and the runtime
56
+ #: rejects it again rather than trusting that every document came through a gate.
57
+ _GUARDRAIL_STAGES = frozenset({"input", "output", "retrieval", "tool-call"})
58
+ _GUARDRAIL_ACTIONS = frozenset({"block", "redact", "flag", "escalate"})
59
+ _GUARDRAIL_POLICIES = frozenset(
60
+ {
61
+ "pii-detect",
62
+ "pii-redact",
63
+ "prompt-injection",
64
+ "jailbreak",
65
+ "moderation",
66
+ "toxicity",
67
+ "secret-scan",
68
+ }
69
+ )
70
+
71
+ _SIDE_EFFECTS = frozenset({"none", "read", "write", "destructive"})
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class ToolSpec:
76
+ """An IR tool, as the loop needs it: name, side effects, and its auth policy."""
77
+
78
+ id: str
79
+ name: str
80
+ description: str
81
+ requires_confirmation: bool
82
+ side_effects: str
83
+ auth: Mapping[str, Any] | None
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class GuardrailDefinition:
88
+ """One resolved guardrail: where it runs, what it detects, and what it does.
89
+
90
+ ``tools`` holds tool *ids*, because that is how the IR scopes a tool-call
91
+ guardrail; the loop translates a model's tool *name* to the id before asking.
92
+ """
93
+
94
+ id: str
95
+ stages: tuple[str, ...]
96
+ policies: tuple[str, ...]
97
+ action: str
98
+ redact: tuple[str, ...]
99
+ tools: tuple[str, ...]
100
+ message: str
101
+ fallback_prompt: str | None
102
+ log: bool
103
+
104
+
105
+ @dataclass(frozen=True, slots=True)
106
+ class MemoryDefinition:
107
+ """One IR ``memory`` entry, resolved for the scope rules in ``agents.memory``."""
108
+
109
+ id: str
110
+ kind: str
111
+ scope: str
112
+ token_budget: int
113
+ backend: str | None
114
+ store: str | None
115
+ summarize_model: str | None
116
+ ttl: str | None
117
+ redact: tuple[str, ...]
118
+
119
+
120
+ @dataclass(frozen=True, slots=True)
121
+ class HumanApproval:
122
+ """The parsed ``humanInTheLoop`` block: a policy, and a channel to ask on."""
123
+
124
+ when: Mapping[str, Any]
125
+ channel: str
126
+
127
+
128
+ @dataclass(frozen=True, slots=True)
129
+ class AgentDefinition:
130
+ """One agent, with every reference resolved."""
131
+
132
+ id: str
133
+ name: str
134
+ instructions: str
135
+ model: str
136
+ tools: tuple[str, ...]
137
+ memory: tuple[MemoryDefinition, ...]
138
+ max_steps: int
139
+ max_tokens_budget: int | None
140
+ max_cost_budget: float | None
141
+ on_budget_exhausted: str
142
+ handoffs: tuple[str, ...]
143
+ delegates: tuple[str, ...]
144
+ engine: str
145
+ # The pinned Deep Agents release, when the engine is ``deepagents``. Empty for
146
+ # ``native``; the IR gate refuses ``deepagents`` without a pin, so an empty
147
+ # value here means the document skipped the gate.
148
+ engine_version: str
149
+ approval: HumanApproval | None
150
+ guardrails: tuple[GuardrailDefinition, ...] = ()
151
+
152
+ @property
153
+ def has_control_tools(self) -> bool:
154
+ """Whether this agent may move control at all (transfer *or* delegate)."""
155
+ return bool(self.handoffs or self.delegates)
156
+
157
+ def stage_guardrails(self, stage: str) -> tuple[GuardrailDefinition, ...]:
158
+ """Every guardrail declared on this agent that runs at ``stage``.
159
+
160
+ Declaration order is the order of ``guardrails`` in the IR, and that is the
161
+ order the engine evaluates in. It has to come from the document: an order
162
+ the runtime invented would make the outcome of two conflicting guardrails
163
+ depend on which version of the runtime ran the spec.
164
+ """
165
+ return tuple(guardrail for guardrail in self.guardrails if stage in guardrail.stages)
166
+
167
+
168
+ @dataclass(frozen=True, slots=True)
169
+ class RuntimeSpec:
170
+ """Everything the loop needs, resolved from one IR document."""
171
+
172
+ models: Mapping[str, ModelSpec]
173
+ fallbacks: Mapping[str, tuple[str, ...]]
174
+ prompts: Mapping[str, str]
175
+ tools: Mapping[str, ToolSpec]
176
+ agents: Mapping[str, AgentDefinition]
177
+ guardrails: Mapping[str, GuardrailDefinition] = field(default_factory=dict)
178
+ #: The single concurrency cap, read from the IR's ``deployments[].concurrency``.
179
+ #: ``None`` means unbounded: the runtime only shares a limiter when a deployment
180
+ #: declares one, which keeps plain request/response usage free of surprises.
181
+ concurrency: int | None = None
182
+
183
+ def agent(self, agent_id: str) -> AgentDefinition:
184
+ try:
185
+ return self.agents[agent_id]
186
+ except KeyError:
187
+ raise ConfigurationError(
188
+ f"no agent {agent_id!r} in the IR; declared agents: {sorted(self.agents)}"
189
+ ) from None
190
+
191
+ def system_prompt(self, agent: AgentDefinition) -> str:
192
+ try:
193
+ return self.prompts[agent.instructions]
194
+ except KeyError:
195
+ raise ConfigurationError(
196
+ f"agent {agent.id!r} names prompt {agent.instructions!r}, "
197
+ f"which the IR does not declare; declared prompts: {sorted(self.prompts)}"
198
+ ) from None
199
+
200
+ def fallbacks_for(self, alias: str) -> tuple[str, ...]:
201
+ return self.fallbacks.get(alias, ())
202
+
203
+ def tool_specs(self, agent: AgentDefinition) -> tuple[ToolSpec, ...]:
204
+ return tuple(self.tools[tool_id] for tool_id in agent.tools)
205
+
206
+ def price(self, alias: str) -> float:
207
+ """Combined price, the same number ``agents/budget-degrade`` compares.
208
+
209
+ The gate compares ``inputPerMTok + outputPerMTok``; keeping the identical
210
+ sum here means a spec the gate accepted cannot have the runtime pick a
211
+ "cheaper" alias that the gate would have called more expensive.
212
+ """
213
+ spec = self.models[alias]
214
+ return spec.cost.input_per_1k_usd + spec.cost.output_per_1k_usd
215
+
216
+ def cheaper_fallback(self, alias: str) -> str | None:
217
+ """The first declared fallback that is strictly cheaper than ``alias``.
218
+
219
+ One step, in the order the spec declared: this is the alias ladder's next
220
+ rung, not a search for the cheapest reachable model. ``None`` means there
221
+ is nowhere to step, which is what makes a ``degrade`` declaration fail
222
+ closed instead of pretending to degrade.
223
+ """
224
+ budget = self.price(alias)
225
+ for candidate in self.fallbacks_for(alias):
226
+ if candidate in self.models and self.price(candidate) < budget:
227
+ return candidate
228
+ return None
229
+
230
+
231
+ def from_ir(document: Mapping[str, Any]) -> RuntimeSpec:
232
+ """Resolve an emitted IR document into a :class:`RuntimeSpec`."""
233
+ raw_models = _by_id(document.get("models"), "models")
234
+ raw_prompts = _by_id(document.get("prompts"), "prompts")
235
+ raw_tools = _by_id(document.get("tools"), "tools")
236
+ raw_memory = _by_id(document.get("memory"), "memory")
237
+ raw_agents = _by_id(document.get("agents"), "agents")
238
+
239
+ prompts = {
240
+ prompt_id: _required_str(entry, "template", f"prompt {prompt_id!r}")
241
+ for prompt_id, entry in raw_prompts.items()
242
+ }
243
+ tools = {
244
+ tool_id: ToolSpec(
245
+ id=tool_id,
246
+ name=_required_str(entry, "name", f"tool {tool_id!r}"),
247
+ description=_str(entry.get("description")) or "",
248
+ requires_confirmation=entry.get("requiresConfirmation") is True,
249
+ side_effects=_side_effects(tool_id, entry.get("sideEffects")),
250
+ auth=_policy_expr(entry.get("auth"), f"tool {tool_id!r} auth"),
251
+ )
252
+ for tool_id, entry in raw_tools.items()
253
+ }
254
+ memory = {memory_id: _memory(memory_id, entry) for memory_id, entry in raw_memory.items()}
255
+ guardrails = {
256
+ guardrail_id: _guardrail(guardrail_id, entry, tools)
257
+ for guardrail_id, entry in _by_id(document.get("guardrails"), "guardrails").items()
258
+ }
259
+
260
+ fallbacks: dict[str, tuple[str, ...]] = {}
261
+ for model_id, entry in raw_models.items():
262
+ fallbacks[model_id] = tuple(_str_list(entry.get("fallbacks"), f"model {model_id!r}"))
263
+
264
+ agents = {
265
+ agent_id: _agent(agent_id, entry, prompts, tools, memory, guardrails)
266
+ for agent_id, entry in raw_agents.items()
267
+ }
268
+ _check_control_targets(agents)
269
+
270
+ reachable = _reachable_models(agents, fallbacks)
271
+ models = {model_id: _model(model_id, raw_models[model_id]) for model_id in sorted(reachable)}
272
+
273
+ return RuntimeSpec(
274
+ models=models,
275
+ fallbacks={alias: fallbacks.get(alias, ()) for alias in models},
276
+ prompts=prompts,
277
+ tools=tools,
278
+ agents=agents,
279
+ guardrails=guardrails,
280
+ concurrency=_concurrency(document.get("deployments")),
281
+ )
282
+
283
+
284
+ def _agent(
285
+ agent_id: str,
286
+ entry: Mapping[str, Any],
287
+ prompts: Mapping[str, str],
288
+ tools: Mapping[str, ToolSpec],
289
+ memory: Mapping[str, MemoryDefinition],
290
+ guardrails: Mapping[str, GuardrailDefinition],
291
+ ) -> AgentDefinition:
292
+ where = f"agent {agent_id!r}"
293
+ instructions = _str(entry.get("instructions")) or ""
294
+ if instructions not in prompts:
295
+ raise ConfigurationError(
296
+ f"{where} names prompt {instructions!r}, which the IR does not declare; "
297
+ f"declared prompts: {sorted(prompts)}"
298
+ )
299
+ tool_ids = tuple(_str_list(entry.get("tools"), f"{where} tools"))
300
+ missing = sorted(set(tool_ids) - set(tools))
301
+ if missing:
302
+ raise ConfigurationError(f"{where} names undeclared tools: {missing}")
303
+ memory_ids = tuple(_str_list(entry.get("memory"), f"{where} memory"))
304
+ missing_memory = sorted(set(memory_ids) - set(memory))
305
+ if missing_memory:
306
+ raise ConfigurationError(f"{where} names undeclared memory: {missing_memory}")
307
+ guardrail_ids = tuple(_str_list(entry.get("guardrails"), f"{where} guardrails"))
308
+ missing_guardrails = sorted(set(guardrail_ids) - set(guardrails))
309
+ if missing_guardrails:
310
+ raise ConfigurationError(f"{where} names undeclared guardrails: {missing_guardrails}")
311
+
312
+ engine = _str(entry.get("engine")) or "native"
313
+ engine_version = _str(entry.get("engineVersion")) or ""
314
+ on_budget = _str(entry.get("onBudgetExhausted")) or "fail"
315
+ if on_budget not in _ON_BUDGET_EXHAUSTED:
316
+ raise ConfigurationError(
317
+ f"{where} has onBudgetExhausted {on_budget!r}; expected one of "
318
+ f"{sorted(_ON_BUDGET_EXHAUSTED)}"
319
+ )
320
+ max_steps = entry.get("maxSteps")
321
+ if not isinstance(max_steps, int) or isinstance(max_steps, bool) or max_steps < 1:
322
+ raise ConfigurationError(f"{where} needs a positive maxSteps, got {max_steps!r}")
323
+
324
+ return AgentDefinition(
325
+ id=agent_id,
326
+ name=_str(entry.get("name")) or agent_id,
327
+ instructions=instructions,
328
+ model=_str(entry.get("model")) or "",
329
+ tools=tool_ids,
330
+ memory=tuple(memory[memory_id] for memory_id in memory_ids),
331
+ max_steps=max_steps,
332
+ max_tokens_budget=_optional_int(entry.get("maxTokensBudget")),
333
+ max_cost_budget=_optional_float(entry.get("maxCostBudget")),
334
+ on_budget_exhausted=on_budget,
335
+ handoffs=tuple(_str_list(entry.get("handoffs"), f"{where} handoffs")),
336
+ delegates=tuple(_str_list(entry.get("delegates"), f"{where} delegates")),
337
+ engine=engine,
338
+ engine_version=engine_version,
339
+ approval=_approval(where, entry.get("humanInTheLoop")),
340
+ guardrails=tuple(guardrails[guardrail_id] for guardrail_id in guardrail_ids),
341
+ )
342
+
343
+
344
+ def _guardrail(
345
+ guardrail_id: str,
346
+ entry: Mapping[str, Any],
347
+ tools: Mapping[str, ToolSpec],
348
+ ) -> GuardrailDefinition:
349
+ where = f"guardrail {guardrail_id!r}"
350
+ stages = tuple(_str_list(entry.get("stages"), f"{where} stages"))
351
+ unknown_stages = sorted(set(stages) - _GUARDRAIL_STAGES)
352
+ if unknown_stages:
353
+ raise ConfigurationError(
354
+ f"{where} names unknown stages {unknown_stages}; expected a subset of "
355
+ f"{sorted(_GUARDRAIL_STAGES)}"
356
+ )
357
+ policies = tuple(_str_list(entry.get("policies"), f"{where} policies"))
358
+ unknown_policies = sorted(set(policies) - _GUARDRAIL_POLICIES)
359
+ if unknown_policies:
360
+ raise ConfigurationError(
361
+ f"{where} names unknown policies {unknown_policies}; expected a subset of "
362
+ f"{sorted(_GUARDRAIL_POLICIES)}"
363
+ )
364
+ action = _str(entry.get("action")) or ""
365
+ if action not in _GUARDRAIL_ACTIONS:
366
+ raise ConfigurationError(
367
+ f"{where} has action {action!r}; expected one of {sorted(_GUARDRAIL_ACTIONS)}"
368
+ )
369
+ redact = tuple(_str_list(entry.get("redact"), f"{where} redact"))
370
+ if action == "redact" and not redact:
371
+ raise ConfigurationError(
372
+ f"{where} uses action 'redact' with no redact fields; a redaction that names "
373
+ "no field cannot redact anything"
374
+ )
375
+ tool_ids = tuple(_str_list(entry.get("tools"), f"{where} tools"))
376
+ missing = sorted(set(tool_ids) - set(tools))
377
+ if missing:
378
+ raise ConfigurationError(f"{where} names undeclared tools: {missing}")
379
+
380
+ on_fail = entry.get("onFail")
381
+ if not isinstance(on_fail, Mapping):
382
+ raise ConfigurationError(f"{where} needs an onFail object")
383
+ return GuardrailDefinition(
384
+ id=guardrail_id,
385
+ stages=stages,
386
+ policies=policies,
387
+ action=action,
388
+ redact=redact,
389
+ tools=tool_ids,
390
+ message=_required_str(on_fail, "message", f"{where} onFail"),
391
+ fallback_prompt=_str(on_fail.get("fallbackPrompt")),
392
+ log=on_fail.get("log") is True,
393
+ )
394
+
395
+
396
+ def _side_effects(tool_id: str, raw: Any) -> str:
397
+ if raw is None:
398
+ return "none"
399
+ value = _str(raw)
400
+ if value not in _SIDE_EFFECTS:
401
+ raise ConfigurationError(
402
+ f"tool {tool_id!r} has sideEffects {raw!r}; expected one of {sorted(_SIDE_EFFECTS)}"
403
+ )
404
+ return value
405
+
406
+
407
+ def _policy_expr(raw: Any, where: str) -> Mapping[str, Any] | None:
408
+ if raw is None:
409
+ return None
410
+ if not isinstance(raw, Mapping):
411
+ raise ConfigurationError(
412
+ f"{where} must be a parsed policy object, got {type(raw).__name__}"
413
+ )
414
+ return raw
415
+
416
+
417
+ def _approval(where: str, raw: Any) -> HumanApproval | None:
418
+ if raw is None:
419
+ return None
420
+ if not isinstance(raw, Mapping):
421
+ raise ConfigurationError(f"{where} humanInTheLoop must be an object")
422
+ when = raw.get("when")
423
+ channel = _str(raw.get("channel")) or ""
424
+ if not isinstance(when, Mapping):
425
+ raise ConfigurationError(f"{where} humanInTheLoop needs a parsed 'when' policy")
426
+ if not channel:
427
+ raise ConfigurationError(
428
+ f"{where} declares humanInTheLoop with no channel; without a channel there "
429
+ "is nowhere to ask, so the loop refuses to run it"
430
+ )
431
+ return HumanApproval(when=when, channel=channel)
432
+
433
+
434
+ def _check_control_targets(agents: Mapping[str, AgentDefinition]) -> None:
435
+ for agent in agents.values():
436
+ for field_name, targets in (("handoffs", agent.handoffs), ("delegates", agent.delegates)):
437
+ unknown = sorted(set(targets) - set(agents))
438
+ if unknown:
439
+ raise ConfigurationError(
440
+ f"agent {agent.id!r} {field_name} undeclared agents: {unknown}"
441
+ )
442
+ if agent.id in targets:
443
+ raise ConfigurationError(
444
+ f"agent {agent.id!r} {field_name} itself; a control edge must leave the agent"
445
+ )
446
+ both = sorted(set(agent.handoffs) & set(agent.delegates))
447
+ if both:
448
+ raise ConfigurationError(
449
+ f"agent {agent.id!r} lists {both} in both handoffs and delegates; "
450
+ "transfer and delegation are different semantics, so a target is one or the other"
451
+ )
452
+
453
+
454
+ def _reachable_models(
455
+ agents: Mapping[str, AgentDefinition], fallbacks: Mapping[str, tuple[str, ...]]
456
+ ) -> set[str]:
457
+ reachable: set[str] = set()
458
+ queue = [agent.model for agent in agents.values()]
459
+ while queue:
460
+ alias = queue.pop()
461
+ if alias in reachable:
462
+ continue
463
+ reachable.add(alias)
464
+ queue.extend(fallbacks.get(alias, ()))
465
+ return reachable
466
+
467
+
468
+ def _model(model_id: str, entry: Mapping[str, Any]) -> ModelSpec:
469
+ where = f"model {model_id!r}"
470
+ cost = entry.get("costProfile")
471
+ if not isinstance(cost, Mapping):
472
+ raise ConfigurationError(
473
+ f"{where} has no resolved costProfile; a budget cannot be enforced against "
474
+ "an unpriced model"
475
+ )
476
+ limits = entry.get("limits")
477
+ if not isinstance(limits, Mapping):
478
+ raise ConfigurationError(f"{where} has no limits")
479
+ context_tokens = limits.get("contextTokens")
480
+ if (
481
+ isinstance(context_tokens, bool)
482
+ or not isinstance(context_tokens, int)
483
+ or context_tokens < 1
484
+ ):
485
+ raise ConfigurationError(f"{where} has no positive limits.contextTokens")
486
+ max_output = limits.get("maxOutputTokens")
487
+ if not isinstance(max_output, int) or isinstance(max_output, bool) or max_output < 1:
488
+ # The IR treats an output ceiling as optional; the runtime must still have
489
+ # one, so the context window is the only ceiling the IR guarantees.
490
+ max_output = context_tokens
491
+
492
+ capabilities = frozenset(
493
+ _RUNTIME_CAPABILITY[name]
494
+ for name in _str_list(entry.get("capabilities"), f"{where} capabilities")
495
+ if name in _RUNTIME_CAPABILITY
496
+ )
497
+ return ModelSpec(
498
+ name=_required_str(entry, "modelId", where),
499
+ provider=_required_str(entry, "provider", where),
500
+ cost=CostProfile(
501
+ input_per_1k_usd=float(cost.get("inputPerMTok", 0.0)) / 1000.0,
502
+ output_per_1k_usd=float(cost.get("outputPerMTok", 0.0)) / 1000.0,
503
+ ),
504
+ context_window=context_tokens,
505
+ max_output_tokens=max_output,
506
+ capabilities=capabilities,
507
+ cache=_cache(where, entry.get("cache")),
508
+ )
509
+
510
+
511
+ def _cache(where: str, raw: Any) -> ModelCacheConfig | None:
512
+ """Parse the IR ``cache`` shape into a :class:`ModelCacheConfig`, or ``None``.
513
+
514
+ The IR carries ``cache: {prompt?: boolean, semantic?: {threshold, ttlSeconds}}``.
515
+ Semantic reuse is opt-in and carries an explicit threshold and TTL; there is no
516
+ default threshold, so a semantic entry missing either is a configuration error
517
+ rather than a silently lenient cache.
518
+ """
519
+ if raw is None:
520
+ return None
521
+ if not isinstance(raw, Mapping):
522
+ raise ConfigurationError(f"{where} cache must be an object")
523
+ semantic = None
524
+ semantic_raw = raw.get("semantic")
525
+ if semantic_raw is not None:
526
+ if not isinstance(semantic_raw, Mapping):
527
+ raise ConfigurationError(f"{where} cache.semantic must be an object")
528
+ threshold = semantic_raw.get("threshold")
529
+ if isinstance(threshold, bool) or not isinstance(threshold, (int, float)):
530
+ raise ConfigurationError(f"{where} cache.semantic needs a threshold in [0, 1]")
531
+ threshold = float(threshold)
532
+ if not 0.0 <= threshold <= 1.0:
533
+ raise ConfigurationError(
534
+ f"{where} cache.semantic.threshold must be in [0, 1], got {threshold}"
535
+ )
536
+ ttl = semantic_raw.get("ttlSeconds")
537
+ if isinstance(ttl, bool) or not isinstance(ttl, int) or ttl < 1:
538
+ raise ConfigurationError(f"{where} cache.semantic needs a positive ttlSeconds")
539
+ semantic = SemanticCacheConfig(threshold=threshold, ttl_seconds=ttl)
540
+ return ModelCacheConfig(prompt=raw.get("prompt") is True, semantic=semantic)
541
+
542
+
543
+ def _concurrency(raw: Any) -> int | None:
544
+ """The single cap the IR's deployments declare, or ``None`` (unbounded).
545
+
546
+ ``deployments`` is an array; the runtime shares one limiter, so the cap is the
547
+ highest ``concurrency`` any deployment declares. A spec that declares none gets
548
+ ``None``, which means the gateway and executor stay unbounded by default.
549
+ """
550
+ if not isinstance(raw, Sequence) or isinstance(raw, str):
551
+ return None
552
+ declared: list[int] = []
553
+ for entry in raw:
554
+ if not isinstance(entry, Mapping):
555
+ continue
556
+ value = entry.get("concurrency")
557
+ if isinstance(value, int) and not isinstance(value, bool) and value >= 1:
558
+ declared.append(value)
559
+ return max(declared) if declared else None
560
+
561
+
562
+ def _memory(memory_id: str, entry: Mapping[str, Any]) -> MemoryDefinition:
563
+ where = f"memory {memory_id!r}"
564
+ token_budget = entry.get("tokenBudget")
565
+ if not isinstance(token_budget, int) or isinstance(token_budget, bool) or token_budget < 1:
566
+ raise ConfigurationError(f"{where} needs a positive tokenBudget")
567
+ return MemoryDefinition(
568
+ id=memory_id,
569
+ kind=_str(entry.get("kind")) or "buffer",
570
+ scope=_str(entry.get("scope")) or "thread",
571
+ token_budget=token_budget,
572
+ backend=_str(entry.get("backend")),
573
+ store=_str(entry.get("store")),
574
+ summarize_model=_str(entry.get("summarizeModel")),
575
+ ttl=_str(entry.get("ttl")),
576
+ redact=tuple(_str_list(entry.get("redact"), f"{where} redact")),
577
+ )
578
+
579
+
580
+ def _by_id(raw: Any, collection: str) -> dict[str, Mapping[str, Any]]:
581
+ if raw is None:
582
+ return {}
583
+ if not isinstance(raw, Sequence) or isinstance(raw, str):
584
+ raise ConfigurationError(f"IR {collection} must be an array")
585
+ resolved: dict[str, Mapping[str, Any]] = {}
586
+ for entry in raw:
587
+ if not isinstance(entry, Mapping):
588
+ raise ConfigurationError(f"IR {collection} contains a non-object entry")
589
+ entry_id = _str(entry.get("id"))
590
+ if not entry_id:
591
+ raise ConfigurationError(f"IR {collection} contains an entry with no id")
592
+ resolved[entry_id] = entry
593
+ return resolved
594
+
595
+
596
+ def _str(value: Any) -> str | None:
597
+ return value if isinstance(value, str) else None
598
+
599
+
600
+ def _required_str(entry: Mapping[str, Any], key: str, where: str) -> str:
601
+ value = _str(entry.get(key))
602
+ if not value:
603
+ raise ConfigurationError(f"{where} has no {key!r}")
604
+ return value
605
+
606
+
607
+ def _str_list(value: Any, where: str) -> list[str]:
608
+ if value is None:
609
+ return []
610
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
611
+ raise ConfigurationError(f"{where} must be an array of strings")
612
+ return [item for item in value if isinstance(item, str)]
613
+
614
+
615
+ def _optional_int(value: Any) -> int | None:
616
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
617
+
618
+
619
+ def _optional_float(value: Any) -> float | None:
620
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
621
+ return None
622
+ return float(value)