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.
- atif_analytics/__init__.py +9 -0
- atif_analytics/application/__init__.py +3 -0
- atif_analytics/application/analyze.py +158 -0
- atif_analytics/application/prompts.py +750 -0
- atif_analytics/application/use_cases/__init__.py +3 -0
- atif_analytics/application/use_cases/_shared.py +264 -0
- atif_analytics/application/use_cases/classify.py +430 -0
- atif_analytics/application/use_cases/conflicts.py +448 -0
- atif_analytics/application/use_cases/friction.py +634 -0
- atif_analytics/application/use_cases/perceived.py +451 -0
- atif_analytics/domain/__init__.py +3 -0
- atif_analytics/domain/authorship.py +166 -0
- atif_analytics/domain/config.py +30 -0
- atif_analytics/domain/costs.py +50 -0
- atif_analytics/domain/friction.py +117 -0
- atif_analytics/domain/layout.py +94 -0
- atif_analytics/domain/models.py +385 -0
- atif_analytics/domain/ports.py +100 -0
- atif_analytics/domain/transcript.py +359 -0
- atif_analytics/infrastructure/__init__.py +3 -0
- atif_analytics/infrastructure/corpus_reader.py +546 -0
- atif_analytics/infrastructure/parquet_cache.py +186 -0
- atif_analytics/infrastructure/settings.py +114 -0
- atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
- atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
- atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
- atif_analytics/py.typed +0 -0
- atif_cli/__init__.py +3 -0
- atif_cli/__main__.py +8 -0
- atif_cli/app.py +2459 -0
- atif_cli/converter_adapter.py +151 -0
- atif_cli/corpus.py +409 -0
- atif_cli/cron.py +311 -0
- atif_cli/duck_errors.py +97 -0
- atif_cli/embed_lake.py +101 -0
- atif_cli/errors.py +89 -0
- atif_cli/lake.py +373 -0
- atif_cli/lake_sessions.py +270 -0
- atif_cli/output.py +297 -0
- atif_cli/py.typed +0 -0
- atif_converter/__init__.py +3 -0
- atif_converter/application/__init__.py +3 -0
- atif_converter/application/convert_and_audit.py +294 -0
- atif_converter/application/convert_codex.py +210 -0
- atif_converter/domain/__init__.py +9 -0
- atif_converter/domain/agents.py +40 -0
- atif_converter/domain/atif/__init__.py +55 -0
- atif_converter/domain/atif/agent.py +37 -0
- atif_converter/domain/atif/content.py +161 -0
- atif_converter/domain/atif/final_metrics.py +45 -0
- atif_converter/domain/atif/metrics.py +49 -0
- atif_converter/domain/atif/observation.py +21 -0
- atif_converter/domain/atif/observation_result.py +47 -0
- atif_converter/domain/atif/step.py +144 -0
- atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
- atif_converter/domain/atif/tool_call.py +36 -0
- atif_converter/domain/atif/trajectory.py +211 -0
- atif_converter/domain/atif/trajectory_validator.py +299 -0
- atif_converter/domain/blobs.py +510 -0
- atif_converter/domain/claude_code_conversion.py +1077 -0
- atif_converter/domain/codex_conversion.py +1036 -0
- atif_converter/domain/codex_edges.py +192 -0
- atif_converter/domain/codex_enrichment.py +419 -0
- atif_converter/domain/codex_fidelity.py +124 -0
- atif_converter/domain/codex_nested_calls.py +165 -0
- atif_converter/domain/edges.py +113 -0
- atif_converter/domain/enrichment.py +412 -0
- atif_converter/domain/errors.py +60 -0
- atif_converter/domain/fidelity.py +171 -0
- atif_converter/domain/model_prices.json +8538 -0
- atif_converter/domain/pricing.py +790 -0
- atif_converter/domain/result_signals.py +774 -0
- atif_converter/domain/schema_version.py +90 -0
- atif_converter/domain/session_events.py +408 -0
- atif_converter/infrastructure/__init__.py +3 -0
- atif_converter/infrastructure/census.py +89 -0
- atif_converter/infrastructure/claude_code_converter.py +127 -0
- atif_converter/infrastructure/codex_adapter.py +132 -0
- atif_converter/infrastructure/codex_census.py +100 -0
- atif_converter/infrastructure/codex_converter.py +82 -0
- atif_converter/infrastructure/harbor_adapter.py +187 -0
- atif_converter/infrastructure/raw_records.py +426 -0
- atif_converter/infrastructure/source_archive.py +154 -0
- atif_converter/py.typed +0 -0
- atif_corpus/__init__.py +13 -0
- atif_corpus/application/__init__.py +7 -0
- atif_corpus/application/materialize.py +1826 -0
- atif_corpus/domain/__init__.py +9 -0
- atif_corpus/domain/agents.py +33 -0
- atif_corpus/domain/generation.py +34 -0
- atif_corpus/domain/layout.py +168 -0
- atif_corpus/domain/ports.py +175 -0
- atif_corpus/domain/session_id.py +68 -0
- atif_corpus/domain/sessions.py +228 -0
- atif_corpus/domain/slug.py +72 -0
- atif_corpus/domain/source_layout.py +143 -0
- atif_corpus/domain/watermark.py +76 -0
- atif_corpus/infrastructure/__init__.py +7 -0
- atif_corpus/infrastructure/atomic.py +266 -0
- atif_corpus/infrastructure/compress_artifacts.py +161 -0
- atif_corpus/infrastructure/fake_converter.py +174 -0
- atif_corpus/infrastructure/scanner.py +316 -0
- atif_corpus/infrastructure/settings.py +130 -0
- atif_corpus/infrastructure/source_archive.py +202 -0
- atif_corpus/py.typed +0 -0
- atif_duck/__init__.py +43 -0
- atif_duck/domain/__init__.py +32 -0
- atif_duck/domain/artifacts.py +66 -0
- atif_duck/domain/authorship.py +165 -0
- atif_duck/domain/catalog.py +626 -0
- atif_duck/domain/columnar.py +149 -0
- atif_duck/domain/embedding_guard.py +88 -0
- atif_duck/domain/examples.py +265 -0
- atif_duck/domain/lake.py +427 -0
- atif_duck/domain/lake_sessions.py +97 -0
- atif_duck/domain/raw_readers.py +140 -0
- atif_duck/domain/session_id.py +69 -0
- atif_duck/domain/sql_literal.py +50 -0
- atif_duck/infrastructure/__init__.py +34 -0
- atif_duck/infrastructure/analytics.py +469 -0
- atif_duck/infrastructure/authorship.py +139 -0
- atif_duck/infrastructure/columnar.py +641 -0
- atif_duck/infrastructure/lake.py +1383 -0
- atif_duck/infrastructure/lake_sessions.py +194 -0
- atif_duck/infrastructure/lake_settings.py +63 -0
- atif_duck/infrastructure/lake_steps.py +225 -0
- atif_duck/infrastructure/projections.py +221 -0
- atif_duck/infrastructure/registry.py +2194 -0
- atif_duck/infrastructure/stored_artifacts.py +115 -0
- atif_duck/py.typed +0 -0
- atif_embed/__init__.py +12 -0
- atif_embed/application/__init__.py +3 -0
- atif_embed/application/embed.py +293 -0
- atif_embed/application/prune.py +80 -0
- atif_embed/domain/__init__.py +3 -0
- atif_embed/domain/discovery.py +70 -0
- atif_embed/domain/embedding_guard.py +83 -0
- atif_embed/domain/errors.py +94 -0
- atif_embed/domain/ports.py +223 -0
- atif_embed/domain/sql_literal.py +49 -0
- atif_embed/domain/text_stamp.py +71 -0
- atif_embed/infrastructure/__init__.py +3 -0
- atif_embed/infrastructure/cohere_bedrock.py +433 -0
- atif_embed/infrastructure/corpus_text_rows.py +273 -0
- atif_embed/infrastructure/gemma_local.py +355 -0
- atif_embed/infrastructure/lake_text_rows.py +203 -0
- atif_embed/infrastructure/lance_store.py +440 -0
- atif_embed/infrastructure/providers.py +30 -0
- atif_embed/infrastructure/settings.py +154 -0
- atif_embed/py.typed +0 -0
- atif_models/__init__.py +14 -0
- atif_models/domain/__init__.py +9 -0
- atif_models/domain/ports.py +143 -0
- atif_models/domain/registry.py +151 -0
- atif_models/domain/schema.py +109 -0
- atif_models/infrastructure/__init__.py +8 -0
- atif_models/infrastructure/openai_bedrock.py +314 -0
- atif_models/infrastructure/settings.py +85 -0
- atif_models/py.typed +0 -0
- atif_sql-0.2.0.dist-info/METADATA +272 -0
- atif_sql-0.2.0.dist-info/RECORD +164 -0
- atif_sql-0.2.0.dist-info/WHEEL +4 -0
- atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
- 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"]
|