diff --git a/CHANGELOG.md b/CHANGELOG.md index b11f90d..904a7be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,13 @@ versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [bridge-v0.3.3] — 2026-06-04 + +### Added +- `record_llm_call` accepts the optional prompt-cache breakdown (`cache_read_tokens` / `cache_creation_tokens` / `uncached_input_tokens`) and folds it onto the event `args` — but only when caching is active. A prompt-cache hit is now provable from a bridge-written journal too, at parity with korgex's local-journal and HTTP transports. A cold turn keeps the legacy two-field `args`, so older readers and the hash-chain over historical events are undisturbed (field order is irrelevant — `args` canonicalize with sorted keys at hash time). + +--- + ## [bridge-v0.3.2] — 2026-05-27 ### Added @@ -117,3 +124,4 @@ See [ROADMAP.md](ROADMAP.md) for planned features. [bridge-v0.3.0]: https://github.com/New1Direction/korg/releases/tag/bridge-v0.3.0 [bridge-v0.3.1]: https://github.com/New1Direction/korg/releases/tag/bridge-v0.3.1 [bridge-v0.3.2]: https://github.com/New1Direction/korg/releases/tag/bridge-v0.3.2 +[bridge-v0.3.3]: https://github.com/New1Direction/korg/releases/tag/bridge-v0.3.3 diff --git a/Cargo.lock b/Cargo.lock index a76796d..245eb87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2051,7 +2051,7 @@ dependencies = [ [[package]] name = "korg-bridge" -version = "0.3.2" +version = "0.3.3" dependencies = [ "chrono", "korg-core", diff --git a/crates/korg-bridge/Cargo.toml b/crates/korg-bridge/Cargo.toml index 17fd152..61eade1 100644 --- a/crates/korg-bridge/Cargo.toml +++ b/crates/korg-bridge/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "korg-bridge" -version = "0.3.2" +version = "0.3.3" edition = "2021" description = "PyO3 bridge — exposes Korg's in-process WAL (CapabilityJournal) to Python so korgex / KorgChat can write directly without HTTP." license = "MIT OR Apache-2.0" diff --git a/crates/korg-bridge/README.md b/crates/korg-bridge/README.md index 3cb36fe..d2e1ff2 100644 --- a/crates/korg-bridge/README.md +++ b/crates/korg-bridge/README.md @@ -17,6 +17,12 @@ byte-for-byte under both `korg_registry::ledger_chain::verify_chain` (Rust) and korgex's `src/ledger_spec.verify_chain` (Python): the two ledger paths (korgchat-via-bridge, korgex) collapse onto one chained substrate. +`record_llm_call` also carries an optional prompt-cache breakdown +(`cache_read_tokens` / `cache_creation_tokens` / `uncached_input_tokens`), folded +onto the event `args` only when caching is active — so a cache hit is provable +from the journal too, at parity with korgex's local-journal and HTTP transports. +A cold turn keeps the legacy two-field `args`. + Build the wheel and verify: ```bash diff --git a/crates/korg-bridge/pyproject.toml b/crates/korg-bridge/pyproject.toml index 832229f..3868be7 100644 --- a/crates/korg-bridge/pyproject.toml +++ b/crates/korg-bridge/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "korg_bridge" -version = "0.3.2" +version = "0.3.3" description = "In-process Python ↔ Korg WAL bridge (PyO3). Replaces the HTTP loop that korg_ledger.py used to use." requires-python = ">=3.8" readme = "README.md" diff --git a/crates/korg-bridge/src/lib.rs b/crates/korg-bridge/src/lib.rs index de75fb0..fe1d852 100644 --- a/crates/korg-bridge/src/lib.rs +++ b/crates/korg-bridge/src/lib.rs @@ -136,6 +136,11 @@ impl Bridge { /// consumers (search, audit, replay) can find it. Token counts stay in /// the same places as before. Callers responsible for content-addressing /// large replies — the bridge writes whatever text it's given. + /// + /// v0.3.3: optional prompt-cache breakdown (`cache_read_tokens` / + /// `cache_creation_tokens` / `uncached_input_tokens`). Folded onto `args` + /// only when caching is active, so a cache hit is provable from the + /// bridge-written journal too. A cold turn keeps the legacy `args` shape. #[pyo3(signature = ( model, prompt_tokens, @@ -144,6 +149,9 @@ impl Bridge { triggered_by, source_agent = "agent:korgex@0.3.0", assistant_text = None, + cache_read_tokens = 0, + cache_creation_tokens = 0, + uncached_input_tokens = None, ))] fn record_llm_call( &self, @@ -154,11 +162,39 @@ impl Bridge { triggered_by: Option, source_agent: &str, assistant_text: Option<&str>, + cache_read_tokens: u64, + cache_creation_tokens: u64, + uncached_input_tokens: Option, ) -> PyResult { - let args = serde_json::json!({ - "model": model, - "prompt_tokens": prompt_tokens, - }); + // Prompt-cache breakdown — mirrors src/korg_ledger.py::_llm_call_args so the + // bridge transport carries the same provable cache data as the local-journal + // and HTTP paths. Folded in ONLY when caching is active (a read or a write + // happened); a cold turn keeps the legacy two-field shape, so older readers + // and the hash-chain over historical events are undisturbed. Field order is + // irrelevant — args are canonicalised with sorted keys at hash time. + let mut args_map = serde_json::Map::new(); + args_map.insert("model".to_string(), serde_json::Value::from(model)); + args_map.insert( + "prompt_tokens".to_string(), + serde_json::Value::from(prompt_tokens), + ); + if cache_read_tokens != 0 || cache_creation_tokens != 0 { + args_map.insert( + "cache_read_tokens".to_string(), + serde_json::Value::from(cache_read_tokens), + ); + args_map.insert( + "cache_creation_tokens".to_string(), + serde_json::Value::from(cache_creation_tokens), + ); + if let Some(uncached) = uncached_input_tokens { + args_map.insert( + "uncached_input_tokens".to_string(), + serde_json::Value::from(uncached), + ); + } + } + let args = serde_json::Value::Object(args_map); // result carries completion_tokens + (since v0.3.2) optional text. // Keep the field name "text" — short, obvious, and matches what a // future content-addressed variant would also use as a key. diff --git a/crates/korg-bridge/tests/test_bridge.py b/crates/korg-bridge/tests/test_bridge.py index 98deda6..86fe893 100644 --- a/crates/korg-bridge/tests/test_bridge.py +++ b/crates/korg-bridge/tests/test_bridge.py @@ -27,7 +27,7 @@ def _read_events(journal_path: Path) -> list[dict]: def test_module_version_present(): - assert korg_bridge.__version__ == "0.3.2" + assert korg_bridge.__version__ == "0.3.3" def test_record_llm_call_assistant_text_optional(tmp_journal): @@ -59,6 +59,60 @@ def test_record_llm_call_assistant_text_optional(tmp_journal): } +def test_record_llm_call_cache_breakdown(tmp_journal): + """v0.3.3: the prompt-cache breakdown lands on the event args when caching is + active, so a cache hit is provable from the bridge-written journal too — parity + with the local-journal and HTTP transports.""" + bridge = korg_bridge.Bridge(str(tmp_journal)) + # 1. cold turn (no cache) → legacy two-field args, byte-identical to before + bridge.record_llm_call( + model="claude-sonnet-4-6", + prompt_tokens=500, + completion_tokens=5, + duration_ms=100, + triggered_by=None, + ) + # 2. warm turn → disjoint breakdown folded in + bridge.record_llm_call( + model="claude-sonnet-4-6", + prompt_tokens=400, + completion_tokens=8, + duration_ms=120, + triggered_by=1, + cache_read_tokens=800, + cache_creation_tokens=50, + uncached_input_tokens=400, + ) + events = _read_events(tmp_journal) + assert events[0]["event"]["args"] == {"model": "claude-sonnet-4-6", "prompt_tokens": 500} + assert events[1]["event"]["args"] == { + "model": "claude-sonnet-4-6", + "prompt_tokens": 400, + "cache_read_tokens": 800, + "cache_creation_tokens": 50, + "uncached_input_tokens": 400, + } + + +def test_record_llm_call_cache_creation_only(tmp_journal): + """A cold turn that WRITES the cache (creation>0, read==0) is still a cache + event — the breakdown is recorded.""" + bridge = korg_bridge.Bridge(str(tmp_journal)) + bridge.record_llm_call( + model="claude-sonnet-4-6", + prompt_tokens=1000, + completion_tokens=8, + duration_ms=120, + triggered_by=None, + cache_read_tokens=0, + cache_creation_tokens=1000, + uncached_input_tokens=1000, + ) + events = _read_events(tmp_journal) + assert events[0]["event"]["args"]["cache_creation_tokens"] == 1000 + assert events[0]["event"]["args"]["uncached_input_tokens"] == 1000 + + def test_repr_initial_state(tmp_journal): bridge = korg_bridge.Bridge(str(tmp_journal)) rep = repr(bridge)