atif-sql 0.2.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 (164) hide show
  1. atif_analytics/__init__.py +9 -0
  2. atif_analytics/application/__init__.py +3 -0
  3. atif_analytics/application/analyze.py +158 -0
  4. atif_analytics/application/prompts.py +750 -0
  5. atif_analytics/application/use_cases/__init__.py +3 -0
  6. atif_analytics/application/use_cases/_shared.py +264 -0
  7. atif_analytics/application/use_cases/classify.py +430 -0
  8. atif_analytics/application/use_cases/conflicts.py +448 -0
  9. atif_analytics/application/use_cases/friction.py +634 -0
  10. atif_analytics/application/use_cases/perceived.py +451 -0
  11. atif_analytics/domain/__init__.py +3 -0
  12. atif_analytics/domain/authorship.py +166 -0
  13. atif_analytics/domain/config.py +30 -0
  14. atif_analytics/domain/costs.py +50 -0
  15. atif_analytics/domain/friction.py +117 -0
  16. atif_analytics/domain/layout.py +94 -0
  17. atif_analytics/domain/models.py +385 -0
  18. atif_analytics/domain/ports.py +100 -0
  19. atif_analytics/domain/transcript.py +359 -0
  20. atif_analytics/infrastructure/__init__.py +3 -0
  21. atif_analytics/infrastructure/corpus_reader.py +546 -0
  22. atif_analytics/infrastructure/parquet_cache.py +186 -0
  23. atif_analytics/infrastructure/settings.py +114 -0
  24. atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
  25. atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
  26. atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
  27. atif_analytics/py.typed +0 -0
  28. atif_cli/__init__.py +3 -0
  29. atif_cli/__main__.py +8 -0
  30. atif_cli/app.py +2459 -0
  31. atif_cli/converter_adapter.py +151 -0
  32. atif_cli/corpus.py +409 -0
  33. atif_cli/cron.py +311 -0
  34. atif_cli/duck_errors.py +97 -0
  35. atif_cli/embed_lake.py +101 -0
  36. atif_cli/errors.py +89 -0
  37. atif_cli/lake.py +373 -0
  38. atif_cli/lake_sessions.py +270 -0
  39. atif_cli/output.py +297 -0
  40. atif_cli/py.typed +0 -0
  41. atif_converter/__init__.py +3 -0
  42. atif_converter/application/__init__.py +3 -0
  43. atif_converter/application/convert_and_audit.py +294 -0
  44. atif_converter/application/convert_codex.py +210 -0
  45. atif_converter/domain/__init__.py +9 -0
  46. atif_converter/domain/agents.py +40 -0
  47. atif_converter/domain/atif/__init__.py +55 -0
  48. atif_converter/domain/atif/agent.py +37 -0
  49. atif_converter/domain/atif/content.py +161 -0
  50. atif_converter/domain/atif/final_metrics.py +45 -0
  51. atif_converter/domain/atif/metrics.py +49 -0
  52. atif_converter/domain/atif/observation.py +21 -0
  53. atif_converter/domain/atif/observation_result.py +47 -0
  54. atif_converter/domain/atif/step.py +144 -0
  55. atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
  56. atif_converter/domain/atif/tool_call.py +36 -0
  57. atif_converter/domain/atif/trajectory.py +211 -0
  58. atif_converter/domain/atif/trajectory_validator.py +299 -0
  59. atif_converter/domain/blobs.py +510 -0
  60. atif_converter/domain/claude_code_conversion.py +1077 -0
  61. atif_converter/domain/codex_conversion.py +1036 -0
  62. atif_converter/domain/codex_edges.py +192 -0
  63. atif_converter/domain/codex_enrichment.py +419 -0
  64. atif_converter/domain/codex_fidelity.py +124 -0
  65. atif_converter/domain/codex_nested_calls.py +165 -0
  66. atif_converter/domain/edges.py +113 -0
  67. atif_converter/domain/enrichment.py +412 -0
  68. atif_converter/domain/errors.py +60 -0
  69. atif_converter/domain/fidelity.py +171 -0
  70. atif_converter/domain/model_prices.json +8538 -0
  71. atif_converter/domain/pricing.py +790 -0
  72. atif_converter/domain/result_signals.py +774 -0
  73. atif_converter/domain/schema_version.py +90 -0
  74. atif_converter/domain/session_events.py +408 -0
  75. atif_converter/infrastructure/__init__.py +3 -0
  76. atif_converter/infrastructure/census.py +89 -0
  77. atif_converter/infrastructure/claude_code_converter.py +127 -0
  78. atif_converter/infrastructure/codex_adapter.py +132 -0
  79. atif_converter/infrastructure/codex_census.py +100 -0
  80. atif_converter/infrastructure/codex_converter.py +82 -0
  81. atif_converter/infrastructure/harbor_adapter.py +187 -0
  82. atif_converter/infrastructure/raw_records.py +426 -0
  83. atif_converter/infrastructure/source_archive.py +154 -0
  84. atif_converter/py.typed +0 -0
  85. atif_corpus/__init__.py +13 -0
  86. atif_corpus/application/__init__.py +7 -0
  87. atif_corpus/application/materialize.py +1826 -0
  88. atif_corpus/domain/__init__.py +9 -0
  89. atif_corpus/domain/agents.py +33 -0
  90. atif_corpus/domain/generation.py +34 -0
  91. atif_corpus/domain/layout.py +168 -0
  92. atif_corpus/domain/ports.py +175 -0
  93. atif_corpus/domain/session_id.py +68 -0
  94. atif_corpus/domain/sessions.py +228 -0
  95. atif_corpus/domain/slug.py +72 -0
  96. atif_corpus/domain/source_layout.py +143 -0
  97. atif_corpus/domain/watermark.py +76 -0
  98. atif_corpus/infrastructure/__init__.py +7 -0
  99. atif_corpus/infrastructure/atomic.py +266 -0
  100. atif_corpus/infrastructure/compress_artifacts.py +161 -0
  101. atif_corpus/infrastructure/fake_converter.py +174 -0
  102. atif_corpus/infrastructure/scanner.py +316 -0
  103. atif_corpus/infrastructure/settings.py +130 -0
  104. atif_corpus/infrastructure/source_archive.py +202 -0
  105. atif_corpus/py.typed +0 -0
  106. atif_duck/__init__.py +43 -0
  107. atif_duck/domain/__init__.py +32 -0
  108. atif_duck/domain/artifacts.py +66 -0
  109. atif_duck/domain/authorship.py +165 -0
  110. atif_duck/domain/catalog.py +626 -0
  111. atif_duck/domain/columnar.py +149 -0
  112. atif_duck/domain/embedding_guard.py +88 -0
  113. atif_duck/domain/examples.py +265 -0
  114. atif_duck/domain/lake.py +427 -0
  115. atif_duck/domain/lake_sessions.py +97 -0
  116. atif_duck/domain/raw_readers.py +140 -0
  117. atif_duck/domain/session_id.py +69 -0
  118. atif_duck/domain/sql_literal.py +50 -0
  119. atif_duck/infrastructure/__init__.py +34 -0
  120. atif_duck/infrastructure/analytics.py +469 -0
  121. atif_duck/infrastructure/authorship.py +139 -0
  122. atif_duck/infrastructure/columnar.py +641 -0
  123. atif_duck/infrastructure/lake.py +1383 -0
  124. atif_duck/infrastructure/lake_sessions.py +194 -0
  125. atif_duck/infrastructure/lake_settings.py +63 -0
  126. atif_duck/infrastructure/lake_steps.py +225 -0
  127. atif_duck/infrastructure/projections.py +221 -0
  128. atif_duck/infrastructure/registry.py +2194 -0
  129. atif_duck/infrastructure/stored_artifacts.py +115 -0
  130. atif_duck/py.typed +0 -0
  131. atif_embed/__init__.py +12 -0
  132. atif_embed/application/__init__.py +3 -0
  133. atif_embed/application/embed.py +293 -0
  134. atif_embed/application/prune.py +80 -0
  135. atif_embed/domain/__init__.py +3 -0
  136. atif_embed/domain/discovery.py +70 -0
  137. atif_embed/domain/embedding_guard.py +83 -0
  138. atif_embed/domain/errors.py +94 -0
  139. atif_embed/domain/ports.py +223 -0
  140. atif_embed/domain/sql_literal.py +49 -0
  141. atif_embed/domain/text_stamp.py +71 -0
  142. atif_embed/infrastructure/__init__.py +3 -0
  143. atif_embed/infrastructure/cohere_bedrock.py +433 -0
  144. atif_embed/infrastructure/corpus_text_rows.py +273 -0
  145. atif_embed/infrastructure/gemma_local.py +355 -0
  146. atif_embed/infrastructure/lake_text_rows.py +203 -0
  147. atif_embed/infrastructure/lance_store.py +440 -0
  148. atif_embed/infrastructure/providers.py +30 -0
  149. atif_embed/infrastructure/settings.py +154 -0
  150. atif_embed/py.typed +0 -0
  151. atif_models/__init__.py +14 -0
  152. atif_models/domain/__init__.py +9 -0
  153. atif_models/domain/ports.py +143 -0
  154. atif_models/domain/registry.py +151 -0
  155. atif_models/domain/schema.py +109 -0
  156. atif_models/infrastructure/__init__.py +8 -0
  157. atif_models/infrastructure/openai_bedrock.py +314 -0
  158. atif_models/infrastructure/settings.py +85 -0
  159. atif_models/py.typed +0 -0
  160. atif_sql-0.2.0.dist-info/METADATA +272 -0
  161. atif_sql-0.2.0.dist-info/RECORD +164 -0
  162. atif_sql-0.2.0.dist-info/WHEEL +4 -0
  163. atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
  164. atif_sql-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,1036 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Ported from harbor 0.22.0, src/harbor/agents/installed/codex.py (Apache-2.0,
3
+ # Copyright the Harbor authors), brought to harbor 0.24.0, onto the ATIF models vendored in
4
+ # atif_converter.domain.atif, so atif-converter doesn't depend on harbor.
5
+
6
+ """Codex rollout records -> ATIF trajectory, as one pure function.
7
+
8
+ This is harbor's ``Codex._convert_events_to_trajectory`` and the helpers it
9
+ calls, ported line for line onto harbor's ``harbor.models.trajectories``
10
+ data classes, which are vendored as :mod:`atif_converter.domain.atif`. harbor
11
+ documents those classes and the validator and nothing else, so the converter
12
+ itself has to live here. The parity oracle in ``tests/harbor_oracle.py`` measures this port
13
+ against harbor's private method for as long as harbor still ships it.
14
+
15
+ FAITHFUL, NOT IMPROVED. Every quirk below is harbor's, kept so the two agree
16
+ byte for byte; a behavior change is a decision for a later commit, made with
17
+ the golden re-frozen on purpose. Names and structure follow harbor's so an
18
+ upstream diff can be re-applied by hand. harbor 0.24.0's two Codex changes
19
+ are applied: a ``web_search_call`` keeps its item id or gets
20
+ ``<api call>_web_search_<n>`` (harbor #2972), and a structured tool output (a
21
+ list of MCP content blocks or content items) becomes text rather than Python's
22
+ ``str()`` of the list (harbor #3467). The one departure is an inline image in
23
+ such an output, which harbor saves to a file and this pure function writes as
24
+ its blob placeholder (:func:`_tool_output_content` says why):
25
+
26
+ ================================= =========================================
27
+ harbor 0.22.0 here
28
+ ================================= =========================================
29
+ ``_extract_message_text`` :func:`~atif_converter.domain.codex_enrichment.message_text`
30
+ ``_parse_output_blob`` :func:`_parse_output_blob`
31
+ ``_group_events_by_api_call_id`` :func:`_group_events_by_api_call_id`
32
+ ``_metrics_from_token_count_payload`` :func:`_metrics_from_token_count_payload`
33
+ ``_convert_event_to_step`` :func:`_convert_event_to_step`
34
+ ``_compute_cost_from_pricing`` :func:`_compute_cost_from_pricing`
35
+ ``_convert_events_to_trajectory`` :func:`convert_codex_records` (+ the three
36
+ ``_normalize_events`` /
37
+ ``_attach_api_call_metrics`` /
38
+ ``_final_metrics`` slices it was cut into
39
+ for the size gates)
40
+ ================================= =========================================
41
+
42
+ The three instance attributes the method read are replaced as follows:
43
+
44
+ ``self.logger``
45
+ loguru's ``logger``; the message texts are harbor's.
46
+ ``self.model_name``
47
+ the ``model_name`` parameter, default ``None`` — the adapter this replaces
48
+ never set one, so every step's model comes from ``turn_context``.
49
+ ``self._version``
50
+ ``None``. harbor only consults it when ``session_meta`` carries no
51
+ ``cli_version``, and the adapter never set it either, so ``agent.version``
52
+ is the rollout's ``cli_version`` or the literal ``"unknown"``.
53
+
54
+ One substitution under the hood: ``_compute_cost_from_pricing`` reads the
55
+ price table and prices each call through :mod:`atif_converter.domain.pricing`
56
+ rather than ``litellm`` directly. The floats are identical (the module
57
+ repeats ``litellm.cost_per_token``'s arithmetic over a vendored copy of
58
+ litellm's price data); what changed is that litellm isn't installed at all.
59
+
60
+ Pure over already-parsed records in FILE order: no file I/O, no
61
+ ``harbor.agents`` import. Reading the rollout is
62
+ :mod:`atif_converter.infrastructure.codex_converter`'s job.
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ import json
68
+ from typing import Any, Literal
69
+
70
+ from loguru import logger
71
+
72
+ from atif_converter.domain import pricing
73
+ from atif_converter.domain.agents import AgentSource
74
+ from atif_converter.domain.atif import (
75
+ Agent,
76
+ FinalMetrics,
77
+ Metrics,
78
+ Observation,
79
+ ObservationResult,
80
+ Step,
81
+ ToolCall,
82
+ Trajectory,
83
+ )
84
+ from atif_converter.domain.blobs import BlobCollector, codex_tool_image_ref
85
+ from atif_converter.domain.codex_enrichment import message_text
86
+
87
+ #: The schema version harbor 0.22.0 stamps on a Codex trajectory.
88
+ _SCHEMA_VERSION = "ATIF-v1.7"
89
+
90
+ #: ``agent.version`` when neither ``session_meta.cli_version`` nor a runtime
91
+ #: version is known — harbor's literal.
92
+ _UNKNOWN_VERSION = "unknown"
93
+
94
+
95
+ #: harbor 0.24.0's tool output content types (``codex.py``, harbor #3467).
96
+ _TOOL_OUTPUT_TEXT_TYPES = frozenset({"text", "input_text"})
97
+ _TOOL_OUTPUT_IMAGE_TYPES = frozenset({"image", "input_image"})
98
+ _TOOL_OUTPUT_CONTENT_TYPES = (
99
+ _TOOL_OUTPUT_TEXT_TYPES
100
+ | _TOOL_OUTPUT_IMAGE_TYPES
101
+ | frozenset({"audio", "input_audio", "resource", "resource_link", "encrypted_content"})
102
+ )
103
+ #: harbor's text for an image it cannot reference.
104
+ _IMAGE_OMITTED = "[image omitted]"
105
+
106
+
107
+ def _parse_output_blob(raw: Any) -> tuple[str | None, dict[str, Any] | None]:
108
+ """Extract the observation content and metadata from a Codex tool output.
109
+
110
+ harbor 0.24.0's ``Codex._parse_output_blob`` (harbor #3467). A JSON object
111
+ yields its ``output`` (JSON text when that is not a string, or, when the key
112
+ is missing, the whole object re-serialized) plus its ``metadata`` when that
113
+ is an object; a list of MCP content blocks or content items becomes the
114
+ text :func:`_tool_output_content` renders; any other JSON value keeps the
115
+ raw string, or its JSON text when the output was not a string; a non-JSON
116
+ string is the output itself.
117
+ """
118
+ if raw is None:
119
+ return None, None
120
+
121
+ if isinstance(raw, str):
122
+ try:
123
+ parsed = json.loads(raw)
124
+ except json.JSONDecodeError:
125
+ return raw, None
126
+ else:
127
+ parsed = raw
128
+
129
+ if isinstance(parsed, dict):
130
+ output = parsed.get("output")
131
+ if output is None and parsed:
132
+ # dumping remaining structure if output missing
133
+ output = json.dumps(parsed, ensure_ascii=False)
134
+ elif output is not None and not isinstance(output, str):
135
+ output = json.dumps(output, ensure_ascii=False)
136
+ metadata = parsed.get("metadata")
137
+ return output, metadata if isinstance(metadata, dict) else None
138
+
139
+ return _non_object_output(raw, parsed), None
140
+
141
+
142
+ def _non_object_output(raw: Any, parsed: Any) -> str:
143
+ """The text of a tool output that is not a JSON object (harbor's tail branches)."""
144
+ if _is_tool_output_content(parsed):
145
+ return _tool_output_content(parsed)
146
+ if isinstance(raw, str):
147
+ return raw
148
+ return json.dumps(parsed, ensure_ascii=False)
149
+
150
+
151
+ def _is_tool_output_content(value: Any) -> bool:
152
+ """Whether a tool output is a list of MCP content blocks or content items.
153
+
154
+ harbor's ``Codex._is_tool_output_content``: a non-empty list whose every
155
+ element is an object with a known content ``type``.
156
+ """
157
+ if not isinstance(value, list) or not value:
158
+ return False
159
+ for block in value:
160
+ if not isinstance(block, dict):
161
+ return False
162
+ block_type = block.get("type")
163
+ if not isinstance(block_type, str) or block_type not in _TOOL_OUTPUT_CONTENT_TYPES:
164
+ return False
165
+ return True
166
+
167
+
168
+ def _tool_output_content(blocks: list[dict[str, Any]]) -> str:
169
+ """Tool output content blocks -> the observation's text.
170
+
171
+ harbor's ``Codex._tool_output_content``: text blocks keep their text, an
172
+ image becomes :func:`_tool_output_image_text`, and any other block (audio,
173
+ a resource, encrypted content) is kept as its JSON text; the parts are
174
+ joined by newlines.
175
+
176
+ DELIBERATE DIVERGENCE from harbor 0.24.0 for an inline image harbor can
177
+ decode: harbor writes it to ``<logs_dir>/images/codex_<sha256[:16]>.<ext>``
178
+ and returns a list of content parts with an image part pointing there. This
179
+ converter is pure and the corpus keeps attachments in its blob store, so the
180
+ image becomes the blob placeholder text the pre-pass
181
+ (:func:`~atif_converter.domain.blobs.extract_codex_blobs`) writes for the
182
+ same ``input_image``, and the content stays one string. The parity oracle
183
+ forgives exactly that (``tests/harbor_oracle.py::_image_content_forgiven``).
184
+ """
185
+ return "\n".join(_tool_output_block_text(block) for block in blocks)
186
+
187
+
188
+ def structured_output_text(value: Any) -> str | None:
189
+ """:func:`_tool_output_content`'s text for a list of content blocks or items, else ``None``.
190
+
191
+ Not part of harbor's converter: the result-signals pass renders a code-mode
192
+ script's nested MCP result through it
193
+ (:mod:`atif_converter.domain.codex_nested_calls`), so the blocks of a
194
+ nested call read the way the same blocks read in a top-level tool output.
195
+ """
196
+ return _tool_output_content(value) if _is_tool_output_content(value) else None
197
+
198
+
199
+ def _tool_output_block_text(block: dict[str, Any]) -> str:
200
+ block_type = block["type"]
201
+ text = block.get("text")
202
+ if block_type in _TOOL_OUTPUT_TEXT_TYPES and isinstance(text, str):
203
+ return text
204
+ if block_type in _TOOL_OUTPUT_IMAGE_TYPES:
205
+ return _tool_output_image_text(block)
206
+ return json.dumps(block, ensure_ascii=False)
207
+
208
+
209
+ def _tool_output_image_text(block: dict[str, Any]) -> str:
210
+ """The text for an MCP image block or an ``input_image`` item.
211
+
212
+ harbor's ``Codex._tool_output_image_part`` and ``_save_tool_output_image``,
213
+ with the same texts for an image nobody can reference (``[image omitted]``)
214
+ and for a remote URL (``[image: <url>]``); an inline image harbor would save
215
+ becomes its blob placeholder (see :func:`_tool_output_content`).
216
+ """
217
+ if block["type"] == "image":
218
+ data = block.get("data")
219
+ media_type = block.get("mimeType") or block.get("mime_type")
220
+ else:
221
+ data = block.get("image_url")
222
+ media_type = None
223
+ if not isinstance(data, str):
224
+ return _IMAGE_OMITTED
225
+ if "://" in data and not data.startswith("data:"):
226
+ return f"[image: {data}]"
227
+
228
+ return _inline_image_text(data, media_type)
229
+
230
+
231
+ def _inline_image_text(data: str, media_type: Any) -> str:
232
+ """An inline image's placeholder, or harbor's text when harbor would not save it.
233
+
234
+ The rule is :func:`~atif_converter.domain.blobs.codex_tool_image_ref`, the
235
+ one the pre-pass lifts the same image by, so in production the pre-pass has
236
+ already replaced the image with this placeholder and stored its bytes; the
237
+ throwaway collector here only names the bytes when the records were not
238
+ pre-passed.
239
+ """
240
+ ref = codex_tool_image_ref(data, media_type, BlobCollector())
241
+ if ref is None:
242
+ logger.debug("Unsupported or undecodable Codex tool image: {}", media_type)
243
+ return _IMAGE_OMITTED
244
+ return ref.placeholder()
245
+
246
+
247
+ def _group_events_by_api_call_id(
248
+ normalized_events: list[dict[str, Any]],
249
+ ) -> list[dict[str, Any]]:
250
+ """Merge assistant events from the same Codex model request into one step.
251
+
252
+ harbor's ``Codex._group_events_by_api_call_id``. Every event carrying a
253
+ string ``api_call_id`` joins the group for that request; a non-assistant
254
+ message flushes the open groups first, which is what keeps a user turn
255
+ between two agent steps rather than after both.
256
+ """
257
+ result: list[dict[str, Any]] = []
258
+ groups: dict[str, dict[str, Any]] = {}
259
+ group_order: list[str] = []
260
+
261
+ def flush() -> None:
262
+ for group_id in group_order:
263
+ group = groups.pop(group_id, None)
264
+ if group is None:
265
+ continue
266
+ tool_calls: list[dict[str, Any]] = group["tool_calls"]
267
+ tool_calls.sort(key=lambda tc: tc.get("tool_order", 0))
268
+ message_parts = [
269
+ part for part in group.pop("message_parts") if isinstance(part, str) and part
270
+ ]
271
+ group["text"] = "\n\n".join(message_parts)
272
+ result.append(group)
273
+ group_order.clear()
274
+
275
+ for event in normalized_events:
276
+ api_call_id = event.get("api_call_id")
277
+ kind = event.get("kind")
278
+ role = event.get("role")
279
+
280
+ if kind == "message" and role != "assistant":
281
+ flush()
282
+ result.append(event)
283
+ continue
284
+
285
+ if not isinstance(api_call_id, str):
286
+ flush()
287
+ result.append(event)
288
+ continue
289
+
290
+ if api_call_id not in groups:
291
+ groups[api_call_id] = {
292
+ "kind": "bundled",
293
+ "api_call_id": api_call_id,
294
+ "codex_turn_id": event.get("codex_turn_id"),
295
+ "timestamp": event.get("timestamp"),
296
+ "message_parts": [],
297
+ "reasoning": None,
298
+ "tool_calls": [],
299
+ "metrics": event.get("metrics"),
300
+ }
301
+ group_order.append(api_call_id)
302
+
303
+ group = groups[api_call_id]
304
+ if kind == "message":
305
+ text = event.get("text")
306
+ if isinstance(text, str) and text:
307
+ group["message_parts"].append(text)
308
+ if event.get("reasoning"):
309
+ group["reasoning"] = event["reasoning"]
310
+ if event.get("timestamp"):
311
+ group["timestamp"] = event["timestamp"]
312
+ elif kind == "tool_call":
313
+ group["tool_calls"].append(event)
314
+ if not group["reasoning"] and event.get("reasoning"):
315
+ group["reasoning"] = event["reasoning"]
316
+ if not group.get("metrics") and event.get("metrics"):
317
+ group["metrics"] = event["metrics"]
318
+
319
+ flush()
320
+ return result
321
+
322
+
323
+ def _metrics_from_token_count_payload(
324
+ payload: dict[str, Any],
325
+ ) -> dict[str, Any] | None:
326
+ """Per-call metrics from a ``token_count`` event's ``last_token_usage``.
327
+
328
+ harbor's ``Codex._metrics_from_token_count_payload``. Zero counts become
329
+ ``None`` (``or None``), so a call with no cached tokens reports no
330
+ ``cached_tokens`` key rather than ``0``.
331
+ """
332
+ info = payload.get("info")
333
+ if not isinstance(info, dict):
334
+ return None
335
+
336
+ last_usage = info.get("last_token_usage")
337
+ if not isinstance(last_usage, dict):
338
+ return None
339
+
340
+ prompt_tokens = last_usage.get("input_tokens")
341
+ completion_tokens = last_usage.get("output_tokens")
342
+ cached_tokens = last_usage.get("cached_input_tokens")
343
+ cache_write_tokens = last_usage.get("cache_write_input_tokens")
344
+ reasoning_tokens = last_usage.get("reasoning_output_tokens")
345
+ total_tokens = last_usage.get("total_tokens")
346
+
347
+ extra = {
348
+ "reasoning_output_tokens": reasoning_tokens,
349
+ "total_tokens": total_tokens,
350
+ }
351
+ if cache_write_tokens is not None:
352
+ extra["cache_write_input_tokens"] = cache_write_tokens
353
+
354
+ return {
355
+ "prompt_tokens": prompt_tokens or None,
356
+ "completion_tokens": completion_tokens or None,
357
+ "cached_tokens": cached_tokens or None,
358
+ "extra": extra,
359
+ }
360
+
361
+
362
+ def _convert_event_to_step(event: dict[str, Any], step_id: int, model_name: str | None) -> Step:
363
+ """Convert a normalized Codex event dictionary into an ATIF step.
364
+
365
+ harbor's ``Codex._convert_event_to_step`` with ``self.model_name`` as the
366
+ ``model_name`` parameter. Three kinds: ``message`` (in practice only
367
+ non-assistant ones reach here — every assistant event carries an
368
+ ``api_call_id`` and is bundled first), ``tool_call`` (kept for the same
369
+ reason harbor keeps it; unreachable through the grouping above) and
370
+ ``bundled``.
371
+
372
+ Raises
373
+ ------
374
+ ValueError: an unsupported kind, or a pydantic validation failure
375
+ (``ValidationError`` is a ``ValueError``), which the caller skips.
376
+ """
377
+ kind = event.get("kind")
378
+ timestamp = event.get("timestamp")
379
+
380
+ if kind == "message":
381
+ role = event.get("role", "user")
382
+ text = event.get("text", "")
383
+ reasoning = event.get("reasoning")
384
+ source: Literal["system", "user", "agent"]
385
+ if role == "assistant":
386
+ source = "agent"
387
+ elif role == "user":
388
+ source = "user"
389
+ else:
390
+ source = "system"
391
+
392
+ message_extra = event.get("extra")
393
+
394
+ return Step(
395
+ step_id=step_id,
396
+ timestamp=timestamp,
397
+ source=source,
398
+ message=text,
399
+ reasoning_content=reasoning if source == "agent" and reasoning else None,
400
+ model_name=model_name if source == "agent" and model_name else None,
401
+ llm_call_count=1 if source == "agent" else None,
402
+ extra=message_extra or None,
403
+ )
404
+
405
+ if kind == "tool_call":
406
+ return _tool_call_step(event, step_id, model_name)
407
+
408
+ if kind == "bundled":
409
+ return _bundled_step(event, step_id, model_name)
410
+
411
+ msg = f"Unsupported event kind '{kind}'"
412
+ raise ValueError(msg)
413
+
414
+
415
+ def _tool_call_step(event: dict[str, Any], step_id: int, model_name: str | None) -> Step:
416
+ """The ``kind == "tool_call"`` branch of harbor's ``_convert_event_to_step``."""
417
+ timestamp = event.get("timestamp")
418
+ call_id = event.get("call_id", "")
419
+ tool_name = event.get("tool_name", "")
420
+ reasoning = event.get("reasoning")
421
+ arguments = event.get("arguments") or {}
422
+ if not isinstance(arguments, dict):
423
+ arguments = {"value": arguments}
424
+
425
+ tool_call = ToolCall(
426
+ tool_call_id=call_id,
427
+ function_name=tool_name,
428
+ arguments=arguments,
429
+ )
430
+
431
+ observation: Observation | None = None
432
+ output_text = event.get("output")
433
+ if output_text is not None:
434
+ observation = Observation(
435
+ results=[
436
+ ObservationResult(
437
+ source_call_id=call_id or None,
438
+ content=output_text,
439
+ )
440
+ ]
441
+ )
442
+
443
+ metrics_payload = event.get("metrics")
444
+ metrics: Metrics | None = None
445
+ if isinstance(metrics_payload, dict):
446
+ metrics = Metrics(**metrics_payload)
447
+
448
+ extra: dict[str, Any] | None = None
449
+ metadata = event.get("metadata")
450
+ if metadata:
451
+ extra = {"tool_metadata": metadata}
452
+ raw_arguments = event.get("raw_arguments")
453
+ if raw_arguments:
454
+ extra = extra or {}
455
+ extra["raw_arguments"] = raw_arguments
456
+ status = event.get("status")
457
+ if status:
458
+ extra = extra or {}
459
+ extra["status"] = status
460
+ api_call_id = event.get("api_call_id")
461
+ if api_call_id:
462
+ extra = extra or {}
463
+ extra["api_call_id"] = api_call_id
464
+ codex_turn_id = event.get("codex_turn_id")
465
+ if codex_turn_id:
466
+ extra = extra or {}
467
+ extra["codex_turn_id"] = codex_turn_id
468
+
469
+ message = event.get("message") or ""
470
+
471
+ return Step(
472
+ step_id=step_id,
473
+ timestamp=timestamp,
474
+ source="agent",
475
+ message=message,
476
+ tool_calls=[tool_call],
477
+ observation=observation,
478
+ model_name=model_name or None,
479
+ reasoning_content=reasoning or None,
480
+ metrics=metrics,
481
+ llm_call_count=1,
482
+ extra=extra,
483
+ )
484
+
485
+
486
+ def _bundled_step(event: dict[str, Any], step_id: int, model_name: str | None) -> Step:
487
+ """The ``kind == "bundled"`` branch of harbor's ``_convert_event_to_step``.
488
+
489
+ One step per model request: every tool call in the group becomes a
490
+ ``ToolCall`` AND an ``ObservationResult`` (content ``None`` when the call
491
+ produced no output, as ``web_search_call`` never does), and the per-call
492
+ details harbor cannot fit on the public ``ToolCall`` land in
493
+ ``extra["tool_call_details"]`` keyed by call id.
494
+ """
495
+ text = event.get("text", "")
496
+ reasoning = event.get("reasoning")
497
+
498
+ tool_calls: list[ToolCall] = []
499
+ observation_results: list[ObservationResult] = []
500
+ for tc in event.get("tool_calls", []):
501
+ call_id = tc.get("call_id", "")
502
+ arguments = tc.get("arguments") or {}
503
+ if not isinstance(arguments, dict):
504
+ arguments = {"value": arguments}
505
+
506
+ tool_calls.append(
507
+ ToolCall(
508
+ tool_call_id=call_id,
509
+ function_name=tc.get("tool_name", ""),
510
+ arguments=arguments,
511
+ )
512
+ )
513
+ observation_results.append(
514
+ ObservationResult(
515
+ source_call_id=call_id or None,
516
+ content=tc.get("output"),
517
+ )
518
+ )
519
+
520
+ extra: dict[str, Any] | None = None
521
+ api_call_id = event.get("api_call_id")
522
+ if api_call_id:
523
+ extra = {"api_call_id": api_call_id}
524
+ codex_turn_id = event.get("codex_turn_id")
525
+ if codex_turn_id:
526
+ extra = extra or {}
527
+ extra["codex_turn_id"] = codex_turn_id
528
+
529
+ tool_details: dict[str, Any] = {}
530
+ for tc in event.get("tool_calls", []):
531
+ call_id = tc.get("call_id", "")
532
+ details: dict[str, Any] = {}
533
+ for source_key, target_key in (
534
+ ("metadata", "metadata"),
535
+ ("raw_arguments", "raw_arguments"),
536
+ ("item_type", "item_type"),
537
+ ("status", "status"),
538
+ ):
539
+ value = tc.get(source_key)
540
+ if value:
541
+ details[target_key] = value
542
+ if details:
543
+ tool_details[call_id] = details
544
+ if tool_details:
545
+ extra = extra or {}
546
+ extra["tool_call_details"] = tool_details
547
+
548
+ observation = Observation(results=observation_results) if observation_results else None
549
+
550
+ return Step(
551
+ step_id=step_id,
552
+ timestamp=event.get("timestamp"),
553
+ source="agent",
554
+ message=text,
555
+ model_name=model_name or None,
556
+ reasoning_content=reasoning or None,
557
+ tool_calls=tool_calls or None,
558
+ observation=observation,
559
+ metrics=Metrics(**event["metrics"]) if event.get("metrics") else None,
560
+ llm_call_count=1,
561
+ extra=extra,
562
+ )
563
+
564
+
565
+ def _compute_cost_from_pricing(
566
+ prompt_tokens: int | None,
567
+ completion_tokens: int | None,
568
+ cached_tokens: int | None,
569
+ cache_write_tokens: int | None,
570
+ model_name: str | None = None,
571
+ fallback_model_name: str | None = None,
572
+ ) -> float | None:
573
+ """Compute one API call's cost in USD via LiteLLM's pricing logic.
574
+
575
+ harbor's ``Codex._compute_cost_from_pricing``; ``fallback_model_name``
576
+ stands in for ``self.model_name``. LiteLLM selects context-dependent rates
577
+ from the token count for this individual request. Returns None when the
578
+ pricing table is unavailable, the model is missing from it (tried as given
579
+ and with any ``provider/`` prefix stripped) or the calculation fails.
580
+
581
+ The table lookup and the arithmetic go through
582
+ :mod:`atif_converter.domain.pricing`, which reads the vendored copy of
583
+ litellm's price data and returns the same floats litellm would.
584
+ """
585
+ resolved_model_name = model_name or fallback_model_name
586
+ if not resolved_model_name:
587
+ return None
588
+
589
+ pricing_model_name = next(
590
+ (
591
+ key
592
+ for key in (resolved_model_name, resolved_model_name.split("/", 1)[-1])
593
+ if pricing.has_pricing_entry(key)
594
+ ),
595
+ None,
596
+ )
597
+
598
+ if pricing_model_name is None:
599
+ logger.debug(
600
+ "No LiteLLM pricing entry for model '{}'; leaving codex cost_usd as None",
601
+ resolved_model_name,
602
+ )
603
+ return None
604
+
605
+ try:
606
+ input_cost, output_cost = pricing.cost_per_token(
607
+ model=pricing_model_name,
608
+ prompt_tokens=prompt_tokens or 0,
609
+ completion_tokens=completion_tokens or 0,
610
+ cache_creation_input_tokens=cache_write_tokens or 0,
611
+ cache_read_input_tokens=cached_tokens or 0,
612
+ )
613
+ except Exception: # noqa: BLE001 — harbor swallows every pricing failure into None
614
+ logger.opt(exception=True).debug(
615
+ "Failed to calculate Codex cost for model '{}'", resolved_model_name
616
+ )
617
+ return None
618
+
619
+ return float(input_cost + output_cost)
620
+
621
+
622
+ def _normalize_events(
623
+ raw_events: list[dict[str, Any]],
624
+ default_model_name: str | None,
625
+ model_name: str | None,
626
+ ) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]:
627
+ """The "normalize events" pass of harbor's ``_convert_events_to_trajectory``.
628
+
629
+ Walks the raw records once and returns the normalized event list plus the
630
+ per-API-call metrics, keyed ``api_call_<n>``. The counter advances on a
631
+ ``token_count`` event only when the request it closes produced model
632
+ output (an assistant message, a web search or a tool call); a tool call's
633
+ normalized event is emitted when its OUTPUT arrives but carries the request
634
+ id current when the CALL was seen.
635
+ """
636
+ normalized_events: list[dict[str, Any]] = []
637
+ pending_calls: dict[str, dict[str, Any]] = {}
638
+ pending_reasoning: str | None = None
639
+ codex_turn_id: str | None = None
640
+ api_call_index = 1
641
+ current_api_call_id = f"api_call_{api_call_index}"
642
+ api_call_metrics: dict[str, dict[str, Any]] = {}
643
+ saw_model_output_in_api_call = False
644
+ tool_order_counter = 0
645
+
646
+ def record_model_output() -> None:
647
+ nonlocal saw_model_output_in_api_call
648
+ saw_model_output_in_api_call = True
649
+
650
+ def finish_api_call(token_count_payload: dict[str, Any]) -> None:
651
+ nonlocal api_call_index, current_api_call_id, saw_model_output_in_api_call
652
+ nonlocal tool_order_counter
653
+
654
+ if not saw_model_output_in_api_call:
655
+ return
656
+
657
+ metrics = _metrics_from_token_count_payload(token_count_payload)
658
+ if metrics:
659
+ cache_write_tokens = metrics["extra"].get("cache_write_input_tokens")
660
+ metrics["cost_usd"] = _compute_cost_from_pricing(
661
+ prompt_tokens=metrics.get("prompt_tokens"),
662
+ completion_tokens=metrics.get("completion_tokens"),
663
+ cached_tokens=metrics.get("cached_tokens"),
664
+ cache_write_tokens=cache_write_tokens,
665
+ model_name=default_model_name,
666
+ fallback_model_name=model_name,
667
+ )
668
+ api_call_metrics[current_api_call_id] = metrics
669
+
670
+ api_call_index += 1
671
+ current_api_call_id = f"api_call_{api_call_index}"
672
+ saw_model_output_in_api_call = False
673
+ tool_order_counter = 0
674
+
675
+ for event in raw_events:
676
+ etype = event.get("type")
677
+ payload = event.get("payload", {})
678
+ timestamp = event.get("timestamp")
679
+
680
+ if etype == "event_msg" and isinstance(payload, dict):
681
+ event_type = payload.get("type")
682
+ if event_type in {"task_started", "turn_started"}:
683
+ turn_id = payload.get("turn_id")
684
+ codex_turn_id = turn_id if isinstance(turn_id, str) else None
685
+ elif event_type in {"task_complete", "turn_complete", "turn_aborted"}:
686
+ codex_turn_id = None
687
+ elif event_type == "token_count":
688
+ # A token_count event closes one model API call.
689
+ finish_api_call(payload)
690
+ continue
691
+
692
+ if etype == "turn_context":
693
+ turn_id = payload.get("turn_id") if isinstance(payload, dict) else None
694
+ if isinstance(turn_id, str) and codex_turn_id is None:
695
+ codex_turn_id = turn_id
696
+ continue
697
+
698
+ if etype != "response_item":
699
+ continue
700
+
701
+ payload_type = payload.get("type")
702
+ if payload_type == "reasoning":
703
+ summary = payload.get("summary")
704
+ if isinstance(summary, list) and summary:
705
+ reasoning_parts: list[str] = []
706
+ for item in summary:
707
+ if isinstance(item, str):
708
+ reasoning_parts.append(item)
709
+ elif isinstance(item, dict):
710
+ text = item.get("text")
711
+ if isinstance(text, str):
712
+ reasoning_parts.append(text)
713
+ pending_reasoning = "\n".join(reasoning_parts) if reasoning_parts else None
714
+ else:
715
+ pending_reasoning = None
716
+ continue
717
+
718
+ if payload_type == "message":
719
+ normalized_events.append(
720
+ {
721
+ "kind": "message",
722
+ "api_call_id": current_api_call_id,
723
+ "codex_turn_id": codex_turn_id,
724
+ "timestamp": timestamp,
725
+ "role": payload.get("role", "user"),
726
+ # harbor: ``_extract_message_text(content)`` when content
727
+ # is a list, else "" — which is exactly ``message_text``.
728
+ "text": message_text(payload),
729
+ "reasoning": pending_reasoning if payload.get("role") == "assistant" else None,
730
+ }
731
+ )
732
+ if payload.get("role") == "assistant":
733
+ record_model_output()
734
+ pending_reasoning = None
735
+ continue
736
+
737
+ if payload_type == "web_search_call":
738
+ action = payload.get("action") or {}
739
+ action_type = action.get("type", "")
740
+ arguments: dict[str, Any] = {"action_type": action_type}
741
+ if "query" in action:
742
+ arguments["query"] = action["query"]
743
+ if "queries" in action:
744
+ arguments["queries"] = action["queries"]
745
+ if "url" in action:
746
+ arguments["url"] = action["url"]
747
+
748
+ # harbor 0.24.0 (harbor #2972): the call keeps Codex's own item id,
749
+ # or gets one unique within its request.
750
+ native_call_id = payload.get("id")
751
+ web_search_call_id = (
752
+ native_call_id
753
+ if isinstance(native_call_id, str) and native_call_id.strip()
754
+ else f"{current_api_call_id}_web_search_{tool_order_counter}"
755
+ )
756
+
757
+ normalized_events.append(
758
+ {
759
+ "kind": "tool_call",
760
+ "api_call_id": current_api_call_id,
761
+ "codex_turn_id": codex_turn_id,
762
+ "tool_order": tool_order_counter,
763
+ "timestamp": timestamp,
764
+ "call_id": web_search_call_id,
765
+ "tool_name": "web_search_call",
766
+ "arguments": arguments,
767
+ "raw_arguments": None,
768
+ "reasoning": pending_reasoning,
769
+ "status": payload.get("status"),
770
+ "message": None,
771
+ }
772
+ )
773
+ tool_order_counter += 1
774
+ record_model_output()
775
+ pending_reasoning = None
776
+ continue
777
+
778
+ if payload_type in {"function_call", "custom_tool_call"}:
779
+ call_id = payload.get("call_id")
780
+ if not call_id:
781
+ continue
782
+
783
+ raw_args_key = "arguments" if payload_type == "function_call" else "input"
784
+ raw_arguments = payload.get(raw_args_key)
785
+ try:
786
+ parsed_args = json.loads(raw_arguments)
787
+ except (json.JSONDecodeError, TypeError):
788
+ if isinstance(raw_arguments, str):
789
+ parsed_args = {"input": raw_arguments}
790
+ elif raw_arguments is None:
791
+ parsed_args = {}
792
+ else:
793
+ parsed_args = {"value": raw_arguments}
794
+
795
+ pending_calls[call_id] = {
796
+ "kind": "tool_call",
797
+ "api_call_id": current_api_call_id,
798
+ "codex_turn_id": codex_turn_id,
799
+ "tool_order": tool_order_counter,
800
+ "timestamp": timestamp,
801
+ "call_id": call_id,
802
+ "tool_name": payload.get("name") or "",
803
+ "arguments": parsed_args,
804
+ "raw_arguments": raw_arguments,
805
+ "item_type": payload_type,
806
+ "reasoning": pending_reasoning,
807
+ "status": payload.get("status"),
808
+ "message": None,
809
+ }
810
+ tool_order_counter += 1
811
+ record_model_output()
812
+ pending_reasoning = None
813
+ continue
814
+
815
+ if payload_type in {"function_call_output", "custom_tool_call_output"}:
816
+ call_id = payload.get("call_id")
817
+ output_text, metadata = _parse_output_blob(payload.get("output"))
818
+
819
+ call_info = pending_calls.pop(call_id, None) if call_id else None
820
+
821
+ if call_info is None:
822
+ call_info = {
823
+ "kind": "tool_call",
824
+ "api_call_id": current_api_call_id,
825
+ "codex_turn_id": codex_turn_id,
826
+ "tool_order": tool_order_counter,
827
+ "timestamp": timestamp,
828
+ "call_id": call_id or "",
829
+ "tool_name": payload.get("name", "") or "",
830
+ "arguments": {},
831
+ "raw_arguments": None,
832
+ "reasoning": pending_reasoning,
833
+ "status": None,
834
+ "message": None,
835
+ }
836
+ tool_order_counter += 1
837
+
838
+ call_info["output"] = output_text
839
+ call_info["metadata"] = metadata
840
+ call_info["timestamp"] = call_info.get("timestamp") or timestamp
841
+ normalized_events.append(call_info)
842
+ pending_reasoning = None
843
+ continue
844
+
845
+ return normalized_events, api_call_metrics
846
+
847
+
848
+ def _attach_api_call_metrics(
849
+ normalized_events: list[dict[str, Any]],
850
+ api_call_metrics: dict[str, dict[str, Any]],
851
+ ) -> None:
852
+ """Stamp each normalized event with its request's metrics, in place.
853
+
854
+ The same metrics dict is shared by every event of one request; grouping
855
+ then keeps the first non-empty one per step.
856
+ """
857
+ for norm_event in normalized_events:
858
+ event_api_call_id = norm_event.get("api_call_id")
859
+ if isinstance(event_api_call_id, str) and event_api_call_id in api_call_metrics:
860
+ norm_event["metrics"] = api_call_metrics[event_api_call_id]
861
+
862
+
863
+ def _final_metrics(
864
+ raw_events: list[dict[str, Any]],
865
+ total_steps: int,
866
+ estimated_total_cost_usd: float | None,
867
+ ) -> FinalMetrics | None:
868
+ """Final metrics from the LAST ``token_count`` event carrying totals.
869
+
870
+ The "Extract final metrics" pass of harbor's
871
+ ``_convert_events_to_trajectory``. Codex CLI does not include cost in
872
+ ``token_count`` events, so ``total_cost`` / ``cost_usd`` in ``info`` win
873
+ when present and the LiteLLM estimate fills in otherwise — explicit
874
+ ``is None`` checks so a legitimate $0 is not mistaken for a missing field.
875
+ """
876
+ for event in reversed(raw_events):
877
+ if event.get("type") != "event_msg":
878
+ continue
879
+ payload = event.get("payload", {})
880
+ if not isinstance(payload, dict) or payload.get("type") != "token_count":
881
+ continue
882
+
883
+ info = payload.get("info")
884
+ if not isinstance(info, dict):
885
+ continue
886
+
887
+ total_usage = info.get("total_token_usage")
888
+ if not isinstance(total_usage, dict):
889
+ continue
890
+
891
+ prompt_tokens = total_usage.get("input_tokens")
892
+ completion_tokens = total_usage.get("output_tokens")
893
+ reasoning_tokens = total_usage.get("reasoning_output_tokens")
894
+ cached_tokens = total_usage.get("cached_input_tokens")
895
+ cache_write_tokens = total_usage.get("cache_write_input_tokens")
896
+ overall_tokens = total_usage.get("total_tokens")
897
+
898
+ total_cost_usd = info.get("total_cost")
899
+ if total_cost_usd is None:
900
+ total_cost_usd = info.get("cost_usd")
901
+ if total_cost_usd is None:
902
+ total_cost_usd = estimated_total_cost_usd
903
+
904
+ final_extra: dict[str, Any] = {
905
+ "reasoning_output_tokens": reasoning_tokens,
906
+ "total_tokens": overall_tokens,
907
+ "last_token_usage": info.get("last_token_usage"),
908
+ }
909
+ if cache_write_tokens is not None:
910
+ final_extra["total_cache_write_input_tokens"] = cache_write_tokens
911
+
912
+ return FinalMetrics(
913
+ total_prompt_tokens=prompt_tokens or None,
914
+ total_completion_tokens=completion_tokens or None,
915
+ total_cached_tokens=cached_tokens or None,
916
+ total_cost_usd=total_cost_usd,
917
+ total_steps=total_steps,
918
+ extra=final_extra,
919
+ )
920
+
921
+ return None
922
+
923
+
924
+ def convert_codex_records(
925
+ raw_events: list[dict[str, Any]],
926
+ *,
927
+ fallback_session_id: str,
928
+ model_name: str | None = None,
929
+ ) -> Trajectory | None:
930
+ """Convert one rollout's parsed records into an ATIF trajectory.
931
+
932
+ harbor's ``Codex._convert_events_to_trajectory`` from the point where the
933
+ file has been read. Returns ``None`` exactly when harbor does: no records
934
+ at all, or no record that becomes a step.
935
+
936
+ Parameters
937
+ ----------
938
+ raw_events
939
+ The rollout's JSON records in FILE order, malformed lines already
940
+ dropped (see :mod:`atif_converter.infrastructure.codex_converter`).
941
+ fallback_session_id
942
+ ``session_id`` when the rollout carries no ``session_meta`` record.
943
+ harbor uses the session DIRECTORY's name, so the reader passes the
944
+ rollout's parent directory name — the ``<DD>`` of Codex's
945
+ ``sessions/<YYYY>/<MM>/<DD>/`` layout.
946
+ model_name
947
+ harbor's ``self.model_name``: the fallback model when no
948
+ ``turn_context`` names one, and the pricing fallback. ``None`` in
949
+ every production call, which is what the adapter this replaces did.
950
+ """
951
+ if not raw_events:
952
+ return None
953
+
954
+ session_meta = next((e for e in raw_events if e.get("type") == "session_meta"), None)
955
+ # harbor: ``if session_meta and isinstance(session_meta, dict)`` — the
956
+ # isinstance is redundant on a list of dicts and the type checkers say so.
957
+ session_id = session_meta.get("payload", {}).get("id") if session_meta else fallback_session_id
958
+
959
+ agent_version = _UNKNOWN_VERSION
960
+ agent_extra: dict[str, Any] | None = None
961
+ default_model_name: str | None = None
962
+
963
+ if session_meta:
964
+ payload = session_meta.get("payload", {})
965
+ agent_version = payload.get("cli_version") or agent_version
966
+ extra: dict[str, Any] = {}
967
+ for key in ("originator", "cwd", "git", "instructions"):
968
+ value = payload.get(key)
969
+ if value is not None:
970
+ extra[key] = value
971
+ agent_extra = extra or None
972
+
973
+ # harbor: ``if agent_version == "unknown" and self._version:`` — the CLI
974
+ # version probed at install time. Never set in this port (see the module
975
+ # docstring), so "unknown" stands.
976
+
977
+ for event in raw_events:
978
+ if event.get("type") == "turn_context":
979
+ turn_model_name = event.get("payload", {}).get("model")
980
+ if isinstance(turn_model_name, str):
981
+ default_model_name = turn_model_name
982
+ break
983
+
984
+ if default_model_name is None:
985
+ default_model_name = model_name
986
+
987
+ normalized_events, api_call_metrics = _normalize_events(
988
+ raw_events, default_model_name, model_name
989
+ )
990
+ _attach_api_call_metrics(normalized_events, api_call_metrics)
991
+ grouped_events = _group_events_by_api_call_id(normalized_events)
992
+
993
+ steps: list[Step] = []
994
+ for idx, norm_event in enumerate(grouped_events, start=1):
995
+ try:
996
+ step = _convert_event_to_step(norm_event, idx, model_name)
997
+ except ValueError as exc:
998
+ logger.debug("Skipping event during step conversion: {}", exc)
999
+ continue
1000
+
1001
+ # Provide default model name if not set for agent steps
1002
+ if step.source == "agent" and not step.model_name and default_model_name:
1003
+ step.model_name = default_model_name
1004
+
1005
+ steps.append(step)
1006
+
1007
+ if not steps:
1008
+ logger.debug("No valid steps produced from Codex session")
1009
+ return None
1010
+
1011
+ estimated_total_cost_usd: float | None = 0.0 if api_call_metrics else None
1012
+ for metrics in api_call_metrics.values():
1013
+ call_cost = metrics["cost_usd"]
1014
+ if call_cost is None:
1015
+ estimated_total_cost_usd = None
1016
+ break
1017
+ if estimated_total_cost_usd is not None:
1018
+ estimated_total_cost_usd += call_cost
1019
+
1020
+ total_metrics = _final_metrics(raw_events, len(steps), estimated_total_cost_usd)
1021
+
1022
+ return Trajectory(
1023
+ schema_version=_SCHEMA_VERSION,
1024
+ session_id=session_id,
1025
+ agent=Agent(
1026
+ name=AgentSource.CODEX.value,
1027
+ version=agent_version,
1028
+ model_name=default_model_name,
1029
+ extra=agent_extra,
1030
+ ),
1031
+ steps=steps,
1032
+ final_metrics=total_metrics,
1033
+ )
1034
+
1035
+
1036
+ __all__ = ["convert_codex_records", "structured_output_text"]