Compare commits

...

1 Commits

Author SHA1 Message Date
Pat Sukprasert b60df8ae67 feat(kiro-native): forward credit usage as session cost (#1696)
kiro-cli meters in credits (not tokens), recorded per-turn under
session_state.conversation_metadata.user_turn_metadatas[*].metering_usage in
the session .json snapshot; the forwarder only tailed the .jsonl transcript, so
Omnigent showed no cost for kiro sessions.

Sum the per-turn credit values and post the cumulative total as
external_session_usage cumulative_cost_usd (the monotonic, authoritative cost
path the claude-/codex-native forwarders use). Credits are forwarded 1:1 into
cost_usd since no credit->USD conversion exists, matching the Copilot AI-credit
convention; documented in the helper.

Co-authored-by: Isaac
2026-06-30 20:27:22 +07:00
2 changed files with 252 additions and 13 deletions
+108
View File
@@ -344,6 +344,95 @@ async def _patch_external_session_id(
return
def _read_kiro_cumulative_credits(metadata_path: Path) -> tuple[float | None, str | None]:
"""Sum the per-turn credit metering from a Kiro session ``.json`` snapshot.
kiro-cli meters in credits, not tokens: each turn under
``session_state.conversation_metadata.user_turn_metadatas`` carries a
``metering_usage`` list of ``{"value": <float>, "unit": "credit"}`` entries
(token counts are 0), and the CLI shows a per-turn ``Credits:`` line. The
cumulative session cost is the sum of every turn's credit values. This data
lives only in the ``.json`` snapshot, not the ``.jsonl`` transcript the
forwarder tails.
:returns: ``(cumulative_credits, model_id)``, or ``(None, None)`` when the
file is missing/unparseable or carries no metering yet.
"""
try:
raw = metadata_path.read_text(encoding="utf-8")
except OSError:
return None, None
try:
data = json.loads(raw)
except ValueError:
return None, None
if not isinstance(data, dict):
return None, None
session_state = data.get("session_state")
if not isinstance(session_state, dict):
return None, None
conversation_metadata = session_state.get("conversation_metadata")
turns = (
conversation_metadata.get("user_turn_metadatas")
if isinstance(conversation_metadata, dict)
else None
)
if not isinstance(turns, list):
return None, None
total = 0.0
saw_credit = False
for turn in turns:
if not isinstance(turn, dict):
continue
metering = turn.get("metering_usage")
if not isinstance(metering, list):
continue
for entry in metering:
if not isinstance(entry, dict):
continue
value = entry.get("value")
if isinstance(value, int | float) and not isinstance(value, bool):
total += float(value)
saw_credit = True
if not saw_credit:
return None, None
model_id: str | None = None
rts_model_state = session_state.get("rts_model_state")
if isinstance(rts_model_state, dict):
model_info = rts_model_state.get("model_info")
if isinstance(model_info, dict):
candidate = model_info.get("model_id")
if isinstance(candidate, str) and candidate:
model_id = candidate
return total, model_id
async def _post_session_cost(
client: httpx.AsyncClient,
*,
session_id: str,
cumulative_cost_usd: float,
model: str | None,
) -> None:
"""POST Kiro's cumulative credit spend as authoritative session cost.
kiro-cli reports cost in credits and there is no credit->USD conversion
available, so credits are forwarded 1:1 into ``cumulative_cost_usd`` (the
same convention the Copilot relay uses for its AI-credit total). The server
treats this value as authoritative and monotonic, in preference to
token x catalog pricing, via the ``external_session_usage`` event used by
the claude-/codex-native forwarders.
"""
data: dict[str, object] = {"cumulative_cost_usd": cumulative_cost_usd}
if model:
data["model"] = model
resp = await client.post(
f"/v1/sessions/{session_id}/events",
json={"type": "external_session_usage", "data": data},
)
resp.raise_for_status()
async def forward_kiro_session_to_omnigent(
*,
base_url: str,
@@ -362,6 +451,7 @@ async def forward_kiro_session_to_omnigent(
jsonl_path: Path | None = None
timeout = httpx.Timeout(_POST_TIMEOUT_S)
mirrored_external_session_id: str | None = None
last_posted_cost: float | None = None
async with httpx.AsyncClient(
base_url=base_url, headers=headers, auth=auth, timeout=timeout
) as client:
@@ -420,6 +510,24 @@ async def forward_kiro_session_to_omnigent(
state.byte_offset = byte_offset
_write_state(bridge_dir, state)
write_forwarder_ready(bridge_dir)
# Forward kiro's credit metering as authoritative session
# cost. It lives in the ``.json`` snapshot (sibling of the
# tailed ``.jsonl``), not the transcript, and is cumulative;
# the server treats ``cumulative_cost_usd`` as monotonic, so
# only post when it advances.
cumulative_cost, cost_model = await asyncio.to_thread(
_read_kiro_cumulative_credits, jsonl_path.with_suffix(".json")
)
if cumulative_cost is not None and (
last_posted_cost is None or cumulative_cost > last_posted_cost
):
await _post_session_cost(
client,
session_id=session_id,
cumulative_cost_usd=cumulative_cost,
model=cost_model,
)
last_posted_cost = cumulative_cost
except asyncio.CancelledError:
raise
except Exception:
+144 -13
View File
@@ -21,21 +21,31 @@ def _write_kiro_session(
created_at: str = "2026-06-21T01:39:34.528139806Z",
updated_at: str = "2026-06-21T01:40:41.838294036Z",
lines: list[dict[str, Any]] | None = None,
user_turn_metadatas: list[dict[str, Any]] | None = None,
model_id: str | None = None,
) -> Path:
"""Create a minimal Kiro CLI session metadata + JSONL fixture."""
"""Create a minimal Kiro CLI session metadata + JSONL fixture.
Pass ``user_turn_metadatas`` / ``model_id`` to populate the credit-metering
fields the ``.json`` snapshot carries (``session_state.conversation_metadata
.user_turn_metadatas`` and ``session_state.rts_model_state.model_info``).
"""
root.mkdir(parents=True, exist_ok=True)
(root / f"{session_id}.json").write_text(
json.dumps(
{
"session_id": session_id,
"cwd": str(cwd),
"created_at": created_at,
"updated_at": updated_at,
"title": "hello",
}
),
encoding="utf-8",
)
metadata: dict[str, Any] = {
"session_id": session_id,
"cwd": str(cwd),
"created_at": created_at,
"updated_at": updated_at,
"title": "hello",
}
if user_turn_metadatas is not None or model_id is not None:
session_state: dict[str, Any] = {}
if user_turn_metadatas is not None:
session_state["conversation_metadata"] = {"user_turn_metadatas": user_turn_metadatas}
if model_id is not None:
session_state["rts_model_state"] = {"model_info": {"model_id": model_id}}
metadata["session_state"] = session_state
(root / f"{session_id}.json").write_text(json.dumps(metadata), encoding="utf-8")
jsonl_path = root / f"{session_id}.jsonl"
jsonl_path.write_text(
"\n".join(json.dumps(line) for line in (lines or [])) + "\n",
@@ -347,6 +357,127 @@ async def test_forward_kiro_session_posts_conversation_messages(
# kiro-native is owned by the PTY watcher's emit_status (#1137). See
# test_forward_kiro_session_does_not_post_session_status for the guard.
assert external_ids == [("conv_kiro", "kiro-session")]
def test_read_kiro_cumulative_credits_sums_every_turn(tmp_path: Path) -> None:
"""Cumulative credits = sum of every turn's metering_usage values."""
sessions_dir = tmp_path / "cli"
_write_kiro_session(
sessions_dir,
session_id="k",
cwd=tmp_path,
user_turn_metadatas=[
{
"metering_usage": [
{"value": 0.06, "unit": "credit"},
{"value": 0.03, "unit": "credit"},
],
"input_token_count": 0,
},
{"metering_usage": [{"value": 0.04, "unit": "credit"}], "input_token_count": 0},
],
model_id="auto",
)
total, model = forwarder._read_kiro_cumulative_credits(sessions_dir / "k.json")
assert total == pytest.approx(0.13)
assert model == "auto"
def test_read_kiro_cumulative_credits_none_without_metering(tmp_path: Path) -> None:
"""No metering (or missing file) yields ``(None, None)`` so callers skip."""
sessions_dir = tmp_path / "cli"
_write_kiro_session(sessions_dir, session_id="k", cwd=tmp_path)
assert forwarder._read_kiro_cumulative_credits(sessions_dir / "k.json") == (None, None)
assert forwarder._read_kiro_cumulative_credits(sessions_dir / "missing.json") == (None, None)
@pytest.mark.asyncio
async def test_forward_kiro_session_posts_cumulative_cost_once(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The forwarder posts kiro's summed credits as cumulative cost, then dedupes."""
sessions_dir = tmp_path / "home" / ".kiro" / "sessions" / "cli"
workspace = tmp_path / "repo"
workspace.mkdir()
_write_kiro_session(
sessions_dir,
session_id="kiro-session",
cwd=workspace,
lines=[
{
"version": "v1",
"kind": "AssistantMessage",
"data": {"message_id": "a1", "content": [{"kind": "text", "data": "hi"}]},
},
],
user_turn_metadatas=[
{
"metering_usage": [
{"value": 0.06, "unit": "credit"},
{"value": 0.03, "unit": "credit"},
]
},
{"metering_usage": [{"value": 0.04, "unit": "credit"}]},
],
model_id="auto",
)
monkeypatch.setattr(forwarder, "_kiro_cli_sessions_dir", lambda: sessions_dir)
costs: list[tuple[str, float, str | None]] = []
async def _fake_post_cost(
client: httpx.AsyncClient,
*,
session_id: str,
cumulative_cost_usd: float,
model: str | None,
) -> None:
del client
costs.append((session_id, cumulative_cost_usd, model))
async def _noop_message(
client: httpx.AsyncClient,
*,
session_id: str,
agent_name: str,
message: forwarder._KiroConversationMessage,
) -> None:
del client
async def _noop_patch(
client: httpx.AsyncClient, *, session_id: str, external_session_id: str
) -> None:
del client
calls = {"n": 0}
async def _cancel_after_two_polls(_seconds: float) -> None:
calls["n"] += 1
if calls["n"] >= 2:
raise asyncio.CancelledError
monkeypatch.setattr(forwarder, "_post_session_cost", _fake_post_cost)
monkeypatch.setattr(forwarder, "_post_conversation_message", _noop_message)
monkeypatch.setattr(forwarder, "_patch_external_session_id", _noop_patch)
monkeypatch.setattr(forwarder.asyncio, "sleep", _cancel_after_two_polls)
with pytest.raises(asyncio.CancelledError):
await forwarder.forward_kiro_session_to_omnigent(
base_url="http://127.0.0.1:6767",
headers={},
session_id="conv_kiro",
bridge_dir=tmp_path / "bridge",
agent_name="kiro-native-ui",
workspace=str(workspace),
launch_epoch_ms=forwarder._parse_iso_epoch_ms("2026-06-21T01:39:34Z"),
)
# Posted once with the summed credits + model; the unchanged second poll
# does not re-post (server treats cumulative_cost_usd as monotonic).
assert costs == [("conv_kiro", pytest.approx(0.13), "auto")]
state = json.loads((tmp_path / "bridge" / "kiro_session_forwarder.json").read_text())
assert state["session_id"] == "kiro-session"
assert state["byte_offset"] > 0