pdl-taskmaster 2.4.0__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 (92) hide show
  1. pdl_taskmaster/__init__.py +6 -0
  2. pdl_taskmaster/contracts/AUTHORITY_MAP.json +19 -0
  3. pdl_taskmaster/contracts/CALIBRATION.json +7 -0
  4. pdl_taskmaster/contracts/CONTRACT_MANIFEST.json +231 -0
  5. pdl_taskmaster/contracts/EXECUTION_CONTRACT.json +615 -0
  6. pdl_taskmaster/contracts/IMPLEMENTATION_BOUNDARY.json +32 -0
  7. pdl_taskmaster/contracts/VERIFICATION_CONTRACT.json +379 -0
  8. pdl_taskmaster/contracts/standards/ARTIFACT_STANDARD.md +7 -0
  9. pdl_taskmaster/contracts/standards/AUTHORITY_STANDARD.md +17 -0
  10. pdl_taskmaster/contracts/standards/CONFORMANCE_STANDARD.md +15 -0
  11. pdl_taskmaster/contracts/standards/CONTEXT_STANDARD.md +15 -0
  12. pdl_taskmaster/contracts/standards/EXECUTION_STANDARD.md +14 -0
  13. pdl_taskmaster/contracts/standards/HANDOFF_STANDARD.md +21 -0
  14. pdl_taskmaster/contracts/standards/INSTALLATION_STANDARD.md +17 -0
  15. pdl_taskmaster/contracts/standards/PDL_STANDARD.md +19 -0
  16. pdl_taskmaster/contracts/standards/PROMPT_STANDARD.md +13 -0
  17. pdl_taskmaster/contracts/standards/PROTOCOL_STANDARD.md +17 -0
  18. pdl_taskmaster/contracts/standards/RESPONSE_PLAN_STANDARD.md +24 -0
  19. pdl_taskmaster/contracts/standards/RESULT_STANDARD.md +35 -0
  20. pdl_taskmaster/contracts/standards/REVIEW_STANDARD.md +31 -0
  21. pdl_taskmaster/contracts/standards/SEMANTIC_INPUT_STANDARD.md +15 -0
  22. pdl_taskmaster/contracts/standards/TASK_SEMANTICS_STANDARD.md +9 -0
  23. pdl_taskmaster/controller/__init__.py +0 -0
  24. pdl_taskmaster/controller/mechanical_controller.py +542 -0
  25. pdl_taskmaster/controller/schemas/activation_decision.schema.json +34 -0
  26. pdl_taskmaster/controller/schemas/artifact_body.schema.json +14 -0
  27. pdl_taskmaster/controller/schemas/artifact_review_facts.schema.json +91 -0
  28. pdl_taskmaster/controller/schemas/bootstrap_analysis.schema.json +60 -0
  29. pdl_taskmaster/controller/schemas/execution_draft.schema.json +47 -0
  30. pdl_taskmaster/controller/schemas/execution_input_decision.schema.json +43 -0
  31. pdl_taskmaster/controller/schemas/execution_outcome.schema.json +201 -0
  32. pdl_taskmaster/controller/schemas/plan_artifact.schema.json +14 -0
  33. pdl_taskmaster/controller/schemas/prompt_artifact.schema.json +63 -0
  34. pdl_taskmaster/controller/schemas/protocol_discussion.schema.json +8 -0
  35. pdl_taskmaster/controller/schemas/result_ir_repair.schema.json +140 -0
  36. pdl_taskmaster/eval/__init__.py +1 -0
  37. pdl_taskmaster/eval/build_adversarial_battery.py +351 -0
  38. pdl_taskmaster/eval/compare_eval_runs.py +251 -0
  39. pdl_taskmaster/eval/control_api_call.py +428 -0
  40. pdl_taskmaster/eval/export_rlcd_dataset.py +799 -0
  41. pdl_taskmaster/eval/fidelity_scan.py +268 -0
  42. pdl_taskmaster/eval/gen_fidelity_battery.py +457 -0
  43. pdl_taskmaster/eval/leak_scan.py +260 -0
  44. pdl_taskmaster/eval/rerecord_fixtures.py +139 -0
  45. pdl_taskmaster/eval/run_canary_shape_probe.py +229 -0
  46. pdl_taskmaster/eval/run_dual_gate_probe.py +404 -0
  47. pdl_taskmaster/eval/run_qualified_batch.py +841 -0
  48. pdl_taskmaster/eval/summarize_run.py +194 -0
  49. pdl_taskmaster/host/__init__.py +1 -0
  50. pdl_taskmaster/host/app.py +164 -0
  51. pdl_taskmaster/host/cli.py +100 -0
  52. pdl_taskmaster/host/repl.py +866 -0
  53. pdl_taskmaster/observation/__init__.py +1 -0
  54. pdl_taskmaster/observation/cli.py +47 -0
  55. pdl_taskmaster/observation/observed_session.py +219 -0
  56. pdl_taskmaster/observation/parsers.py +66 -0
  57. pdl_taskmaster/observation/records.py +58 -0
  58. pdl_taskmaster/observation/session.py +3 -0
  59. pdl_taskmaster/observation/sink.py +3 -0
  60. pdl_taskmaster/observation/sinks.py +20 -0
  61. pdl_taskmaster/providers/README.md +66 -0
  62. pdl_taskmaster/providers/__init__.py +1 -0
  63. pdl_taskmaster/providers/api_worker.py +411 -0
  64. pdl_taskmaster/providers/base.py +26 -0
  65. pdl_taskmaster/providers/codex_worker.py +328 -0
  66. pdl_taskmaster/providers/fixtures.py +157 -0
  67. pdl_taskmaster/providers/live_stub.py +66 -0
  68. pdl_taskmaster/providers/recorded.py +120 -0
  69. pdl_taskmaster/py.typed +1 -0
  70. pdl_taskmaster/runtime/__init__.py +0 -0
  71. pdl_taskmaster/runtime/context_compiler.py +151 -0
  72. pdl_taskmaster/runtime/model_classification.py +290 -0
  73. pdl_taskmaster/runtime/normative_store.py +157 -0
  74. pdl_taskmaster/runtime/operation_bridge.py +336 -0
  75. pdl_taskmaster/runtime/presentation.py +18 -0
  76. pdl_taskmaster/runtime/quarantine.py +91 -0
  77. pdl_taskmaster/runtime/result_ir.py +401 -0
  78. pdl_taskmaster/runtime/semantic_observer.py +40 -0
  79. pdl_taskmaster/runtime/session_engine.py +1168 -0
  80. pdl_taskmaster/runtime/standard_registry.py +55 -0
  81. pdl_taskmaster/runtime/wire_payloads.py +462 -0
  82. pdl_taskmaster/runtime/worker-bootstrap.txt +4 -0
  83. pdl_taskmaster/runtime/workspace.py +602 -0
  84. pdl_taskmaster/tracking/__init__.py +1 -0
  85. pdl_taskmaster/tracking/log_live_session.py +98 -0
  86. pdl_taskmaster/tracking/mlflow_sink.py +58 -0
  87. pdl_taskmaster/verify/verify_repl_baseline.py +299 -0
  88. pdl_taskmaster-2.4.0.dist-info/METADATA +155 -0
  89. pdl_taskmaster-2.4.0.dist-info/RECORD +92 -0
  90. pdl_taskmaster-2.4.0.dist-info/WHEEL +4 -0
  91. pdl_taskmaster-2.4.0.dist-info/entry_points.txt +2 -0
  92. pdl_taskmaster-2.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,411 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import socket
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ import urllib.error
10
+ import urllib.request
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from pdl_taskmaster.providers.base import TransportError, WorkerResult
15
+
16
+ _JSON_ONLY_SUFFIX = (
17
+ "\n\nReturn only a JSON object. Do not include markdown fences, commentary, or extra text."
18
+ )
19
+
20
+
21
+ _SEMANTIC_READ_OPERATIONS = {"BOOTSTRAP_ANALYSIS", "INTERPRET_ACTIVATION"}
22
+
23
+ DEFAULT_PROVIDER_PINNING: dict[str, Any] = {
24
+ "order": ["Google"],
25
+ "allow_fallbacks": False,
26
+ }
27
+
28
+ DEFAULT_SAFETY_SETTINGS: list[dict[str, str]] = [
29
+ {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
30
+ {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
31
+ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
32
+ {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
33
+ ]
34
+
35
+
36
+ class ApiWorker:
37
+ """LIVE SEMANTIC WORKER backed by a direct Responses-API HTTP call.
38
+
39
+ This is a drop-in replacement for CodexWorker that talks straight to an
40
+ OpenAI-compatible `/responses` endpoint (e.g. OpenRouter) instead of
41
+ shelling out to `codex exec`. It sends exactly the same request.prompt
42
+ every other worker receives -- no tool definitions, no sandbox, no
43
+ agentic system prompt -- so the paid overhead is the actual projection
44
+ content, not a coding-agent's scaffold.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ *,
50
+ model: str = "z-ai/glm-4.7",
51
+ repo_root: str | Path,
52
+ base_url: str = "https://openrouter.ai/api/v1",
53
+ api_key_env: str = "OPENROUTER_API_KEY",
54
+ api_key_command: list[str] | None = None,
55
+ timeout: float = 600.0,
56
+ capture_tokens: bool = True,
57
+ reasoning_effort: str | None = None,
58
+ reasoning_by_operation: dict[str, str] | None = None,
59
+ model_by_operation: dict[str, str] | None = None,
60
+ reorder_keys_for_cache: bool = False,
61
+ structured_output: bool = False,
62
+ provider_pinning: dict[str, Any] | None = None,
63
+ safety_settings: list[dict[str, str]] | None = None,
64
+ on_progress: Any = None,
65
+ ):
66
+ self.model = model
67
+ self.base_url = base_url.rstrip("/")
68
+ self.api_key_env = api_key_env
69
+ self.timeout = timeout
70
+ self.capture_tokens = capture_tokens
71
+ self.reasoning_effort = reasoning_effort
72
+ if reasoning_by_operation:
73
+ self.reasoning_by_operation = dict(reasoning_by_operation)
74
+ elif reasoning_effort is None:
75
+ from pdl_taskmaster.runtime.model_classification import get_proportional_reasoning_mapping
76
+ self.reasoning_by_operation = get_proportional_reasoning_mapping(self.model)
77
+ else:
78
+ self.reasoning_by_operation = {}
79
+ self.model_by_operation = dict(model_by_operation or {})
80
+ self.reorder_keys_for_cache = reorder_keys_for_cache
81
+ self.structured_output = structured_output
82
+ self.provider_pinning = provider_pinning if provider_pinning is not None else dict(DEFAULT_PROVIDER_PINNING)
83
+ self.safety_settings = safety_settings if safety_settings is not None else list(DEFAULT_SAFETY_SETTINGS)
84
+ self.on_progress = on_progress
85
+ self.worker_profile = "api"
86
+ self.api_key_command = api_key_command or self._default_api_key_command(api_key_env)
87
+ bootstrap_path = Path(__file__).resolve().parents[1] / "runtime" / "worker-bootstrap.txt"
88
+ if not bootstrap_path.is_file():
89
+ bootstrap_path = Path(repo_root) / "src" / "pdl_taskmaster" / "runtime" / "worker-bootstrap.txt"
90
+ if not bootstrap_path.is_file():
91
+ bootstrap_path = Path(repo_root) / "scripts" / "runtime" / "worker-bootstrap.txt"
92
+ self._bootstrap = bootstrap_path.read_text(encoding="utf-8")
93
+ self._bootstrap_prefix = self._bootstrap.rstrip() + "\n\n"
94
+
95
+ @staticmethod
96
+ def _default_api_key_command(env_name: str) -> list[str] | None:
97
+ if os.environ.get(env_name):
98
+ return None
99
+ if sys.platform != "win32":
100
+ return None
101
+ # On Windows, fall back to reading Machine then User scope via PowerShell
102
+ # to pick up variables defined outside the current process environment.
103
+ script = (
104
+ f"$v=[Environment]::GetEnvironmentVariable('{env_name}','Machine'); "
105
+ f"if ([string]::IsNullOrWhiteSpace($v)) {{ $v=[Environment]::GetEnvironmentVariable('{env_name}','User') }}; "
106
+ f"if ([string]::IsNullOrWhiteSpace($v)) {{ Write-Error '{env_name} not found in Machine or User environment'; exit 1 }}; "
107
+ "[Console]::Out.Write($v)"
108
+ )
109
+ return ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script]
110
+
111
+ def _resolve_api_key(self) -> str:
112
+ if self.api_key_command is None:
113
+ key = (os.environ.get(self.api_key_env) or "").strip()
114
+ if not key:
115
+ raise TransportError(f"could not resolve {self.api_key_env}: variable is unset or empty")
116
+ return key
117
+ try:
118
+ proc = subprocess.run(
119
+ self.api_key_command,
120
+ capture_output=True,
121
+ text=True,
122
+ encoding="utf-8",
123
+ errors="replace",
124
+ timeout=15,
125
+ )
126
+ except Exception as exc:
127
+ raise TransportError(f"could not run api_key_command: {exc}") from exc
128
+ key = (proc.stdout or "").strip()
129
+ if proc.returncode != 0 or not key:
130
+ detail = (proc.stderr or "").strip() or "empty key"
131
+ raise TransportError(f"could not resolve {self.api_key_env}: {detail}")
132
+ return key
133
+
134
+ def _reasoning_for(self, operation: str | None) -> str | int | None:
135
+ """Per-operation effort wins over the global default; 'none' disables reasoning."""
136
+ if operation is not None and operation in self.reasoning_by_operation:
137
+ return self.reasoning_by_operation[operation]
138
+ return self.reasoning_effort
139
+
140
+ def _model_for(self, operation: str | None) -> str:
141
+ """Per-operation model wins over the global default."""
142
+ if operation is not None and operation in self.model_by_operation:
143
+ return self.model_by_operation[operation]
144
+ return self.model
145
+
146
+ def _split_prompt(self, prompt: str) -> tuple[str, str]:
147
+ """Split a rendered request.prompt back into (instructions, input).
148
+
149
+ request.prompt is always bootstrap.rstrip() + "\\n\\n" + <document>.
150
+ If the current bootstrap text doesn't match (e.g. it changed since
151
+ this worker was constructed), fall back to sending the whole prompt
152
+ as input with no separate instructions -- still correct, just
153
+ without the system/user split.
154
+ """
155
+ if prompt.startswith(self._bootstrap_prefix):
156
+ return self._bootstrap.rstrip(), prompt[len(self._bootstrap_prefix):]
157
+ return "", prompt
158
+
159
+ @staticmethod
160
+ def _reorder_for_cache(document_json: str) -> str:
161
+ """Reorder the projection document for provider prefix caching.
162
+
163
+ Stable/shared content first (output_schema, clause list), volatile
164
+ content last (bound values, operation id). Parsed content is identical;
165
+ only key order changes. Same-shape operations (e.g. the two REVIEW
166
+ calls) then hold a byte-identical prompt prefix, which is what
167
+ provider prefix caches key on. No-op on non-JSON input.
168
+ """
169
+ try:
170
+ doc = json.loads(document_json)
171
+ except json.JSONDecodeError:
172
+ return document_json
173
+ if not isinstance(doc, dict) or not isinstance(doc.get("operation_inputs"), dict):
174
+ return document_json
175
+ inputs = doc["operation_inputs"]
176
+ ordered: dict[str, Any] = {}
177
+ for key in ("output_schema", "artifact_kind", "output_kind"):
178
+ if key in doc:
179
+ ordered[key] = doc[key]
180
+ new_inputs: dict[str, Any] = {}
181
+ for key in ("APPLICABLE_STANDARD_CLAUSES", "HIGHER_PRIORITY_CONSTRAINTS"):
182
+ if key in inputs:
183
+ new_inputs[key] = inputs[key]
184
+ for key, value in inputs.items():
185
+ if key not in new_inputs:
186
+ new_inputs[key] = value
187
+ ordered["operation_inputs"] = new_inputs
188
+ for key, value in doc.items():
189
+ if key not in ordered and key != "operation":
190
+ ordered[key] = value
191
+ ordered["operation"] = doc["operation"]
192
+ return json.dumps(ordered, ensure_ascii=False, separators=(",", ":"))
193
+
194
+ @staticmethod
195
+ def _sanitize_schema_for_grammar(schema: Any) -> Any:
196
+ """Sanitize schema for grammar engines (Vertex, Venice, Outlines, vLLM).
197
+
198
+ Cleans unsupported keywords like $schema, minLength, etc., converts 'const'
199
+ to single-item 'enum', while strictly preserving oneOf branch structures
200
+ and required field invariants.
201
+ """
202
+ if not isinstance(schema, dict):
203
+ return schema
204
+
205
+ def _clean_node(node: Any) -> Any:
206
+ if not isinstance(node, dict):
207
+ return node
208
+ res = dict(node)
209
+ for key_to_drop in ("$schema", "minLength", "maxLength", "minItems", "maxItems", "uniqueItems"):
210
+ res.pop(key_to_drop, None)
211
+ if "const" in res:
212
+ res["enum"] = [res.pop("const")]
213
+ if "type" not in res:
214
+ res["type"] = "string"
215
+ if "properties" in res and isinstance(res["properties"], dict):
216
+ clean_props: dict[str, Any] = {}
217
+ for k, v in res["properties"].items():
218
+ clean_props[k] = _clean_node(v)
219
+ res["properties"] = clean_props
220
+ if "items" in res:
221
+ res["items"] = _clean_node(res["items"])
222
+ if "oneOf" in res and isinstance(res["oneOf"], list):
223
+ # Vertex/Gemini grammar enforcement accepts anyOf but rejects
224
+ # oneOf; all oneOf branches here are distinguished by a const
225
+ # 'kind' discriminator, so anyOf is semantically equivalent.
226
+ res.pop("oneOf", None)
227
+ res["anyOf"] = [_clean_node(b) for b in node["oneOf"]]
228
+ if "anyOf" in res and isinstance(res["anyOf"], list):
229
+ res["anyOf"] = [_clean_node(b) for b in res["anyOf"]]
230
+ if "allOf" in res and isinstance(res["allOf"], list):
231
+ res["allOf"] = [_clean_node(b) for b in res["allOf"]]
232
+ return res
233
+
234
+ return _clean_node(schema)
235
+
236
+ def _send_json_with_retries(self, req: urllib.request.Request) -> dict[str, Any]:
237
+ """POST with exponential-backoff retries; returns the parsed response.
238
+
239
+ Retries transient transport conditions: 429/5xx, URLError, timeouts.
240
+ Non-retryable HTTP errors (4xx besides 429) raise immediately.
241
+ """
242
+ raw = None
243
+ for attempt in range(5):
244
+ try:
245
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
246
+ raw = resp.read().decode("utf-8", errors="replace")
247
+ break
248
+ except urllib.error.HTTPError as exc:
249
+ if exc.code in (429, 502, 503, 504) and attempt < 4:
250
+ time.sleep(3.0 * (2 ** attempt))
251
+ continue
252
+ detail = exc.read().decode("utf-8", errors="replace")[:2000]
253
+ raise TransportError(f"api worker HTTP {exc.code}: {detail}") from exc
254
+ except urllib.error.URLError as exc:
255
+ if attempt < 4:
256
+ time.sleep(3.0 * (2 ** attempt))
257
+ continue
258
+ raise TransportError(f"api worker transport error: {exc.reason}") from exc
259
+ except (TimeoutError, socket.timeout) as exc:
260
+ if attempt < 4:
261
+ time.sleep(3.0 * (2 ** attempt))
262
+ continue
263
+ raise TransportError(f"api worker timed out after {self.timeout}s") from exc
264
+ if raw is None:
265
+ raise TransportError("api worker failed after retries")
266
+ try:
267
+ return json.loads(raw)
268
+ except json.JSONDecodeError as exc:
269
+ raise TransportError(f"api worker returned non-JSON response: {raw[:500]}") from exc
270
+
271
+ def call(self, request: Any) -> WorkerResult:
272
+ instructions, input_text = self._split_prompt(request.prompt)
273
+ if self.reorder_keys_for_cache:
274
+ input_text = self._reorder_for_cache(input_text.lstrip())
275
+ input_text = input_text.rstrip() + _JSON_ONLY_SUFFIX
276
+
277
+ body: dict[str, Any] = {"model": self._model_for(getattr(request, "operation", None)), "input": input_text}
278
+ # ADR-0009 finding: schema enforcement on the semantic-read boundary
279
+ # (BOOTSTRAP_ANALYSIS) degrades interpretation quality — a lazy
280
+ # structured summary classifies substantive tasks as instruction-free,
281
+ # losing the entire task upstream of every gate. Structured output is
282
+ # for ops whose SHAPE is the contract (drafts, executions, reviews);
283
+ # the semantic read must stay free-text.
284
+ operation_name = getattr(request, "operation", None)
285
+ schema_enforced = self.structured_output and operation_name not in _SEMANTIC_READ_OPERATIONS
286
+ if instructions:
287
+ body["instructions"] = instructions
288
+ effort = self._reasoning_for(getattr(request, "operation", None))
289
+ if effort == "none":
290
+ body["reasoning"] = {"enabled": False}
291
+ elif effort is not None:
292
+ if isinstance(effort, int) or (isinstance(effort, str) and effort.isdigit()):
293
+ body["reasoning"] = {"max_tokens": int(effort)}
294
+ else:
295
+ body["reasoning"] = {"effort": effort}
296
+
297
+ if self.provider_pinning:
298
+ body["provider"] = self.provider_pinning
299
+ if self.safety_settings:
300
+ body["safety_settings"] = self.safety_settings
301
+
302
+ if schema_enforced:
303
+ manifest = getattr(request, "manifest", None) or {}
304
+ output_kind = manifest.get("output_kind", "json_object")
305
+ from pdl_taskmaster.runtime.wire_payloads import get_operation_pydantic_schema
306
+ schema = get_operation_pydantic_schema(operation_name)
307
+ if not isinstance(schema, dict):
308
+ projection = getattr(request, "projection", None)
309
+ if projection is not None and isinstance(getattr(projection, "document", None), dict):
310
+ schema = projection.document.get("output_schema")
311
+ if not isinstance(schema, dict) and isinstance(manifest.get("output_schema"), dict):
312
+ schema = manifest["output_schema"]
313
+ elif not isinstance(schema, dict) and isinstance(manifest.get("output_schema"), str):
314
+ schema_path = self.repo_root / manifest["output_schema"]
315
+ if schema_path.is_file():
316
+ try:
317
+ schema = json.loads(schema_path.read_text(encoding="utf-8"))
318
+ except Exception:
319
+ schema = None
320
+ if schema and isinstance(schema, dict):
321
+ body["text"] = {
322
+ "format": {
323
+ "type": "json_schema",
324
+ "name": output_kind,
325
+ "schema": self._sanitize_schema_for_grammar(schema),
326
+ }
327
+ }
328
+
329
+ api_key = self._resolve_api_key()
330
+ req = urllib.request.Request(
331
+ f"{self.base_url}/responses",
332
+ data=json.dumps(body).encode("utf-8"),
333
+ headers={
334
+ "Authorization": f"Bearer {api_key}",
335
+ "Content-Type": "application/json",
336
+ },
337
+ method="POST",
338
+ )
339
+
340
+ started = time.perf_counter()
341
+ data = self._send_json_with_retries(req)
342
+ latency_ms = (time.perf_counter() - started) * 1000.0
343
+
344
+ if self.on_progress is not None:
345
+ try:
346
+ self.on_progress(f"response received in {latency_ms:.0f}ms")
347
+ except Exception:
348
+ pass
349
+
350
+ if data.get("error"):
351
+ raise TransportError(f"api worker reported an error: {data['error']}")
352
+ status = data.get("status")
353
+ if status not in (None, "completed"):
354
+ raise TransportError(f"api worker response status={status}: {data.get('incomplete_details')}")
355
+
356
+ text = self._extract_output_text(data)
357
+ if not text:
358
+ # Empty output with status=completed is a stochastic upstream fault
359
+ # (output tokens consumed, message dropped — observed on shared-pool
360
+ # aggregators). It is a transport condition, not model behavior:
361
+ # retry with the same exponential-backoff treatment as 429/5xx.
362
+ for retry in range(3):
363
+ time.sleep(3.0 * (2 ** retry))
364
+ data = self._send_json_with_retries(req)
365
+ text = self._extract_output_text(data)
366
+ if text:
367
+ break
368
+ if not text:
369
+ raise TransportError("api worker returned no output_text content after empty-output retries")
370
+
371
+ usage_raw = data.get("usage") or {}
372
+ usage: dict[str, Any] = {}
373
+ for key in ("input_tokens", "output_tokens", "total_tokens"):
374
+ if isinstance(usage_raw.get(key), (int, float)):
375
+ usage[key] = usage_raw[key]
376
+ cached = (usage_raw.get("input_tokens_details") or {}).get("cached_tokens")
377
+ if isinstance(cached, (int, float)):
378
+ usage["cached_tokens"] = cached
379
+ reasoning = (usage_raw.get("output_tokens_details") or {}).get("reasoning_tokens")
380
+ if isinstance(reasoning, (int, float)):
381
+ usage["reasoning_tokens"] = reasoning
382
+
383
+ metadata: dict[str, Any] = {
384
+ "worker": "api",
385
+ "model": self.model,
386
+ "observed_model": data.get("model"),
387
+ "mode": "live-api",
388
+ "reasoning_effort": effort if effort is not None else "provider_default",
389
+ "operation": getattr(request, "operation", None),
390
+ "not_a_qualified_measurement_condition": True,
391
+ "base_url": self.base_url,
392
+ "response_id": data.get("id"),
393
+ "latency_ms": round(latency_ms, 3),
394
+ "token_telemetry": "enabled" if self.capture_tokens else "disabled",
395
+ }
396
+ if self.capture_tokens and usage:
397
+ metadata["usage"] = usage
398
+ metadata["usage_source"] = "responses_api"
399
+ metadata["usage_exact"] = True
400
+ return WorkerResult(text, metadata)
401
+
402
+ @staticmethod
403
+ def _extract_output_text(data: dict[str, Any]) -> str:
404
+ chunks: list[str] = []
405
+ for item in data.get("output") or []:
406
+ if item.get("type") != "message":
407
+ continue
408
+ for part in item.get("content") or []:
409
+ if part.get("type") == "output_text" and isinstance(part.get("text"), str):
410
+ chunks.append(part["text"])
411
+ return "".join(chunks).strip()
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Protocol
5
+
6
+
7
+ @dataclass
8
+ class WorkerResult:
9
+ text: str
10
+ metadata: dict[str, Any] = field(default_factory=dict)
11
+
12
+
13
+ class TransportError(RuntimeError):
14
+ """Worker/transport failure before PDL wire validity is evaluated."""
15
+
16
+
17
+ class WorkerAdapter(Protocol):
18
+ def call(self, request: Any) -> WorkerResult:
19
+ ...
20
+
21
+
22
+ def make_model_call(adapter: WorkerAdapter):
23
+ def model_call(request: Any) -> str:
24
+ return adapter.call(request).text
25
+
26
+ return model_call